7 Commits
Author SHA1 Message Date
zwitschi 497f7743f2 fix: increase nginx client_max_body_size for resumable admin uploads
CI / test (push) Successful in 1m39s
CI / docker (push) Successful in 4m27s
CI / release (push) Skipped
2026-08-04 20:35:24 +02:00
zwitschi 2c06265d11 feat: implement resumable chunked media upload with progress indicators in admin UI
CI / test (push) Successful in 1m38s
CI / docker (push) Successful in 3m11s
CI / release (push) Skipped
2026-08-04 20:21:53 +02:00
zwitschi 930cdfdff9 chore: moving AGENTS.md to parent folder 2026-08-04 19:52:58 +02:00
zwitschi ab3fab3e68 fix: minor optimization of build 2026-08-04 19:49:07 +02:00
zwitschi dd5789fcd9 feat: implement lazy loading for hls.js and enhance video player initialization
CI / test (push) Successful in 6m9s
CI / docker (push) Successful in 3m11s
CI / release (push) Skipped
2026-08-04 19:43:39 +02:00
zwitschi fca4286d87 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.
2026-08-04 19:36:11 +02:00
zwitschi 7cfddfc7ef feat: enhance media management with CRUD operations and runtime tracking in admin UI
CI / test (push) Successful in 1m39s
CI / docker (push) Successful in 3m9s
CI / release (push) Skipped
2026-08-04 19:28:23 +02:00
18 changed files with 3102 additions and 1436 deletions

No files matched your search

File renamed without changes.
+29
View File
@@ -2,6 +2,35 @@
<!-- markdownlint-disable MD024 --> <!-- markdownlint-disable MD024 -->
## Unreleased
### Added
- Documentation for Admin UI Media Library (arc42 updates, refined TODO, media DB model decisions per ADR-006).
- Started implementation of full Media CRUD + video duration extraction using ffprobe (Media Library - Full CRUD section).
- 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.
- Added resumable chunked admin media upload flow with upload-session endpoints and client progress indicators.
### 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.
- Code-split `hls.js` via lazy loading in the viewer bootstrap to shrink the main frontend bundle and isolate stream playback logic into a dedicated chunk.
### 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.
- Enforced 2GB upload ceiling plus server-side media file type/size validation for admin uploads.
- Increased nginx `client_max_body_size` to allow resumable admin upload chunks and prevent `413 Content Too Large` responses.
## [1.0.3] - 2026-08-03 ## [1.0.3] - 2026-08-03
### Added ### Added
+1 -1
View File
@@ -9,7 +9,7 @@ RUN go mod tidy
RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go
FROM alpine:3.20 FROM alpine:3.20
RUN apk --no-cache add ca-certificates curl curl RUN apk --no-cache add ca-certificates curl ffmpeg
WORKDIR /app WORKDIR /app
COPY --from=builder /chat-server /usr/local/bin/chat-server COPY --from=builder /chat-server /usr/local/bin/chat-server
+859 -27
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -18,14 +18,15 @@ RUN apk add --no-cache \
bash bash
# Set working directory for playout # Set working directory for playout
WORKDIR /app WORKDIR /app
# Create directories for playlist, media, and HLS output
RUN mkdir -p /app/hls /app/media /app/playlists
# Copy the playout start script and the built playlist manager binary # Copy the playout start script and the built playlist manager binary
COPY start-playout.sh /app/ COPY start-playout.sh /app/
# Copy demo data for out-of-the-box playout # Copy demo data for out-of-the-box playout
COPY demodata/ /app/ COPY demodata/ /app/
# Copy the built playlist manager binary from the builder stage # Copy the built playlist manager binary from the builder stage
COPY --from=builder /playlist-manager /app/playlist-manager COPY --from=builder /playlist-manager /app/playlist-manager
# Create directories for playlist, media, and HLS output # Set execute permissions for the start script and playlist manager binary
RUN mkdir -p /app/hls
RUN chmod +x /app/start-playout.sh /app/playlist-manager RUN chmod +x /app/start-playout.sh /app/playlist-manager
# Expose HLS directory (will be served by nginx) # Expose HLS directory (will be served by nginx)
VOLUME ["/app/hls"] VOLUME ["/app/hls"]
+193 -2
View File
@@ -315,11 +315,96 @@ All source assets must be pre-normalized (H.264/AVC, AAC-LC, constant 30 fps, 2s
### Monitoring & Resilience ### Monitoring & Resilience
Alert on no new segment (>30–60s), low cache hits, high R2 ops, WebSocket errors. Maintain emergency slate, auto-restart, weekly backups, incident runbook. Linear Void uses a two-layer monitoring model:
1. **Application layer (chat + platform health)** via Prometheus metrics and a lightweight Grafana dashboard.
2. **Infrastructure/runtime layer (Prometheus self-observability, TSDB, query engine, Go runtime)** via a detailed Grafana dashboard.
#### Prometheus Baseline
- Config file: `monitoring/prometheus/prometheus.yml`
- Default scrape interval: `15s`
- Current scrape jobs: `chat` -> `chat:3000`, `prometheus` -> `prometheus:9090`.
Recommended next-step scrape targets (if/when exporters are introduced):
- `nginx` (via nginx-exporter)
- `redis` (via redis-exporter)
- `postgres` (via postgres-exporter)
- `node` (via node-exporter)
#### Grafana Dashboards
- `monitoring/grafana/dashboard.json` (Linear Void app metrics: connected clients, messages, moderation actions, Redis reachability, admin sessions, scrape health).
- `monitoring/grafana/dashboards/dashboard.json` (Prometheus operational visibility: status, uptime, CPU/memory, TSDB ingestion/churn/errors, HTTP latencies, query load, GC/scheduler).
Use the app dashboard for service-level behavior and the Prometheus overview dashboard for platform diagnostics and capacity planning.
#### Alerting Rules (Prometheus Rule Groups)
Define alert rules as grouped policies (for example in `monitoring/prometheus/alerts.yml`) and include them from `prometheus.yml`.
Minimum recommended alert set:
- **Availability**
`up{job="chat"} == 0` for `2m`; `up{job="prometheus"} == 0` for `2m`.
- **Chat service health**
`linearvoid_chat_redis_up == 0` for `2m`; sudden disconnect spike / connected clients collapse.
- **Playout continuity**
No HLS segment progression for `30-60s` (from playout-specific metric when available).
- **Prometheus capacity and ingestion health**
Sustained growth in `prometheus_tsdb_wal_writes_failed_total` or `prometheus_tsdb_compactions_failed_total`, abnormal jump in `prometheus_tsdb_out_of_order_samples_total`, and high `prometheus_engine_queries` near concurrent limit.
- **Latency and user-impact**
Elevated p99 handler latency from `prometheus_http_request_duration_seconds_bucket` and elevated p99 query execution latency from `prometheus_engine_query_duration_histogram_seconds_bucket`.
Route warning/critical severities to the team channel and keep runbook links attached to each alert definition.
Operational resilience baseline:
- Emergency slate fallback for playout failure.
- Auto-restart policies for playout/chat/nginx containers.
- Weekly backup and restore drills for stateful data (`data/chat`, playlist metadata, Postgres).
- Incident runbook with triage order: edge reachability -> origin health -> playout progression -> chat connectivity -> data-plane dependencies.
### Security & Moderation ### Security & Moderation
HTTPS/WSS only, strict CORS, Cloudflare WAF, authenticated admin paths, rate limiting, immutable moderation log. Private DMs not E2EE by default. Security is split between edge controls (Cloudflare), reverse proxy controls (nginx), and application controls (chat/admin backend).
#### Transport and Edge Protection
- Public traffic uses HTTPS/WSS at the edge.
- Cloudflare WAF and rate limiting are part of the baseline (see `infrastructure/cloudflare/cache-rules.md`).
- Cloudflare Worker can enforce immutable cache headers for segment paths (`infrastructure/cloudflare/worker.js`).
#### Reverse Proxy Enforcement (nginx)
- Admin UI and admin API paths are protected by `auth_request` before reaching backend handlers (`/admin/`, `/api/admin/`).
- Unauthorized admin UI requests are redirected to `/admin/login/`.
- Unauthorized admin API requests return JSON `401` from a dedicated location handler.
- WebSocket upgrade headers are explicitly forwarded for `/ws`.
#### Admin Authentication and Session Security
- Admin authentication is cookie-session based (`linearvoid_admin_session`).
- Session cookie controls in backend include `HttpOnly`, `SameSite=Strict`, and `Secure` when request is HTTPS (derived from TLS or `X-Forwarded-Proto`).
- Session expiration is enforced server-side.
#### Rate Limiting and Abuse Controls
- Chat message rate limiting uses Redis `INCR + EXPIRE` windowing.
- Current baseline in chat handler: `>10` messages within `10s` triggers a user-facing throttle response.
- If Redis is unavailable, presence/rate-limit features degrade gracefully (warning logged; core chat remains available).
#### CORS and Cross-Origin Behavior
- HLS endpoints exposed by nginx include explicit `Access-Control-Allow-Origin: *` for player compatibility.
- Admin/authenticated routes are expected to run same-origin behind nginx auth gates.
#### Moderation and Privacy
- Moderation commands (`/mod`, `/mute`, `/ban`) are handled in backend chat flow and persisted through chat storage paths.
- Private DMs are application-level private channels, not end-to-end encrypted in current architecture.
- Moderation and audit retention policies should be formalized in runbook/ops docs as part of production hardening.
### Chat ### Chat
@@ -354,6 +439,112 @@ These ADRs together form the canonical decision log.
See **Quality Goals** table in the Introduction section. Primary scenarios focus on cost (near-zero egress), availability (auto-restart + fallback), scalability (edge caching), and usability (synchronized player + responsive chat). See **Quality Goals** table in the Introduction section. Primary scenarios focus on cost (near-zero egress), availability (auto-restart + fallback), scalability (edge caching), and usability (synchronized player + responsive chat).
### Performance Benchmarks and Capacity Planning
#### Baseline SLO Targets (v1)
- Playback startup: target < 4s after first manifest request on warm cache.
- Steady-state playback: no segment starvation during normal operation.
- End-to-end HLS latency: expected 15-35s (standard HLS trade-off).
- Chat delivery latency: p95 < 500ms inside same region under normal load.
- Admin page responsiveness: p95 API response < 1s for common read operations.
#### Workload Assumptions
- Primary audience in US; origin in US-East.
- Edge cache serves most segment traffic; origin handles manifest churn and cache misses.
- Chat traffic scales with concurrent viewers but remains low-bandwidth compared to video.
#### Initial Capacity Model
- Video bitrate envelopes (from ADR-001):
- 720p30: ~2.6-4.2 Mbps total with audio.
- 1080p30: ~5.1-8.2 Mbps total with audio.
- Storage planning for 48h rolling library:
- 720p30: ~57-90 GB raw (allocate ~80-110 GB with headroom).
- 1080p30: ~110-175 GB raw (allocate ~130-180 GB with headroom).
- Cost behavior:
- Viewer scale is mostly constrained by cache efficiency and request volume, not by direct origin egress.
#### Benchmark Plan
- Playback path:
- Measure time-to-first-frame and rebuffer events from multiple US regions.
- Track manifest freshness and segment availability at edge.
- Chat path:
- Load-test concurrent websocket connections and message fan-out.
- Measure p50/p95 send-to-receive latency and Redis fallback behavior.
- Admin/API path:
- Benchmark playlist/system endpoints for p95 latency and error rate.
- Platform path:
- Observe TSDB ingestion, query p99, and GC pause behavior in Grafana.
#### Capacity Triggers and Actions
- Trigger: sustained viewer growth with rising cache-miss ratio.
- Action: tighten cache policy, enable/verify Tiered Cache, review manifest TTL.
- Trigger: chat p95 latency drift or websocket error growth.
- Action: scale chat service, tune Redis, separate noisy moderation workloads.
- Trigger: Prometheus WAL/compaction failures or query saturation.
- Action: tune retention/cardinality, increase resources, split dashboards/queries.
- Trigger: origin CPU/memory pressure on playout host.
- Action: optimize FFmpeg profile, isolate services, add secondary origin.
### Disaster Recovery and Backup Procedures
#### Recovery Targets
- RPO target: <= 24h for chat/moderation/admin metadata.
- RTO target: <= 60 minutes for restoring baseline streaming + chat operations.
#### Backup Scope
- Persistent service volumes:
- Postgres data (`postgres-data` volume).
- Redis append-only persistence (`redis-data` volume) where enabled.
- Prometheus long-term TSDB (`prometheus-data` volume) if historical metrics retention is required.
- Grafana state (`grafana-data` volume) if dashboards/users are managed in-instance.
- Host-mounted state directories:
- `data/chat`
- `data/playlist`
- `data/media` (authoritative source may be R2/object storage)
- `data/hls` (optional; can be regenerated by playout)
- Configuration and infra source of truth:
- Compose files, nginx config, monitoring configs, and environment template.
#### Backup Cadence
- Daily logical Postgres backups (plus weekly full snapshot).
- Daily sync of chat/playlist state directories.
- Weekly integrity check by restoring backups into an isolated environment.
- Retention policy baseline:
- Daily backups: 14 days
- Weekly backups: 8 weeks
- Monthly backups: 6 months
#### Recovery Runbook (Minimum)
1. Restore infrastructure configuration (compose, nginx, env, monitoring config).
2. Restore Postgres and Redis persisted state.
3. Restore `data/playlist` and chat persistence directories.
4. Rehydrate media from authoritative object storage if local media is missing.
5. Start stack and validate in order:
- `playout` health endpoint
- `chat` health endpoint
- nginx `/health`
- HLS playlist availability
- websocket chat connectivity
6. Verify admin authentication and moderation audit visibility.
7. Announce recovery and continue elevated monitoring for at least one playlist cycle.
#### Failure Modes and Strategy
- Single-host loss: redeploy stack on standby host, restore data backups, repoint DNS/edge origin.
- Storage corruption: restore last known-good snapshot and replay latest logical backups.
- Partial service failure: replace only impacted service and restore scoped data (for example Postgres only).
Document and rehearse this runbook quarterly; recovery that is not exercised is not reliable.
## Risks and Technical Debts ## Risks and Technical Debts
- Cloudflare ToS risk for heavy video on free tier (use R2 path; fallback to BunnyCDN if challenged). - Cloudflare ToS risk for heavy video on free tier (use R2 path; fallback to BunnyCDN if challenged).
+45 -56
View File
@@ -1,7 +1,7 @@
# ADR-006: Data Storage Strategy (SQLite vs PostgreSQL) # ADR-006: Data Storage Strategy (SQLite vs PostgreSQL)
**Status:** Proposed **Status:** Accepted
**Date:** 2026-08-02 **Date:** 2026-08-04
**Author:** AI Assistant (following AGENTS.md, ADR-002, ADR-003, ADR-004, ADR-005) **Author:** AI Assistant (following AGENTS.md, ADR-002, ADR-003, ADR-004, ADR-005)
**Related ADRs:** [ADR-001](./ADR-001.md), [ADR-003](./ADR-003-tech-stack.md), [ADR-005](./ADR-005-admin-moderation-analytics.md), [ADR-007](./ADR-007-redis-usage.md) **Related ADRs:** [ADR-001](./ADR-001.md), [ADR-003](./ADR-003-tech-stack.md), [ADR-005](./ADR-005-admin-moderation-analytics.md), [ADR-007](./ADR-007-redis-usage.md)
@@ -12,89 +12,78 @@ The system needs persistent storage for:
- Chat messages and history (public rooms and private DMs). - Chat messages and history (public rooms and private DMs).
- User accounts, roles, and ban lists. - User accounts, roles, and ban lists.
- Moderation audit log (immutable record of admin and moderator actions). - Moderation audit log (immutable record of admin and moderator actions).
- Playlist metadata and schedule overrides. - Playlist and media metadata used by admin flows.
- System configuration (including Redis settings adjustable via admin UI per ADR-005/ADR-007). - System configuration visibility in admin APIs.
From ADR-003 we prefer operational simplicity. ADR-005 requires a robust audit trail. We must decide between **SQLite** (embedded, zero-config) and **PostgreSQL** (full-featured, scalable) and document how the admin interface can view and adjust storage-related configuration. The codebase now ships with a PostgreSQL service in Docker Compose (`db`) and chat is configured with a PostgreSQL `DATABASE_URL` by default. The backend still supports SQLite for optional local fallback, but the deployed and compose-default path is PostgreSQL.
## Decision Drivers ## Decision Drivers
1. **Operational Simplicity** – Minimize infrastructure (no separate DB server for SQLite). 1. **Implemented Reality** - Docker Compose already provisions PostgreSQL and chat depends on it.
2. **Performance & Concurrency** – Chat and moderation may have high write volume. 2. **Concurrency & Throughput** - Chat, moderation, and media metadata writes require stronger concurrent write behavior.
3. **Backup and Recovery** – Easy point-in-time recovery and replication. 3. **Operational Traceability** - Structured backups, service health checks, and persistent volume management are already in place for PostgreSQL.
4. **Admin Capabilities** – Admin UI must be able to view storage stats, run vacuum/analyze, and switch between SQLite and PostgreSQL (with migration path). 4. **Admin Growth** - Upcoming admin features (media CRUD, analytics, moderation tooling) benefit from relational querying and indexing.
5. **Auditability** – Moderation log must be immutable and queryable. 5. **Fallback Simplicity** - Keep SQLite support for lightweight local or recovery scenarios without blocking developer workflows.
6. **Cost** – Avoid managed database costs during early stages.
## Decision ## Decision
**Primary storage for v1: SQLite** (embedded in the chat and admin services). **Primary storage for current implementation: PostgreSQL (Docker Compose `db` service).**
**Migration Path**: Provide a clear upgrade path to PostgreSQL when concurrency or replication needs exceed SQLite capabilities (target v2 or when concurrent users > 500). **Secondary/Fallback mode:** SQLite remains supported via `DATABASE_URL=sqlite://...` for local-only scenarios.
**Admin Interface Capabilities** (extension of ADR-005): **Current Backend Behavior**:
- "System Configuration" tab showing current storage engine, database size, connection stats, and vacuum status. - Chat backend (`backend/chat/src/main.go`) resolves `DATABASE_URL` at startup.
- Buttons to run `VACUUM`, `ANALYZE`, or trigger a backup. - `postgres://` or `postgresql://` selects PostgreSQL driver.
- Configuration form to switch connection string (SQLite file path vs PostgreSQL URL) and restart services gracefully. - `sqlite://` selects SQLite driver.
- Audit log of all configuration changes. - Empty `DATABASE_URL` falls back to local `chat.db` for developer convenience.
- GORM AutoMigrate is used for chat/admin data models.
**Technical Details**: **Media Model Decision (Admin UI Feature Track)**:
- Use SQLite with WAL mode for better concurrency. - Media metadata is persisted in the same database as messages and moderation logs.
- Use GORM or sqlx (Go) or Prisma (if Node.js fallback) for ORM/migrations. - Physical media files remain on the shared filesystem (`/app/media`, mapped from `data/media`).
- Store large chat history in a separate table with retention policy (e.g., 30 days). - The database is the source of truth for media library metadata and future runtime analytics.
- Moderation log as an append-only table with triggers for immutability.
**Folder Placement** (per ADR-002):
- Database models and migrations in `backend/shared/db/` or `backend/chat/internal/db/`.
- Admin UI pages for storage management in `frontend/admin/system/`.
## Rationale ## Rationale
- SQLite aligns with ADR-003's emphasis on operational simplicity and low resource usage for the initial deployment on a single Hetzner VPS. - The repository already operationalizes PostgreSQL in both `docker-compose.yml` and `docker-compose.prod.yml` with persistent storage.
- It eliminates the need for a separate database container in `docker-compose.yml` during early development. - Default environment documentation (`.env.example`, setup docs) already points to PostgreSQL.
- The admin interface can provide visibility and basic maintenance operations, fulfilling the requirement from the previous TODO plan. - This aligns architecture documentation with implemented behavior and avoids drift.
- PostgreSQL can be introduced later with a migration script when scale demands it (replication, advanced querying, full-text search on chat). - Keeping SQLite support preserves local flexibility while avoiding a hard breaking change.
## Consequences ## Consequences
**Positive**: **Positive**:
- Zero-config database for quick local development and small-scale production. - Better write concurrency and query flexibility for chat + admin workloads.
- Simple backups (just copy the `.db` file). - Clear operational model for containerized deployments (health checks, named volume, service dependency).
- Admin UI can directly manage and monitor storage without external tools. - Easier extension for admin analytics and media metadata features.
**Negative / Risks**: **Negative / Risks**:
- SQLite has limited concurrency compared to PostgreSQL (WAL mode helps but is not a full replacement). - Additional service to operate versus embedded-only SQLite.
- Large chat history or analytics queries may become slow – plan retention policy and archiving. - Misconfigured `DATABASE_URL` can still route to SQLite fallback unintentionally if left empty outside compose.
- Migration to PostgreSQL will require downtime or dual-write strategy (documented in this ADR). - Requires backup/restore procedures for PostgreSQL volume in production runbooks.
**Alternatives Considered**: ## Alternatives Considered
- PostgreSQL from day one – rejected for added operational complexity (separate service, connection pooling, managed service cost). - SQLite-first in production: rejected for current implementation because compose and deployment already run PostgreSQL.
- Pure key-value store (e.g., BoltDB) – rejected because SQL is better for audit logs and complex moderation queries. - PostgreSQL-only with no fallback: rejected to preserve local dev and troubleshooting flexibility.
- Managed database (Neon, Supabase) – rejected to maintain full self-hosted independence per ADR-001. - Managed external PostgreSQL: deferred; self-hosted compose remains the preferred operational model.
## Implementation Plan ## Implementation Status
1. Create this ADR-006 (done). 1. ADR updated to match implemented state. Done.
2. Add SQLite support in `backend/shared/db/` with GORM migrations. 2. Compose includes PostgreSQL service and persistent volume. Done.
3. Implement admin UI pages for storage monitoring and configuration (linked to ADR-005). 3. Chat backend supports PostgreSQL and SQLite based on `DATABASE_URL`. Done.
4. Add configuration reload capability without full restart where possible. 4. Media metadata persistence model documented here for Admin UI track. In progress (implementation ongoing).
5. Write migration script from SQLite to PostgreSQL. 5. Admin storage observability and operations UI (vacuum/analyze/backup flows). Planned.
6. Update `docker-compose.yml` to optionally include PostgreSQL.
7. Add tests for data access patterns and admin configuration changes.
**Next ADR**: ADR-007 (Redis usage and admin configuration capabilities).
## References ## References
- ADR-001: Chat persistence, moderation log, monitoring requirements. - ADR-001: Chat persistence, moderation log, monitoring requirements.
- ADR-003: Preference for operational simplicity and Go backend. - ADR-003: Preference for operational simplicity and Go backend.
- ADR-005: Admin interface for user management, moderation, and analytics – this ADR extends it with storage and Redis config capabilities. - ADR-005: Admin interface for user management, moderation, and analytics.
- arc42 documentation: Cross-cutting Concepts (data management, monitoring) and Risks & Technical Debts. - ADR-007: Redis usage for presence, rate limiting, pub/sub.
- `docker-compose.yml` and `docker-compose.prod.yml` database service definitions.
This ADR ensures all data storage decisions are documented before code is written, maintaining full traceability.
+249 -709
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;
} }
+43 -50
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 for all viewers (per ADR-001)</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"
} }
} }
+773
View File
@@ -0,0 +1,773 @@
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 MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024;
const UPLOAD_RESUME_KEY_PREFIX = "linearvoid-upload-resume:";
const state = {
mediaUploading: false,
moderationSubmitting: false,
playlistSaving: false,
};
const uploadProgressWrap = () =>
document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
const uploadProgressBar = () =>
document.getElementById("media-upload-progress") as HTMLProgressElement | null;
const uploadProgressText = () =>
document.getElementById("media-upload-progress-text") as HTMLElement | null;
const setUploadProgress = (value: number, total: number, label: string) => {
const wrap = uploadProgressWrap();
const progress = uploadProgressBar();
const text = uploadProgressText();
if (!wrap || !progress || !text) return;
wrap.hidden = false;
progress.max = total;
progress.value = value;
const pct = total > 0 ? Math.min(100, Math.round((value / total) * 100)) : 0;
text.textContent = `${label} (${pct}%)`;
};
const hideUploadProgress = () => {
const wrap = uploadProgressWrap();
if (wrap) wrap.hidden = true;
};
const getFileFingerprint = (file: File) =>
`${file.name}:${file.size}:${file.lastModified}`;
const getResumeStorageKey = (fingerprint: string) =>
`${UPLOAD_RESUME_KEY_PREFIX}${fingerprint}`;
const getStoredUploadId = (fingerprint: string) => {
try {
return window.localStorage.getItem(getResumeStorageKey(fingerprint)) || "";
} catch {
return "";
}
};
const setStoredUploadId = (fingerprint: string, uploadId: string) => {
try {
window.localStorage.setItem(getResumeStorageKey(fingerprint), uploadId);
} catch {
// ignore storage failures
}
};
const clearStoredUploadId = (fingerprint: string) => {
try {
window.localStorage.removeItem(getResumeStorageKey(fingerprint));
} catch {
// ignore storage failures
}
};
const readErrorMessage = async (response: Response) => {
const fallback = `HTTP ${response.status}`;
try {
const text = (await response.text()).trim();
return text || fallback;
} catch {
return fallback;
}
};
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;
if (file.size <= 0) {
setStatus("media-status", "Cannot upload an empty file.", true);
return;
}
if (file.size > MAX_UPLOAD_BYTES) {
setStatus("media-status", "File exceeds 2GB upload limit.", true);
return;
}
state.mediaUploading = true;
setStatus("media-status", `Preparing upload for ${file.name}...`);
try {
const totalChunks = Math.ceil(file.size / UPLOAD_CHUNK_BYTES);
const fingerprint = getFileFingerprint(file);
const initResponse = await fetch("/api/admin/media/uploads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
uploadId: getStoredUploadId(fingerprint),
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
chunkSize: UPLOAD_CHUNK_BYTES,
totalChunks,
}),
});
if (!initResponse.ok) {
throw new Error(await readErrorMessage(initResponse));
}
const initPayload = await initResponse.json();
const uploadId = String(initPayload.uploadId || "");
if (!uploadId) {
throw new Error("Upload session could not be initialized.");
}
setStoredUploadId(fingerprint, uploadId);
const alreadyUploaded = Number(initPayload.uploadedChunks || 0);
const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks);
const resumeFrom = Math.min(Math.max(alreadyUploaded, 0), effectiveTotalChunks);
setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks");
for (let chunkIndex = resumeFrom; chunkIndex < effectiveTotalChunks; chunkIndex += 1) {
const start = chunkIndex * UPLOAD_CHUNK_BYTES;
const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES);
const chunk = file.slice(start, end);
const chunkResponse = await fetch(
`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/chunk?index=${chunkIndex}`,
{
method: "PUT",
headers: { "Content-Type": "application/octet-stream" },
body: chunk,
},
);
if (!chunkResponse.ok) {
throw new Error(await readErrorMessage(chunkResponse));
}
setUploadProgress(chunkIndex + 1, effectiveTotalChunks, "Uploading chunks");
setStatus(
"media-status",
`Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`,
);
}
const completeResponse = await fetch(
`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`,
{
method: "POST",
},
);
if (!completeResponse.ok) {
throw new Error(await readErrorMessage(completeResponse));
}
setUploadProgress(effectiveTotalChunks, effectiveTotalChunks, "Finalizing");
const payload = await completeResponse.json();
renderMediaRows(payload.files || []);
setText("media-generated-at", payload.generatedAt || "unavailable");
setStatus("media-status", `${file.name} uploaded.`);
clearStoredUploadId(fingerprint);
await refreshOverview();
await refreshSchedule();
} catch (error) {
console.error("Failed to upload media", error);
const details = error instanceof Error ? error.message : "Unknown upload error.";
setStatus(
"media-status",
`Upload interrupted. Re-select the same file to resume. (${details})`,
true,
);
} finally {
state.mediaUploading = false;
setTimeout(() => hideUploadProgress(), 1200);
}
}
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;
const uploadDropzone = document.getElementById("upload-dropzone");
document
.getElementById("upload-trigger-btn")
?.addEventListener("click", () => uploadInput?.click());
uploadDropzone?.addEventListener("dragover", (event) => {
event.preventDefault();
uploadDropzone.classList.add("is-dragover");
});
uploadDropzone?.addEventListener("dragleave", () => {
uploadDropzone.classList.remove("is-dragover");
});
uploadDropzone?.addEventListener("drop", async (event) => {
event.preventDefault();
uploadDropzone.classList.remove("is-dragover");
const droppedFiles = event.dataTransfer?.files;
if (!droppedFiles || droppedFiles.length === 0) return;
await uploadVideo(droppedFiles[0]);
});
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));
});
+58 -66
View File
@@ -1,17 +1,16 @@
import Hls from "hls.js";
import "./style.css"; import "./style.css";
// Main application state with Alpine.js type HlsClass = typeof import("hls.js")["default"];
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;
} }
} }
let hls: Hls | null = null; let hls: InstanceType<HlsClass> | null = null;
let ws: WebSocket | null = null; let ws: WebSocket | null = null;
let currentRoom = "main"; let currentRoom = "main";
let username = "viewer" + Math.floor(Math.random() * 1000); let username = "viewer" + Math.floor(Math.random() * 1000);
@@ -89,17 +88,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 +110,49 @@ function addMessage(
messages.scrollTop = messages.scrollHeight; messages.scrollTop = messages.scrollHeight;
} }
async function initPlayerAndChat() {
const video = document.getElementById("player") as HTMLVideoElement;
const manifestUrl = resolveManifestUrl();
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = manifestUrl;
video.addEventListener("loadedmetadata", () => video.play());
} else {
try {
const { default: Hls } = await import("hls.js");
if (Hls.isSupported()) {
hls = new Hls();
hls.loadSource(manifestUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
}
} catch (error) {
console.error("Failed to load HLS player", error);
addMessage("system", "Unable to initialize video player.");
}
}
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 +187,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 void initPlayerAndChat();
window.app().init();
}); });
+821 -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: [],
};
+1
View File
@@ -3,6 +3,7 @@ import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
envDir: "..", envDir: "..",
build: { build: {
chunkSizeWarningLimit: 600,
rollupOptions: { rollupOptions: {
input: { input: {
main: "index.html", main: "index.html",
+3
View File
@@ -24,6 +24,9 @@ http {
listen 80 default_server; listen 80 default_server;
server_name linearvoid.tv localhost 127.0.0.1; server_name linearvoid.tv localhost 127.0.0.1;
# Resumable admin upload chunks are 8MB by default; allow safe headroom.
client_max_body_size 64m;
root /srv/frontend; root /srv/frontend;
index index.html; index index.html;