Compare commits
1 Commits
main
...
33c6648b84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33c6648b84 |
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