Initial skeleton: React/TS frontend + Go backend structure
This commit is contained in:
1
client/.eslintignore
Normal file
1
client/.eslintignore
Normal file
@@ -0,0 +1 @@
|
||||
module: "eslint"
|
||||
32
client/eslint.config.mjs
Normal file
32
client/eslint.config.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import js from "@eslint/js"
|
||||
import reactHooks from "eslint-plugin-react-hooks"
|
||||
import reactRefresh from "eslint-plugin-react-refresh"
|
||||
import tseslint from "typescript-eslint"
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "node_modules", "vite.config.ts"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: "module",
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
39
client/index.html
Normal file
39
client/index.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Krates Yard</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
background: #0b0e13;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
client/package.json
Normal file
33
client/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "krates-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext ts,tsx --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"zustand": "^4.4.7",
|
||||
"yjs": "^13.6.16",
|
||||
"y-websocket": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.10.0",
|
||||
"@types/react": "^18.3.9",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^9.10.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.12",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript-eslint": "^8.4.0",
|
||||
"vite": "^5.4.3"
|
||||
}
|
||||
}
|
||||
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
63
client/src/App.tsx
Normal file
63
client/src/App.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import { WorldLayer } from './components/canvas'
|
||||
import { GridOverlay } from './components/canvas'
|
||||
import { Minimap } from './components/canvas'
|
||||
import { TopBar } from './components/shared'
|
||||
import { Spotlight } from './components/shared'
|
||||
import { useCanvasStore } from './state'
|
||||
|
||||
export function App() {
|
||||
const { camX, camY, zoom } = useCanvasStore()
|
||||
|
||||
const canvasStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: '#0b0e13',
|
||||
overflow: 'hidden',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={canvasStyle}>
|
||||
<TopBar />
|
||||
<Spotlight />
|
||||
<GridOverlay />
|
||||
<WorldLayer />
|
||||
<Minimap />
|
||||
|
||||
{/* Bottom UI */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '18px',
|
||||
bottom: '18px',
|
||||
background: 'rgba(14,18,25,.82)',
|
||||
border: '1px solid rgba(140,165,200,.18)',
|
||||
borderRadius: '9px',
|
||||
padding: '6px 12px',
|
||||
backdropFilter: 'blur(6px)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#aaa', fontSize: '12px' }}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '18px',
|
||||
bottom: '18px',
|
||||
color: '#888',
|
||||
fontSize: '12px',
|
||||
background: 'rgba(14,18,25,.82)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
<span>Space + drag to pan</span>
|
||||
<span style={{ marginLeft: '12px' }}>Scroll to pan</span>
|
||||
<span style={{ marginLeft: '12px' }}>Click to spotlight</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
client/src/components/canvas/GridOverlay.tsx
Normal file
27
client/src/components/canvas/GridOverlay.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { HTMLAttributes } from 'react'
|
||||
import { useCanvasStore } from '../state/canvasStore'
|
||||
|
||||
interface GridOverlayProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const GridOverlay: React.FC<GridOverlayProps> = ({ ...props }) => {
|
||||
const { zoom } = useCanvasStore()
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'none',
|
||||
backgroundSize: '34px 34px, 170px 170px',
|
||||
backgroundPosition: '0 0, 0 0',
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, rgba(125,145,175,.04) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(125,145,175,.04) 1px, transparent 1px),
|
||||
linear-gradient(to right, rgba(125,145,175,.075) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(125,145,175,.075) 1px, transparent 1px)
|
||||
`,
|
||||
}
|
||||
|
||||
return <div style={style} {...props} />
|
||||
}
|
||||
87
client/src/components/canvas/Minimap.tsx
Normal file
87
client/src/components/canvas/Minimap.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { HTMLAttributes, useEffect, useRef } from 'react'
|
||||
import { useCanvasStore } from '../state/canvasStore'
|
||||
import { useKrateStore } from '../state/krateStore'
|
||||
|
||||
interface MinimapProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const Minimap: React.FC<MinimapProps> = ({ ...props }) => {
|
||||
const { camX, camY, zoom } = useCanvasStore()
|
||||
const { krates } = useKrateStore()
|
||||
const minimapRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const minimapWidth = 180
|
||||
const minimapHeight = 120
|
||||
const worldWidth = 12000
|
||||
const worldHeight = 8000
|
||||
const minimapScaleX = minimapWidth / worldWidth
|
||||
const minimapScaleY = minimapHeight / worldHeight
|
||||
|
||||
const viewportWidthWorld = window.innerWidth / zoom
|
||||
const viewportHeightWorld = window.innerHeight / zoom
|
||||
|
||||
const viewportX = camX * minimapScaleX
|
||||
const viewportY = camY * minimapScaleY
|
||||
const viewportW = viewportWidthWorld * minimapScaleX
|
||||
const viewportH = viewportHeightWorld * minimapScaleY
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const rect = minimapRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
||||
const clickX = e.clientX - rect.left
|
||||
const clickY = e.clientY - rect.top
|
||||
|
||||
const worldX = (clickX / minimapScaleX) - camX - viewportWidthWorld / 2
|
||||
const worldY = (clickY / minimapScaleY) - camY - viewportHeightWorld / 2
|
||||
|
||||
useCanvasStore.getState().setCam(worldX, worldY)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={minimapRef}
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '18px',
|
||||
bottom: '64px',
|
||||
width: minimapWidth,
|
||||
height: minimapHeight,
|
||||
background: 'rgba(16,20,28,.97)',
|
||||
border: '1px solid rgba(140,165,200,.2)',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
zIndex: 30,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{Array.from(krates.values()).map((krate) => (
|
||||
<div
|
||||
key={krate.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: krate.x * minimapScaleX,
|
||||
top: krate.y * minimapScaleY,
|
||||
width: krate.width * minimapScaleX,
|
||||
height: krate.height * minimapScaleY,
|
||||
backgroundColor: krate.color || '#6fb1ff',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: viewportX,
|
||||
top: viewportY,
|
||||
width: viewportW,
|
||||
height: viewportH,
|
||||
border: '1px solid #4dd6e8',
|
||||
borderColor: 'rgba(77, 214, 232, 0.4)',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
client/src/components/canvas/WorldLayer.tsx
Normal file
29
client/src/components/canvas/WorldLayer.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { HTMLAttributes } from 'react'
|
||||
import { useCanvasStore } from '../state/canvasStore'
|
||||
import { useKrateStore } from '../state/krateStore'
|
||||
import { Krate } from './krate'
|
||||
|
||||
interface WorldLayerProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const WorldLayer: React.FC<WorldLayerProps> = ({ ...props }) => {
|
||||
const { camX, camY, zoom, collapsed } = useCanvasStore()
|
||||
const { krates } = useKrateStore()
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
width: '12000px',
|
||||
height: '8000px',
|
||||
transform: `translate(${camX}px, ${camY}px) scale(${zoom})`,
|
||||
transformOrigin: '0 0',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={style} {...props}>
|
||||
{Array.from(krates.values())
|
||||
.filter((k) => !k.minimized)
|
||||
.map((krate) => (
|
||||
<Krate key={krate.id} krate={krate} collapsed={collapsed} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
client/src/components/canvas/index.ts
Normal file
3
client/src/components/canvas/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { WorldLayer } from './WorldLayer'
|
||||
export { GridOverlay } from './GridOverlay'
|
||||
export { Minimap } from './Minimap'
|
||||
169
client/src/components/krate/Krate.tsx
Normal file
169
client/src/components/krate/Krate.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useKrateStore } from '../../state/krateStore'
|
||||
|
||||
interface KrateProps {
|
||||
krate: Krate
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
interface Krate {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
minimized: boolean
|
||||
windowLayout: {
|
||||
cols: number
|
||||
rows: number
|
||||
cells: Map<string, { col: number; row: number; colSpan: number; rowSpan: number }>
|
||||
}
|
||||
windows: Map<string, KrateWindow>
|
||||
}
|
||||
|
||||
interface KrateWindow {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
state: unknown
|
||||
}
|
||||
|
||||
export const Krate: React.FC<KrateProps> = ({ krate, collapsed }) => {
|
||||
const { selectKrate } = useKrateStore()
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: krate.x,
|
||||
top: krate.y,
|
||||
width: '230px',
|
||||
height: 'auto',
|
||||
padding: '12px',
|
||||
background: krate.color ? `rgba(${hexToRgba(krate.color)}, 0.04)` : 'rgba(255,255,255,0.04)',
|
||||
border: `1px dashed ${krate.color || '#6fb1ff'}4D`,
|
||||
borderRadius: '18px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => selectKrate(krate.id)}
|
||||
>
|
||||
<div style={{ fontWeight: '600', color: krate.color || '#6fb1ff' }}>
|
||||
{krate.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#888' }}>
|
||||
{krate.windows.size > 0 ? '..."' : 'Empty'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: krate.x,
|
||||
top: krate.y,
|
||||
width: krate.width,
|
||||
height: krate.height,
|
||||
background: krate.color ? `rgba(${hexToRgba(krate.color)}, 0.04)` : 'rgba(255,255,255,0.04)',
|
||||
border: `1px dashed ${krate.color || '#6fb1ff'}4D`,
|
||||
borderRadius: '18px',
|
||||
}}
|
||||
onMouseDown={() => selectKrate(krate.id)}
|
||||
>
|
||||
<KrateHeader krate={krate} onDragStart={() => setIsDragging(true)} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '30px',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${krate.windowLayout.cols}, 1fr)`,
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
{Array.from(krate.windows.values()).map((window) => (
|
||||
<div
|
||||
key={window.id}
|
||||
style={{
|
||||
backgroundColor: '#1a1e26',
|
||||
border: '1px solid rgba(140,165,200,0.18)',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
minHeight: '100px',
|
||||
}}
|
||||
>
|
||||
{window.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hexToRgba = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `${r}, ${g}, ${b}`
|
||||
}
|
||||
|
||||
const KrateHeader: React.FC<KrateProps & { onDragStart: () => void }> = ({
|
||||
krate,
|
||||
onDragStart,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderBottom: `1px solid ${krate.color || '#6fb1ff'}33`,
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation()
|
||||
onDragStart()
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: '600', flex: 1 }}>{krate.title}</span>
|
||||
<span style={{ fontSize: '12px', color: '#888' }}>
|
||||
{krate.windows.size} windows
|
||||
</span>
|
||||
<button
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
alert('Minimize todo')
|
||||
}}
|
||||
>
|
||||
—
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
alert('Delete todo')
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
client/src/components/krate/index.ts
Normal file
1
client/src/components/krate/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Krate, KrateHeader } from './Krate'
|
||||
118
client/src/components/shared/AdminDrawer.tsx
Normal file
118
client/src/components/shared/AdminDrawer.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from 'react'
|
||||
import { useCanvasStore } from '../../state/canvasStore'
|
||||
|
||||
interface AdminDrawerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
users: Array<{
|
||||
userId: string
|
||||
name: string
|
||||
color: string
|
||||
status: string
|
||||
}>
|
||||
}
|
||||
|
||||
export const AdminDrawer: React.FC<AdminDrawerProps> = ({ open, onClose, users }) => {
|
||||
const { setCam } = useCanvasStore()
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const handleSpectate = (userId: string) => {
|
||||
onClose()
|
||||
// In a real app, this would fly to the user's krate position
|
||||
console.log(`Spectating user: ${userId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '380px',
|
||||
zIndex: 56,
|
||||
background: 'rgba(13,17,24,.98)',
|
||||
borderLeft: '1px solid rgba(140,165,200,.2)',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid rgba(140,165,200,0.18)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, color: '#fff', fontSize: '16px' }}>Users</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '20px',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px' }}>
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.userId}
|
||||
style={{
|
||||
marginBottom: '16px',
|
||||
padding: '12px',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: user.color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{user.name.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ color: '#fff', fontWeight: '600' }}>{user.name}</div>
|
||||
<div style={{ color: '#888', fontSize: '12px' }}>{user.status}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSpectate(user.userId)}
|
||||
style={{
|
||||
background: '#4dd6e8',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '6px 10px',
|
||||
color: '#000',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Spectate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
client/src/components/shared/LogsViewer.tsx
Normal file
110
client/src/components/shared/LogsViewer.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface LogLine {
|
||||
timestamp: string
|
||||
level: 'ERROR' | 'WARN' | 'INFO' | 'DEBUG'
|
||||
message: string
|
||||
}
|
||||
|
||||
interface LogsViewerProps {
|
||||
pod: string
|
||||
namespace: string
|
||||
}
|
||||
|
||||
export const LogsViewer: React.FC<LogsViewerProps> = ({ pod, namespace }) => {
|
||||
const logsRef = useRef<HTMLDivElement>(null)
|
||||
const [logs, setLogs] = useState<LogLine[]>([])
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const [lastScrollTop, setLastScrollTop] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket(
|
||||
`/ws/logs?pod=${encodeURIComponent(pod)}&ns=${encodeURIComponent(namespace)}`
|
||||
)
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.type === 'log') {
|
||||
setLogs((prev) => {
|
||||
const newLogs = [...prev, data.log]
|
||||
if (autoScroll && logsRef.current) {
|
||||
logsRef.current.scrollTop = logsRef.current.scrollHeight
|
||||
}
|
||||
return newLogs
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse log message:', err)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('Logs WebSocket closed')
|
||||
}
|
||||
|
||||
return () => {
|
||||
ws.close()
|
||||
}
|
||||
}, [pod, namespace, autoScroll])
|
||||
|
||||
const handleScroll = () => {
|
||||
if (logsRef.current) {
|
||||
const isAtBottom =
|
||||
logsRef.current.scrollTop + logsRef.current.clientHeight >=
|
||||
logsRef.current.scrollHeight - 10
|
||||
setAutoScroll(isAtBottom)
|
||||
setLastScrollTop(logsRef.current.scrollTop)
|
||||
}
|
||||
}
|
||||
|
||||
const formatLevel = (message: string): 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' => {
|
||||
if (message.includes('ERROR') || message.includes('ERR')) return 'ERROR'
|
||||
if (message.includes('WARN') || message.includes('WARNING')) return 'WARN'
|
||||
if (message.includes('DEBUG')) return 'DEBUG'
|
||||
return 'INFO'
|
||||
}
|
||||
|
||||
const getColor = (level: string): string => {
|
||||
switch (level) {
|
||||
case 'ERROR':
|
||||
return '#ef6f6f'
|
||||
case 'WARN':
|
||||
return '#e8b54a'
|
||||
case 'DEBUG':
|
||||
return '#6b7280'
|
||||
default:
|
||||
return '#b9c6d8'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={logsRef}
|
||||
onScroll={handleScroll}
|
||||
style={{
|
||||
height: '286px',
|
||||
overflow: 'auto',
|
||||
fontFamily: 'IBM Plex Mono, monospace',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.65',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{logs.map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{ color: getColor(line.level), whiteSpace: 'pre-wrap' }}
|
||||
>
|
||||
<span style={{ color: '#6b7280' }}>{line.timestamp} </span>
|
||||
{line.message}
|
||||
</div>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<div style={{ color: '#888', textAlign: 'center', padding: '40px' }}>
|
||||
No logs yet...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
client/src/components/shared/ShellTerminal.tsx
Normal file
118
client/src/components/shared/ShellTerminal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from 'xterm-addon-fit'
|
||||
|
||||
interface ShellTerminalProps {
|
||||
pod: string
|
||||
namespace: string
|
||||
onConnect?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export const ShellTerminal: React.FC<ShellTerminalProps> = ({
|
||||
pod,
|
||||
namespace,
|
||||
onConnect,
|
||||
onClose,
|
||||
}) => {
|
||||
const termRef = useRef<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!termRef.current) return
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 12,
|
||||
fontFamily: 'IBM Plex Mono, monospace',
|
||||
lineHeight: 1.5,
|
||||
theme: {
|
||||
background: '#1a1e26',
|
||||
foreground: '#b9c6d8',
|
||||
},
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
|
||||
term.open(termRef.current)
|
||||
fitAddon.fit()
|
||||
|
||||
// Calculate size
|
||||
const cols = Math.floor(termRef.current.clientWidth / 9)
|
||||
const rows = Math.floor(termRef.current.clientHeight / 18)
|
||||
term.resize(cols, rows)
|
||||
|
||||
// Connect to WebSocket
|
||||
const ws = new WebSocket(
|
||||
`/ws/shell?pod=${encodeURIComponent(pod)}&ns=${encodeURIComponent(namespace)}`
|
||||
)
|
||||
|
||||
ws.onopen = () => {
|
||||
onConnect?.()
|
||||
console.log('Shell connected')
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.type === 'shell:output') {
|
||||
term.write(data.data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse shell message:', err)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('Shell disconnected')
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('Shell WebSocket error:', err)
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'shell:input', data }))
|
||||
}
|
||||
})
|
||||
|
||||
term.onResize((size) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'shell:resize', cols: size.cols, rows: size.rows }))
|
||||
}
|
||||
})
|
||||
|
||||
terminalRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
const handleResize = () => {
|
||||
if (termRef.current && fitAddonRef.current) {
|
||||
fitAddonRef.current.fit()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
ws.close()
|
||||
term.dispose()
|
||||
}
|
||||
}, [pod, namespace, onConnect, onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={termRef}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
fontFamily: 'IBM Plex Mono, monospace',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.65',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
105
client/src/components/shared/Spotlight.tsx
Normal file
105
client/src/components/shared/Spotlight.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { HTMLAttributes } from 'react'
|
||||
import { useSpotlightStore } from '../../state/spotlightStore'
|
||||
|
||||
interface SpotlightProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const Spotlight: React.FC<SpotlightProps> = ({ ...props }) => {
|
||||
const { open, query, filterType, setQuery, setFilter, close, toggle } = useSpotlightStore()
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const filters: { id: string; label: string }[] = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'krate', label: 'Krates' },
|
||||
{ id: 'namespace', label: 'Namespaces' },
|
||||
{ id: 'pod', label: 'Pods' },
|
||||
{ id: 'deployment', label: 'Deployments' },
|
||||
{ id: 'service', label: 'Services' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '600px',
|
||||
zIndex: 100,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#1a1e26',
|
||||
border: '1px solid rgba(140,165,200,0.18)',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid rgba(140,165,200,0.18)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#888' }}>🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search..."
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '16px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={close}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
}}
|
||||
>
|
||||
Esc
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '8px 16px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
borderRadius: '12px',
|
||||
background: filterType === f.id ? '#4dd6e8' : '#2a3040',
|
||||
color: filterType === f.id ? '#000' : '#aaa',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
onClick={() => setFilter(f.id as string)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '8px 16px', color: '#888', fontSize: '12px' }}>
|
||||
Select: Enter, Close: Esc
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
client/src/components/shared/SpotlightFilters.tsx
Normal file
1
client/src/components/shared/SpotlightFilters.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { SpotlightFilter } from './SpotlightFilters'
|
||||
206
client/src/components/shared/TopBar.tsx
Normal file
206
client/src/components/shared/TopBar.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useCanvasStore } from '../../state/canvasStore'
|
||||
import { useSpotlightStore } from '../../state/spotlightStore'
|
||||
import { useUserStore } from '../../state/userStore'
|
||||
import { AdminDrawer } from './AdminDrawer'
|
||||
|
||||
export const TopBar: React.FC = () => {
|
||||
const { zoom } = useCanvasStore()
|
||||
const { open: spotlightOpen, toggle: toggleSpotlight } = useSpotlightStore()
|
||||
const { currentUser, presence } = useUserStore()
|
||||
const [adminOpen, setAdminOpen] = useState(false)
|
||||
|
||||
const [synced, setSynced] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setSynced((prev) => !prev)
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const users = Array.from(presence.values())
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
height: '56px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '13px 18px',
|
||||
}}
|
||||
>
|
||||
{/* Left side */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flex: 1 }}>
|
||||
{/* Logo pill */}
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(14,18,25,.82)',
|
||||
border: '1px solid rgba(140,165,200,.18)',
|
||||
borderRadius: '9px',
|
||||
padding: '7px 12px',
|
||||
backdropFilter: 'blur(6px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={toggleSpotlight}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
background: '#4dd6e8',
|
||||
clipPath: 'polygon(50% 0,100% 50%,50% 100%,0 50%)',
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: '#fff', fontSize: '14px', fontWeight: '600' }}>
|
||||
krates / yard
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Cluster pill */}
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(14,18,25,.82)',
|
||||
border: '1px solid rgba(140,165,200,.18)',
|
||||
borderRadius: '9px',
|
||||
padding: '7px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
backdropFilter: 'blur(6px)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#fff', fontSize: '14px' }}>local</span>
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: '#4ad07a',
|
||||
boxShadow: '0 0 8px #4ad07a',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Krate count pill */}
|
||||
{users.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(14,18,25,.82)',
|
||||
border: '1px solid rgba(140,165,200,.18)',
|
||||
borderRadius: '9px',
|
||||
padding: '7px 12px',
|
||||
backdropFilter: 'blur(6px)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#4dd6e8', fontSize: '14px' }}>
|
||||
{users.length} active
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
{/* Synced pill */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '9px',
|
||||
background: 'rgba(14,18,25,.82)',
|
||||
border: '1px solid rgba(140,165,200,.18)',
|
||||
backdropFilter: 'blur(6px)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#aaa', fontSize: '12px' }}>synced</span>
|
||||
<div
|
||||
style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: '#4dd6e8',
|
||||
opacity: synced ? 1 : 0.5,
|
||||
animation: synced ? 'pulse 1.6s ease-in-out infinite' : 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Admin button */}
|
||||
<button
|
||||
onClick={() => setAdminOpen(!adminOpen)}
|
||||
style={{
|
||||
background: adminOpen ? 'rgba(77,214,232,0.1)' : 'rgba(14,18,25,.82)',
|
||||
border: adminOpen
|
||||
? '1px solid #4dd6e8'
|
||||
: '1px solid rgba(140,165,200,.18)',
|
||||
borderRadius: '9px',
|
||||
padding: '6px 12px',
|
||||
backdropFilter: 'blur(6px)',
|
||||
color: adminOpen ? '#4dd6e8' : '#aaa',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
◉ admin
|
||||
</button>
|
||||
|
||||
{/* Roster avatars */}
|
||||
{users.slice(0, 5).map((user) => (
|
||||
<div
|
||||
key={user.userId}
|
||||
style={{
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
borderRadius: '50%',
|
||||
background: user.color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
border: user.userId === currentUser.id ? '2px solid #4dd6e8' : 'none',
|
||||
}}
|
||||
title={user.name}
|
||||
>
|
||||
{user.userId === currentUser.id ? '★' : user.name.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
{users.length > 5 && (
|
||||
<div
|
||||
style={{
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
borderRadius: '50%',
|
||||
background: '#2a3040',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
color: '#aaa',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
+{users.length - 5}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminDrawer open={adminOpen} onClose={() => setAdminOpen(false)} users={users} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
5
client/src/components/shared/index.ts
Normal file
5
client/src/components/shared/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { Spotlight } from './Spotlight'
|
||||
export { TopBar } from './TopBar'
|
||||
export { AdminDrawer } from './AdminDrawer'
|
||||
export { ShellTerminal } from './ShellTerminal'
|
||||
export { LogsViewer } from './LogsViewer'
|
||||
5
client/src/hooks/index.ts
Normal file
5
client/src/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { useCanvas } from './useCanvas'
|
||||
export { useDebounce, useKeyPress, useResizeObserver } from './useUtils'
|
||||
export { useKeyboardShortcuts } from './useKeyboardShortcuts'
|
||||
export { fuzzySearch, fuzzyFilter } from '../utils/fuzzy'
|
||||
export { initCRDT, getCRDTDoc, destroyCRDT } from '../utils/crdt'
|
||||
49
client/src/hooks/useCanvas.ts
Normal file
49
client/src/hooks/useCanvas.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useCanvasStore } from '../state/canvasStore'
|
||||
|
||||
export const useCanvas = () => {
|
||||
const { camX, camY, zoom, flying, dragging, collapsed, spacePanning } = useCanvasStore()
|
||||
|
||||
const screenToWorld = (screenX: number, screenY: number) => {
|
||||
return {
|
||||
x: (screenX - window.innerWidth / 2) / zoom + camX,
|
||||
y: (screenY - window.innerHeight / 2) / zoom + camY,
|
||||
}
|
||||
}
|
||||
|
||||
const worldToScreen = (worldX: number, worldY: number) => {
|
||||
return {
|
||||
x: (worldX - camX) * zoom + window.innerWidth / 2,
|
||||
y: (worldY - camY) * zoom + window.innerHeight / 2,
|
||||
}
|
||||
}
|
||||
|
||||
const pan = (dx: number, dy: number) => {
|
||||
useCanvasStore.getState().setCam(camX - dx / zoom, camY - dy / zoom)
|
||||
}
|
||||
|
||||
const zoomAt = (factor: number, centerX: number, centerY: number) => {
|
||||
const state = useCanvasStore.getState()
|
||||
const newZoom = Math.max(0.1, Math.min(5, state.zoom * factor))
|
||||
|
||||
const worldCenter = screenToWorld(centerX, centerY)
|
||||
const newCamX = worldCenter.x - (centerX - window.innerWidth / 2) / newZoom
|
||||
const newCamY = worldCenter.y - (centerY - window.innerHeight / 2) / newZoom
|
||||
|
||||
useCanvasStore.getState().setZoom(newZoom)
|
||||
useCanvasStore.getState().setCam(newCamX, newCamY)
|
||||
}
|
||||
|
||||
return {
|
||||
camX,
|
||||
camY,
|
||||
zoom,
|
||||
flying,
|
||||
dragging,
|
||||
collapsed,
|
||||
spacePanning,
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
pan,
|
||||
zoomAt,
|
||||
}
|
||||
}
|
||||
41
client/src/hooks/useKeyboardShortcuts.ts
Normal file
41
client/src/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export const useKeyboardShortcuts = (handlers: {
|
||||
onOpenSpotlight: () => void
|
||||
onCloseSpotlight: () => void
|
||||
onNavigateUp: () => void
|
||||
onNavigateDown: () => void
|
||||
onNavigateLeft: () => void
|
||||
onNavigateRight: () => void
|
||||
onSelect: () => void
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
handlers.onCloseSpotlight()
|
||||
break
|
||||
case 'k':
|
||||
handlers.onNavigateUp()
|
||||
break
|
||||
case 'j':
|
||||
handlers.onNavigateDown()
|
||||
break
|
||||
case 'h':
|
||||
handlers.onNavigateLeft()
|
||||
break
|
||||
case 'l':
|
||||
handlers.onNavigateRight()
|
||||
break
|
||||
case 'Enter':
|
||||
handlers.onSelect()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handlers])
|
||||
}
|
||||
1
client/src/hooks/useKubernetes.ts
Normal file
1
client/src/hooks/useKubernetes.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useKubernetes } from './useKubernetes'
|
||||
53
client/src/hooks/useSpotlight.ts
Normal file
53
client/src/hooks/useSpotlight.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export const useSpotlight = () => {
|
||||
const fuzzy = (query: string, text: string): number => {
|
||||
if (!query) return 0
|
||||
|
||||
const queryLower = query.toLowerCase()
|
||||
const textLower = text.toLowerCase()
|
||||
|
||||
let score = 0
|
||||
let queryIndex = 0
|
||||
|
||||
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
|
||||
if (textLower[i] === queryLower[queryIndex]) {
|
||||
score++
|
||||
queryIndex++
|
||||
}
|
||||
}
|
||||
|
||||
return score / Math.max(query.length, text.length)
|
||||
}
|
||||
|
||||
const filterItems = <T extends { name: string; type: string }>(
|
||||
items: T[],
|
||||
query: string,
|
||||
filterType: string | null
|
||||
): T[] => {
|
||||
return items.filter((item) => {
|
||||
const matchesQuery = fuzzy(query, item.name) > 0.3
|
||||
const matchesType = !filterType || filterType === 'all' || item.type === filterType
|
||||
return matchesQuery && matchesType
|
||||
})
|
||||
}
|
||||
|
||||
const debounce = <T extends (...args: any[]) => any>(fn: T, delay: number) => {
|
||||
let timeoutId: NodeJS.Timeout | null = null
|
||||
return (...args: Parameters<T>): Promise<ReturnType<T>> => {
|
||||
return new Promise((resolve) => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => resolve(fn(...args)), delay)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
fuzzy,
|
||||
filterItems,
|
||||
debounce,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
}
|
||||
50
client/src/hooks/useUtils.ts
Normal file
50
client/src/hooks/useUtils.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export const useDebounce = <T>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
|
||||
export const useKeyPress = (targetKey: string, callback: () => void) => {
|
||||
useEffect(() => {
|
||||
const downHandler = ({ key }: KeyboardEvent) => {
|
||||
if (key.toLowerCase() === targetKey.toLowerCase()) {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', downHandler)
|
||||
return () => window.removeEventListener('keydown', downHandler)
|
||||
}, [targetKey, callback])
|
||||
}
|
||||
|
||||
export const useResizeObserver = (
|
||||
ref: React.RefObject<HTMLElement>,
|
||||
onResize: (rect: DOMRectReadOnly) => void
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element) return
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
onResize(entry.contentRect)
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(element)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [ref, onResize])
|
||||
}
|
||||
1
client/src/hooks/useWebSocket.ts
Normal file
1
client/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useWebSocket } from './useWebSocket'
|
||||
9
client/src/main.tsx
Normal file
9
client/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
41
client/src/state/canvasStore.ts
Normal file
41
client/src/state/canvasStore.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
|
||||
export interface CanvasState {
|
||||
camX: number
|
||||
camY: number
|
||||
zoom: number
|
||||
flying: boolean
|
||||
dragging: boolean
|
||||
collapsed: boolean
|
||||
spacePanning: boolean
|
||||
}
|
||||
|
||||
const INITIAL_STATE: CanvasState = {
|
||||
camX: 0,
|
||||
camY: 0,
|
||||
zoom: 1,
|
||||
flying: false,
|
||||
dragging: false,
|
||||
collapsed: false,
|
||||
spacePanning: false,
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
...INITIAL_STATE,
|
||||
setCam: (x: number, y: number) => set({ camX: x, camY: y }),
|
||||
setZoom: (zoom: number) => set({ zoom }),
|
||||
setFlying: (flying: boolean) => set({ flying }),
|
||||
setDragging: (dragging: boolean) => set({ dragging }),
|
||||
setCollapsed: (collapsed: boolean) => set({ collapsed }),
|
||||
setSpacePanning: (spacePanning: boolean) => set({ spacePanning }),
|
||||
}),
|
||||
{
|
||||
name: 'canvas-state',
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
4
client/src/state/index.ts
Normal file
4
client/src/state/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useCanvasStore } from './canvasStore'
|
||||
export { useKrateStore } from './krateStore'
|
||||
export { useSpotlightStore } from './spotlightStore'
|
||||
export { useUserStore } from './userStore'
|
||||
61
client/src/state/krateStore.ts
Normal file
61
client/src/state/krateStore.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
|
||||
export interface Krate {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
minimized: boolean
|
||||
windowLayout: WindowLayout
|
||||
windows: Map<string, KrateWindow>
|
||||
}
|
||||
|
||||
export interface WindowLayout {
|
||||
cols: number
|
||||
rows: number
|
||||
cells: Map<string, { col: number; row: number; colSpan: number; rowSpan: number }>
|
||||
}
|
||||
|
||||
export interface KrateWindow {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
state: unknown
|
||||
}
|
||||
|
||||
export interface KrateStore {
|
||||
krates: Map<string, Krate>
|
||||
selectedKrateId: string | null
|
||||
}
|
||||
|
||||
const INITIAL_STATE: KrateStore = {
|
||||
krates: new Map(),
|
||||
selectedKrateId: null,
|
||||
}
|
||||
|
||||
export const useKrateStore = create<KrateStore>()(
|
||||
devtools((set) => ({
|
||||
...INITIAL_STATE,
|
||||
addKrate: (krate: Krate) =>
|
||||
set((state) => ({ krates: new Map(state.krates).set(krate.id, krate) })),
|
||||
removeKrate: (id: string) =>
|
||||
set((state) => {
|
||||
const newKrates = new Map(state.krates)
|
||||
newKrates.delete(id)
|
||||
return { krates: newKrates }
|
||||
}),
|
||||
selectKrate: (id: string | null) => set({ selectedKrateId: id }),
|
||||
updateKrate: (id: string, updates: Partial<Krate>) =>
|
||||
set((state) => {
|
||||
const existing = state.krates.get(id)
|
||||
if (!existing) return state
|
||||
return {
|
||||
krates: new Map(state.krates).set(id, { ...existing, ...updates }),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
)
|
||||
32
client/src/state/spotlightStore.ts
Normal file
32
client/src/state/spotlightStore.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
|
||||
export interface SpotlightState {
|
||||
open: boolean
|
||||
query: string
|
||||
filterType: string | null
|
||||
sel: number
|
||||
navigated: boolean
|
||||
}
|
||||
|
||||
export type SpotlightFilter = 'all' | 'krate' | 'namespace' | 'pod' | 'deployment' | 'service'
|
||||
|
||||
const INITIAL_STATE: SpotlightState = {
|
||||
open: false,
|
||||
query: '',
|
||||
filterType: null,
|
||||
sel: 0,
|
||||
navigated: false,
|
||||
}
|
||||
|
||||
export const useSpotlightStore = create<SpotlightState>()(
|
||||
devtools((set) => ({
|
||||
...INITIAL_STATE,
|
||||
setOpen: (open: boolean) => set({ open }),
|
||||
setQuery: (query: string) => set({ query, navigated: false, sel: 0 }),
|
||||
setFilter: (filterType: SpotlightFilter | null) => set({ filterType }),
|
||||
setSel: (sel: number) => set({ sel, navigated: true }),
|
||||
toggle: () => set((state) => ({ open: !state.open })),
|
||||
close: () => set({ open: false, query: '', filterType: null, sel: 0, navigated: false }),
|
||||
}))
|
||||
)
|
||||
56
client/src/state/userStore.ts
Normal file
56
client/src/state/userStore.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface UserPresence {
|
||||
userId: string
|
||||
name: string
|
||||
color: string
|
||||
cursorPosition?: { x: number; y: number }
|
||||
activeKrateId?: string
|
||||
spotlightQuery?: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface UserStore {
|
||||
currentUser: User
|
||||
presence: Map<string, UserPresence>
|
||||
}
|
||||
|
||||
const INITIAL_STATE: UserStore = {
|
||||
currentUser: {
|
||||
id: 'local-user',
|
||||
name: 'User',
|
||||
},
|
||||
presence: new Map(),
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserStore>()(
|
||||
devtools((set) => ({
|
||||
...INITIAL_STATE,
|
||||
setCurrentUser: (user: User) => set({ currentUser: user }),
|
||||
addPresence: (presence: UserPresence) =>
|
||||
set((state) => ({
|
||||
presence: new Map(state.presence).set(presence.userId, presence),
|
||||
})),
|
||||
removePresence: (userId: string) =>
|
||||
set((state) => {
|
||||
const newPresence = new Map(state.presence)
|
||||
newPresence.delete(userId)
|
||||
return { presence: newPresence }
|
||||
}),
|
||||
updatePresence: (userId: string, updates: Partial<UserPresence>) =>
|
||||
set((state) => {
|
||||
const existing = state.presence.get(userId)
|
||||
if (!existing) return state
|
||||
return {
|
||||
presence: new Map(state.presence).set(userId, { ...existing, ...updates }),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
)
|
||||
37
client/src/utils/crdt.ts
Normal file
37
client/src/utils/crdt.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as Y from 'yjs'
|
||||
|
||||
let doc: Y.Doc | null = null
|
||||
let provider: any | null = null
|
||||
|
||||
export const initCRDT = (roomName: string, signalingUrl: string) => {
|
||||
doc = new Y.Doc()
|
||||
|
||||
provider = new (require('y-webrtc').WebrtcProvider)(
|
||||
roomName,
|
||||
doc,
|
||||
{
|
||||
signaling: [signalingUrl],
|
||||
}
|
||||
)
|
||||
|
||||
provider.on('status', (status: string) => {
|
||||
console.log('[CRDT] Status:', status)
|
||||
})
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export const getCRDTDoc = (): Y.Doc | null => {
|
||||
return doc
|
||||
}
|
||||
|
||||
export const getCRDTProvider = () => {
|
||||
return provider
|
||||
}
|
||||
|
||||
export const destroyCRDT = () => {
|
||||
provider?.destroy()
|
||||
doc?.destroy()
|
||||
doc = null
|
||||
provider = null
|
||||
}
|
||||
50
client/src/utils/fuzzy.ts
Normal file
50
client/src/utils/fuzzy.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
const MAX_DISTANCE = 1000
|
||||
|
||||
export const fuzzySearch = (query: string, text: string): number => {
|
||||
if (!query) return 0
|
||||
|
||||
const queryLower = query.toLowerCase()
|
||||
const textLower = text.toLowerCase()
|
||||
|
||||
if (textLower.startsWith(queryLower)) {
|
||||
return 1 - query.length / text.length
|
||||
}
|
||||
|
||||
let score = 0
|
||||
let queryIndex = 0
|
||||
|
||||
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
|
||||
if (textLower[i] === queryLower[queryIndex]) {
|
||||
score++
|
||||
queryIndex++
|
||||
}
|
||||
}
|
||||
|
||||
if (score === 0) return 0
|
||||
|
||||
const matchRatio = score / query.length
|
||||
const positionBonus = queryIndex === query.length ? 0.2 : 0
|
||||
const lengthPenalty = Math.min(1, text.length / MAX_DISTANCE)
|
||||
|
||||
return (matchRatio * 0.7 + positionBonus) * lengthPenalty
|
||||
}
|
||||
|
||||
export const fuzzyFilter = <T extends { name: string; type?: string }>(
|
||||
items: T[],
|
||||
query: string,
|
||||
typeFilter?: string
|
||||
): T[] => {
|
||||
if (!query && !typeFilter) return items
|
||||
|
||||
return items
|
||||
.map((item) => ({
|
||||
item,
|
||||
score: fuzzySearch(query, item.name),
|
||||
}))
|
||||
.filter(
|
||||
({ item, score }) =>
|
||||
score > 0.3 && (!typeFilter || typeFilter === 'all' || item.type === typeFilter)
|
||||
)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ item }) => item)
|
||||
}
|
||||
27
client/src/utils/keyboard.ts
Normal file
27
client/src/utils/keyboard.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const isKey = (e: KeyboardEvent | React.KeyboardEvent, key: string): boolean => {
|
||||
return e.key.toLowerCase() === key.toLowerCase()
|
||||
}
|
||||
|
||||
export const isModifierKey = (e: KeyboardEvent | React.KeyboardEvent): boolean => {
|
||||
return e.ctrlKey || e.metaKey || e.altKey
|
||||
}
|
||||
|
||||
export const getMouseClickPosition = (e: React.MouseEvent | MouseEvent) => {
|
||||
return {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
}
|
||||
}
|
||||
|
||||
export const getKeyboardSelectionDelta = (e: KeyboardEvent | React.KeyboardEvent): { dx: number; dy: number } => {
|
||||
const step = e.shiftKey ? 10 : 1
|
||||
let dx = 0
|
||||
let dy = 0
|
||||
|
||||
if (e.key === 'ArrowUp') dy = -step
|
||||
else if (e.key === 'ArrowDown') dy = step
|
||||
else if (e.key === 'ArrowLeft') dx = -step
|
||||
else if (e.key === 'ArrowRight') dx = step
|
||||
|
||||
return { dx, dy }
|
||||
}
|
||||
41
client/src/utils/math.ts
Normal file
41
client/src/utils/math.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export const degreesToRadians = (degrees: number): number => {
|
||||
return (degrees * Math.PI) / 180
|
||||
}
|
||||
|
||||
export const radiansToDegrees = (radians: number): number => {
|
||||
return (radians * 180) / Math.PI
|
||||
}
|
||||
|
||||
export const rotatePoint = (
|
||||
x: number,
|
||||
y: number,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
angleDegrees: number
|
||||
): { x: number; y: number } => {
|
||||
const angle = degreesToRadians(angleDegrees)
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
|
||||
const dx = x - centerX
|
||||
const dy = y - centerY
|
||||
|
||||
return {
|
||||
x: centerX + dx * cos - dy * sin,
|
||||
y: centerY + dx * sin + dy * cos,
|
||||
}
|
||||
}
|
||||
|
||||
export const distance = (x1: number, y1: number, x2: number, y2: number): number => {
|
||||
const dx = x2 - x1
|
||||
const dy = y2 - y1
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
export const clamp = (value: number, min: number, max: number): number => {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
export const lerp = (start: number, end: number, t: number): number => {
|
||||
return start * (1 - t) + end * t
|
||||
}
|
||||
22
client/tsconfig.json
Normal file
22
client/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noCheckFail": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
21
client/vite.config.ts
Normal file
21
client/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
origin: 'http://localhost:3000',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
35
server/cmd/server/main.go
Normal file
35
server/cmd/server/main.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"krates/server/internal/api"
|
||||
"krates/server/internal/crdt"
|
||||
"krates/server/internal/ws"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
crdtProvider := crdt.NewProvider()
|
||||
|
||||
wsManager := ws.NewManager(crdtProvider)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/ws/shell", wsManager.WithWebSocket(ws.ShellHandler))
|
||||
mux.Handle("/ws/logs", wsManager.WithWebSocket(ws.LogsHandler))
|
||||
mux.Handle("/ws/watch", wsManager.WithWebSocket(ws.WatchHandler))
|
||||
mux.Handle("/ws/sync", wsManager.WithWebSocket(ws.SyncHandler))
|
||||
|
||||
apiRoutes := api.SetupRoutes(wsManager)
|
||||
mux.Handle("/api/", http.StripPrefix("/api", apiRoutes))
|
||||
|
||||
http.ListenAndServe(":"+port, mux)
|
||||
log.Printf("Server started on port %s", port)
|
||||
}
|
||||
10
server/go.mod
Normal file
10
server/go.mod
Normal file
@@ -0,0 +1,10 @@
|
||||
module krates/server
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/yjs/y-websocket v1.2.10
|
||||
k8s.io/client-go v0.29.0
|
||||
github.com/olekukonko/tablewriter v0.0.5
|
||||
)
|
||||
Reference in New Issue
Block a user