From fca4286d873bf7b4d12dd1ed45393c09850b5ac0 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Tue, 4 Aug 2026 19:36:11 +0200 Subject: [PATCH] Refactor frontend: Enhance chat UI and player initialization - Updated main.ts to streamline player and chat initialization, removing Alpine.js dependency. - Improved message rendering with new CSS classes for better styling and layout. - Added a function to simulate "now playing" titles at regular intervals. - Revamped style.css with a new design system using CSS variables for better maintainability. - Removed tailwind.config.js as Tailwind CSS is no longer used. --- CHANGELOG.md | 4 + frontend/admin/index.html | 1174 ++++++++----------------------- frontend/admin/login/index.html | 41 +- frontend/index.html | 91 ++- frontend/package-lock.json | 214 +----- frontend/package.json | 6 +- frontend/src/admin.ts | 604 ++++++++++++++++ frontend/src/main.ts | 111 ++- frontend/src/style.css | 1103 +++++++++++++++++++++-------- frontend/tailwind.config.js | 8 - 10 files changed, 1831 insertions(+), 1525 deletions(-) create mode 100644 frontend/src/admin.ts delete mode 100644 frontend/tailwind.config.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9133476..83fe50b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,17 +11,21 @@ - Added `/api/admin/media/{id}` GET/PUT/DELETE handlers for media metadata retrieval, updates, and file+record deletion. - Extended admin schedule and dashboard data endpoints with media duration totals, runtime strings, and media counts. - Added admin UI runtime display for schedule/dashboard plus media library edit/delete actions. +- Added a dedicated plain TypeScript admin module for tab navigation, media CRUD actions, schedule/system analytics refresh, and moderation workflows without Alpine dependencies. ### Changed - Revised ADR-006 to reflect current implemented storage: PostgreSQL is the compose default and deployed primary backend, with SQLite retained as optional fallback. - Updated chat runtime image to include ffmpeg/ffprobe for media duration probing on upload. +- Rebuilt viewer, admin, and admin-login pages around custom semantic class structures with accessibility-focused landmarks, tab semantics, and keyboard-focus behavior. +- Replaced legacy and Tailwind-based stylesheet rules with a custom design-token CSS system covering responsive layouts, forms, tables, and reduced-motion behavior. ### Fixed - Started Admin UI Media Library backend implementation by adding the `Media` GORM model and AutoMigrate integration in chat startup. - Implemented media upload duration probing with ffprobe and persisted media metadata to the database alongside filesystem storage. - Updated media listing to read from the media table and return duration metadata with HH:MM:SS runtime formatting. +- Removed Tailwind and Alpine runtime/build dependencies from the frontend package configuration and eliminated Tailwind directives/warnings during production build. ## [1.0.3] - 2026-08-03 diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 0dab2f1..359b0ce 100644 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -5,922 +5,338 @@ Linear Void Admin - - - -
- -
-
-
LV
-
Admin
+ +
+
+ - -
- +

Loading live admin overview...

+ - + - - - + - - - - -
+ + + +
- + \ No newline at end of file diff --git a/frontend/admin/login/index.html b/frontend/admin/login/index.html index 53895d7..d15e5bc 100644 --- a/frontend/admin/login/index.html +++ b/frontend/admin/login/index.html @@ -5,30 +5,27 @@ Linear Void Admin Login - - -
-
-
Linear Void
-

Admin Login

-

Authenticate to access protected admin controls.

-
+ +
+
+

Linear Void

+

Admin Login

+

Authenticate to access protected admin controls.

-
- - - -
-
+
+ + + + + + + +

+
+
- - -
- -
-
-
-
LV
+ +
+
+
+
+
-

Linear Void

-

24/7 Synchronized Channel • Live

+

Linear Void

+

24/7 Synchronized Channel • Live

-
-
+
+ Loading schedule...
-
+
-
- -
-
+
-
+ + + - -
-
-
- ● +
+ + - -
- -
+
- -
-
- - +
+
+ + +
-
+

Synced with video • Moderation active -

+

-
-
+ +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 26a4e85..87f62e6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,11 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "alpinejs": "^3.15.12", - "autoprefixer": "^10.5.4", - "hls.js": "^1.6.16", - "postcss": "^8.5.25", - "tailwindcss": "^4.3.3" + "hls.js": "^1.6.16" }, "devDependencies": { "typescript": "~6.0.2", @@ -375,131 +371,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@vue/reactivity": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", - "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", - "license": "MIT", - "dependencies": { - "@vue/shared": "3.1.5" - } - }, - "node_modules/@vue/shared": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", - "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", - "license": "MIT" - }, - "node_modules/alpinejs": { - "version": "3.15.12", - "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz", - "integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "~3.1.1" - } - }, - "node_modules/autoprefixer": { - "version": "10.5.4", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", - "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.6", - "caniuse-lite": "^1.0.30001806", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz", - "integrity": "sha512-35JEvJ5/KKlbCHjMCsONI2w6HE88STjVdHk+C7d8LtcFxUjZR1KeLP9izofn2qs0KUxX5r4z73bwH/rd+JHacw==", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/browserslist": { - "version": "4.28.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", - "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -510,21 +381,6 @@ "node": ">=8" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.399", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", - "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", - "license": "ISC" - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -543,19 +399,6 @@ } } }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -854,6 +697,7 @@ "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, "funding": [ { "type": "github", @@ -868,19 +712,11 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -900,6 +736,7 @@ "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -924,12 +761,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "license": "MIT" - }, "node_modules/rolldown": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", @@ -968,17 +799,12 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/tailwindcss": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", - "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1018,36 +844,6 @@ "node": ">=14.17" } }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, "node_modules/vite": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b0800d1..257c6a9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,10 +13,6 @@ "vite": "^8.2.0" }, "dependencies": { - "alpinejs": "^3.15.12", - "autoprefixer": "^10.5.4", - "hls.js": "^1.6.16", - "postcss": "^8.5.25", - "tailwindcss": "^4.3.3" + "hls.js": "^1.6.16" } } diff --git a/frontend/src/admin.ts b/frontend/src/admin.ts new file mode 100644 index 0000000..ccead29 --- /dev/null +++ b/frontend/src/admin.ts @@ -0,0 +1,604 @@ +import "./style.css"; + +type ServiceHealth = { + name: string; + status: string; + detail: string; + checked: string; +}; + +type MediaEntry = { + id?: number; + name: string; + title?: string; + status?: string; + size?: number; + runtime?: string; + modifiedAt?: string; + tags?: string; +}; + +const bodyDataset = document.body.dataset; +const moderationLogLimit = Number.parseInt( + bodyDataset.adminModerationLogLimit || "", + 10, +); + +const adminConfig = { + defaultModerator: bodyDataset.adminDefaultModerator || "admin", + defaultRoom: bodyDataset.adminDefaultRoom || "main", + moderationLogLimit: + Number.isFinite(moderationLogLimit) && moderationLogLimit > 0 + ? moderationLogLimit + : 50, +}; + +const state = { + mediaUploading: false, + moderationSubmitting: false, + playlistSaving: false, +}; + +const setText = (id: string, text: string) => { + const el = document.getElementById(id); + if (el) el.textContent = text; +}; + +const setStatus = (id: string, text: string, isError = false) => { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text; + el.classList.toggle("status-error", isError); +}; + +const fmtSize = (value?: number) => { + if (!value || value <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + let size = value; + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; +}; + +function switchTab(tabName: string) { + const triggers = document.querySelectorAll(".tab-trigger"); + const panels = document.querySelectorAll(".tab-panel"); + + triggers.forEach((trigger) => { + const active = trigger.dataset.tab === tabName; + trigger.classList.toggle("is-active", active); + trigger.setAttribute("aria-selected", String(active)); + }); + + panels.forEach((panel) => { + const active = panel.id === `tab-${tabName}`; + panel.hidden = !active; + panel.classList.toggle("is-active", active); + }); + + if (tabName === "upload") refreshMediaLibrary(); + if (tabName === "playlist") refreshPlaylist(); + if (tabName === "schedule") refreshSchedule(); + if (tabName === "moderation") refreshModerationLogs(); + if (tabName === "users") refreshUsers(); + if (tabName === "analytics") refreshAnalytics(); + if (tabName === "system") refreshSystemSummary(); +} + +async function refreshOverview() { + try { + const response = await fetch("/api/admin/overview"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const payload = await response.json(); + setText("stat-viewers", String(payload.viewers ?? 0)); + setText("stat-stored-messages", String(payload.storedMessages ?? 0)); + setText("stat-moderation-actions", String(payload.moderationActions ?? 0)); + setText("stat-media-runtime", payload.mediaTotalRuntime || "00:00:00"); + setText("stat-media-count", `${payload.mediaCount || 0} files`); + + setText("system-stored-messages", String(payload.storedMessages ?? 0)); + setText("system-redis-clients", String(payload.redisClients ?? 0)); + setText("system-redis-enabled", payload.redisEnabled ? "yes" : "no"); + setText("system-redis-memory", payload.redisMemoryUsed || "unavailable"); + setText("system-db-driver", payload.databaseDriver || "unavailable"); + setText("system-generated-at", payload.generatedAt || "unavailable"); + + setStatus("overview-status", `Live data loaded at ${payload.generatedAt}`); + } catch (error) { + console.error("Failed to load admin overview", error); + setStatus("overview-status", "Failed to load live admin data.", true); + } +} + +async function refreshAnalytics() { + setStatus("analytics-status", "Loading analytics..."); + try { + const response = await fetch("/api/admin/analytics"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const payload = await response.json(); + setText("analytics-total-messages", String(payload.totalMessages ?? 0)); + setText("analytics-messages-24h", String(payload.messagesLast24Hours ?? 0)); + setText("analytics-distinct-rooms", String(payload.distinctRooms ?? 0)); + setText( + "analytics-moderation-actions", + String(payload.moderationActions ?? 0), + ); + setText("analytics-generated-at", payload.generatedAt || "unavailable"); + + const tbody = document.getElementById("analytics-table-body"); + if (!tbody) return; + tbody.innerHTML = ""; + + const rows = (payload.topRooms || []) as Array<{ + room: string; + messageCount: number; + }>; + if (rows.length === 0) { + tbody.innerHTML = `No room analytics available yet.`; + } else { + rows.forEach((row) => { + const tr = document.createElement("tr"); + tr.innerHTML = `${row.room}${row.messageCount}`; + tbody.appendChild(tr); + }); + } + + setStatus("analytics-status", "Analytics loaded."); + } catch (error) { + console.error("Failed to load analytics", error); + setStatus("analytics-status", "Failed to load analytics.", true); + } +} + +async function refreshPlaylist() { + setStatus("playlist-status", "Loading playlist..."); + try { + const response = await fetch("/api/admin/playlist"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const payload = await response.json(); + const textarea = document.getElementById( + "playlist-content", + ) as HTMLTextAreaElement | null; + if (textarea) textarea.value = payload.content || ""; + setStatus("playlist-status", "Playlist loaded."); + } catch (error) { + console.error("Failed to load playlist", error); + setStatus("playlist-status", "Failed to load playlist.", true); + } +} + +async function savePlaylist() { + if (state.playlistSaving) return; + state.playlistSaving = true; + setStatus("playlist-status", "Saving playlist..."); + + try { + const textarea = document.getElementById( + "playlist-content", + ) as HTMLTextAreaElement | null; + const content = textarea?.value || ""; + + const response = await fetch("/api/admin/playlist", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + setStatus("playlist-status", "Playlist saved."); + } catch (error) { + console.error("Failed to save playlist", error); + setStatus("playlist-status", "Failed to save playlist.", true); + } finally { + state.playlistSaving = false; + } +} + +async function refreshSchedule() { + setStatus("schedule-status", "Loading schedule..."); + try { + const response = await fetch("/api/admin/schedule"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const payload = await response.json(); + setText("schedule-total-entries", String(payload.totalEntries ?? 0)); + setText("schedule-matched-entries", String(payload.matchedEntries ?? 0)); + setText("schedule-total-runtime", payload.totalRuntime || "00:00:00"); + + const tbody = document.getElementById("schedule-table-body"); + if (!tbody) return; + tbody.innerHTML = ""; + + const entries = payload.entries || []; + if (entries.length === 0) { + tbody.innerHTML = `No schedule entries found.`; + } else { + entries.forEach((entry: any) => { + const tr = document.createElement("tr"); + tr.innerHTML = `${entry.index}${entry.label}${entry.runtime || "00:00:00"}${entry.status || "missing"}${entry.path}`; + tbody.appendChild(tr); + }); + } + + setStatus( + "schedule-status", + `Schedule loaded. Total runtime ${payload.totalRuntime || "00:00:00"}.`, + ); + } catch (error) { + console.error("Failed to load schedule", error); + setStatus("schedule-status", "Failed to load schedule.", true); + } +} + +async function refreshSystemSummary() { + setStatus("system-status", "Loading system summary..."); + try { + const response = await fetch("/api/admin/system"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const payload = await response.json(); + setText("system-db-driver", payload.databaseDriver || "unavailable"); + setText("system-playlist-path", payload.playlistPath || "unavailable"); + setText("system-playlist-present", payload.playlistExists ? "yes" : "no"); + setText("system-redis-enabled", payload.redisEnabled ? "yes" : "no"); + setText("system-redis-memory", payload.redisMemoryUsed || "unavailable"); + setText("system-generated-at", payload.generatedAt || "unavailable"); + + const servicesEl = document.getElementById("system-services"); + if (servicesEl) { + servicesEl.innerHTML = ""; + const services = (payload.services || []) as ServiceHealth[]; + services.forEach((service) => { + const row = document.createElement("article"); + row.className = "service-row"; + row.innerHTML = ` +
+

${service.name}

+

${service.checked}

+
+
+

${service.status}

+

${service.detail}

+
+ `; + servicesEl.appendChild(row); + }); + } + + setStatus("system-status", "System summary loaded."); + } catch (error) { + console.error("Failed to load system summary", error); + setStatus("system-status", "Failed to load system summary.", true); + } +} + +function renderMediaRows(files: MediaEntry[]) { + const tbody = document.getElementById("media-table-body"); + if (!tbody) return; + tbody.innerHTML = ""; + + if (files.length === 0) { + tbody.innerHTML = `No media files available.`; + return; + } + + files.forEach((file) => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${file.name} + ${file.title || "-"} + ${file.runtime || "00:00:00"} + ${file.status || "unknown"} + ${fmtSize(file.size)} + ${file.modifiedAt || "-"} + + + + + `; + tr.querySelector('[data-action="edit"]')?.addEventListener("click", () => + editMedia(file), + ); + tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => + deleteMedia(file), + ); + tbody.appendChild(tr); + }); +} + +async function refreshMediaLibrary() { + setStatus("media-status", "Loading media library..."); + try { + const response = await fetch("/api/admin/media"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const payload = await response.json(); + renderMediaRows(payload.files || []); + setText("media-generated-at", payload.generatedAt || "unavailable"); + setStatus("media-status", "Media library loaded."); + } catch (error) { + console.error("Failed to load media library", error); + setStatus("media-status", "Failed to load media library.", true); + } +} + +async function uploadVideo(file: File) { + if (state.mediaUploading) return; + state.mediaUploading = true; + setStatus("media-status", `Uploading ${file.name}...`); + + try { + const formData = new FormData(); + formData.append("file", file); + + const response = await fetch("/api/admin/media", { + method: "POST", + body: formData, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const payload = await response.json(); + renderMediaRows(payload.files || []); + setText("media-generated-at", payload.generatedAt || "unavailable"); + setStatus("media-status", `${file.name} uploaded.`); + await refreshOverview(); + await refreshSchedule(); + } catch (error) { + console.error("Failed to upload media", error); + setStatus("media-status", "Failed to upload media file.", true); + } finally { + state.mediaUploading = false; + } +} + +async function editMedia(file: MediaEntry) { + if (!file.id) { + setStatus("media-status", "Cannot edit media item without id.", true); + return; + } + + const title = window.prompt("Title", file.title || file.name || ""); + if (title === null) return; + const status = window.prompt("Status", file.status || "ready"); + if (status === null) return; + const tags = window.prompt("Tags (comma-separated)", file.tags || ""); + if (tags === null) return; + + setStatus("media-status", `Saving metadata for ${file.name}...`); + try { + const response = await fetch(`/api/admin/media/${file.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, status, tags }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + setStatus("media-status", "Media metadata updated."); + await refreshMediaLibrary(); + await refreshOverview(); + await refreshSchedule(); + } catch (error) { + console.error("Failed to update media metadata", error); + setStatus("media-status", "Failed to update media metadata.", true); + } +} + +async function deleteMedia(file: MediaEntry) { + if (!file.id) { + setStatus("media-status", "Cannot delete media item without id.", true); + return; + } + + if (!window.confirm(`Delete ${file.name}? This removes file and metadata.`)) { + return; + } + + setStatus("media-status", `Deleting ${file.name}...`); + try { + const response = await fetch(`/api/admin/media/${file.id}`, { + method: "DELETE", + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + setStatus("media-status", `${file.name} deleted.`); + await refreshMediaLibrary(); + await refreshOverview(); + await refreshSchedule(); + } catch (error) { + console.error("Failed to delete media", error); + setStatus("media-status", "Failed to delete media file.", true); + } +} + +async function refreshModerationLogs() { + setStatus("moderation-status", "Loading moderation logs..."); + try { + const response = await fetch( + `/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`, + ); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const payload = await response.json(); + const tbody = document.getElementById("moderation-table-body"); + if (!tbody) return; + tbody.innerHTML = ""; + + const rows = payload.logs || []; + if (rows.length === 0) { + tbody.innerHTML = `No moderation actions logged yet.`; + } else { + rows.forEach((entry: any) => { + const tr = document.createElement("tr"); + tr.innerHTML = `${entry.timestamp}${entry.action}${entry.target}${entry.moderator}${entry.reason}`; + tbody.appendChild(tr); + }); + } + + setStatus("moderation-status", "Moderation logs loaded."); + } catch (error) { + console.error("Failed to load moderation logs", error); + setStatus("moderation-status", "Failed to load moderation logs.", true); + } +} + +async function submitModerationAction(event: Event) { + event.preventDefault(); + if (state.moderationSubmitting) return; + + state.moderationSubmitting = true; + setStatus("moderation-status", "Submitting moderation action..."); + + const payload = { + action: (document.getElementById("mod-action") as HTMLSelectElement).value, + target: (document.getElementById("mod-target") as HTMLInputElement).value, + moderator: (document.getElementById("mod-moderator") as HTMLInputElement) + .value, + reason: (document.getElementById("mod-reason") as HTMLTextAreaElement) + .value, + room: (document.getElementById("mod-room") as HTMLInputElement).value, + }; + + try { + const response = await fetch("/api/admin/moderation/actions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + setStatus("moderation-status", "Moderation action recorded."); + (document.getElementById("mod-target") as HTMLInputElement).value = ""; + (document.getElementById("mod-reason") as HTMLTextAreaElement).value = ""; + await refreshModerationLogs(); + await refreshOverview(); + } catch (error) { + console.error("Failed to submit moderation action", error); + setStatus("moderation-status", "Failed to submit moderation action.", true); + } finally { + state.moderationSubmitting = false; + } +} + +async function refreshUsers() { + setStatus("users-status", "Loading active users..."); + try { + const response = await fetch("/api/admin/users"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const payload = await response.json(); + const tbody = document.getElementById("users-table-body"); + if (!tbody) return; + tbody.innerHTML = ""; + + const users = payload.users || []; + if (users.length === 0) { + tbody.innerHTML = `No active users reported.`; + } else { + users.forEach((user: any) => { + const tr = document.createElement("tr"); + tr.innerHTML = `${user.username}${user.room}${user.online ? "yes" : "no"}`; + tbody.appendChild(tr); + }); + } + + setStatus( + "users-status", + payload.redisEnabled + ? "Active users loaded." + : "Redis disabled; no live presence data available.", + ); + } catch (error) { + console.error("Failed to load users", error); + setStatus("users-status", "Failed to load active users.", true); + } +} + +function bindEvents() { + document + .querySelectorAll(".tab-trigger") + .forEach((button) => { + button.addEventListener("click", () => { + const tab = button.dataset.tab; + if (tab) switchTab(tab); + }); + }); + + document.getElementById("logout-btn")?.addEventListener("click", async () => { + try { + await fetch("/api/admin/logout", { method: "POST" }); + } finally { + window.location.href = "/admin/login/"; + } + }); + + document + .getElementById("reload-media-btn") + ?.addEventListener("click", refreshMediaLibrary); + document + .getElementById("reload-schedule-btn") + ?.addEventListener("click", refreshSchedule); + document + .getElementById("reload-playlist-btn") + ?.addEventListener("click", refreshPlaylist); + document + .getElementById("save-playlist-btn") + ?.addEventListener("click", savePlaylist); + document + .getElementById("reload-moderation-btn") + ?.addEventListener("click", refreshModerationLogs); + document + .getElementById("reload-analytics-btn") + ?.addEventListener("click", refreshAnalytics); + document + .getElementById("reload-users-btn") + ?.addEventListener("click", refreshUsers); + document + .getElementById("reload-system-btn") + ?.addEventListener("click", refreshSystemSummary); + + const uploadInput = document.getElementById( + "video-upload", + ) as HTMLInputElement | null; + document + .getElementById("upload-trigger-btn") + ?.addEventListener("click", () => uploadInput?.click()); + uploadInput?.addEventListener("change", async (event) => { + const target = event.target as HTMLInputElement; + if (!target.files || target.files.length === 0) return; + await uploadVideo(target.files[0]); + target.value = ""; + }); + + document + .getElementById("moderation-form") + ?.addEventListener("submit", submitModerationAction); + + (document.getElementById("mod-moderator") as HTMLInputElement).value = + adminConfig.defaultModerator; + (document.getElementById("mod-room") as HTMLInputElement).value = + adminConfig.defaultRoom; +} + +async function init() { + bindEvents(); + await Promise.all([ + refreshOverview(), + refreshAnalytics(), + refreshMediaLibrary(), + refreshPlaylist(), + refreshSchedule(), + refreshModerationLogs(), + refreshSystemSummary(), + refreshUsers(), + ]); +} + +document.addEventListener("DOMContentLoaded", () => { + init().catch((error) => console.error("Admin initialization failed", error)); +}); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index df373f6..906c456 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,10 +1,8 @@ import Hls from "hls.js"; import "./style.css"; -// Main application state with Alpine.js declare global { interface Window { - app: () => any; switchRoom: (room: string) => void; sendMessage: () => void; togglePrivateDM: () => void; @@ -89,17 +87,21 @@ function addMessage( ) { const messages = document.getElementById("messages")!; const div = document.createElement("div"); - div.className = `message flex ${type === "system" ? "justify-center" : sender === username ? "justify-end" : "justify-start"}`; + div.className = "message-row"; if (type === "system") { - div.innerHTML = `
${text}
`; + div.classList.add("message-system-row"); + div.innerHTML = `
${text}
`; } else { + div.classList.add( + sender === username ? "message-self-row" : "message-other-row", + ); div.innerHTML = ` -
- ${sender && sender !== username ? `
${sender}
` : ""} -
${text}
-
${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
-
+
+ ${sender && sender !== username ? `
${sender}
` : ""} +
${text}
+ +
`; } @@ -107,6 +109,41 @@ function addMessage( messages.scrollTop = messages.scrollHeight; } +function initPlayerAndChat() { + const video = document.getElementById("player") as HTMLVideoElement; + const manifestUrl = resolveManifestUrl(); + + if (Hls.isSupported()) { + hls = new Hls(); + hls.loadSource(manifestUrl); + hls.attachMedia(video); + hls.on(Hls.Events.MANIFEST_PARSED, () => video.play()); + } else if (video.canPlayType("application/vnd.apple.mpegurl")) { + video.src = manifestUrl; + video.addEventListener("loadedmetadata", () => video.play()); + } + + connectWebSocket("main"); + + setInterval(() => { + const titles = [ + "Midnight Jazz Lounge", + "Synthwave Nights", + "Classic Cinema", + "Ambient Void", + ]; + const title = titles[Math.floor(Math.random() * titles.length)]; + document.getElementById("now-playing")!.textContent = title; + const overlay = document.getElementById("now-playing-overlay")!; + overlay.classList.remove("hidden"); + document.getElementById("overlay-title")!.textContent = title; + }, 15000); + + console.log( + "Linear Void Frontend initialized - HLS Player + Chat Sidebar ready", + ); +} + function sendMessage() { const input = document.getElementById("message-input") as HTMLInputElement; const text = input.value.trim(); @@ -141,64 +178,10 @@ function togglePrivateDM() { } } -// Alpine.js app -window.app = () => { - return { - init() { - // Initialize Tailwind - // @ts-ignore - tailwind.config = { - content: [], - theme: { extend: {} }, - }; - - // Initialize HLS Player - const video = document.getElementById("player") as HTMLVideoElement; - const manifestUrl = resolveManifestUrl(); - - if (Hls.isSupported()) { - hls = new Hls(); - hls.loadSource(manifestUrl); - hls.attachMedia(video); - hls.on(Hls.Events.MANIFEST_PARSED, () => video.play()); - } else if (video.canPlayType("application/vnd.apple.mpegurl")) { - video.src = manifestUrl; - video.addEventListener("loadedmetadata", () => video.play()); - } - - // Connect to default chat room - connectWebSocket("main"); - - // Simulate now playing - setInterval(() => { - const titles = [ - "Midnight Jazz Lounge", - "Synthwave Nights", - "Classic Cinema", - "Ambient Void", - ]; - document.getElementById("now-playing")!.textContent = - titles[Math.floor(Math.random() * titles.length)]; - const overlay = document.getElementById("now-playing-overlay")!; - overlay.classList.remove("hidden"); - document.getElementById("overlay-title")!.textContent = - titles[Math.floor(Math.random() * titles.length)]; - }, 15000); - - console.log( - "Linear Void Frontend initialized - HLS Player + Chat Sidebar ready", - ); - }, - }; -}; - -// Make functions global for onclick handlers window.switchRoom = switchRoom; window.sendMessage = sendMessage; window.togglePrivateDM = togglePrivateDM; -// Auto initialize when loaded document.addEventListener("DOMContentLoaded", () => { - // @ts-ignore - window.app().init(); + initPlayerAndChat(); }); diff --git a/frontend/src/style.css b/frontend/src/style.css index c887467..cfea6e6 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1,40 +1,762 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +:root { + --font-body: + "Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif; + --font-display: "Space Grotesk", "Segoe UI", sans-serif; + --font-mono: "IBM Plex Mono", Consolas, monospace; -body { - font-family: - system-ui, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - sans-serif; - background: #0f172a; - color: #e2e8f0; + --bg-0: #f4f6ef; + --bg-1: #e3e8db; + --ink-0: #101b18; + --ink-1: #2d3a36; + --ink-soft: #5a6963; + + --panel: #fffffd; + --panel-2: #f7fbf2; + --line: #c6d1c5; + + --brand: #005f73; + --brand-strong: #00464f; + --brand-contrast: #f9fffd; + --ok: #2f7a43; + --warn: #b76a00; + --danger: #a7283f; + + --focus: #0a9396; + --shadow-1: 0 14px 30px rgba(31, 54, 47, 0.12); + --shadow-2: 0 22px 44px rgba(20, 39, 35, 0.2); + + --radius-1: 0.6rem; + --radius-2: 1rem; + --radius-3: 1.6rem; + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.5rem; + --space-6: 2rem; + --space-7: 3rem; + + --fast: 160ms ease; } -#player { - max-width: 100%; +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + min-height: 100%; +} + +body { + font-family: var(--font-body); + color: var(--ink-0); + background: radial-gradient( + circle at 20% 10%, + #dce9d6 0%, + var(--bg-0) 50%, + #eff3ea 100% + ); + line-height: 1.45; +} + +h1, +h2, +h3 { + margin: 0; + font-family: var(--font-display); + color: var(--ink-0); + letter-spacing: -0.02em; +} + +p { + margin: 0; +} + +a { + color: var(--brand-strong); +} + +a:hover { + color: var(--brand); +} + +button, +input, +textarea, +select { + font: inherit; +} + +:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +.hidden { + display: none !important; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.small-muted { + color: var(--ink-soft); + font-size: 0.9rem; +} + +.status-line { + margin-top: var(--space-2); + color: var(--ink-soft); + min-height: 1.4rem; +} + +.status-error { + color: var(--danger); +} + +.btn { + border: 1px solid transparent; + border-radius: var(--radius-1); + padding: 0.62rem 0.9rem; + font-weight: 650; + cursor: pointer; + transition: + transform var(--fast), + background-color var(--fast), + color var(--fast), + border-color var(--fast); +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.btn-primary { + background: var(--brand); + color: var(--brand-contrast); + border-color: var(--brand); +} + +.btn-primary:hover { + background: var(--brand-strong); + border-color: var(--brand-strong); +} + +.btn-secondary { + background: var(--panel); + color: var(--ink-1); + border-color: var(--line); +} + +.btn-secondary:hover { + background: var(--panel-2); +} + +.btn-danger { + background: #fff3f6; + color: var(--danger); + border-color: #f3c0ca; +} + +.btn-danger:hover { + background: #ffe8ee; +} + +.btn-block { + width: 100%; +} + +.btn-large { + padding: 0.85rem 1.2rem; +} + +.btn-small { + padding: 0.3rem 0.58rem; + font-size: 0.85rem; +} + +.text-input { + width: 100%; + border: 1px solid var(--line); + border-radius: var(--radius-1); + background: #fff; + color: var(--ink-0); + padding: 0.65rem 0.8rem; +} + +.text-input::placeholder { + color: #74817b; +} + +.text-area { + min-height: 160px; + resize: vertical; +} + +.form-stack { + display: grid; + gap: var(--space-3); +} + +.form-stack label { + display: grid; + gap: var(--space-2); + font-weight: 600; + color: var(--ink-1); +} + +.table-wrap { + width: 100%; + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + min-width: 520px; +} + +.data-table th, +.data-table td { + text-align: left; + padding: 0.72rem 0.65rem; + border-bottom: 1px solid #dfe7de; + vertical-align: top; +} + +.data-table th { + color: var(--ink-soft); + font-size: 0.83rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.action-cell { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.kv-list { + margin: 0; + display: grid; + gap: var(--space-3); +} + +.kv-list div { + display: grid; + gap: var(--space-1); +} + +.kv-list dt { + font-size: 0.88rem; + color: var(--ink-soft); +} + +.kv-list dd { + margin: 0; + font-weight: 700; +} + +.lv-body { + min-height: 100vh; + padding: var(--space-5); +} + +.viewer-shell { + width: min(1250px, 100%); + margin: 0 auto; + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); + background: linear-gradient(170deg, #f7fbf2 0%, #ebf2e7 40%, #f8fbf5 100%); + border: 1px solid var(--line); + border-radius: var(--radius-3); + box-shadow: var(--shadow-2); + overflow: hidden; +} + +.viewer-main { + display: grid; + grid-template-rows: auto 1fr auto; + min-height: 76vh; +} + +.viewer-header, +.viewer-footer { + padding: var(--space-5); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} + +.viewer-brand-group { + display: flex; + align-items: center; + gap: var(--space-4); +} + +.brand-badge { + width: 2.5rem; + height: 2.5rem; + border-radius: 0.9rem; + display: grid; + place-items: center; + font-weight: 800; + font-size: 0.95rem; + letter-spacing: 0.05em; + color: #fff; + background: linear-gradient(140deg, #0a9396 0%, #005f73 100%); +} + +.brand-text, +.viewer-title { + font-family: var(--font-display); +} + +.viewer-title { + font-size: clamp(1.35rem, 2.2vw, 2rem); +} + +.viewer-subtitle { + color: var(--ink-soft); + font-size: 0.95rem; +} + +.now-playing-pill { + display: inline-flex; + align-items: center; + gap: var(--space-2); + border-radius: 999px; + background: #fff; + border: 1px solid #c9d6c8; + color: var(--ink-1); + padding: 0.35rem 0.8rem; +} + +.live-dot, +.chat-live-marker { + color: #cb271a; + font-weight: 800; +} + +.player-stage { + position: relative; + padding: 0 var(--space-5) var(--space-5); +} + +.player { + width: 100%; + border-radius: 1.1rem; + border: 1px solid #bfd0be; background: #000; - border-radius: 8px; - box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.3); + box-shadow: var(--shadow-1); +} + +.now-playing-overlay { + position: absolute; + right: calc(var(--space-5) + 0.7rem); + bottom: calc(var(--space-5) + 0.7rem); +} + +.now-playing-overlay-inner { + display: flex; + align-items: center; + gap: var(--space-2); + background: rgba(0, 16, 19, 0.84); + color: #f0fffb; + padding: 0.44rem 0.72rem; + border-radius: 999px; + backdrop-filter: blur(3px); +} + +.live-label { + background: #d62828; + color: #fff; + border-radius: 999px; + padding: 0.1rem 0.45rem; + font-size: 0.72rem; + font-weight: 700; +} + +.overlay-title { + font-size: 0.86rem; +} + +.room-links { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} + +.room-links a { + text-decoration: none; + font-weight: 650; } .chat-sidebar { - background: #1e2937; - color: #e2e8f0; - border-left: 1px solid #334155; + background: #f0f6ee; + border-left: 1px solid var(--line); + display: grid; + grid-template-rows: auto 1fr auto; + min-height: 76vh; } -.message { - animation: fadeIn 0.2s ease-in; +.chat-header, +.chat-input-area { + padding: var(--space-4); } -@keyframes fadeIn { +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #d4dfd3; + gap: var(--space-3); +} + +.chat-room-title { + font-weight: 700; + display: inline-flex; + gap: var(--space-2); + align-items: center; +} + +.chat-messages { + padding: var(--space-4); + overflow-y: auto; + display: grid; + align-content: start; + gap: var(--space-2); +} + +.message-row { + display: flex; +} + +.message-self-row { + justify-content: flex-end; +} + +.message-other-row { + justify-content: flex-start; +} + +.message-system-row { + justify-content: center; +} + +.message-bubble { + max-width: 83%; + border-radius: 1rem; + padding: 0.58rem 0.72rem; + box-shadow: var(--shadow-1); + animation: messageIn var(--fast); +} + +.message-other { + background: #ffffff; + border: 1px solid #d3e0d2; +} + +.message-self { + background: #005f73; + color: #fff; +} + +.message-system { + font-size: 0.83rem; + color: #1d4441; + background: #d9efea; + border: 1px solid #b5ddd6; + border-radius: 999px; + padding: 0.24rem 0.68rem; + animation: messageIn var(--fast); +} + +.message-sender { + font-size: 0.75rem; + opacity: 0.85; + margin-bottom: 0.1rem; +} + +.message-text { + word-break: break-word; +} + +.message-time { + display: block; + margin-top: 0.2rem; + font-size: 0.7rem; + opacity: 0.75; +} + +.chat-input-row { + display: grid; + grid-template-columns: 1fr auto; + gap: var(--space-2); +} + +.chat-note { + margin-top: var(--space-2); + color: var(--ink-soft); + font-size: 0.85rem; +} + +.input { + width: 100%; + border: 1px solid #c2d2c1; + border-radius: var(--radius-1); + padding: 0.65rem 0.8rem; + background: #fff; + color: var(--ink-0); +} + +.admin-body { + min-height: 100vh; + padding: var(--space-4); +} + +.admin-shell { + width: min(1320px, 100%); + margin: 0 auto; + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + border-radius: var(--radius-3); + overflow: hidden; + border: 1px solid var(--line); + background: #f7fbf3; + box-shadow: var(--shadow-2); + min-height: calc(100vh - 2 * var(--space-4)); +} + +.admin-sidebar { + background: #16423d; + color: #ecfff6; + display: grid; + grid-template-rows: auto auto 1fr; + gap: var(--space-4); + padding: var(--space-5); +} + +.brand-block { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.brand-text { + font-weight: 700; + letter-spacing: 0.03em; +} + +.admin-nav { + display: grid; + gap: var(--space-2); + align-content: start; +} + +.tab-trigger { + width: 100%; + text-align: left; + background: transparent; + color: #ddf8ee; + border: 1px solid transparent; + border-radius: var(--radius-1); + padding: 0.62rem 0.72rem; + cursor: pointer; + transition: + background-color var(--fast), + border-color var(--fast); +} + +.tab-trigger:hover { + background: rgba(255, 255, 255, 0.08); +} + +.tab-trigger.is-active { + background: #0a9396; + color: #fff; + border-color: #89d1c8; +} + +.admin-main { + padding: var(--space-6); + overflow: auto; +} + +.tab-panel { + display: grid; + gap: var(--space-4); +} + +.tab-panel[hidden] { + display: none; +} + +.panel-header-row { + display: flex; + justify-content: space-between; + align-items: end; + gap: var(--space-4); + flex-wrap: wrap; +} + +.panel-card { + border: 1px solid #d8e2d8; + border-radius: var(--radius-2); + background: #fff; + padding: var(--space-4); + box-shadow: var(--shadow-1); +} + +.button-row { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.upload-dropzone { + border: 2px dashed #97b8aa; + border-radius: var(--radius-2); + padding: var(--space-5); + background: #effaf5; + display: grid; + justify-items: start; + gap: var(--space-2); +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-3); +} + +.stat-card { + border: 1px solid #dce7db; + border-radius: var(--radius-2); + background: #fff; + padding: var(--space-4); + box-shadow: var(--shadow-1); +} + +.stat-value { + margin-top: var(--space-2); + font-size: clamp(1.35rem, 2.8vw, 2rem); + font-family: var(--font-mono); +} + +.stat-caption { + margin-top: var(--space-1); + color: var(--ink-soft); +} + +.moderation-grid { + display: grid; + grid-template-columns: minmax(300px, 1fr) minmax(0, 1.5fr); + gap: var(--space-4); +} + +.split-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); +} + +.service-list { + display: grid; + gap: var(--space-2); +} + +.service-row { + border: 1px solid #dbe5db; + border-radius: var(--radius-1); + padding: 0.75rem; + display: flex; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; +} + +.service-row-right { + text-align: right; +} + +.service-status { + margin: 0; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.service-status-healthy { + color: var(--ok); +} + +.service-status-degraded { + color: var(--warn); +} + +.service-status-unhealthy { + color: var(--danger); +} + +.auth-body { + min-height: 100vh; + display: grid; + place-items: center; + padding: var(--space-4); +} + +.auth-shell { + width: min(460px, 100%); +} + +.auth-card { + border: 1px solid #cad8c9; + border-radius: var(--radius-3); + background: #fff; + box-shadow: var(--shadow-2); + padding: var(--space-6); + display: grid; + gap: var(--space-3); +} + +.auth-kicker { + font-family: var(--font-display); + color: var(--brand); + text-transform: uppercase; + letter-spacing: 0.2em; + font-size: 0.72rem; +} + +.auth-subtitle { + color: var(--ink-soft); +} + +@keyframes messageIn { from { opacity: 0; - transform: translateY(8px); + transform: translateY(5px); } to { opacity: 1; @@ -42,283 +764,86 @@ body { } } -.now-playing { - background: linear-gradient(90deg, #3b82f6, #1e40af); - color: white; -} - -.input { - background: #334155; - border: 1px solid #475569; - color: white; -} - -.input:focus { - border-color: #60a5fa; - outline: none; -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; +@media (max-width: 1080px) { + .viewer-shell, + .admin-shell { + grid-template-columns: 1fr; } - #social .button-icon { - filter: invert(1) brightness(2); + .chat-sidebar { + border-left: 0; + border-top: 1px solid var(--line); + } + + .admin-sidebar { + grid-template-rows: auto; + } + + .admin-nav { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .admin-main { + padding: var(--space-5); + } + + .moderation-grid, + .split-grid, + .stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .service-row-right { + text-align: left; } } -body { - margin: 0; -} - -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); -} - -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; +@media (max-width: 760px) { + .lv-body, + .admin-body { + padding: var(--space-2); } - .base { - width: 170px; - position: relative; - z-index: 0; + .viewer-header, + .viewer-footer, + .player-stage, + .chat-header, + .chat-input-area { + padding: var(--space-3); } - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#app { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { + .viewer-footer { flex-direction: column; - text-align: center; + align-items: start; + } + + .admin-nav { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-main { + padding: var(--space-3); + } + + .moderation-grid, + .split-grid, + .stat-grid, + .chat-input-row { + grid-template-columns: 1fr; + } + + .auth-card { + padding: var(--space-5); } } -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ""; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + transition-duration: 1ms !important; + scroll-behavior: auto !important; } } diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js deleted file mode 100644 index 614c86b..0000000 --- a/frontend/tailwind.config.js +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], - theme: { - extend: {}, - }, - plugins: [], -};