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.
This commit is contained in:
zwitschi committed 2026-08-04 19:36:11 +02:00
1 parent 7cfddfc7ef
commit fca4286d87
10 files changed
+1771 -1465

No files matched your search

+4
View File
@@ -11,17 +11,21 @@
- Added `/api/admin/media/{id}` GET/PUT/DELETE handlers for media metadata retrieval, updates, and file+record deletion. - 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. - 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 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 ### Changed
- Revised ADR-006 to reflect current implemented storage: PostgreSQL is the compose default and deployed primary backend, with SQLite retained as optional fallback. - 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. - 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 ### Fixed
- Started Admin UI Media Library backend implementation by adding the `Media` GORM model and AutoMigrate integration in chat startup. - 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. - 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. - 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 ## [1.0.3] - 2026-08-03
+245 -829
View File
File diff suppressed because it is too large. Load diff
+18 -21
View File
@@ -5,30 +5,27 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Linear Void Admin Login</title> <title>Linear Void Admin Login</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="../../src/style.css" /> <link rel="stylesheet" href="../../src/style.css" />
</head> </head>
<body class="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center px-6"> <body class="auth-body">
<main class="w-full max-w-md bg-slate-900 border border-slate-800 rounded-[2rem] shadow-2xl p-8"> <main class="auth-shell" aria-label="Admin authentication">
<div class="mb-8"> <section class="auth-card" aria-labelledby="auth-title">
<div class="text-xs uppercase tracking-[0.3em] text-slate-500 mb-3">Linear Void</div> <p class="auth-kicker">Linear Void</p>
<h1 class="text-4xl font-semibold text-white mb-2">Admin Login</h1> <h1 id="auth-title">Admin Login</h1>
<p class="text-sm text-slate-400">Authenticate to access protected admin controls.</p> <p class="auth-subtitle">Authenticate to access protected admin controls.</p>
</div>
<form id="login-form" class="space-y-4"> <form id="login-form" class="form-stack" novalidate>
<label class="block text-sm"> <label for="username">Username</label>
<span class="block text-slate-400 mb-2">Username</span> <input id="username" type="text" class="text-input" autocomplete="username" required />
<input id="username" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" autocomplete="username" required />
</label> <label for="password">Password</label>
<label class="block text-sm"> <input id="password" type="password" class="text-input" autocomplete="current-password" required />
<span class="block text-slate-400 mb-2">Password</span>
<input id="password" type="password" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" autocomplete="current-password" required /> <button id="submit" type="submit" class="btn btn-primary btn-block">Sign in</button>
</label> <p id="status" class="status-line" aria-live="polite"></p>
<button id="submit" type="submit" class="w-full bg-blue-600 hover:bg-blue-500 px-4 py-3 rounded-xl font-medium text-white">Sign in</button>
<div id="status" class="text-sm text-slate-400 min-h-6"></div>
</form> </form>
</section>
</main> </main>
<script> <script>
@@ -42,7 +39,7 @@
event.preventDefault(); event.preventDefault();
submit.disabled = true; submit.disabled = true;
status.textContent = 'Signing in...'; status.textContent = 'Signing in...';
status.className = 'text-sm text-slate-400 min-h-6'; status.className = 'status-line';
try { try {
const response = await fetch('/api/admin/login', { const response = await fetch('/api/admin/login', {
@@ -59,7 +56,7 @@
} catch (error) { } catch (error) {
console.error('Failed to sign in', error); console.error('Failed to sign in', error);
status.textContent = 'Login failed. Check your credentials.'; status.textContent = 'Login failed. Check your credentials.';
status.className = 'text-sm text-rose-400 min-h-6'; status.className = 'status-line status-error';
} finally { } finally {
submit.disabled = false; submit.disabled = false;
} }
+42 -49
View File
@@ -5,76 +5,69 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Linear Void - 24/7 Synchronized Channel</title> <title>Linear Void - 24/7 Synchronized Channel</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="/src/style.css" /> <link rel="stylesheet" href="/src/style.css" />
</head> </head>
<body class="bg-slate-950 text-slate-200"> <body class="lv-body">
<div class="flex h-screen"> <main class="viewer-shell" aria-label="Live player and chat">
<!-- Video Player --> <section class="viewer-main" aria-label="Video stream panel">
<div class="flex-1 flex flex-col"> <header class="viewer-header">
<div class="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900"> <div class="viewer-brand-group">
<div class="flex items-center gap-3"> <div class="brand-badge" aria-hidden="true">LV</div>
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">LV</div>
<div> <div>
<h1 class="text-2xl font-bold text-white">Linear Void</h1> <h1 class="viewer-title">Linear Void</h1>
<p class="text-xs text-emerald-400">24/7 Synchronized Channel • Live</p> <p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
</div> </div>
</div> </div>
<div class="now-playing px-4 py-1 rounded-full text-sm font-medium flex items-center gap-2"> <div class="now-playing-pill" aria-live="polite">
<div class="w-2 h-2 bg-emerald-400 rounded-full animate-pulse"></div> <span class="live-dot" aria-hidden="true"></span>
<span id="now-playing">Loading schedule...</span> <span id="now-playing">Loading schedule...</span>
</div> </div>
</div> </header>
<div class="flex-1 flex items-center justify-center p-8 bg-black relative"> <section class="player-stage" aria-label="Video stage">
<video id="player" class="max-h-full rounded-xl shadow-2xl" controls autoplay muted playsinline></video> <video id="player" class="player" controls autoplay muted playsinline></video>
<div id="now-playing-overlay" class="absolute bottom-8 left-8 bg-black/70 backdrop-blur-md px-6 py-3 rounded-2xl text-sm hidden"> <div id="now-playing-overlay" class="now-playing-overlay hidden" aria-live="polite">
<div class="flex items-center gap-3"> <div class="now-playing-overlay-inner">
<div class="text-emerald-400">● LIVE</div> <span class="live-label">LIVE</span>
<div id="overlay-title" class="font-medium"></div> <span id="overlay-title" class="overlay-title"></span>
</div>
</div> </div>
</div> </div>
</section>
<div class="p-4 bg-slate-900 border-t border-slate-800 text-xs text-slate-400 flex justify-between"> <footer class="viewer-footer">
<div>Latency: ~25s • Synced</div> <div>Latency: ~25s • Synced</div>
<div class="flex gap-6"> <nav class="room-links" aria-label="Quick room switch">
<a href="#" onclick="switchRoom('main')" class="hover:text-white">Main Channel</a> <a href="#" onclick="switchRoom('main'); return false;">Main Channel</a>
<a href="#" onclick="switchRoom('chill')" class="hover:text-white">Chill Room</a> <a href="#" onclick="switchRoom('chill'); return false;">Chill Room</a>
<a href="#" onclick="switchRoom('talk')" class="hover:text-white">Talk Room</a> <a href="#" onclick="switchRoom('talk'); return false;">Talk Room</a>
</div> </nav>
</div> </footer>
</div> </section>
<!-- Chat Sidebar --> <aside class="chat-sidebar" aria-label="Chat panel">
<div class="w-96 flex flex-col border-l border-slate-800 bg-slate-900 chat-sidebar"> <header class="chat-header">
<div class="p-4 border-b border-slate-700 font-medium flex items-center justify-between"> <div class="chat-room-title">
<div class="flex items-center gap-2"> <span class="chat-live-marker" aria-hidden="true">●</span>
<span class="text-emerald-400">●</span>
<span id="room-name">Main Channel</span> <span id="room-name">Main Channel</span>
</div> </div>
<button onclick="togglePrivateDM()" class="text-xs px-3 py-1 bg-slate-700 hover:bg-slate-600 rounded-lg">New DM</button> <button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
</div> </header>
<!-- Messages --> <section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
<div id="messages" class="flex-1 p-4 overflow-y-auto space-y-4 text-sm">
<!-- Messages populated by JS -->
</div>
<!-- Input --> <div class="chat-input-area">
<div class="p-4 border-t border-slate-700"> <div class="chat-input-row">
<div class="flex gap-2"> <label for="message-input" class="sr-only">Message input</label>
<input id="message-input" type="text" placeholder="Type a message..." class="input flex-1 px-4 py-3 rounded-2xl text-sm focus:outline-none" onkeydown="if(event.key === 'Enter') sendMessage()"> <input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if(event.key === 'Enter') sendMessage()" />
<button onclick="sendMessage()" class="bg-blue-600 hover:bg-blue-500 px-6 rounded-2xl text-sm font-medium">Send</button> <button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
</div> </div>
<div class="text-[10px] text-slate-500 mt-2 text-center"> <p class="chat-note">
Synced with video • Moderation active Synced with video • Moderation active
</p>
</div> </div>
</div> </aside>
</div> </main>
</div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
+5 -209
View File
@@ -8,11 +8,7 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"alpinejs": "^3.15.12", "hls.js": "^1.6.16"
"autoprefixer": "^10.5.4",
"hls.js": "^1.6.16",
"postcss": "^8.5.25",
"tailwindcss": "^4.3.3"
}, },
"devDependencies": { "devDependencies": {
"typescript": "~6.0.2", "typescript": "~6.0.2",
@@ -375,131 +371,6 @@
"tslib": "^2.4.0" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -510,21 +381,6 @@
"node": ">=8" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -854,6 +697,7 @@
"version": "3.3.16", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -868,19 +712,11 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
@@ -900,6 +736,7 @@
"version": "8.5.25", "version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -924,12 +761,6 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/rolldown": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz",
@@ -968,17 +799,12 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1018,36 +844,6 @@
"node": ">=14.17" "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": { "node_modules/vite": {
"version": "8.2.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
+1 -5
View File
@@ -13,10 +13,6 @@
"vite": "^8.2.0" "vite": "^8.2.0"
}, },
"dependencies": { "dependencies": {
"alpinejs": "^3.15.12", "hls.js": "^1.6.16"
"autoprefixer": "^10.5.4",
"hls.js": "^1.6.16",
"postcss": "^8.5.25",
"tailwindcss": "^4.3.3"
} }
} }
+604
View File
@@ -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<HTMLButtonElement>(".tab-trigger");
const panels = document.querySelectorAll<HTMLElement>(".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 = `<tr><td colspan="2">No room analytics available yet.</td></tr>`;
} else {
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${row.room}</td><td>${row.messageCount}</td>`;
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 = `<tr><td colspan="5">No schedule entries found.</td></tr>`;
} else {
entries.forEach((entry: any) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${entry.index}</td><td>${entry.label}</td><td>${entry.runtime || "00:00:00"}</td><td>${entry.status || "missing"}</td><td>${entry.path}</td>`;
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 = `
<div>
<h3>${service.name}</h3>
<p class="small-muted">${service.checked}</p>
</div>
<div class="service-row-right">
<p class="service-status service-status-${service.status}">${service.status}</p>
<p>${service.detail}</p>
</div>
`;
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 = `<tr><td colspan="7">No media files available.</td></tr>`;
return;
}
files.forEach((file) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${file.name}</td>
<td>${file.title || "-"}</td>
<td>${file.runtime || "00:00:00"}</td>
<td>${file.status || "unknown"}</td>
<td>${fmtSize(file.size)}</td>
<td>${file.modifiedAt || "-"}</td>
<td class="action-cell">
<button type="button" class="btn btn-secondary btn-small" data-action="edit" data-id="${file.id || ""}">Edit</button>
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
</td>
`;
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 = `<tr><td colspan="5">No moderation actions logged yet.</td></tr>`;
} else {
rows.forEach((entry: any) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${entry.timestamp}</td><td>${entry.action}</td><td>${entry.target}</td><td>${entry.moderator}</td><td>${entry.reason}</td>`;
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 = `<tr><td colspan="3">No active users reported.</td></tr>`;
} else {
users.forEach((user: any) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${user.username}</td><td>${user.room}</td><td>${user.online ? "yes" : "no"}</td>`;
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<HTMLButtonElement>(".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));
});
+47 -64
View File
@@ -1,10 +1,8 @@
import Hls from "hls.js"; import Hls from "hls.js";
import "./style.css"; import "./style.css";
// Main application state with Alpine.js
declare global { declare global {
interface Window { interface Window {
app: () => any;
switchRoom: (room: string) => void; switchRoom: (room: string) => void;
sendMessage: () => void; sendMessage: () => void;
togglePrivateDM: () => void; togglePrivateDM: () => void;
@@ -89,17 +87,21 @@ function addMessage(
) { ) {
const messages = document.getElementById("messages")!; const messages = document.getElementById("messages")!;
const div = document.createElement("div"); 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") { if (type === "system") {
div.innerHTML = `<div class="text-xs text-slate-400 bg-slate-800 px-3 py-1 rounded-full">${text}</div>`; div.classList.add("message-system-row");
div.innerHTML = `<div class="message-system">${text}</div>`;
} else { } else {
div.classList.add(
sender === username ? "message-self-row" : "message-other-row",
);
div.innerHTML = ` div.innerHTML = `
<div class="${sender === username ? "bg-blue-600" : "bg-slate-700"} px-4 py-2 rounded-3xl max-w-[75%]"> <article class="message-bubble ${sender === username ? "message-self" : "message-other"}">
${sender && sender !== username ? `<div class="text-xs text-blue-300">${sender}</div>` : ""} ${sender && sender !== username ? `<div class="message-sender">${sender}</div>` : ""}
<div>${text}</div> <div class="message-text">${text}</div>
<div class="text-[10px] text-slate-400 mt-1">${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div> <time class="message-time">${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</time>
</div> </article>
`; `;
} }
@@ -107,6 +109,41 @@ function addMessage(
messages.scrollTop = messages.scrollHeight; 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() { function sendMessage() {
const input = document.getElementById("message-input") as HTMLInputElement; const input = document.getElementById("message-input") as HTMLInputElement;
const text = input.value.trim(); 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.switchRoom = switchRoom;
window.sendMessage = sendMessage; window.sendMessage = sendMessage;
window.togglePrivateDM = togglePrivateDM; window.togglePrivateDM = togglePrivateDM;
// Auto initialize when loaded
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
// @ts-ignore initPlayerAndChat();
window.app().init();
}); });
+805 -280
View File
File diff suppressed because it is too large. Load diff
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
};