Compare commits
3
Commits
497f7743f2
...
c10d98f3b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c10d98f3b4 | ||
|
|
207401b5fb | ||
|
|
d4d7806189 |
No files matched your search
@@ -0,0 +1,282 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
This document describes the currently implemented API surfaces in Linear Void.
|
||||||
|
|
||||||
|
## Base URLs
|
||||||
|
|
||||||
|
- Public app and admin: <https://linearvoid.tv>
|
||||||
|
- WebSocket endpoint: <wss://linearvoid.tv/ws>
|
||||||
|
- Local fallback: <http://localhost> and <ws://localhost/ws>
|
||||||
|
|
||||||
|
## Authentication Model
|
||||||
|
|
||||||
|
Admin REST endpoints use a cookie session.
|
||||||
|
|
||||||
|
- Cookie name: linearvoid_admin_session
|
||||||
|
- Cookie attributes: HttpOnly, SameSite=Strict, Secure when request is HTTPS (or X-Forwarded-Proto=https)
|
||||||
|
- Session TTL: controlled by ADMIN_SESSION_TTL_HOURS (default 12h)
|
||||||
|
|
||||||
|
## Chat WebSocket API
|
||||||
|
|
||||||
|
### Connect
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
- GET /ws?username={name}&room={room}
|
||||||
|
|
||||||
|
Client sends JSON messages over the socket.
|
||||||
|
|
||||||
|
### Message Envelope (client -> server)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room": "main",
|
||||||
|
"username": "viewer123",
|
||||||
|
"text": "hello world",
|
||||||
|
"type": "public"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- type is usually public for room chat and private for DM room routing.
|
||||||
|
- Private room names are formatted like dm:userA-userB (sorted usernames).
|
||||||
|
- Moderation/system events are sent as type=system messages.
|
||||||
|
|
||||||
|
### Message Envelope (server -> client)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room": "main",
|
||||||
|
"username": "viewer123",
|
||||||
|
"text": "hello world",
|
||||||
|
"type": "public",
|
||||||
|
"timestamp": "2026-08-04T12:34:56Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin Session API
|
||||||
|
|
||||||
|
### Login
|
||||||
|
|
||||||
|
- POST /api/admin/login
|
||||||
|
- Auth: none
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "your-password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authenticated": true,
|
||||||
|
"username": "admin"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logout
|
||||||
|
|
||||||
|
- POST /api/admin/logout
|
||||||
|
- Auth: optional (clears cookie either way)
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authenticated": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Check
|
||||||
|
|
||||||
|
- GET /api/admin/session
|
||||||
|
- Auth: cookie required
|
||||||
|
|
||||||
|
Success response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authenticated": true,
|
||||||
|
"username": "admin"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Failure:
|
||||||
|
|
||||||
|
- 401 unauthorized
|
||||||
|
|
||||||
|
## Admin Data API
|
||||||
|
|
||||||
|
All endpoints below require admin authentication.
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
- GET /api/admin/overview
|
||||||
|
|
||||||
|
Returns aggregate counters and runtime/system snapshot fields.
|
||||||
|
|
||||||
|
### Playlist
|
||||||
|
|
||||||
|
- GET /api/admin/playlist
|
||||||
|
- PUT /api/admin/playlist
|
||||||
|
|
||||||
|
PUT request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "file '/app/media/video1.mp4'\nfile '/app/media/video2.mp4'\n"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schedule
|
||||||
|
|
||||||
|
- GET /api/admin/schedule
|
||||||
|
|
||||||
|
Returns parsed playlist entries plus matched media metadata/runtime.
|
||||||
|
|
||||||
|
### Moderation
|
||||||
|
|
||||||
|
- GET /api/admin/moderation/logs?limit=50
|
||||||
|
- POST /api/admin/moderation/actions
|
||||||
|
|
||||||
|
Action request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "mute",
|
||||||
|
"target": "username-or-id",
|
||||||
|
"moderator": "admin",
|
||||||
|
"reason": "spam",
|
||||||
|
"room": "main"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analytics
|
||||||
|
|
||||||
|
- GET /api/admin/analytics
|
||||||
|
|
||||||
|
Returns totals (messages, moderation actions, rooms) and top room summary.
|
||||||
|
|
||||||
|
### System
|
||||||
|
|
||||||
|
- GET /api/admin/system
|
||||||
|
|
||||||
|
Returns service health snapshot for chat/playout/redis/database and config metadata.
|
||||||
|
|
||||||
|
### Active Users
|
||||||
|
|
||||||
|
- GET /api/admin/users
|
||||||
|
|
||||||
|
Returns currently discovered presence users (room + username) when Redis is available.
|
||||||
|
|
||||||
|
## Media Library API
|
||||||
|
|
||||||
|
### Media Collection
|
||||||
|
|
||||||
|
- GET /api/admin/media
|
||||||
|
- POST /api/admin/media (multipart upload, legacy path)
|
||||||
|
|
||||||
|
Multipart form field name: file
|
||||||
|
|
||||||
|
### Media Item
|
||||||
|
|
||||||
|
- GET /api/admin/media/{id}
|
||||||
|
- PUT /api/admin/media/{id}
|
||||||
|
- DELETE /api/admin/media/{id}
|
||||||
|
|
||||||
|
PUT request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ready",
|
||||||
|
"title": "My Clip",
|
||||||
|
"tags": "music,live"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resumable Upload API
|
||||||
|
|
||||||
|
Maximum file size: 2GB
|
||||||
|
|
||||||
|
Default chunk size: 8MB
|
||||||
|
|
||||||
|
### Initialize or Resume Session
|
||||||
|
|
||||||
|
- POST /api/admin/media/uploads
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uploadId": "optional-existing-id",
|
||||||
|
"fileName": "clip.mp4",
|
||||||
|
"fileSize": 123456789,
|
||||||
|
"mimeType": "video/mp4",
|
||||||
|
"chunkSize": 8388608,
|
||||||
|
"totalChunks": 15
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uploadId": "hex-token",
|
||||||
|
"fileName": "clip.mp4",
|
||||||
|
"fileSize": 123456789,
|
||||||
|
"chunkSize": 8388608,
|
||||||
|
"totalChunks": 15,
|
||||||
|
"uploadedChunks": 3,
|
||||||
|
"progress": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Upload Status
|
||||||
|
|
||||||
|
- GET /api/admin/media/uploads/{uploadId}
|
||||||
|
|
||||||
|
Returns same status envelope as initialize.
|
||||||
|
|
||||||
|
### Upload Chunk
|
||||||
|
|
||||||
|
- PUT /api/admin/media/uploads/{uploadId}/chunk?index={chunkIndex}
|
||||||
|
- Content-Type: application/octet-stream
|
||||||
|
|
||||||
|
Response returns updated upload status envelope.
|
||||||
|
|
||||||
|
### Complete Upload
|
||||||
|
|
||||||
|
- POST /api/admin/media/uploads/{uploadId}/complete
|
||||||
|
|
||||||
|
On success returns admin media listing payload (files + generatedAt).
|
||||||
|
|
||||||
|
## Metrics and Health
|
||||||
|
|
||||||
|
### Chat metrics
|
||||||
|
|
||||||
|
- GET /metrics
|
||||||
|
|
||||||
|
Prometheus exposition includes connected clients, stored messages, moderation actions, Redis indicators, active admin sessions, and build info.
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
- GET /health on chat service
|
||||||
|
- GET /health on playout service
|
||||||
|
- GET /health on nginx reverse proxy
|
||||||
|
|
||||||
|
## HTTP Status Conventions
|
||||||
|
|
||||||
|
- 200/201: successful request
|
||||||
|
- 400: malformed payload, invalid input, missing required fields
|
||||||
|
- 401: unauthorized admin request
|
||||||
|
- 404: not found
|
||||||
|
- 405: method not allowed
|
||||||
|
- 409: logical conflict (for example, mismatched resumed upload)
|
||||||
|
- 413: payload too large
|
||||||
|
- 500: internal server error
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
- 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.
|
- 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.
|
- Added resumable chunked admin media upload flow with upload-session endpoints and client progress indicators.
|
||||||
|
- Expanded architecture documentation with scaling strategy, API surface reference, detailed video normalization spec, and Cloudflare configuration reference.
|
||||||
|
- Added `API.md`, `DEPLOYMENT.md`, `MONITORING.md`, and `SECURITY.md` as dedicated operational documentation guides.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -20,7 +22,10 @@
|
|||||||
- 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.
|
- 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.
|
- Replaced legacy and Tailwind-based stylesheet rules with a custom design-token CSS system covering responsive layouts, forms, tables, and reduced-motion behavior.
|
||||||
|
- Switched the frontend theme to dark-mode-first token defaults with a light-scheme override path for users who prefer light appearance.
|
||||||
|
- Refactored the viewer page to a single header plus unified content layout with responsive video/chat split, mobile stacking, and chat panel toggle 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.
|
- 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.
|
||||||
|
- Added navigation links in root README and architecture docs to the new API, deployment, monitoring, and security guide files.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -30,6 +35,7 @@
|
|||||||
- Removed Tailwind and Alpine runtime/build dependencies from the frontend package configuration and eliminated Tailwind directives/warnings during production build.
|
- 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.
|
- 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.
|
- Increased nginx `client_max_body_size` to allow resumable admin upload chunks and prevent `413 Content Too Large` responses.
|
||||||
|
- Removed legacy now-playing viewer UI elements that conflicted with the streamlined single-header layout.
|
||||||
|
|
||||||
## [1.0.3] - 2026-08-03
|
## [1.0.3] - 2026-08-03
|
||||||
|
|
||||||
|
|||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
# Production Deployment Guide
|
||||||
|
|
||||||
|
This guide describes a production-style deployment for Linear Void using Docker Compose, Gitea registry images, and Cloudflare fronting.
|
||||||
|
|
||||||
|
## Target Topology
|
||||||
|
|
||||||
|
- Host: Linux server with Docker Engine
|
||||||
|
- Reverse proxy: nginx container (linearvoid-nginx)
|
||||||
|
- Core services: playout, chat, redis, postgres
|
||||||
|
- Observability: prometheus, grafana
|
||||||
|
- CDN/edge: Cloudflare
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker Engine + Docker Compose plugin
|
||||||
|
- Network access to git.allucanget.biz
|
||||||
|
- A valid .env file with production values
|
||||||
|
- DNS configured for linearvoid.tv (and media.linearvoid.tv if split media domain is used)
|
||||||
|
|
||||||
|
## Environment File
|
||||||
|
|
||||||
|
Use .env with at least:
|
||||||
|
|
||||||
|
- CI_REGISTRY_URL
|
||||||
|
- CI_REGISTRY_NAMESPACE
|
||||||
|
- CHAT_PORT
|
||||||
|
- NGINX_PORT
|
||||||
|
- DATABASE_URL (if overridden)
|
||||||
|
- REDIS_URL (if overridden)
|
||||||
|
- ADMIN_USERNAME
|
||||||
|
- ADMIN_PASSWORD
|
||||||
|
- VITE_PUBLIC_APP_URL
|
||||||
|
- VITE_PUBLIC_WS_URL
|
||||||
|
- VITE_PUBLIC_HLS_URL
|
||||||
|
- Cloudflare/R2 variables when media sync is enabled
|
||||||
|
|
||||||
|
See .env.example and SETUP.md for full variable reference.
|
||||||
|
|
||||||
|
## Pull-and-Run Deployment
|
||||||
|
|
||||||
|
From repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login git.allucanget.biz
|
||||||
|
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||||
|
```
|
||||||
|
|
||||||
|
## First-Time or Recovery Provisioning
|
||||||
|
|
||||||
|
1. Ensure host paths exist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p data/chat data/playlist data/media data/hls
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Confirm volumes and networks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker volume ls
|
||||||
|
docker network ls
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Start stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health Verification
|
||||||
|
|
||||||
|
After startup, check service status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoint checks:
|
||||||
|
|
||||||
|
- App: <http://{host}:8080/>
|
||||||
|
- Nginx health: <http://{host}:8080/health>
|
||||||
|
- Chat health: <http://{host}:3000/health>
|
||||||
|
- Playout health: <http://{host}:8081/health>
|
||||||
|
- Prometheus: <http://{host}:9090>
|
||||||
|
- Grafana: <http://{host}:3001>
|
||||||
|
|
||||||
|
Domain checks:
|
||||||
|
|
||||||
|
- <https://linearvoid.tv>
|
||||||
|
- <https://linearvoid.tv/admin>
|
||||||
|
- <https://linearvoid.tv/metrics>
|
||||||
|
|
||||||
|
## Zero/Low-Downtime Update Pattern
|
||||||
|
|
||||||
|
1. Pull newest images.
|
||||||
|
1. Recreate changed services only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
|
||||||
|
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Re-run health checks.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If new images are unhealthy:
|
||||||
|
|
||||||
|
1. Pin previous tags in docker-compose.prod.yml.
|
||||||
|
2. Pull pinned tags.
|
||||||
|
3. Restart impacted services.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
|
||||||
|
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operational Tasks
|
||||||
|
|
||||||
|
### View logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml logs -f nginx chat playout
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart one service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml restart chat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keep volumes (recommended) vs remove volumes (destructive)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# keep data
|
||||||
|
docker compose -f docker-compose.prod.yml down
|
||||||
|
|
||||||
|
# destructive (remove data)
|
||||||
|
docker compose -f docker-compose.prod.yml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nginx and Proxy Considerations
|
||||||
|
|
||||||
|
- If an external proxy (for example Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx.
|
||||||
|
- For resumable admin uploads, ensure client_max_body_size supports chunk requests in both proxy layers.
|
||||||
|
|
||||||
|
## Security Baseline
|
||||||
|
|
||||||
|
- Use strong ADMIN_PASSWORD and rotate regularly.
|
||||||
|
- Keep Docker host access restricted.
|
||||||
|
- Prefer HTTPS/WSS through Cloudflare.
|
||||||
|
- Restrict exposed ports if only proxy access is needed.
|
||||||
|
|
||||||
|
## Common Deployment Pitfalls
|
||||||
|
|
||||||
|
- Missing frontend build artifacts in local-only compose workflows.
|
||||||
|
- Wrong registry credentials or missing login.
|
||||||
|
- Misaligned domain env vars (app/ws/hls) causing mixed-origin errors.
|
||||||
|
- Proxy-layer body size limits returning 413 on upload.
|
||||||
|
- Health checks failing before service warmup is complete.
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
# Monitoring Guide
|
||||||
|
|
||||||
|
This guide covers Prometheus and Grafana setup for Linear Void.
|
||||||
|
|
||||||
|
## Current Monitoring Components
|
||||||
|
|
||||||
|
- Prometheus container: linearvoid-prometheus
|
||||||
|
- Grafana container: linearvoid-grafana
|
||||||
|
- Metrics target: chat service /metrics
|
||||||
|
- Base scrape config: monitoring/prometheus/prometheus.yml
|
||||||
|
|
||||||
|
## Prometheus Configuration
|
||||||
|
|
||||||
|
Current scrape config:
|
||||||
|
|
||||||
|
- Job chat -> chat:3000
|
||||||
|
- Job prometheus -> prometheus:9090
|
||||||
|
- Global scrape interval: 15s
|
||||||
|
|
||||||
|
File:
|
||||||
|
|
||||||
|
- monitoring/prometheus/prometheus.yml
|
||||||
|
|
||||||
|
## Start Monitoring Stack
|
||||||
|
|
||||||
|
With compose stack up:
|
||||||
|
|
||||||
|
- Prometheus UI: <http://localhost:9090>
|
||||||
|
- Grafana UI: <http://localhost:3001>
|
||||||
|
|
||||||
|
Compose services include prometheus and grafana in both docker-compose.yml and docker-compose.prod.yml.
|
||||||
|
|
||||||
|
## Grafana Provisioning
|
||||||
|
|
||||||
|
Current mount paths:
|
||||||
|
|
||||||
|
- Dashboards directory: monitoring/grafana/dashboards
|
||||||
|
- Datasources directory: monitoring/grafana/datasources
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The dashboards directory includes a Prometheus overview dashboard file.
|
||||||
|
- Datasource provisioning can be done with a YAML file in monitoring/grafana/datasources, or by configuring a datasource in Grafana UI.
|
||||||
|
|
||||||
|
Example datasource provisioning file (create if needed):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dashboard References
|
||||||
|
|
||||||
|
- Provisioned dashboard: [monitoring/grafana/dashboards/dashboard.json](monitoring/grafana/dashboards/dashboard.json)
|
||||||
|
- Root dashboard snapshot: [monitoring/grafana/dashboard.json](monitoring/grafana/dashboard.json)
|
||||||
|
|
||||||
|
Grafana screenshot:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Key Metrics to Track
|
||||||
|
|
||||||
|
### Chat/Application
|
||||||
|
|
||||||
|
- linearvoid_chat_connected_clients
|
||||||
|
- linearvoid_chat_stored_messages_total
|
||||||
|
- linearvoid_chat_moderation_actions_total
|
||||||
|
- linearvoid_chat_redis_enabled
|
||||||
|
- linearvoid_chat_redis_up
|
||||||
|
- linearvoid_chat_active_admin_sessions
|
||||||
|
|
||||||
|
### Prometheus Runtime
|
||||||
|
|
||||||
|
- up
|
||||||
|
- process_cpu_seconds_total
|
||||||
|
- process_resident_memory_bytes
|
||||||
|
- prometheus_tsdb_head_series
|
||||||
|
- prometheus_tsdb_wal_writes_failed_total
|
||||||
|
- prometheus_tsdb_compactions_failed_total
|
||||||
|
|
||||||
|
## Suggested Alerts
|
||||||
|
|
||||||
|
Create a Prometheus rules file (for example monitoring/prometheus/alerts.yml) and wire it into Prometheus.
|
||||||
|
|
||||||
|
Recommended baseline alerts:
|
||||||
|
|
||||||
|
- Chat target down for 2m
|
||||||
|
- Prometheus target down for 2m
|
||||||
|
- Redis indicator down for 2m
|
||||||
|
- TSDB WAL/compaction failure rate above zero
|
||||||
|
- Elevated handler/query latency p99
|
||||||
|
|
||||||
|
## Validate Monitoring End-to-End
|
||||||
|
|
||||||
|
1. Verify /metrics through nginx:
|
||||||
|
|
||||||
|
- <https://linearvoid.tv/metrics>
|
||||||
|
|
||||||
|
1. Confirm targets in Prometheus:
|
||||||
|
|
||||||
|
- Status -> Targets page in Prometheus UI
|
||||||
|
|
||||||
|
1. Confirm dashboard data in Grafana:
|
||||||
|
|
||||||
|
- Import or open provisioned dashboards
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### No chat metrics in Prometheus
|
||||||
|
|
||||||
|
- Check chat container health and logs.
|
||||||
|
- Confirm /metrics is reachable from Prometheus container.
|
||||||
|
- Verify scrape target is chat:3000 in prometheus.yml.
|
||||||
|
|
||||||
|
### Grafana shows no data
|
||||||
|
|
||||||
|
- Ensure datasource points to <http://prometheus:9090>.
|
||||||
|
- Confirm Prometheus is healthy.
|
||||||
|
- Check dashboard query labels match active jobs.
|
||||||
|
|
||||||
|
### High cardinality or query slowness
|
||||||
|
|
||||||
|
- Reduce unneeded labels.
|
||||||
|
- Limit expensive dashboard panels.
|
||||||
|
- Tune retention and query windows.
|
||||||
|
|
||||||
|
## Operational Recommendations
|
||||||
|
|
||||||
|
- Keep alert thresholds tied to actual user impact.
|
||||||
|
- Review dashboards after each release that changes endpoints or metrics names.
|
||||||
|
- Periodically test alert routing and incident response flow.
|
||||||
@@ -204,6 +204,10 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
|
|||||||
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
|
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
|
||||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project.
|
- [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project.
|
||||||
- [SETUP.md](SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
|
- [SETUP.md](SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
|
||||||
|
- [API.md](API.md) - Implemented WebSocket and REST API reference.
|
||||||
|
- [DEPLOYMENT.md](DEPLOYMENT.md) - Production deployment and operations guide.
|
||||||
|
- [MONITORING.md](MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
|
||||||
|
- [SECURITY.md](SECURITY.md) - Security controls, hardening checklist, and incident baseline.
|
||||||
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
|
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
|
||||||
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
|
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
|
||||||
|
|
||||||
|
|||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
# Security Guide
|
||||||
|
|
||||||
|
This document summarizes security controls and best practices for Linear Void.
|
||||||
|
|
||||||
|
## Security Model Overview
|
||||||
|
|
||||||
|
Security is layered across:
|
||||||
|
|
||||||
|
- Cloudflare edge
|
||||||
|
- nginx reverse proxy
|
||||||
|
- chat/admin backend
|
||||||
|
- container and host operations
|
||||||
|
|
||||||
|
## Authentication and Sessions
|
||||||
|
|
||||||
|
Admin access is session-cookie based.
|
||||||
|
|
||||||
|
- Login endpoint: /api/admin/login
|
||||||
|
- Session endpoint: /api/admin/session
|
||||||
|
- Logout endpoint: /api/admin/logout
|
||||||
|
- Cookie: linearvoid_admin_session
|
||||||
|
- Cookie flags: HttpOnly, SameSite=Strict, Secure over HTTPS
|
||||||
|
- Session expiry enforced server-side
|
||||||
|
|
||||||
|
Best practices:
|
||||||
|
|
||||||
|
- Set strong ADMIN_USERNAME and ADMIN_PASSWORD values.
|
||||||
|
- Rotate admin credentials periodically.
|
||||||
|
- Keep ADMIN_SESSION_TTL_HOURS aligned with operational policy.
|
||||||
|
|
||||||
|
## Transport Security
|
||||||
|
|
||||||
|
- Enforce HTTPS for app/admin endpoints.
|
||||||
|
- Use WSS for chat WebSocket.
|
||||||
|
- Keep Cloudflare TLS mode strict and valid at origin.
|
||||||
|
|
||||||
|
## Reverse Proxy Controls
|
||||||
|
|
||||||
|
nginx controls include:
|
||||||
|
|
||||||
|
- auth_request guard for /admin and /api/admin paths
|
||||||
|
- dedicated unauthorized behavior for admin API responses
|
||||||
|
- explicit websocket upgrade headers for /ws
|
||||||
|
- upload body-size limits for resumable media chunk uploads
|
||||||
|
|
||||||
|
Best practices:
|
||||||
|
|
||||||
|
- Keep proxy body-size and timeout settings aligned across all proxy layers.
|
||||||
|
- Avoid exposing backend service ports publicly unless required.
|
||||||
|
|
||||||
|
## Input Validation and Upload Safety
|
||||||
|
|
||||||
|
Server-side protections include:
|
||||||
|
|
||||||
|
- extension allow-list for media uploads
|
||||||
|
- MIME checks
|
||||||
|
- file-size ceiling (2GB)
|
||||||
|
- resumable chunk bounds and chunk-index validation
|
||||||
|
- media path safety checks on delete
|
||||||
|
|
||||||
|
Best practices:
|
||||||
|
|
||||||
|
- Keep upload chunk size bounded.
|
||||||
|
- Reject unsupported content types consistently.
|
||||||
|
- Monitor 4xx/5xx rates on upload routes.
|
||||||
|
|
||||||
|
## Rate Limiting and Abuse Controls
|
||||||
|
|
||||||
|
- Chat rate limiting uses Redis INCR + EXPIRE windows.
|
||||||
|
- Current baseline: more than 10 messages in 10 seconds is throttled.
|
||||||
|
- Moderation actions are logged for auditability.
|
||||||
|
|
||||||
|
Best practices:
|
||||||
|
|
||||||
|
- Tune thresholds by observed abuse patterns.
|
||||||
|
- Keep moderation logs reviewed and retained per policy.
|
||||||
|
|
||||||
|
## Infrastructure and Secrets
|
||||||
|
|
||||||
|
- Store secrets in .env (not in committed source).
|
||||||
|
- Use Gitea Actions secrets for CI/CD credentials and admin credentials.
|
||||||
|
- Restrict Docker host control plane exposure.
|
||||||
|
- Limit who can read deployment environment files.
|
||||||
|
|
||||||
|
## Cloudflare and Edge Security
|
||||||
|
|
||||||
|
Recommended baseline:
|
||||||
|
|
||||||
|
- WAF enabled
|
||||||
|
- Rate limits on sensitive paths
|
||||||
|
- Bot protection where appropriate
|
||||||
|
- Cache rules scoped carefully to avoid caching private/control-plane content
|
||||||
|
|
||||||
|
## Data Protection and Retention
|
||||||
|
|
||||||
|
- Persistent data: postgres, redis append-only, chat state, playlist/media metadata
|
||||||
|
- Backup and restore drills should be periodic
|
||||||
|
- Restore procedures should be tested in isolated environments
|
||||||
|
|
||||||
|
## Logging and Monitoring for Security
|
||||||
|
|
||||||
|
Track and alert on:
|
||||||
|
|
||||||
|
- repeated failed admin login attempts
|
||||||
|
- spikes in 401 and 403 responses
|
||||||
|
- sudden upload errors (413/400/500 patterns)
|
||||||
|
- unusual websocket disconnect or message-rate anomalies
|
||||||
|
|
||||||
|
## Hardening Checklist
|
||||||
|
|
||||||
|
- [ ] HTTPS/WSS enforced end-to-end
|
||||||
|
- [ ] Strong admin credentials configured
|
||||||
|
- [ ] Secrets only in env/secrets stores
|
||||||
|
- [ ] Proxy auth and websocket headers validated
|
||||||
|
- [ ] Upload limits and validation active
|
||||||
|
- [ ] Rate limiting active and tested
|
||||||
|
- [ ] Cloudflare WAF/rate-limit rules active
|
||||||
|
- [ ] Backup/restore drills completed
|
||||||
|
- [ ] Security-related monitoring alerts routed
|
||||||
|
|
||||||
|
## Incident Response Baseline
|
||||||
|
|
||||||
|
1. Detect issue (alerts/logs/user reports).
|
||||||
|
2. Triage scope (edge, proxy, app, data plane).
|
||||||
|
3. Contain impact (rate limit, credential rotate, route disable).
|
||||||
|
4. Recover services and verify health.
|
||||||
|
5. Record timeline and corrective actions.
|
||||||
+168
-1
@@ -5,6 +5,7 @@
|
|||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Introduction and Goals](#introduction-and-goals)
|
- [Introduction and Goals](#introduction-and-goals)
|
||||||
|
- [Companion Documentation](#companion-documentation)
|
||||||
- [Architecture Constraints](#architecture-constraints)
|
- [Architecture Constraints](#architecture-constraints)
|
||||||
- [Context and Scope](#context-and-scope)
|
- [Context and Scope](#context-and-scope)
|
||||||
- [Solution Strategy](#solution-strategy)
|
- [Solution Strategy](#solution-strategy)
|
||||||
@@ -17,6 +18,13 @@
|
|||||||
- [Risks and Technical Debts](#risks-and-technical-debts)
|
- [Risks and Technical Debts](#risks-and-technical-debts)
|
||||||
- [Glossary](#glossary)
|
- [Glossary](#glossary)
|
||||||
|
|
||||||
|
## Companion Documentation
|
||||||
|
|
||||||
|
- [API.md](../../API.md) - Implemented WebSocket and REST API reference.
|
||||||
|
- [DEPLOYMENT.md](../../DEPLOYMENT.md) - Production deployment and operational runbook.
|
||||||
|
- [MONITORING.md](../../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
||||||
|
- [SECURITY.md](../../SECURITY.md) - Security model, controls, and hardening baseline.
|
||||||
|
|
||||||
## Introduction and Goals
|
## Introduction and Goals
|
||||||
|
|
||||||
### Requirements Overview
|
### Requirements Overview
|
||||||
@@ -311,7 +319,39 @@ Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = ne
|
|||||||
|
|
||||||
### Video Normalization & Playout
|
### Video Normalization & Playout
|
||||||
|
|
||||||
All source assets must be pre-normalized (H.264/AVC, AAC-LC, constant 30 fps, 2s keyframe interval, CRF). Prevents glitches during continuous concat. FFmpeg `-c copy` preferred for efficiency.
|
All source assets must be pre-normalized before they are accepted into production playlists. This prevents decode resets, timestamp drift, and audio/video discontinuities during continuous concat playout.
|
||||||
|
|
||||||
|
#### Required Source Profile (Linear Void v1)
|
||||||
|
|
||||||
|
| Field | Required Value | Notes |
|
||||||
|
| ----------------------- | ---------------------------------- | ------------------------------------------------------- |
|
||||||
|
| Container | MP4 | Single-video-asset input format |
|
||||||
|
| Video codec | H.264/AVC (`libx264`) | Baseline delivery codec for broad compatibility |
|
||||||
|
| Audio codec | AAC-LC | Stereo preferred |
|
||||||
|
| Resolution | 1280x720 or 1920x1080 | Keep single resolution per playlist block when possible |
|
||||||
|
| Framerate | Constant 30 fps (`-r 30`) | Variable frame rate content must be normalized |
|
||||||
|
| GOP / keyframe interval | 2 seconds (`-g 60 -keyint_min 60`) | Matches HLS segment boundaries better |
|
||||||
|
| Pixel format | `yuv420p` | Browser compatibility baseline |
|
||||||
|
| Audio sample rate | 48 kHz | Stable timing for concat playout |
|
||||||
|
| Audio bitrate | 128k-192k | Predictable quality envelope |
|
||||||
|
|
||||||
|
#### Reference Normalization Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ffmpeg -i input.mov \
|
||||||
|
-c:v libx264 -preset medium -crf 21 \
|
||||||
|
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
|
||||||
|
-pix_fmt yuv420p \
|
||||||
|
-c:a aac -b:a 160k -ar 48000 -ac 2 \
|
||||||
|
-movflags +faststart \
|
||||||
|
output-normalized.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Verification Gate (ffprobe)
|
||||||
|
|
||||||
|
- Validate codec, fps, resolution, and keyframe cadence before upload acceptance.
|
||||||
|
- Reject non-conforming files from admin ingest with actionable error feedback.
|
||||||
|
- Keep verification logic aligned with runtime playout assumptions to preserve `-c copy` efficiency where possible.
|
||||||
|
|
||||||
### Monitoring & Resilience
|
### Monitoring & Resilience
|
||||||
|
|
||||||
@@ -366,6 +406,98 @@ Operational resilience baseline:
|
|||||||
- Weekly backup and restore drills for stateful data (`data/chat`, playlist metadata, Postgres).
|
- 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.
|
- Incident runbook with triage order: edge reachability -> origin health -> playout progression -> chat connectivity -> data-plane dependencies.
|
||||||
|
|
||||||
|
### API Documentation Reference
|
||||||
|
|
||||||
|
Linear Void currently exposes three operational API surfaces. The canonical detailed contract should live in `API.md`, while this section provides the architecture-level map.
|
||||||
|
|
||||||
|
#### Chat WebSocket API
|
||||||
|
|
||||||
|
- Endpoint: `/ws`
|
||||||
|
- Transport: `wss://` in production, `ws://` locally
|
||||||
|
- Query parameters:
|
||||||
|
- `username`
|
||||||
|
- `room`
|
||||||
|
- Message envelope (typical):
|
||||||
|
- `room`
|
||||||
|
- `username`
|
||||||
|
- `text`
|
||||||
|
- `type` (`public`, `private`, `system`)
|
||||||
|
- Responsibilities:
|
||||||
|
- room fan-out
|
||||||
|
- private DM channel routing
|
||||||
|
- moderation command handling
|
||||||
|
- rate limiting and presence integration
|
||||||
|
|
||||||
|
#### Playout and Delivery REST API
|
||||||
|
|
||||||
|
- Health endpoints:
|
||||||
|
- playout health (`/health` on playout service)
|
||||||
|
- edge/origin health (`/health` via nginx)
|
||||||
|
- Stream delivery:
|
||||||
|
- HLS manifest and segment path namespace under `/hls/`
|
||||||
|
- Responsibilities:
|
||||||
|
- expose liveness for orchestration and monitoring
|
||||||
|
- provide stable HLS path contract for viewer and CDN
|
||||||
|
|
||||||
|
#### Admin REST API
|
||||||
|
|
||||||
|
- Auth/session:
|
||||||
|
- `/api/admin/login`
|
||||||
|
- `/api/admin/logout`
|
||||||
|
- `/api/admin/session`
|
||||||
|
- Operational/admin domains:
|
||||||
|
- overview, analytics, users, moderation, schedule, playlist, media
|
||||||
|
- Media upload domain:
|
||||||
|
- `/api/admin/media/uploads` (init/resume)
|
||||||
|
- `/api/admin/media/uploads/{uploadId}/chunk`
|
||||||
|
- `/api/admin/media/uploads/{uploadId}/complete`
|
||||||
|
- Responsibilities:
|
||||||
|
- protected control plane for operator workflows
|
||||||
|
- media metadata + upload orchestration
|
||||||
|
- moderation/audit and system status management
|
||||||
|
|
||||||
|
### Cloudflare Configuration Reference
|
||||||
|
|
||||||
|
Cloudflare is part of the production control plane, not only a CDN edge. The following configuration set is required to maintain cost, latency, and reliability targets.
|
||||||
|
|
||||||
|
#### DNS and Zone Routing
|
||||||
|
|
||||||
|
- `linearvoid.tv` -> application origin (nginx)
|
||||||
|
- `www.linearvoid.tv` -> redirect to apex
|
||||||
|
- `media.linearvoid.tv` -> media domain for HLS/object delivery when split-domain mode is enabled
|
||||||
|
|
||||||
|
#### Cache Rules Baseline
|
||||||
|
|
||||||
|
- HLS segments (`.ts`, `.m4s`):
|
||||||
|
- Cache Everything
|
||||||
|
- long TTL (immutable)
|
||||||
|
- respect immutable semantics
|
||||||
|
- Manifests (`.m3u8`):
|
||||||
|
- short TTL (1-10s)
|
||||||
|
- revalidation-friendly policy for timeline freshness
|
||||||
|
|
||||||
|
#### Edge Security and Controls
|
||||||
|
|
||||||
|
- Enforce HTTPS/WSS and redirect HTTP to HTTPS.
|
||||||
|
- Enable WAF baseline protections for common web exploits.
|
||||||
|
- Apply rate limits for abusive request patterns (especially auth/admin paths).
|
||||||
|
- Keep origin exposure minimized (prefer Cloudflare-only public ingress where feasible).
|
||||||
|
|
||||||
|
#### Worker and Header Policy
|
||||||
|
|
||||||
|
- Use Worker logic for immutable cache headers and request normalization where cache rules alone are insufficient.
|
||||||
|
- Keep CORS behavior explicit for HLS resources to support browser playback.
|
||||||
|
|
||||||
|
#### Operations and Validation
|
||||||
|
|
||||||
|
- Validate cache behavior after deployment:
|
||||||
|
- expected cache status progression (MISS -> HIT)
|
||||||
|
- manifest freshness under active playout
|
||||||
|
- Track edge metrics and adjust:
|
||||||
|
- cache hit ratio
|
||||||
|
- request volume and error rates
|
||||||
|
- ToS/cost posture for sustained traffic profiles
|
||||||
|
|
||||||
### Security & Moderation
|
### Security & Moderation
|
||||||
|
|
||||||
Security is split between edge controls (Cloudflare), reverse proxy controls (nginx), and application controls (chat/admin backend).
|
Security is split between edge controls (Cloudflare), reverse proxy controls (nginx), and application controls (chat/admin backend).
|
||||||
@@ -490,6 +622,41 @@ See **Quality Goals** table in the Introduction section. Primary scenarios focus
|
|||||||
- Trigger: origin CPU/memory pressure on playout host.
|
- Trigger: origin CPU/memory pressure on playout host.
|
||||||
- Action: optimize FFmpeg profile, isolate services, add secondary origin.
|
- Action: optimize FFmpeg profile, isolate services, add secondary origin.
|
||||||
|
|
||||||
|
### Scaling Strategy
|
||||||
|
|
||||||
|
Scaling is designed to preserve cost efficiency first, then increase throughput and resilience in controlled phases.
|
||||||
|
|
||||||
|
#### Phase 1: Vertical + Cache-First (Current Baseline)
|
||||||
|
|
||||||
|
- Single primary origin in US-East with strong Cloudflare edge caching.
|
||||||
|
- Optimize segment immutability and manifest TTL before adding infrastructure.
|
||||||
|
- Scale compute vertically when CPU/memory pressure appears on playout/chat host.
|
||||||
|
|
||||||
|
#### Phase 2: Service Separation
|
||||||
|
|
||||||
|
- Split playout, chat, and observability workloads across dedicated nodes/VMs.
|
||||||
|
- Isolate noisy workloads (analytics, heavy admin tasks) from latency-sensitive chat paths.
|
||||||
|
- Introduce managed PostgreSQL/Redis or dedicated state nodes where operationally justified.
|
||||||
|
|
||||||
|
#### Phase 3: Multi-Origin Resilience
|
||||||
|
|
||||||
|
- Add warm standby or active-secondary origin.
|
||||||
|
- Use health-aware DNS/origin failover strategy.
|
||||||
|
- Keep HLS path and cache contract stable so viewer impact during failover is minimized.
|
||||||
|
|
||||||
|
#### Phase 4: Regional and Throughput Expansion
|
||||||
|
|
||||||
|
- Add additional origin region only when audience distribution and latency justify it.
|
||||||
|
- Keep chat affinity strategy explicit (regional routing, state replication considerations).
|
||||||
|
- Continuously evaluate cache miss economics vs additional origin complexity.
|
||||||
|
|
||||||
|
#### Scaling Triggers
|
||||||
|
|
||||||
|
- Rising cache miss ratio with sustained traffic growth.
|
||||||
|
- Chat p95/p99 latency drift or websocket error growth.
|
||||||
|
- Persistent origin resource saturation despite optimization.
|
||||||
|
- Increased blast-radius concerns from single-host dependency.
|
||||||
|
|
||||||
### Disaster Recovery and Backup Procedures
|
### Disaster Recovery and Backup Procedures
|
||||||
|
|
||||||
#### Recovery Targets
|
#### Recovery Targets
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 284 KiB |
+32
-50
@@ -10,63 +10,45 @@
|
|||||||
|
|
||||||
<body class="lv-body">
|
<body class="lv-body">
|
||||||
<main class="viewer-shell" aria-label="Live player and chat">
|
<main class="viewer-shell" aria-label="Live player and chat">
|
||||||
<section class="viewer-main" aria-label="Video stream panel">
|
<header class="viewer-header">
|
||||||
<header class="viewer-header">
|
<div class="viewer-brand-group">
|
||||||
<div class="viewer-brand-group">
|
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
<div>
|
||||||
<div>
|
<h1 class="viewer-title">Linear Void</h1>
|
||||||
<h1 class="viewer-title">Linear Void</h1>
|
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="now-playing-pill" aria-live="polite">
|
</div>
|
||||||
<span class="live-dot" aria-hidden="true"></span>
|
<button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">Hide Chat</button>
|
||||||
<span id="now-playing">Loading schedule...</span>
|
</header>
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
|
<section class="viewer-content" aria-label="Live stream and chat layout">
|
||||||
<section class="player-stage" aria-label="Video stage">
|
<section class="player-stage" aria-label="Video stage">
|
||||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||||
<div id="now-playing-overlay" class="now-playing-overlay hidden" aria-live="polite">
|
|
||||||
<div class="now-playing-overlay-inner">
|
|
||||||
<span class="live-label">LIVE</span>
|
|
||||||
<span id="overlay-title" class="overlay-title"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="viewer-footer">
|
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
||||||
<div>Latency: ~25s • Synced</div>
|
<header class="chat-header">
|
||||||
<nav class="room-links" aria-label="Quick room switch">
|
<div class="chat-room-title">
|
||||||
<a href="#" onclick="switchRoom('main'); return false;">Main Channel</a>
|
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||||
<a href="#" onclick="switchRoom('chill'); return false;">Chill Room</a>
|
<span id="room-name">Main Channel</span>
|
||||||
<a href="#" onclick="switchRoom('talk'); return false;">Talk Room</a>
|
</div>
|
||||||
</nav>
|
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||||
</footer>
|
</header>
|
||||||
|
|
||||||
|
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
||||||
|
|
||||||
|
<div class="chat-input-area">
|
||||||
|
<div class="chat-input-row">
|
||||||
|
<label for="message-input" class="sr-only">Message input</label>
|
||||||
|
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if(event.key === 'Enter') sendMessage()" />
|
||||||
|
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||||
|
</div>
|
||||||
|
<p class="chat-note">
|
||||||
|
Synced with video • Moderation active
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="chat-sidebar" aria-label="Chat panel">
|
|
||||||
<header class="chat-header">
|
|
||||||
<div class="chat-room-title">
|
|
||||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
|
||||||
<span id="room-name">Main Channel</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
|
||||||
|
|
||||||
<div class="chat-input-area">
|
|
||||||
<div class="chat-input-row">
|
|
||||||
<label for="message-input" class="sr-only">Message input</label>
|
|
||||||
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if(event.key === 'Enter') sendMessage()" />
|
|
||||||
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
|
||||||
</div>
|
|
||||||
<p class="chat-note">
|
|
||||||
Synced with video • Moderation active
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
|||||||
+15
-15
@@ -1,12 +1,13 @@
|
|||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
|
||||||
type HlsClass = typeof import("hls.js")["default"];
|
type HlsClass = (typeof import("hls.js"))["default"];
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
switchRoom: (room: string) => void;
|
switchRoom: (room: string) => void;
|
||||||
sendMessage: () => void;
|
sendMessage: () => void;
|
||||||
togglePrivateDM: () => void;
|
togglePrivateDM: () => void;
|
||||||
|
toggleChatPanel: () => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ 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);
|
||||||
|
let chatVisible = true;
|
||||||
|
|
||||||
function resolveWebSocketBaseUrl() {
|
function resolveWebSocketBaseUrl() {
|
||||||
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
||||||
@@ -134,25 +136,22 @@ async function initPlayerAndChat() {
|
|||||||
|
|
||||||
connectWebSocket("main");
|
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(
|
console.log(
|
||||||
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleChatPanel() {
|
||||||
|
const shell = document.querySelector(".viewer-shell");
|
||||||
|
const toggle = document.getElementById("chat-toggle");
|
||||||
|
if (!shell || !toggle) return;
|
||||||
|
|
||||||
|
chatVisible = !chatVisible;
|
||||||
|
shell.classList.toggle("chat-collapsed", !chatVisible);
|
||||||
|
toggle.textContent = chatVisible ? "Hide Chat" : "Show Chat";
|
||||||
|
toggle.setAttribute("aria-expanded", String(chatVisible));
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
@@ -190,6 +189,7 @@ function togglePrivateDM() {
|
|||||||
window.switchRoom = switchRoom;
|
window.switchRoom = switchRoom;
|
||||||
window.sendMessage = sendMessage;
|
window.sendMessage = sendMessage;
|
||||||
window.togglePrivateDM = togglePrivateDM;
|
window.togglePrivateDM = togglePrivateDM;
|
||||||
|
window.toggleChatPanel = toggleChatPanel;
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
void initPlayerAndChat();
|
void initPlayerAndChat();
|
||||||
|
|||||||
+188
-86
@@ -1,29 +1,48 @@
|
|||||||
:root {
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
--font-body:
|
--font-body:
|
||||||
"Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif;
|
"Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||||
--font-display: "Space Grotesk", "Segoe UI", sans-serif;
|
--font-display: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
--font-mono: "IBM Plex Mono", Consolas, monospace;
|
--font-mono: "IBM Plex Mono", Consolas, monospace;
|
||||||
|
|
||||||
--bg-0: #f4f6ef;
|
--bg-0: #0d1417;
|
||||||
--bg-1: #e3e8db;
|
--bg-1: #172227;
|
||||||
--ink-0: #101b18;
|
--bg-2: #0a1012;
|
||||||
--ink-1: #2d3a36;
|
--ink-0: #edf6f3;
|
||||||
--ink-soft: #5a6963;
|
--ink-1: #cadbd6;
|
||||||
|
--ink-soft: #9cb2ab;
|
||||||
|
|
||||||
--panel: #fffffd;
|
--panel: #17252b;
|
||||||
--panel-2: #f7fbf2;
|
--panel-2: #1f3138;
|
||||||
--line: #c6d1c5;
|
--line: #30464d;
|
||||||
|
--line-soft: #3d5962;
|
||||||
|
|
||||||
--brand: #005f73;
|
--brand: #2ec4b6;
|
||||||
--brand-strong: #00464f;
|
--brand-strong: #20a79a;
|
||||||
--brand-contrast: #f9fffd;
|
--brand-contrast: #061412;
|
||||||
--ok: #2f7a43;
|
--ok: #5add94;
|
||||||
--warn: #b76a00;
|
--warn: #f2b560;
|
||||||
--danger: #a7283f;
|
--danger: #ff7d8f;
|
||||||
|
--danger-soft: #3a1f27;
|
||||||
|
|
||||||
--focus: #0a9396;
|
--focus: #78f2e0;
|
||||||
--shadow-1: 0 14px 30px rgba(31, 54, 47, 0.12);
|
--shadow-1: 0 14px 30px rgba(0, 0, 0, 0.34);
|
||||||
--shadow-2: 0 22px 44px rgba(20, 39, 35, 0.2);
|
--shadow-2: 0 22px 44px rgba(0, 0, 0, 0.45);
|
||||||
|
|
||||||
|
--bg-grad-a: #132129;
|
||||||
|
--bg-grad-b: var(--bg-0);
|
||||||
|
--bg-grad-c: var(--bg-2);
|
||||||
|
|
||||||
|
--live-dot: #ff6b6b;
|
||||||
|
--overlay-bg: rgba(4, 14, 18, 0.84);
|
||||||
|
|
||||||
|
--input-bg: #131f24;
|
||||||
|
--input-border: var(--line-soft);
|
||||||
|
--placeholder: #90a8a1;
|
||||||
|
|
||||||
|
--badge-start: #35c8bb;
|
||||||
|
--badge-end: #1f8d85;
|
||||||
|
|
||||||
--radius-1: 0.6rem;
|
--radius-1: 0.6rem;
|
||||||
--radius-2: 1rem;
|
--radius-2: 1rem;
|
||||||
@@ -40,6 +59,50 @@
|
|||||||
--fast: 160ms ease;
|
--fast: 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--bg-0: #f4f6ef;
|
||||||
|
--bg-1: #e3e8db;
|
||||||
|
--bg-2: #eff3ea;
|
||||||
|
--ink-0: #101b18;
|
||||||
|
--ink-1: #2d3a36;
|
||||||
|
--ink-soft: #5a6963;
|
||||||
|
|
||||||
|
--panel: #fffffd;
|
||||||
|
--panel-2: #f7fbf2;
|
||||||
|
--line: #c6d1c5;
|
||||||
|
--line-soft: #d8e2d8;
|
||||||
|
|
||||||
|
--brand: #005f73;
|
||||||
|
--brand-strong: #00464f;
|
||||||
|
--brand-contrast: #f9fffd;
|
||||||
|
--ok: #2f7a43;
|
||||||
|
--warn: #b76a00;
|
||||||
|
--danger: #a7283f;
|
||||||
|
--danger-soft: #fff3f6;
|
||||||
|
|
||||||
|
--focus: #0a9396;
|
||||||
|
--shadow-1: 0 14px 30px rgba(31, 54, 47, 0.12);
|
||||||
|
--shadow-2: 0 22px 44px rgba(20, 39, 35, 0.2);
|
||||||
|
|
||||||
|
--bg-grad-a: #dce9d6;
|
||||||
|
--bg-grad-b: var(--bg-0);
|
||||||
|
--bg-grad-c: #eff3ea;
|
||||||
|
|
||||||
|
--live-dot: #cb271a;
|
||||||
|
--overlay-bg: rgba(0, 16, 19, 0.84);
|
||||||
|
|
||||||
|
--input-bg: #fff;
|
||||||
|
--input-border: #c2d2c1;
|
||||||
|
--placeholder: #74817b;
|
||||||
|
|
||||||
|
--badge-start: #0a9396;
|
||||||
|
--badge-end: #005f73;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -48,6 +111,7 @@ html,
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,9 +120,9 @@ body {
|
|||||||
color: var(--ink-0);
|
color: var(--ink-0);
|
||||||
background: radial-gradient(
|
background: radial-gradient(
|
||||||
circle at 20% 10%,
|
circle at 20% 10%,
|
||||||
#dce9d6 0%,
|
var(--bg-grad-a) 0%,
|
||||||
var(--bg-0) 50%,
|
var(--bg-grad-b) 50%,
|
||||||
#eff3ea 100%
|
var(--bg-grad-c) 100%
|
||||||
);
|
);
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
@@ -172,13 +236,13 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: #fff3f6;
|
background: var(--danger-soft);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
border-color: #f3c0ca;
|
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover {
|
.btn-danger:hover {
|
||||||
background: #ffe8ee;
|
background: color-mix(in srgb, var(--danger-soft) 70%, var(--danger));
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-block {
|
.btn-block {
|
||||||
@@ -198,13 +262,13 @@ select {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-1);
|
border-radius: var(--radius-1);
|
||||||
background: #fff;
|
background: var(--input-bg);
|
||||||
color: var(--ink-0);
|
color: var(--ink-0);
|
||||||
padding: 0.65rem 0.8rem;
|
padding: 0.65rem 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-input::placeholder {
|
.text-input::placeholder {
|
||||||
color: #74817b;
|
color: var(--placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-area {
|
.text-area {
|
||||||
@@ -239,7 +303,7 @@ select {
|
|||||||
.data-table td {
|
.data-table td {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.72rem 0.65rem;
|
padding: 0.72rem 0.65rem;
|
||||||
border-bottom: 1px solid #dfe7de;
|
border-bottom: 1px solid var(--line-soft);
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,34 +343,44 @@ select {
|
|||||||
|
|
||||||
.lv-body {
|
.lv-body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: var(--space-5);
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-shell {
|
.viewer-shell {
|
||||||
width: min(1250px, 100%);
|
width: 100vw;
|
||||||
margin: 0 auto;
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
background: linear-gradient(170deg, #f7fbf2 0%, #ebf2e7 40%, #f8fbf5 100%);
|
background: linear-gradient(
|
||||||
|
170deg,
|
||||||
|
color-mix(in srgb, var(--panel-2) 72%, var(--panel)) 0%,
|
||||||
|
var(--panel) 45%,
|
||||||
|
color-mix(in srgb, var(--panel) 75%, var(--bg-1)) 100%
|
||||||
|
);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-3);
|
border-radius: 0;
|
||||||
box-shadow: var(--shadow-2);
|
box-shadow: var(--shadow-2);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-main {
|
.viewer-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-columns: minmax(0, 1fr) minmax(300px, 420px);
|
||||||
min-height: 76vh;
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-header,
|
.viewer-header {
|
||||||
.viewer-footer {
|
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-brand-group {
|
.viewer-brand-group {
|
||||||
@@ -324,8 +398,12 @@ select {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: #fff;
|
color: var(--brand-contrast);
|
||||||
background: linear-gradient(140deg, #0a9396 0%, #005f73 100%);
|
background: linear-gradient(
|
||||||
|
140deg,
|
||||||
|
var(--badge-start) 0%,
|
||||||
|
var(--badge-end) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-text,
|
.brand-text,
|
||||||
@@ -347,29 +425,33 @@ select {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #fff;
|
background: var(--panel);
|
||||||
border: 1px solid #c9d6c8;
|
border: 1px solid var(--line);
|
||||||
color: var(--ink-1);
|
color: var(--ink-1);
|
||||||
padding: 0.35rem 0.8rem;
|
padding: 0.35rem 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-dot,
|
.live-dot,
|
||||||
.chat-live-marker {
|
.chat-live-marker {
|
||||||
color: #cb271a;
|
color: var(--live-dot);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-stage {
|
.player-stage {
|
||||||
position: relative;
|
min-height: 0;
|
||||||
padding: 0 var(--space-5) var(--space-5);
|
height: 100%;
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player {
|
.player {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
border-radius: 1.1rem;
|
border-radius: 1.1rem;
|
||||||
border: 1px solid #bfd0be;
|
border: 1px solid var(--line-soft);
|
||||||
background: #000;
|
background: #000;
|
||||||
box-shadow: var(--shadow-1);
|
box-shadow: var(--shadow-1);
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.now-playing-overlay {
|
.now-playing-overlay {
|
||||||
@@ -382,7 +464,7 @@ select {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
background: rgba(0, 16, 19, 0.84);
|
background: var(--overlay-bg);
|
||||||
color: #f0fffb;
|
color: #f0fffb;
|
||||||
padding: 0.44rem 0.72rem;
|
padding: 0.44rem 0.72rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -402,23 +484,16 @@ select {
|
|||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-links {
|
.chat-toggle-btn {
|
||||||
display: flex;
|
white-space: nowrap;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-links a {
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar {
|
.chat-sidebar {
|
||||||
background: #f0f6ee;
|
background: color-mix(in srgb, var(--panel) 82%, var(--bg-1));
|
||||||
border-left: 1px solid var(--line);
|
border-left: 1px solid var(--line);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
min-height: 76vh;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header,
|
.chat-header,
|
||||||
@@ -430,7 +505,7 @@ select {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
border-bottom: 1px solid #d4dfd3;
|
border-bottom: 1px solid var(--line-soft);
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,20 +549,20 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-other {
|
.message-other {
|
||||||
background: #ffffff;
|
background: var(--panel);
|
||||||
border: 1px solid #d3e0d2;
|
border: 1px solid var(--line-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-self {
|
.message-self {
|
||||||
background: #005f73;
|
background: var(--brand);
|
||||||
color: #fff;
|
color: var(--brand-contrast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-system {
|
.message-system {
|
||||||
font-size: 0.83rem;
|
font-size: 0.83rem;
|
||||||
color: #1d4441;
|
color: var(--ink-1);
|
||||||
background: #d9efea;
|
background: color-mix(in srgb, var(--brand) 22%, var(--panel-2));
|
||||||
border: 1px solid #b5ddd6;
|
border: 1px solid color-mix(in srgb, var(--brand) 45%, var(--line));
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.24rem 0.68rem;
|
padding: 0.24rem 0.68rem;
|
||||||
animation: messageIn var(--fast);
|
animation: messageIn var(--fast);
|
||||||
@@ -524,16 +599,28 @@ select {
|
|||||||
|
|
||||||
.input {
|
.input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #c2d2c1;
|
border: 1px solid var(--input-border);
|
||||||
border-radius: var(--radius-1);
|
border-radius: var(--radius-1);
|
||||||
padding: 0.65rem 0.8rem;
|
padding: 0.65rem 0.8rem;
|
||||||
background: #fff;
|
background: var(--input-bg);
|
||||||
color: var(--ink-0);
|
color: var(--ink-0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-body {
|
.admin-body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
circle at 82% 12%,
|
||||||
|
rgba(20, 48, 54, 0.5) 0%,
|
||||||
|
transparent 44%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
circle at 16% 85%,
|
||||||
|
rgba(8, 28, 34, 0.55) 0%,
|
||||||
|
transparent 40%
|
||||||
|
),
|
||||||
|
var(--bg-0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-shell {
|
.admin-shell {
|
||||||
@@ -544,13 +631,13 @@ select {
|
|||||||
border-radius: var(--radius-3);
|
border-radius: var(--radius-3);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: #f7fbf3;
|
background: color-mix(in srgb, var(--panel) 88%, var(--bg-1));
|
||||||
box-shadow: var(--shadow-2);
|
box-shadow: var(--shadow-2);
|
||||||
min-height: calc(100vh - 2 * var(--space-4));
|
min-height: calc(100vh - 2 * var(--space-4));
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-sidebar {
|
.admin-sidebar {
|
||||||
background: #16423d;
|
background: #102a2f;
|
||||||
color: #ecfff6;
|
color: #ecfff6;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto 1fr;
|
grid-template-rows: auto auto 1fr;
|
||||||
@@ -594,9 +681,9 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab-trigger.is-active {
|
.tab-trigger.is-active {
|
||||||
background: #0a9396;
|
background: var(--brand);
|
||||||
color: #fff;
|
color: var(--brand-contrast);
|
||||||
border-color: #89d1c8;
|
border-color: color-mix(in srgb, var(--brand) 55%, #fff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-main {
|
.admin-main {
|
||||||
@@ -604,6 +691,14 @@ select {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viewer-shell.chat-collapsed .viewer-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-shell.chat-collapsed .chat-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.tab-panel {
|
.tab-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
@@ -622,9 +717,9 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-card {
|
.panel-card {
|
||||||
border: 1px solid #d8e2d8;
|
border: 1px solid var(--line-soft);
|
||||||
border-radius: var(--radius-2);
|
border-radius: var(--radius-2);
|
||||||
background: #fff;
|
background: var(--panel);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
box-shadow: var(--shadow-1);
|
box-shadow: var(--shadow-1);
|
||||||
}
|
}
|
||||||
@@ -636,10 +731,10 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.upload-dropzone {
|
.upload-dropzone {
|
||||||
border: 2px dashed #97b8aa;
|
border: 2px dashed color-mix(in srgb, var(--brand) 55%, var(--line));
|
||||||
border-radius: var(--radius-2);
|
border-radius: var(--radius-2);
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
background: #effaf5;
|
background: color-mix(in srgb, var(--brand) 12%, var(--panel-2));
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: start;
|
justify-items: start;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
@@ -647,7 +742,7 @@ select {
|
|||||||
|
|
||||||
.upload-dropzone.is-dragover {
|
.upload-dropzone.is-dragover {
|
||||||
border-color: var(--brand);
|
border-color: var(--brand);
|
||||||
background: #e6f6ef;
|
background: color-mix(in srgb, var(--brand) 20%, var(--panel-2));
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-progress {
|
.upload-progress {
|
||||||
@@ -668,9 +763,9 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
border: 1px solid #dce7db;
|
border: 1px solid var(--line-soft);
|
||||||
border-radius: var(--radius-2);
|
border-radius: var(--radius-2);
|
||||||
background: #fff;
|
background: var(--panel);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
box-shadow: var(--shadow-1);
|
box-shadow: var(--shadow-1);
|
||||||
}
|
}
|
||||||
@@ -704,7 +799,7 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.service-row {
|
.service-row {
|
||||||
border: 1px solid #dbe5db;
|
border: 1px solid var(--line-soft);
|
||||||
border-radius: var(--radius-1);
|
border-radius: var(--radius-1);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -748,9 +843,9 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
border: 1px solid #cad8c9;
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-3);
|
border-radius: var(--radius-3);
|
||||||
background: #fff;
|
background: var(--panel);
|
||||||
box-shadow: var(--shadow-2);
|
box-shadow: var(--shadow-2);
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -781,11 +876,15 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1080px) {
|
@media (max-width: 1080px) {
|
||||||
.viewer-shell,
|
|
||||||
.admin-shell {
|
.admin-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viewer-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(320px, 52vh) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.chat-sidebar {
|
.chat-sidebar {
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
@@ -821,16 +920,15 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.viewer-header,
|
.viewer-header,
|
||||||
.viewer-footer,
|
|
||||||
.player-stage,
|
.player-stage,
|
||||||
.chat-header,
|
.chat-header,
|
||||||
.chat-input-area {
|
.chat-input-area {
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-footer {
|
.viewer-header {
|
||||||
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav {
|
.admin-nav {
|
||||||
@@ -848,6 +946,10 @@ select {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viewer-shell.chat-collapsed .viewer-content {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user