This commit is contained in:
2026-01-14 13:11:07 +00:00
parent 0a4a1d3e48
commit ad6f1ed6c5
25 changed files with 8685 additions and 1662 deletions
+72 -12
View File
@@ -1,17 +1,77 @@
# Klangkiste 🎶📦 # Klangkiste
Klangkiste ist eine selbstgebaute, offlinefähige Kinder-Audiobox Klangkiste ist eine DIY-Kinder-Audiobox mit serverseitiger Verwaltung und
(Phoniebox-Prinzip, aber vollständig eigenständig). vollstaendiger Offline-Faehigkeit. Ziel ist eine robuste, kindgerechte Box:
Tags auflegen, Inhalte starten, und Eltern verwalten alles bequem ueber eine
einfache Web-Oberflaeche.
## Ziel ## Was die Box aktuell kann
- Mehrere Boxen - NFC-Workflow (simuliert): Tags auflegen, Tags erkennen, Tag-IDs in der
- Zentrale Verwaltung über Web-GUI (später) Datenbank speichern, Tags Medienordnern zuordnen.
- Jede Box arbeitet offlinefähig mit lokalem Medien-Cache - Playback-Steuerung (simuliert): Play/Pause, Next, Prev, Stop, Volume.
- Medienverwaltung: Ordnerstruktur anzeigen, Uploads, Umbenennen, Verschieben,
Loeschen.
- Box-Verwaltung: Pairing/Unpairing, Box-Status, Commands an einzelne Boxen.
- Tag-Verwaltung: Alias, Zuordnung, Sperren pro Box, Import von lokalen Tags.
- Offline-Logik (simuliert): Box arbeitet mit lokalem Cache, Server kann wieder
synchronisieren.
- Mehrere Boxen gleichzeitig (box + box2) fuer Multi-Box-Tests.
## Was geplant ist
- Echte NFC-Hardware-Integration (Lesen/Schreiben von Tag-IDs).
- Reale Audio-Ausgabe auf der Box, nicht nur Simulation.
- Robuste Setup-Flows fuer WLAN, Updates und Fehlerdiagnose.
- Feinschliff der GUI (Mobile-Flows, Accessibility, klare Nutzerfuehrung).
- Vereinfachte Installation fuer Familien (Image/Installer).
## Status ## Status
Phase 1a lokale Entwicklung ohne Hardware (Mocks) Phase 1.x lokale Entwicklung ohne Hardware (Mocks + API + GUI)
## Tech ## Repo-Struktur
- Python 3 - `box/` Box-Software (Python, NFC/Playback/Resume, API)
- Raspberry Pi Zero W / Zero 2 W (später) - `box2/` zweite Box-Instanz fuer Multi-Box-Tests
- NFC, Buttons, Audio abstrahiert über Interfaces - `gui/backend/` Backend (Node.js + SQLite, Box-Verwaltung, Media-API)
- `gui/frontend/` Control-Panel (React + Vite)
- `docs/` Dev-Playbook, API-Vertrag, Tests
## Quickstart (lokal)
1) Alles starten:
```
./dev.sh
```
2) Setup-WebGUI (Box):
```
http://127.0.0.1:9000/setup
```
3) Backend pruefen:
```
curl http://127.0.0.1:5001/api/boxes
```
4) Frontend oeffnen:
```
http://127.0.0.1:5174
```
## Ports (Default)
- Backend API: `5001`
- Frontend GUI: `5174`
- Box API: `8000`
- Setup UI: `9000`
- Box2 API (optional): `8001`
- Box2 Setup (optional): `9001`
## Entwicklungsprinzipien (Kurz)
- Box und GUI/Backend teilen keinen Code.
- Kommunikation nur ueber JSON-APIs.
- Box ist Quelle der Wahrheit fuer Playback/State.
- Server verwaltet Tags, Medien und Boxen.
## Technisch (kurz)
- Start via `./dev.sh` (startet Frontend, Backend, Box-Simulation).
- Frontend spricht mit Backend (`/api`), Backend spricht mit Boxen (HTTP).
- NFC/Playback sind aktuell simuliert; reale Hardware folgt.
## Doku
- `docs/README.md` Startpunkt fuer den Dev-Playbook
- `docs/dev-workflow.md` Start/Stop, Ports, Logs
- `docs/api.md` API-Vertrag
+8 -2
View File
@@ -16,7 +16,7 @@ Quickstart (3-5 Minuten):
``` ```
./dev.sh ./dev.sh
``` ```
2) Setup-WebGUI oeffnen: 2) Setup-WebGUI der Box oeffnen:
``` ```
http://127.0.0.1:9000/setup http://127.0.0.1:9000/setup
``` ```
@@ -29,12 +29,18 @@ curl http://127.0.0.1:5001/api/boxes
``` ```
http://127.0.0.1:5174 http://127.0.0.1:5174
``` ```
6) Tags testen:
- Tag auflegen
- Medien zuordnen
- Playback starten
## Troubleshooting ## Troubleshooting
- Box startet nicht: - Box startet nicht:
- Pruefe Abhaengigkeiten und ob Ports 8000/9000 frei sind. - Pruefe Abhaengigkeiten und ob Ports 8000/9000 frei sind.
- GUI erreicht API nicht: - GUI erreicht API nicht:
- Pruefe `VITE_BACKEND_URL` und Backend-Logs. - Vite-Proxy: GUI ruft `/api/...` auf.
- Backend laeuft auf `http://127.0.0.1:5001`.
- Wenn Reverse-Proxy genutzt wird, `/api` auf das Backend routen.
## Was ist Mock vs. real ## Was ist Mock vs. real
- Mock: WLAN, IP, Spotify, Audio, Hardware. Diese Backends sind logische Simulationen. - Mock: WLAN, IP, Spotify, Audio, Hardware. Diese Backends sind logische Simulationen.
+5 -1
View File
@@ -7,7 +7,11 @@ Konsistenter Testkatalog basierend auf dem aktuellen Code. Alle Tests trennen Si
- Repo-Root - Repo-Root
- Python 3 - Python 3
- Box API fuer Simulation: `python3 box/main.py run` - Box API fuer Simulation: `python3 box/main.py run`
- GUI optional: `cd gui && npm run dev` - GUI optional: `cd gui/frontend && npm run dev`
- Backend optional: `cd gui/backend && npm run dev`
Hinweis:
- Sobald die Box gepairt ist, erfordern `/command` Aufrufe den Header `X-API-Token`.
## Schritt-fuer-Schritt ## Schritt-fuer-Schritt
Die Tests koennen einzeln oder der Reihe nach ausgefuehrt werden. Die Tests koennen einzeln oder der Reihe nach ausgefuehrt werden.
+17
View File
@@ -32,6 +32,7 @@ Felder:
- `last_nfc`: - `last_nfc`:
- `uid`: letzte erkannte UID oder `null` - `uid`: letzte erkannte UID oder `null`
- `known`: `true` wenn Tag in der Box zugeordnet ist - `known`: `true` wenn Tag in der Box zugeordnet ist
- `blocked_tags`: Liste gesperrter Tag-UIDs
- `playback_state`: - `playback_state`:
- `state`: `IDLE | PLAYING | PAUSED` - `state`: `IDLE | PLAYING | PAUSED`
- `active_uid` - `active_uid`
@@ -53,6 +54,7 @@ Beispielantwort:
"connected_ssid": "HomeWiFi", "connected_ssid": "HomeWiFi",
"wifi_profiles_count": 1, "wifi_profiles_count": 1,
"spotify_status": "READY", "spotify_status": "READY",
"blocked_tags": [],
"playback_state": { "playback_state": {
"state": "PLAYING", "state": "PLAYING",
"active_uid": "UID_1", "active_uid": "UID_1",
@@ -67,6 +69,14 @@ Beispielantwort:
} }
``` ```
## GET /local-tags
Nur fuer gepairte Boxen (Header: `X-API-Token`).
Zeigt lokale Tags, inkl. Dateiliste und Groesse.
```
curl http://127.0.0.1:8000/local-tags \
-H "X-API-Token: <api_token>"
```
## POST /command ## POST /command
Request Body: Request Body:
``` ```
@@ -95,10 +105,17 @@ Unterstuetzte Commands:
- `unpair` - `unpair`
- `tag_assign` `{ uid, media_path }` - `tag_assign` `{ uid, media_path }`
- `tag_remove` `{ uid }` - `tag_remove` `{ uid }`
- `tag_block` `{ uid }`
- `tag_unblock` `{ uid }`
- `export_tag` `{ uid, target_folder }`
- `factory_reset`
## Tag-Commands ## Tag-Commands
- `tag_assign` `{ uid, media_path }` weist ein Tag einem Medienordner zu. - `tag_assign` `{ uid, media_path }` weist ein Tag einem Medienordner zu.
- `tag_remove` `{ uid }` entfernt die Zuordnung fuer ein Tag. - `tag_remove` `{ uid }` entfernt die Zuordnung fuer ein Tag.
- `tag_block` sperrt den Tag fuer diese Box.
- `tag_unblock` hebt die Sperre auf.
- `export_tag` uebertraegt die lokalen Medien dieses Tags an den Server.
## Auth fuer Commands ## Auth fuer Commands
- Vor Pairing sind nur diese Commands erlaubt: - Vor Pairing sind nur diese Commands erlaubt:
+6 -1
View File
@@ -17,7 +17,9 @@ Nutze die Sektionen nach Symptomen.
curl http://127.0.0.1:5001/api/boxes curl http://127.0.0.1:5001/api/boxes
``` ```
- GUI API-Base pruefen: - GUI API-Base pruefen:
- `VITE_BACKEND_URL` oder Default `http://127.0.0.1:5001` - GUI nutzt `/api/...` und Vite-Proxy.
- `VITE_BACKEND_URL` zeigt auf das Backend (ohne `/api`).
- Reverse-Proxy: `/api` muss auf Port 5001 zeigen (ohne doppeltes `/api`).
### Playback startet nicht ### Playback startet nicht
- Tags pruefen: - Tags pruefen:
@@ -32,6 +34,9 @@ ls -la gui/backend/media
``` ```
curl http://127.0.0.1:8000/status curl http://127.0.0.1:8000/status
``` ```
- Tag lokal vorhanden, aber nicht in DB:
- GUI: "Tags nur auf dieser Box" pruefen.
- Import ueber "Auf Server uebertragen".
### Resume bleibt leer ### Resume bleibt leer
- State pruefen: - State pruefen:
+7 -4
View File
@@ -24,7 +24,7 @@ python3 box/main.py run
Frontend only: Frontend only:
``` ```
cd gui/frontend cd gui/frontend
npm run dev -- --host 0.0.0.0 --port 5174 --strictPort VITE_BACKEND_URL=http://127.0.0.1:5001 npm run dev -- --host 0.0.0.0 --port 5174 --strictPort
``` ```
Backend only: Backend only:
``` ```
@@ -37,10 +37,12 @@ npm run dev
- Frontend GUI: `http://127.0.0.1:5174` - Frontend GUI: `http://127.0.0.1:5174`
- Box API: `http://127.0.0.1:8000` - Box API: `http://127.0.0.1:8000`
- Setup UI: `http://127.0.0.1:9000/setup` - Setup UI: `http://127.0.0.1:9000/setup`
- GUI: `http://127.0.0.1:5174` - Box2 API (optional): `http://127.0.0.1:8001`
- Box2 Setup (optional): `http://127.0.0.1:9001/setup`
## API-URL fuer die GUI ## API-URL fuer die GUI
Default ist `http://<HOST-IP>:5001` (aus `hostname -I`). Override: Die GUI nutzt relative Pfade (`/api/...`) und einen Vite-Proxy.
Der Proxy-Target wird so gesetzt:
``` ```
KLANGKISTE_BACKEND_URL=http://<server-ip>:5001 ./dev.sh KLANGKISTE_BACKEND_URL=http://<server-ip>:5001 ./dev.sh
VITE_BACKEND_URL=http://<server-ip>:5001 npm run dev VITE_BACKEND_URL=http://<server-ip>:5001 npm run dev
@@ -67,4 +69,5 @@ VITE_BACKEND_URL=http://<server-ip>:5001 npm run dev
- CORS-Fehler: - CORS-Fehler:
- API muss laufen und erreichbar sein. - API muss laufen und erreichbar sein.
- GUI zeigt "Failed to fetch": - GUI zeigt "Failed to fetch":
- `VITE_BACKEND_URL` pruefen. - Vite-Proxy pruefen (`VITE_BACKEND_URL`).
- Reverse-Proxy: `/api` muss auf das Backend zeigen (ohne doppeltes `/api`).
+20 -6
View File
@@ -14,18 +14,31 @@ Nutzung des Frontend Control Panels zur Steuerung und Beobachtung der Boxen uebe
http://127.0.0.1:5174 http://127.0.0.1:5174
``` ```
2) In "Neue Boxen" die Box auswaehlen und "Pairen" klicken. 2) In "Neue Boxen" die Box auswaehlen und "Pairen" klicken.
3) In "Gepairte Boxen" eine Box anklicken. 3) In der Sidebar unter "Aktive Sessions" eine Box aktiv setzen (gilt fuer alle Seiten).
4) Status und Commands nutzen. 4) Status und Commands nutzen.
## Controls (im GUI vorhanden) ## Controls (im GUI vorhanden)
- Pairen (Button in "Neue Boxen") - Pairen (Button in "Neue Boxen")
- Statusanzeige (automatisch) - Statusanzeige (automatisch, Polling)
- Play/Pause, Next, Prev, Vol +/-, Stop - Play/Pause, Next, Prev, Vol +/-, Stop
- NFC on/off (UID eingeben) - NFC on/off (UID manuell eingeben; kein Auto-Fill)
- Unpair (Button in "Gepairte Boxen") - Unpair (Button in "Gepairte Boxen")
- Medienkatalog (Explorer-Ansicht, nur Lesen) - Medien-Explorer (Ordner/Dateien, Upload, Rename, Move, Delete)
- Neuer Tag erkannt (Panel) mit ID-Zuweisung und Zuordnung - Neuer/Leerer Tag erkannt (Panel)
- Tags werden nur nach NFC-Erkennung erstellt und zugeordnet. - Wenn keine UID erkannt: ID zuweisen & schreiben
- Wenn UID erkannt, `known=false`: nur Medienordnerwahl + "Nur in die DB uebernehmen"
- Medienordner zuordnen
- Importierte Tag-IDs wiederverwenden
- Tags (Datenbank)
- Alias setzen, Medium zuweisen, Tag loeschen
- Tag-Matrix (Sperren)
- Tags pro Box sperren/entsperren
- Tags nur auf dieser Box
- Lokale Tags ohne DB-Eintrag
- Dateien anzeigen
- Auf Server uebertragen (mit neuem Ordnernamen)
- Mobile: Aktive Sessions als Bottom-Sheet (Topbar-Icon)
- Hardware-UID (simuliert) wird im Tag-Panel und Live-Status angezeigt, wenn NFC on ohne UID genutzt wird
## Controls (nur Backend/API) ## Controls (nur Backend/API)
- WiFi add/reset - WiFi add/reset
@@ -46,6 +59,7 @@ curl -X POST http://127.0.0.1:5001/api/boxes/<box_id>/command \
- ✅ GUI laedt und zeigt Backend-URL - ✅ GUI laedt und zeigt Backend-URL
- ✅ Neue Boxen erscheinen unter "Neue Boxen" - ✅ Neue Boxen erscheinen unter "Neue Boxen"
- ✅ Pairing verschiebt die Box in die gepaarte Liste - ✅ Pairing verschiebt die Box in die gepaarte Liste
- ✅ Aktive Box ist global gesetzt (Sidebar "Aktive Sessions")
- ✅ Buttons aendern den `/status` - ✅ Buttons aendern den `/status`
## Troubleshooting ## Troubleshooting
+2
View File
@@ -14,6 +14,8 @@ Beschreibt den vollstaendigen, hardwarefreien Box-Flow fuer die IDE.
3) Setup UI unter `http://127.0.0.1:9000/setup`. 3) Setup UI unter `http://127.0.0.1:9000/setup`.
4) Nach Submit wird `WIFI_ONLINE`. 4) Nach Submit wird `WIFI_ONLINE`.
5) API unter `http://127.0.0.1:8000`. 5) API unter `http://127.0.0.1:8000`.
6) Box meldet sich beim Backend (announce).
7) Pairing ueber GUI oder API.
## Troubleshooting ## Troubleshooting
- Setup UI nicht erreichbar: - Setup UI nicht erreichbar:
Binary file not shown.
Binary file not shown.
+1
View File
@@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS boxes (
CREATE TABLE IF NOT EXISTS tags ( CREATE TABLE IF NOT EXISTS tags (
uid TEXT PRIMARY KEY, uid TEXT PRIMARY KEY,
hardware_uid TEXT,
label TEXT, label TEXT,
alias TEXT, alias TEXT,
status TEXT NOT NULL, status TEXT NOT NULL,
+17 -4
View File
@@ -479,6 +479,10 @@ async function ensureTagMediaColumn() {
if (!hasAlias) { if (!hasAlias) {
await run('ALTER TABLE tags ADD COLUMN alias TEXT'); await run('ALTER TABLE tags ADD COLUMN alias TEXT');
} }
const hasHardwareUid = columns.some((column) => column.name === 'hardware_uid');
if (!hasHardwareUid) {
await run('ALTER TABLE tags ADD COLUMN hardware_uid TEXT');
}
await run( await run(
`UPDATE tags `UPDATE tags
SET media_path = ( SET media_path = (
@@ -1374,7 +1378,9 @@ app.post('/api/boxes/:box_id/tag-blocks', async (req, res) => {
app.get('/api/tags', async (req, res) => { app.get('/api/tags', async (req, res) => {
try { try {
const rows = await all( const rows = await all(
'SELECT uid, label, alias, status, created_at, written_at, media_path FROM tags ORDER BY created_at DESC' `SELECT uid, hardware_uid, label, alias, status, created_at, written_at, media_path
FROM tags
ORDER BY created_at DESC`
); );
res.json({ tags: rows }); res.json({ tags: rows });
} catch (error) { } catch (error) {
@@ -1446,7 +1452,7 @@ app.post('/api/tags/generate', async (req, res) => {
}); });
app.post('/api/tags/claim', async (req, res) => { app.post('/api/tags/claim', async (req, res) => {
const { uid: requestedUid, label } = req.body || {}; const { uid: requestedUid, label, hardware_uid: hardwareUid } = req.body || {};
const now = nowSeconds(); const now = nowSeconds();
let uid = ''; let uid = '';
if (typeof requestedUid === 'string' && requestedUid) { if (typeof requestedUid === 'string' && requestedUid) {
@@ -1470,8 +1476,15 @@ app.post('/api/tags/claim', async (req, res) => {
} }
try { try {
await run( await run(
'INSERT INTO tags (uid, label, status, created_at, written_at) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO tags (uid, hardware_uid, label, status, created_at, written_at) VALUES (?, ?, ?, ?, ?, ?)',
[uid, typeof label === 'string' ? label : null, 'WRITTEN', now, now] [
uid,
typeof hardwareUid === 'string' && hardwareUid ? hardwareUid : null,
typeof label === 'string' ? label : null,
'WRITTEN',
now,
now,
]
); );
res.json({ uid }); res.json({ uid });
} catch (error) { } catch (error) {
+117
View File
@@ -9,6 +9,8 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@phosphor-icons/react": "2.1.7", "@phosphor-icons/react": "2.1.7",
"primeicons": "^7.0.0",
"primereact": "^10.7.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1" "react-dom": "18.3.1"
}, },
@@ -252,6 +254,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1156,6 +1167,25 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.1.tgz",
@@ -1423,6 +1453,12 @@
"node": ">=0.10.40" "node": ">=0.10.40"
} }
}, },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/dashdash": { "node_modules/dashdash": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
@@ -1485,6 +1521,16 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2321,6 +2367,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-keys": { "node_modules/object-keys": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
@@ -2368,6 +2423,46 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/primeicons": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
"integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==",
"license": "MIT"
},
"node_modules/primereact": {
"version": "10.9.7",
"resolved": "https://registry.npmjs.org/primereact/-/primereact-10.9.7.tgz",
"integrity": "sha512-Ap/lg9GGaS8Pq7IIlzguuG3qlaU6PYF6E0cCRo0rnWauRw/SQGvfreSVIIxqEhtR6xqlf7OV759lyvVOvBzmsQ==",
"license": "MIT",
"dependencies": {
"@types/react-transition-group": "^4.4.1",
"react-transition-group": "^4.4.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prr": { "node_modules/prr": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
@@ -2403,6 +2498,12 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.14.2", "version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@@ -2413,6 +2514,22 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.55.1", "version": "4.55.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
+2
View File
@@ -10,6 +10,8 @@
}, },
"dependencies": { "dependencies": {
"@phosphor-icons/react": "2.1.7", "@phosphor-icons/react": "2.1.7",
"primeicons": "^7.0.0",
"primereact": "^10.7.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1" "react-dom": "18.3.1"
}, },
+1018 -687
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1,5 +1,8 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import 'primereact/resources/themes/lara-dark-teal/theme.css';
import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css';
import App from './App.jsx'; import App from './App.jsx';
import './styles.css'; import './styles.css';
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Klangkiste Control Panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2743
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "klangkiste-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@phosphor-icons/react": "2.1.7",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "4.3.1",
"vite": "5.4.3"
}
}
File diff suppressed because it is too large Load Diff
+239
View File
@@ -0,0 +1,239 @@
const defaultHost =
typeof window !== 'undefined' && window.location ? window.location.hostname : '127.0.0.1';
const API_BASE = '/api';
async function readJson(response) {
const text = await response.text();
try {
return { ok: response.ok, status: response.status, data: JSON.parse(text) };
} catch (error) {
return { ok: response.ok, status: response.status, data: { detail: text } };
}
}
export async function getBoxes() {
const response = await fetch(`${API_BASE}/boxes`);
return readJson(response);
}
export async function pairBox(boxId) {
const response = await fetch(`${API_BASE}/boxes/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ box_id: boxId }),
});
return readJson(response);
}
export async function getStatus(boxId) {
const response = await fetch(`${API_BASE}/boxes/${boxId}/status`);
return readJson(response);
}
export async function getMediaTree() {
const response = await fetch(`${API_BASE}/media-tree`);
return readJson(response);
}
export async function createMediaFolder(parentPath, name) {
const response = await fetch(`${API_BASE}/media/folder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ parent_path: parentPath, name }),
});
return readJson(response);
}
export async function renameMedia(path, name) {
const response = await fetch(`${API_BASE}/media/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, name }),
});
return readJson(response);
}
export async function moveMedia(path, targetParent) {
const response = await fetch(`${API_BASE}/media/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, target_parent: targetParent }),
});
return readJson(response);
}
export async function deleteMedia(path) {
const response = await fetch(`${API_BASE}/media/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
return readJson(response);
}
export function uploadMedia(targetPath, files, onProgress) {
const formData = new FormData();
formData.append('target_path', targetPath);
files.forEach((file) => {
formData.append('files', file);
});
return new Promise((resolve) => {
const request = new XMLHttpRequest();
request.open('POST', `${API_BASE}/media/upload`);
request.upload.onprogress = (event) => {
if (!event.lengthComputable || !onProgress) return;
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
};
request.onload = () => {
try {
const data = JSON.parse(request.responseText || '{}');
resolve({ ok: request.status >= 200 && request.status < 300, status: request.status, data });
} catch (error) {
resolve({
ok: request.status >= 200 && request.status < 300,
status: request.status,
data: { detail: request.responseText || '' },
});
}
};
request.onerror = () => {
resolve({ ok: false, status: 0, data: { detail: 'network error' } });
};
request.send(formData);
});
}
export async function getTags() {
const response = await fetch(`${API_BASE}/tags`);
return readJson(response);
}
export async function generateTag(label) {
const response = await fetch(`${API_BASE}/tags/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label }),
});
return readJson(response);
}
export async function claimTag(uid, label) {
const response = await fetch(`${API_BASE}/tags/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid, label }),
});
return readJson(response);
}
export async function markTagWritten(uid) {
const response = await fetch(`${API_BASE}/tags/${uid}/write`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return readJson(response);
}
export async function getBoxTags(boxId) {
const response = await fetch(`${API_BASE}/boxes/${boxId}/tags`);
return readJson(response);
}
export async function getBoxLocalTags(boxId) {
const response = await fetch(`${API_BASE}/boxes/${boxId}/local-tags`);
return readJson(response);
}
export async function getTagBlocks(boxId) {
const response = await fetch(`${API_BASE}/boxes/${boxId}/tag-blocks`);
return readJson(response);
}
export async function setTagBlock(boxId, uid, blocked) {
const response = await fetch(`${API_BASE}/boxes/${boxId}/tag-blocks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid, blocked }),
});
return readJson(response);
}
export async function setBoxAlias(boxId, alias) {
const response = await fetch(`${API_BASE}/boxes/${boxId}/alias`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alias }),
});
return readJson(response);
}
export async function setTagAlias(uid, alias) {
const response = await fetch(`${API_BASE}/tags/${uid}/alias`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alias }),
});
return readJson(response);
}
export async function assignTag(uid, boxId) {
const response = await fetch(`${API_BASE}/tags/${uid}/assign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ box_id: boxId }),
});
return readJson(response);
}
export async function unassignTag(uid, boxId) {
const response = await fetch(`${API_BASE}/tags/${uid}/unassign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ box_id: boxId }),
});
return readJson(response);
}
export async function deleteTag(uid) {
const response = await fetch(`${API_BASE}/tags/${uid}`, {
method: 'DELETE',
});
return readJson(response);
}
export async function setTagMedia(uid, mediaPath) {
const response = await fetch(`${API_BASE}/tags/${uid}/media`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ media_path: mediaPath }),
});
return readJson(response);
}
export async function pullTagFromBox(boxId, uid, targetFolder) {
const response = await fetch(`${API_BASE}/boxes/${boxId}/pull-tag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid, target_folder: targetFolder }),
});
return readJson(response);
}
export async function sendCommand(boxId, command, payload = {}) {
const response = await fetch(`${API_BASE}/boxes/${boxId}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command, payload }),
});
return readJson(response);
}
export async function unpairBox(boxId) {
const response = await fetch(`${API_BASE}/boxes/${boxId}/unpair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return readJson(response);
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+826
View File
@@ -0,0 +1,826 @@
:root {
color-scheme: light;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
color: #131522;
background: #f4f2ef;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
}
.toast-container {
position: fixed;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 9999;
}
.toast {
display: flex;
align-items: center;
padding: 10px 14px;
border-radius: 10px;
font-size: 13px;
line-height: 1.4;
min-height: 38px;
box-shadow: 0 8px 20px rgba(13, 16, 31, 0.12);
background: #ffffff;
border: 1px solid #e4e0dd;
}
.toast.success {
border-color: #b9d9b9;
background: #eef7ee;
color: #205b20;
}
.toast.error {
border-color: #f2c9c9;
background: #ffefef;
color: #9a2a2a;
}
.page {
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px 48px;
}
header {
margin-bottom: 24px;
}
header h1 {
margin: 0 0 6px;
font-size: 28px;
}
header p {
margin: 0;
color: #525466;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 0;
}
.panel {
background: #ffffff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 8px 24px rgba(13, 16, 31, 0.08);
}
.page > section {
margin-bottom: 24px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.subheading {
margin: 16px 0 8px;
font-size: 14px;
color: #3b3f46;
}
.card {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
border: 1px solid #e4e0dd;
border-radius: 12px;
padding: 12px;
margin-top: 12px;
cursor: pointer;
}
.card.selected {
border-color: #d28735;
box-shadow: 0 0 0 2px rgba(210, 135, 53, 0.2);
}
.stack {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
}
.stack-inline {
flex-direction: row;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
}
.tag-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.box-tag-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px;
border: 1px solid #e4e0dd;
border-radius: 12px;
margin-top: 12px;
}
.box-tag-actions {
display: flex;
align-items: flex-start;
}
.file-list {
margin: 8px 0 0;
padding-left: 18px;
color: #3b3f46;
font-size: 13px;
}
.file-list li {
margin-bottom: 4px;
}
.tag-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.alias-input {
border: 1px solid #dde0e6;
border-radius: 8px;
padding: 6px 8px;
font-size: 12px;
min-width: 140px;
}
.alias-input:focus {
outline: none;
border-color: #cfd4dc;
box-shadow: 0 0 0 2px rgba(47, 52, 66, 0.08);
}
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #3b3f46;
}
.toggle input {
width: 16px;
height: 16px;
}
.tag-matrix {
display: grid;
gap: 8px;
overflow-x: auto;
}
.matrix-row {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(120px, 1fr);
gap: 8px;
align-items: center;
}
.matrix-row.header {
font-weight: 600;
color: #3b3f46;
}
.matrix-cell {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid #e6e8ec;
border-radius: 10px;
background: #fbfbfc;
font-size: 12px;
}
.matrix-cell.label {
font-weight: 600;
background: #ffffff;
}
.meta {
font-size: 12px;
color: #6a6c7a;
}
.pill {
background: #f0e2d4;
color: #7a4b17;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
button {
border: none;
background: #1f243d;
color: #ffffff;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #2c3252;
}
select {
border-radius: 8px;
border: 1px solid #d6d1cc;
padding: 8px 10px;
min-width: 180px;
background: #ffffff;
}
.button-ghost {
background: transparent;
color: #1f243d;
border: 1px solid #d6d1cc;
}
.button-ghost:hover {
background: #f6f2ee;
}
.card.compact {
padding: 10px;
margin-top: 10px;
}
input {
border-radius: 8px;
border: 1px solid #d6d1cc;
padding: 8px 10px;
min-width: 140px;
}
.status {
background: #111522;
color: #e8e8ef;
padding: 12px;
border-radius: 12px;
font-size: 12px;
overflow: auto;
}
.error {
background: #ffefef;
color: #9a2a2a;
border: 1px solid #f2c9c9;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 16px;
}
.muted {
color: #6a6c7a;
}
.tree {
border: 1px solid #e6e1dc;
border-radius: 12px;
padding: 12px;
background: #faf7f4;
}
.explorer {
display: grid;
grid-template-columns: 260px 1fr;
grid-template-rows: 1fr auto;
gap: 0;
border: 1px solid #e2e4e8;
border-radius: 12px;
overflow-x: auto;
overflow-y: hidden;
background: #ffffff;
min-height: 200px;
align-items: stretch;
}
.explorer-sidebar {
grid-row: 1;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
background: #ffffff;
height: 100%;
}
.sidebar-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a919d;
margin: 0;
}
.sidebar-toolbar {
display: flex;
align-items: center;
justify-content: flex-start;
border: 1px solid #e6e8ec;
background: #fbfbfc;
padding: 10px 12px;
min-height: 44px;
gap: 10px;
}
.sidebar-tree {
border: 1px solid #e6e8ec;
border-radius: 10px;
background: #ffffff;
padding: 8px;
overflow: auto;
}
.sidebar-search {
flex: 1;
width: 100%;
border: 1px solid #dde0e6;
background: #ffffff;
border-radius: 8px;
padding: 6px 10px;
font-size: 12px;
color: #2f3442;
min-width: 0;
}
.sidebar-search:focus {
outline: none;
border-color: #cfd4dc;
box-shadow: 0 0 0 2px rgba(47, 52, 66, 0.08);
}
.explorer-toolbar,
.sidebar-toolbar {
height: 44px;
min-height: 44px;
box-shadow: 0 1px 2px rgba(16, 18, 22, 0.06);
align-items: center;
}
.explorer-actions.footer {
padding-top: 6px;
}
.explorer-main {
grid-row: 1;
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px 14px;
background: #ffffff;
height: 100%;
}
.explorer-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 10px 12px;
border: 1px solid #e6e8ec;
border-radius: 10px;
background: #fbfbfc;
}
.explorer-path {
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 12px;
color: #6a7280;
align-items: center;
}
.breadcrumb-root {
font-weight: 600;
color: #3b3f46;
}
.path-link {
background: transparent;
border: none;
color: #3b3f46;
cursor: pointer;
padding: 0;
font-size: 12px;
font-weight: 600;
}
.path-link:hover,
.path-link:focus {
background: transparent;
color: #3b3f46;
outline: none;
}
.toolbar-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
}
.icon-button {
border: 1px solid #dde0e6;
background: #ffffff;
color: #1f243d;
padding: 6px 8px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.icon-button:hover {
background: #f3f5f8;
}
.icon-button.danger {
border-color: #f2c9c9;
color: #9a2a2a;
}
.icon-button.upload {
position: relative;
overflow: hidden;
}
.icon-button:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.icon-button.upload input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.explorer-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.explorer-list {
border: 1px solid #e6e8ec;
border-radius: 10px;
background: #ffffff;
overflow: auto;
max-height: 420px;
user-select: none;
}
.explorer-row {
display: grid;
grid-template-columns: 1fr 120px 90px;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid #f4f5f7;
cursor: pointer;
}
.explorer-list.has-meta .explorer-row {
grid-template-columns: 1fr 160px 200px 80px 90px;
}
.explorer-footer {
grid-row: 2;
grid-column: 1 / -1;
margin: 0 12px 12px;
padding: 10px 12px;
border: 1px solid #e6e8ec;
border-radius: 10px;
background: #fbfbfc;
font-size: 12px;
color: #3b3f46;
display: flex;
justify-content: flex-end;
}
.sidebar-tree {
flex: 1;
}
.explorer-row.header {
background: #f6f7f9;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
cursor: default;
}
.explorer-row.footer {
background: #fbfbfc;
font-size: 12px;
cursor: default;
}
.footer-count {
text-align: right;
color: #6a7280;
}
.explorer-row.selected {
background: #eef3ff;
}
.tree-row {
background: transparent;
padding: 4px 6px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.tree-row.folder {
background: transparent;
font-weight: 500;
color: #3b3f46;
}
.tree-row.folder:hover {
background: transparent;
}
.tree-row.active {
background: #e7edf7;
}
.tree-tag-count {
font-size: 11px;
color: #6a7280;
background: #eef1f5;
border-radius: 999px;
padding: 0 6px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
line-height: 18px;
}
.media-tag-count {
margin-left: 6px;
font-size: 11px;
color: #6a7280;
background: #eef1f5;
border-radius: 999px;
padding: 2px 6px;
}
.tree-caret {
background: transparent;
border: none;
padding: 0;
margin-right: 6px;
color: #8a919d;
cursor: pointer;
font-size: 12px;
width: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tree-caret:hover,
.tree-caret:focus {
background: transparent;
color: #8a919d;
outline: none;
}
.tree-caret.disabled {
opacity: 0.35;
cursor: default;
}
.tree-icon {
margin-right: 6px;
color: #8a919d;
font-size: 11px;
}
.tree-icon.folder {
color: #5a7bb6;
margin-right: 8px;
}
.row-name {
display: inline-flex;
align-items: center;
gap: 8px;
}
.row-icon {
width: 18px;
display: inline-flex;
justify-content: center;
color: #5a7bb6;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(16, 18, 22, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.modal-card {
background: #ffffff;
border-radius: 12px;
padding: 16px;
width: min(420px, 90vw);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-card h3 {
margin: 0;
font-size: 16px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.modal-label {
font-size: 12px;
color: #5a5f6b;
}
.modal-card input {
width: 100%;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.upload-dropzone {
margin-top: 12px;
border: 1px dashed #d5d9e0;
border-radius: 12px;
padding: 14px;
background: #fbfbfc;
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
font-size: 12px;
color: #6a7280;
}
.upload-dropzone input[type='file'] {
display: none;
}
.upload-dropzone.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.dropzone-title {
font-size: 13px;
color: #2f3442;
font-weight: 600;
}
.dropzone-hint {
font-size: 12px;
color: #6a7280;
}
.dropzone-files {
font-size: 11px;
color: #3b3f46;
word-break: break-word;
}
.upload-progress {
height: 6px;
border-radius: 999px;
background: #eef1f5;
overflow: hidden;
margin-top: 10px;
}
.upload-progress > div {
height: 100%;
background: #5b7cfa;
width: 0%;
transition: width 0.2s ease;
}
.upload-status {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
padding: 10px 12px;
border: 1px solid #e6e8ec;
border-radius: 10px;
background: #fbfbfc;
font-size: 12px;
color: #3b3f46;
}
.tree-node {
margin-left: 0;
}
.tree-label {
display: inline-block;
user-select: none;
cursor: pointer;
}
.depth-1 .tree-row {
margin-left: 12px;
}
.depth-2 .tree-row {
margin-left: 24px;
}
.depth-3 .tree-row {
margin-left: 36px;
}
.depth-4 .tree-row {
margin-left: 48px;
}
.depth-5 .tree-row {
margin-left: 60px;
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const rawTarget = env.VITE_BACKEND_URL || 'http://127.0.0.1:5001';
const apiTarget = rawTarget.replace(/\/api\/?$/, '');
return {
plugins: [react()],
server: {
host: true,
port: 5174,
strictPort: true,
proxy: {
'/api': {
target: apiTarget,
changeOrigin: true,
},
},
},
};
});