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
(Phoniebox-Prinzip, aber vollständig eigenständig).
Klangkiste ist eine DIY-Kinder-Audiobox mit serverseitiger Verwaltung und
vollstaendiger Offline-Faehigkeit. Ziel ist eine robuste, kindgerechte Box:
Tags auflegen, Inhalte starten, und Eltern verwalten alles bequem ueber eine
einfache Web-Oberflaeche.
## Ziel
- Mehrere Boxen
- Zentrale Verwaltung über Web-GUI (später)
- Jede Box arbeitet offlinefähig mit lokalem Medien-Cache
## Was die Box aktuell kann
- NFC-Workflow (simuliert): Tags auflegen, Tags erkennen, Tag-IDs in der
Datenbank speichern, Tags Medienordnern zuordnen.
- 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
Phase 1a lokale Entwicklung ohne Hardware (Mocks)
Phase 1.x lokale Entwicklung ohne Hardware (Mocks + API + GUI)
## Tech
- Python 3
- Raspberry Pi Zero W / Zero 2 W (später)
- NFC, Buttons, Audio abstrahiert über Interfaces
## Repo-Struktur
- `box/` Box-Software (Python, NFC/Playback/Resume, API)
- `box2/` zweite Box-Instanz fuer Multi-Box-Tests
- `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
```
2) Setup-WebGUI oeffnen:
2) Setup-WebGUI der Box oeffnen:
```
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
```
6) Tags testen:
- Tag auflegen
- Medien zuordnen
- Playback starten
## Troubleshooting
- Box startet nicht:
- Pruefe Abhaengigkeiten und ob Ports 8000/9000 frei sind.
- 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
- 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
- Python 3
- 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
Die Tests koennen einzeln oder der Reihe nach ausgefuehrt werden.
+17
View File
@@ -32,6 +32,7 @@ Felder:
- `last_nfc`:
- `uid`: letzte erkannte UID oder `null`
- `known`: `true` wenn Tag in der Box zugeordnet ist
- `blocked_tags`: Liste gesperrter Tag-UIDs
- `playback_state`:
- `state`: `IDLE | PLAYING | PAUSED`
- `active_uid`
@@ -53,6 +54,7 @@ Beispielantwort:
"connected_ssid": "HomeWiFi",
"wifi_profiles_count": 1,
"spotify_status": "READY",
"blocked_tags": [],
"playback_state": {
"state": "PLAYING",
"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
Request Body:
```
@@ -95,10 +105,17 @@ Unterstuetzte Commands:
- `unpair`
- `tag_assign` `{ uid, media_path }`
- `tag_remove` `{ uid }`
- `tag_block` `{ uid }`
- `tag_unblock` `{ uid }`
- `export_tag` `{ uid, target_folder }`
- `factory_reset`
## Tag-Commands
- `tag_assign` `{ uid, media_path }` weist ein Tag einem Medienordner zu.
- `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
- 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
```
- 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
- Tags pruefen:
@@ -32,6 +34,9 @@ ls -la gui/backend/media
```
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
- State pruefen:
+7 -4
View File
@@ -24,7 +24,7 @@ python3 box/main.py run
Frontend only:
```
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:
```
@@ -37,10 +37,12 @@ npm run dev
- Frontend GUI: `http://127.0.0.1:5174`
- Box API: `http://127.0.0.1:8000`
- 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
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
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:
- API muss laufen und erreichbar sein.
- 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
```
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.
## Controls (im GUI vorhanden)
- Pairen (Button in "Neue Boxen")
- Statusanzeige (automatisch)
- Statusanzeige (automatisch, Polling)
- 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")
- Medienkatalog (Explorer-Ansicht, nur Lesen)
- Neuer Tag erkannt (Panel) mit ID-Zuweisung und Zuordnung
- Tags werden nur nach NFC-Erkennung erstellt und zugeordnet.
- Medien-Explorer (Ordner/Dateien, Upload, Rename, Move, Delete)
- Neuer/Leerer Tag erkannt (Panel)
- 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)
- 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
- ✅ Neue Boxen erscheinen unter "Neue Boxen"
- ✅ Pairing verschiebt die Box in die gepaarte Liste
- ✅ Aktive Box ist global gesetzt (Sidebar "Aktive Sessions")
- ✅ Buttons aendern den `/status`
## 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`.
4) Nach Submit wird `WIFI_ONLINE`.
5) API unter `http://127.0.0.1:8000`.
6) Box meldet sich beim Backend (announce).
7) Pairing ueber GUI oder API.
## Troubleshooting
- 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 (
uid TEXT PRIMARY KEY,
hardware_uid TEXT,
label TEXT,
alias TEXT,
status TEXT NOT NULL,
+17 -4
View File
@@ -479,6 +479,10 @@ async function ensureTagMediaColumn() {
if (!hasAlias) {
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(
`UPDATE tags
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) => {
try {
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 });
} catch (error) {
@@ -1446,7 +1452,7 @@ app.post('/api/tags/generate', 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();
let uid = '';
if (typeof requestedUid === 'string' && requestedUid) {
@@ -1470,8 +1476,15 @@ app.post('/api/tags/claim', async (req, res) => {
}
try {
await run(
'INSERT INTO tags (uid, label, status, created_at, written_at) VALUES (?, ?, ?, ?, ?)',
[uid, typeof label === 'string' ? label : null, 'WRITTEN', now, now]
'INSERT INTO tags (uid, hardware_uid, label, status, created_at, written_at) VALUES (?, ?, ?, ?, ?, ?)',
[
uid,
typeof hardwareUid === 'string' && hardwareUid ? hardwareUid : null,
typeof label === 'string' ? label : null,
'WRITTEN',
now,
now,
]
);
res.json({ uid });
} catch (error) {
+117
View File
@@ -9,6 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@phosphor-icons/react": "2.1.7",
"primeicons": "^7.0.0",
"primereact": "^10.7.0",
"react": "18.3.1",
"react-dom": "18.3.1"
},
@@ -252,6 +254,15 @@
"@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": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1156,6 +1167,25 @@
"dev": true,
"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": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.1.tgz",
@@ -1423,6 +1453,12 @@
"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": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
@@ -1485,6 +1521,16 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2321,6 +2367,15 @@
"dev": true,
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
@@ -2368,6 +2423,46 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
@@ -2403,6 +2498,12 @@
"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": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@@ -2413,6 +2514,22 @@
"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": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
+2
View File
@@ -10,6 +10,8 @@
},
"dependencies": {
"@phosphor-icons/react": "2.1.7",
"primeicons": "^7.0.0",
"primereact": "^10.7.0",
"react": "18.3.1",
"react-dom": "18.3.1"
},
+1408 -1077
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1,5 +1,8 @@
import React from 'react';
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 './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,
},
},
},
};
});