5 Commits
Author SHA1 Message Date
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
zwitschi 47740e3f4a debug: adding debug output to workflow
CI / test (push) Successful in 1m36s
CI / docker (push) Successful in 3m4s
CI / release (push) Skipped
2026-08-04 18:04:37 +02:00
zwitschi 693b4a42f6 fix: improve CI workflows and README documentation 2026-08-04 16:58:05 +02:00
17 changed files with 2386 additions and 1522 deletions

No files matched your search

+34 -17
View File
@@ -13,7 +13,6 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.CI_REGISTRY_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -39,20 +38,38 @@ jobs:
cd backend/playout && go get -u ./... && go mod tidy
cd ../chat && go get -u ./... && go mod tidy
- name: Create Pull Request
uses: peter-evans/create-pull-request@v5
with:
token: ${{ secrets.CI_REGISTRY_TOKEN }}
commit-message: "chore: update dependencies"
title: "chore: weekly dependency updates"
body: |
Automated weekly dependency updates.
- name: Commit and push changes
run: |
git config user.name "linearvoid-bot"
git config user.email "bot@linearvoid.local"
git add frontend/package*.json backend/playout/go.* backend/chat/go.*
if git diff --cached --quiet; then
echo "No dependency changes detected."
exit 0
fi
git commit -m "chore: update dependencies"
git push origin HEAD:dependency-updates --force
This PR updates:
- Frontend npm dependencies
- Go module dependencies
Please review the changes and ensure tests pass before merging.
branch: dependency-updates
delete-branch: true
labels: dependencies, automated
- name: Create Pull Request via Gitea API
run: |
RESPONSE=$(curl -s -o /tmp/pr-response.json -w "%{http_code}" \
-X POST "$CI_SERVER_URL/api/v1/repos/zwitschi/linearvoid/pulls" \
-H "Authorization: token ${{ secrets.CI_REGISTRY_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{
"title": "chore: weekly dependency updates",
"head": "dependency-updates",
"base": "main",
"body": "Automated weekly dependency updates.\n\nThis PR updates:\n- Frontend npm dependencies\n- Go module dependencies\n\nPlease review the changes and ensure tests pass before merging."
}')
if [ "$RESPONSE" -eq 201 ]; then
PR_URL=$(cat /tmp/pr-response.json | jq -r '.html_url')
echo "PR created: $PR_URL"
elif [ "$RESPONSE" -eq 409 ]; then
echo "PR already exists. Pushing updated commits..."
git push origin dependency-updates --force
else
echo "Error creating PR (HTTP $RESPONSE):"
cat /tmp/pr-response.json
exit 1
fi
+18 -3
View File
@@ -34,10 +34,25 @@ jobs:
id: version
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
VERSION="${{ github.event.inputs.version }}"
else
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
VERSION="${GITHUB_REF#refs/tags/}"
fi
if [[ -z "$VERSION" ]]; then
echo "::error::VERSION is empty. Provide a version via workflow_dispatch input or push a tag."
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Releasing version: $VERSION"
- name: Print version to gitea runner log
run: |
echo "Releasing version: ${{ steps.version.outputs.version }}"
echo "===DEBUG==="
echo "VERSION: $VERSION"
echo "GITHUB_REF: $GITHUB_REF"
echo "steps.version.outputs.version: ${{ steps.version.outputs.version }}"
echo "github.event_name: ${{ github.event_name }}"
- name: Extract metadata for Playout
id: meta-playout
@@ -96,7 +111,7 @@ jobs:
- name: Create Release on Gitea
run: |
curl -X POST "$CI_SERVER_URL/api/v1/repos/$CI_REPO_OWNER/$CI_REPO_NAME/releases" \
curl -X POST "https://git.allucanget.biz/api/v1/repos/zwitschi/linearvoid/releases" \
-H "Authorization: token ${{ secrets.CI_REGISTRY_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${{ steps.version.outputs.version }}\",\"name\":\"${{ steps.version.outputs.version }}\",\"body\":\"Release ${{ steps.version.outputs.version }}\n\nSee [CHANGELOG.md](https://git.allucanget.biz/zwitschi/linearvoid/src/branch/main/CHANGELOG.md) for details.\",\"draft\":false,\"prerelease\":false}"
+26
View File
@@ -2,6 +2,32 @@
<!-- 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.
### 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.
## [1.0.3] - 2026-08-03
### Added
+83
View File
@@ -7,6 +7,8 @@
[![Build Status](https://git.allucanget.biz/zwitschi/linearvoid/actions/workflows/ci.yml/badge.svg)](https://git.allucanget.biz/zwitschi/linearvoid/actions)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Gitea](https://img.shields.io/badge/Gitea-1.27.1-blue)](https://git.allucanget.biz)
[![Version](https://img.shields.io/badge/version-1.0.3-blue)](https://git.allucanget.biz/zwitschi/linearvoid/releases)
[![Release Notes](https://img.shields.io/badge/release-notes-green)](CHANGELOG.md)
## Architecture
@@ -27,6 +29,23 @@ See [`docs/architecture/adr/`](docs/architecture/adr/) for the full set of decis
- Video normalization, monitoring, fallback slate, and easy playlist management.
- US-centric performance (Ashburn origin + Cloudflare POPs).
## Architecture Diagram
```mermaid
flowchart TD
V[US Viewers] -->|HTTPS| CF[Cloudflare CDN]
CF -->|Cache HIT| R2[Cloudflare R2]
CF -->|Cache MISS| N[Nginx :80]
N -->|/ws| CH[Chat :3000]
N -->|/admin| CH
N -->|/hls| P[Playout :8081]
N -->|/*| FE[Frontend SPA]
CH --> R[(Redis)]
CH --> DB[(PostgreSQL)]
P -->|ffmpeg| HLS[/data/hls/]
P -->|sync| R2
```
## Project Structure
```text
@@ -97,9 +116,16 @@ This structure directly reflects the building blocks, runtime flows, deployment
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
cd linearvoid
cp .env.example .env
# Edit .env with your R2 credentials and domain settings
docker compose up --build
```
Then open:
- **Viewer**: [https://linearvoid.tv](https://linearvoid.tv) (or `http://localhost`)
- **Admin**: [https://linearvoid.tv/admin](https://linearvoid.tv/admin) (or `http://localhost/admin`)
- **Metrics**: [https://linearvoid.tv/metrics](https://linearvoid.tv/metrics)
Full setup guide: [`SETUP.md`](SETUP.md).
See the [architecture docs](docs/architecture/README.md) for detailed architecture and ADRs.
@@ -111,6 +137,50 @@ See the [architecture docs](docs/architecture/README.md) for detailed architectu
- Normalize all source videos before adding to playlist (see ADR-001 for exact codec/fps/keyframe requirements).
- Monitor cache hit ratio and R2 operations closely — they determine real cost.
### Environment Variables
| Variable | Default | Description |
| ---------------------- | ----------------------------------------------- | -------------------------------- |
| `R2_ACCESS_KEY_ID` | — | Cloudflare R2 access key |
| `R2_SECRET_ACCESS_KEY` | — | Cloudflare R2 secret key |
| `R2_BUCKET_NAME` | `linearvoid-media` | R2 bucket for HLS segments |
| `DATABASE_URL` | `postgres://...@db:5432/...` | Chat DB (Postgres or SQLite) |
| `REDIS_URL` | `redis://redis:6379/0` | Redis for presence/rate limiting |
| `VITE_PUBLIC_APP_URL` | `https://linearvoid.tv` | Public viewer URL |
| `VITE_PUBLIC_WS_URL` | `wss://linearvoid.tv/ws` | WebSocket endpoint |
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` | HLS playlist URL |
| `ADMIN_USERNAME` | `admin` | Admin panel username |
| `ADMIN_PASSWORD` | — | Admin panel password |
| `HLS_SEGMENT_DURATION` | `6` | HLS segment duration in seconds |
| `HLS_WINDOW_SIZE` | `24` | HLS playlist window size |
Full reference: [`.env.example`](.env.example).
### Troubleshooting
| Problem | Check |
| ------------------------ | ------------------------------------------------------------------------------------- |
| **Playout not starting** | Verify playlist exists in `data/playlist/playlist.txt` and media files are accessible |
| **HLS not playing** | Check `docker compose logs playout` for ffmpeg errors; verify `data/hls/` permissions |
| **Chat not connecting** | Ensure Redis is running: `docker compose logs redis`; check `DATABASE_URL` |
| **Admin panel 401** | Verify `ADMIN_USERNAME`/`ADMIN_PASSWORD` in `.env` match login credentials |
| **Docker build fails** | Run `docker compose build --no-cache`; check `backend/playout/.dockerignore` |
| **R2 upload failures** | Verify `R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY`; check bucket CORS settings |
### API Endpoints
| Service | Endpoint | Description |
| ----------- | ------------------------ | -------------------------- |
| **Chat** | `GET /health` | Health check |
| **Chat** | `GET /metrics` | Prometheus metrics |
| **Chat** | `GET /ws` | WebSocket upgrade for chat |
| **Chat** | `GET /api/admin/*` | Admin API (auth required) |
| **Chat** | `POST /api/admin/login` | Admin login |
| **Playout** | `GET /health` | Health check (port 8081) |
| **Nginx** | `GET /` | Viewer SPA |
| **Nginx** | `GET /admin/` | Admin panel |
| **Nginx** | `GET /hls/playlist.m3u8` | HLS manifest |
## Architecture Documentation
The architecture is fully documented using the [arc42 template](docs/architecture/README.md) (with populated sections, Mermaid context and building-block diagrams, deployment view, cross-cutting concepts, quality goals, risks, and glossary).
@@ -141,4 +211,17 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
**Container Registry**: `git.allucanget.biz/zwitschi/linearvoid/<service>:latest`
## Contributing
Contributions are welcome! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines. Quick overview:
1. Read the [architecture docs](docs/architecture/README.md) and ADRs first.
2. Plan tasks in `.github/instructions/TODO.md`.
3. Open PRs on [Gitea](https://git.allucanget.biz/zwitschi/linearvoid).
4. CI validates builds and tests automatically.
Since this a linear project, we also rely on a linear development and release model: Only main branch is used for releases, and all features are merged into main after review and CI validation. Releases are tagged with semantic versioning (v1.0.3, v1.0.4, etc.) and automatically built/pushed to the container registry.
[MIT License](LICENSE)
The project is ready for use. See [`SETUP.md`](SETUP.md) for detailed installation instructions.
+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
FROM alpine:3.20
RUN apk --no-cache add ca-certificates curl curl
RUN apk --no-cache add ca-certificates curl ffmpeg
WORKDIR /app
COPY --from=builder /chat-server /usr/local/bin/chat-server
+339 -36
View File
@@ -10,6 +10,7 @@ import (
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
@@ -37,7 +38,7 @@ var (
authSessionsMu = sync.Mutex{}
)
// Message represents a chat message (persisted in SQLite per ADR-006)
// Message represents a chat message (persisted in SQL DB per ADR-006)
type Message struct {
ID uint `gorm:"primarykey" json:"id"`
Room string `json:"room"`
@@ -57,6 +58,21 @@ type ModerationLog struct {
Timestamp time.Time `json:"timestamp"`
}
// Media stores admin media library metadata (filesystem + DB model per ADR-006)
type Media struct {
ID uint `gorm:"primarykey" json:"id"`
Filename string `gorm:"index" json:"filename"`
OriginalName string `json:"originalName"`
Path string `json:"path"`
SizeBytes int64 `json:"sizeBytes"`
DurationSeconds int `json:"durationSeconds"`
Status string `gorm:"index" json:"status"`
Title string `json:"title"`
Tags string `json:"tags"`
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploadedAt"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
}
// Presence tracks online users per room (using Redis per ADR-007)
type Presence struct {
Username string `json:"username"`
@@ -68,6 +84,9 @@ type AdminOverview struct {
Viewers int `json:"viewers"`
StoredMessages int64 `json:"storedMessages"`
ModerationActions int64 `json:"moderationActions"`
MediaCount int64 `json:"mediaCount"`
MediaTotalDurationSeconds int64 `json:"mediaTotalDurationSeconds"`
MediaTotalRuntime string `json:"mediaTotalRuntime"`
RedisEnabled bool `json:"redisEnabled"`
RedisClients int `json:"redisClients"`
RedisMemoryUsed string `json:"redisMemoryUsed"`
@@ -101,6 +120,9 @@ type AdminAnalytics struct {
MessagesLast24Hours int64 `json:"messagesLast24Hours"`
DistinctRooms int64 `json:"distinctRooms"`
ModerationActions int64 `json:"moderationActions"`
MediaCount int64 `json:"mediaCount"`
MediaTotalDurationSeconds int64 `json:"mediaTotalDurationSeconds"`
MediaTotalRuntime string `json:"mediaTotalRuntime"`
TopRooms []RoomAnalytics `json:"topRooms"`
GeneratedAt string `json:"generatedAt"`
}
@@ -135,20 +157,37 @@ type AdminUsersResponse struct {
}
type ScheduleEntry struct {
Index int `json:"index"`
Path string `json:"path"`
Label string `json:"label"`
Index int `json:"index"`
Path string `json:"path"`
Label string `json:"label"`
DurationSeconds int `json:"durationSeconds"`
Runtime string `json:"runtime"`
Status string `json:"status"`
Title string `json:"title"`
}
type AdminScheduleResponse struct {
Entries []ScheduleEntry `json:"entries"`
GeneratedAt string `json:"generatedAt"`
Entries []ScheduleEntry `json:"entries"`
TotalEntries int `json:"totalEntries"`
MatchedEntries int `json:"matchedEntries"`
TotalDurationSeconds int `json:"totalDurationSeconds"`
TotalRuntime string `json:"totalRuntime"`
GeneratedAt string `json:"generatedAt"`
}
type MediaFileEntry struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
ID uint `json:"id"`
Name string `json:"name"`
OriginalName string `json:"originalName"`
Path string `json:"path"`
Size int64 `json:"size"`
DurationSeconds int `json:"durationSeconds"`
Runtime string `json:"runtime"`
Status string `json:"status"`
Title string `json:"title"`
Tags string `json:"tags"`
UploadedAt string `json:"uploadedAt"`
ModifiedAt string `json:"modifiedAt"`
}
type AdminMediaResponse struct {
@@ -199,7 +238,7 @@ func initRedis() {
}
}
// initDB initializes SQLite for message history and moderation audit log (ADR-006)
// initDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006)
func initDB() {
var err error
databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
@@ -226,8 +265,8 @@ func initDB() {
}
databaseDriverName = driverName
db.AutoMigrate(&Message{}, &ModerationLog{})
log.Printf("%s database initialized (chat history and moderation log)", strings.Title(driverName))
db.AutoMigrate(&Message{}, &ModerationLog{}, &Media{})
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driverName))
}
func parseRedisInfoValue(info, key string) string {
@@ -246,6 +285,7 @@ func getAdminOverview() AdminOverview {
RedisEnabled: redisClient != nil,
RedisMemoryUsed: "unavailable",
DatabaseDriver: databaseDriverName,
MediaTotalRuntime: "00:00:00",
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
@@ -256,6 +296,9 @@ func getAdminOverview() AdminOverview {
if db != nil {
db.Model(&Message{}).Count(&overview.StoredMessages)
db.Model(&ModerationLog{}).Count(&overview.ModerationActions)
db.Model(&Media{}).Count(&overview.MediaCount)
db.Model(&Media{}).Select("COALESCE(SUM(duration_seconds), 0)").Scan(&overview.MediaTotalDurationSeconds)
overview.MediaTotalRuntime = formatDuration(int(overview.MediaTotalDurationSeconds))
}
if redisClient != nil {
@@ -541,10 +584,55 @@ func handleAdminSchedule(w http.ResponseWriter, r *http.Request) {
return
}
entries := parsePlaylistEntries(string(content))
var mediaRecords []Media
if err := db.Find(&mediaRecords).Error; err != nil {
http.Error(w, fmt.Sprintf("failed to load media metadata: %v", err), http.StatusInternalServerError)
return
}
mediaByFilename := make(map[string]Media, len(mediaRecords))
mediaByPath := make(map[string]Media, len(mediaRecords))
for _, record := range mediaRecords {
mediaByFilename[strings.ToLower(record.Filename)] = record
mediaByPath[strings.ToLower(filepath.Clean(record.Path))] = record
}
totalDurationSeconds := 0
matchedEntries := 0
for i := range entries {
entry := &entries[i]
entry.Status = "missing"
entry.Runtime = "00:00:00"
cleanPath := strings.ToLower(filepath.Clean(entry.Path))
baseName := strings.ToLower(filepath.Base(entry.Path))
record, ok := mediaByPath[cleanPath]
if !ok {
record, ok = mediaByFilename[baseName]
}
if !ok {
continue
}
entry.DurationSeconds = record.DurationSeconds
entry.Runtime = formatDuration(record.DurationSeconds)
entry.Status = record.Status
entry.Title = record.Title
totalDurationSeconds += record.DurationSeconds
matchedEntries++
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminScheduleResponse{
Entries: parsePlaylistEntries(string(content)),
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Entries: entries,
TotalEntries: len(entries),
MatchedEntries: matchedEntries,
TotalDurationSeconds: totalDurationSeconds,
TotalRuntime: formatDuration(totalDurationSeconds),
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
})
}
@@ -624,6 +712,7 @@ func handleAdminModerationAction(w http.ResponseWriter, r *http.Request) {
func getAdminAnalytics() AdminAnalytics {
analytics := AdminAnalytics{
TopRooms: []RoomAnalytics{},
MediaTotalRuntime: "00:00:00",
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
@@ -637,6 +726,9 @@ func getAdminAnalytics() AdminAnalytics {
Count(&analytics.MessagesLast24Hours)
db.Model(&Message{}).Distinct("room").Count(&analytics.DistinctRooms)
db.Model(&ModerationLog{}).Count(&analytics.ModerationActions)
db.Model(&Media{}).Count(&analytics.MediaCount)
db.Model(&Media{}).Select("COALESCE(SUM(duration_seconds), 0)").Scan(&analytics.MediaTotalDurationSeconds)
analytics.MediaTotalRuntime = formatDuration(int(analytics.MediaTotalDurationSeconds))
var topRooms []RoomAnalytics
db.Model(&Message{}).
@@ -784,37 +876,126 @@ func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
}
func listMediaFiles() ([]MediaFileEntry, error) {
mediaDir := getMediaDir()
entries, err := os.ReadDir(mediaDir)
if err != nil {
var records []Media
if err := db.Order("uploaded_at desc").Find(&records).Error; err != nil {
return nil, err
}
files := make([]MediaFileEntry, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
files := make([]MediaFileEntry, 0, len(records))
for _, record := range records {
files = append(files, MediaFileEntry{
Name: entry.Name(),
Size: info.Size(),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339),
ID: record.ID,
Name: record.Filename,
OriginalName: record.OriginalName,
Path: record.Path,
Size: record.SizeBytes,
DurationSeconds: record.DurationSeconds,
Runtime: formatDuration(record.DurationSeconds),
Status: record.Status,
Title: record.Title,
Tags: record.Tags,
UploadedAt: record.UploadedAt.UTC().Format(time.RFC3339),
ModifiedAt: record.UpdatedAt.UTC().Format(time.RFC3339),
})
}
sort.Slice(files, func(i, j int) bool {
return files[i].Name < files[j].Name
if files[i].UploadedAt == files[j].UploadedAt {
return files[i].Name < files[j].Name
}
return files[i].UploadedAt > files[j].UploadedAt
})
return files, nil
}
func formatDuration(seconds int) string {
if seconds < 0 {
seconds = 0
}
hours := seconds / 3600
minutes := (seconds % 3600) / 60
remainingSeconds := seconds % 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainingSeconds)
}
func getMediaIDFromPath(path string) (uint, error) {
prefix := "/api/admin/media/"
if !strings.HasPrefix(path, prefix) {
return 0, fmt.Errorf("invalid media path")
}
idPart := strings.TrimPrefix(path, prefix)
idPart = strings.TrimSpace(idPart)
if idPart == "" || strings.Contains(idPart, "/") {
return 0, fmt.Errorf("invalid media id")
}
id, err := strconv.ParseUint(idPart, 10, 64)
if err != nil || id == 0 {
return 0, fmt.Errorf("invalid media id")
}
return uint(id), nil
}
func mediaEntryFromRecord(record Media) MediaFileEntry {
return MediaFileEntry{
ID: record.ID,
Name: record.Filename,
OriginalName: record.OriginalName,
Path: record.Path,
Size: record.SizeBytes,
DurationSeconds: record.DurationSeconds,
Runtime: formatDuration(record.DurationSeconds),
Status: record.Status,
Title: record.Title,
Tags: record.Tags,
UploadedAt: record.UploadedAt.UTC().Format(time.RFC3339),
ModifiedAt: record.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func probeMediaDurationSeconds(mediaPath string) (int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
out, err := exec.CommandContext(
ctx,
"ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
mediaPath,
).CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return 0, fmt.Errorf("ffprobe timed out")
}
if err != nil {
return 0, fmt.Errorf("ffprobe failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
rawDuration := strings.TrimSpace(string(out))
if rawDuration == "" {
return 0, fmt.Errorf("ffprobe returned empty duration")
}
secondsFloat, err := strconv.ParseFloat(rawDuration, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse duration %q: %w", rawDuration, err)
}
if secondsFloat < 0 {
secondsFloat = 0
}
seconds := int(secondsFloat + 0.5)
if secondsFloat > 0 && seconds == 0 {
seconds = 1
}
return seconds, nil
}
func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@@ -851,12 +1032,49 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError)
return
}
defer targetFile.Close()
if _, err := io.Copy(targetFile, file); err != nil {
targetFile.Close()
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to store media file: %v", err), http.StatusInternalServerError)
return
}
if err := targetFile.Close(); err != nil {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to finalize media file: %v", err), http.StatusInternalServerError)
return
}
fileInfo, err := os.Stat(targetPath)
if err != nil {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to read media file info: %v", err), http.StatusInternalServerError)
return
}
durationSeconds, err := probeMediaDurationSeconds(targetPath)
if err != nil {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to probe media duration: %v", err), http.StatusBadRequest)
return
}
filename := filepath.Base(header.Filename)
mediaRecord := Media{
Filename: filename,
OriginalName: header.Filename,
Path: targetPath,
SizeBytes: fileInfo.Size(),
DurationSeconds: durationSeconds,
Status: "ready",
Title: strings.TrimSuffix(filename, filepath.Ext(filename)),
Tags: "",
}
if err := db.Create(&mediaRecord).Error; err != nil {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to persist media metadata: %v", err), http.StatusInternalServerError)
return
}
files, err := listMediaFiles()
if err != nil {
@@ -871,6 +1089,90 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
}
}
func handleAdminMediaByID(w http.ResponseWriter, r *http.Request) {
mediaID, err := getMediaIDFromPath(r.URL.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var record Media
if err := db.First(&record, mediaID).Error; err != nil {
http.Error(w, "media item not found", http.StatusNotFound)
return
}
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(mediaEntryFromRecord(record))
case http.MethodPut:
var payload struct {
Status string `json:"status"`
Title string `json:"title"`
Tags string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}
updates := map[string]any{}
if payload.Status != "" {
updates["status"] = strings.TrimSpace(payload.Status)
}
if payload.Title != "" {
updates["title"] = strings.TrimSpace(payload.Title)
}
updates["tags"] = strings.TrimSpace(payload.Tags)
if err := db.Model(&record).Updates(updates).Error; err != nil {
http.Error(w, fmt.Sprintf("failed to update media metadata: %v", err), http.StatusInternalServerError)
return
}
if err := db.First(&record, mediaID).Error; err != nil {
http.Error(w, "media item not found after update", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(mediaEntryFromRecord(record))
case http.MethodDelete:
absMediaDir, err := filepath.Abs(getMediaDir())
if err != nil {
http.Error(w, fmt.Sprintf("failed to resolve media directory: %v", err), http.StatusInternalServerError)
return
}
absTargetPath, err := filepath.Abs(record.Path)
if err != nil {
http.Error(w, fmt.Sprintf("failed to resolve media path: %v", err), http.StatusInternalServerError)
return
}
prefix := absMediaDir + string(os.PathSeparator)
if absTargetPath != absMediaDir && !strings.HasPrefix(absTargetPath, prefix) {
http.Error(w, "media path consistency check failed", http.StatusConflict)
return
}
if err := os.Remove(absTargetPath); err != nil && !os.IsNotExist(err) {
http.Error(w, fmt.Sprintf("failed to delete media file: %v", err), http.StatusInternalServerError)
return
}
if err := db.Delete(&record).Error; err != nil {
http.Error(w, fmt.Sprintf("failed to delete media record: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"deleted": true, "id": mediaID})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func countConnectedClients() int {
mutex.Lock()
defer mutex.Unlock()
@@ -976,7 +1278,7 @@ func broadcast(msg Message) {
mutex.Lock()
defer mutex.Unlock()
// Save to SQLite
// Persist to configured SQL backend.
db.Create(&msg)
for client := range clients {
@@ -1054,7 +1356,7 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text)
broadcast(msg)
// Save to SQLite (already done in broadcast, but ensure for private DMs)
// Save private messages explicitly as well.
if msg.Type == "private" {
db.Create(&msg)
}
@@ -1063,7 +1365,7 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
// handlePrivateDM routes private messages to the specific user pair
func handlePrivateDM(msg Message) {
// Save to SQLite for history
// Persist private messages for history.
db.Create(&msg)
// Broadcast only to participants (simplified - in production use a map of user connections)
@@ -1122,6 +1424,7 @@ func main() {
http.HandleFunc("/api/admin/users", requireAdminAuth(handleAdminUsers))
http.HandleFunc("/api/admin/schedule", requireAdminAuth(handleAdminSchedule))
http.HandleFunc("/api/admin/media", requireAdminAuth(handleAdminMedia))
http.HandleFunc("/api/admin/media/", requireAdminAuth(handleAdminMediaByID))
http.HandleFunc("/metrics", handleMetrics)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
+45 -56
View File
@@ -1,7 +1,7 @@
# ADR-006: Data Storage Strategy (SQLite vs PostgreSQL)
**Status:** Proposed
**Date:** 2026-08-02
**Status:** Accepted
**Date:** 2026-08-04
**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)
@@ -12,89 +12,78 @@ The system needs persistent storage for:
- Chat messages and history (public rooms and private DMs).
- User accounts, roles, and ban lists.
- Moderation audit log (immutable record of admin and moderator actions).
- Playlist metadata and schedule overrides.
- System configuration (including Redis settings adjustable via admin UI per ADR-005/ADR-007).
- Playlist and media metadata used by admin flows.
- 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
1. **Operational Simplicity** – Minimize infrastructure (no separate DB server for SQLite).
2. **Performance & Concurrency** – Chat and moderation may have high write volume.
3. **Backup and Recovery** – Easy point-in-time recovery and replication.
4. **Admin Capabilities** – Admin UI must be able to view storage stats, run vacuum/analyze, and switch between SQLite and PostgreSQL (with migration path).
5. **Auditability** – Moderation log must be immutable and queryable.
6. **Cost** – Avoid managed database costs during early stages.
1. **Implemented Reality** - Docker Compose already provisions PostgreSQL and chat depends on it.
2. **Concurrency & Throughput** - Chat, moderation, and media metadata writes require stronger concurrent write behavior.
3. **Operational Traceability** - Structured backups, service health checks, and persistent volume management are already in place for PostgreSQL.
4. **Admin Growth** - Upcoming admin features (media CRUD, analytics, moderation tooling) benefit from relational querying and indexing.
5. **Fallback Simplicity** - Keep SQLite support for lightweight local or recovery scenarios without blocking developer workflows.
## 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.
- Buttons to run `VACUUM`, `ANALYZE`, or trigger a backup.
- Configuration form to switch connection string (SQLite file path vs PostgreSQL URL) and restart services gracefully.
- Audit log of all configuration changes.
- Chat backend (`backend/chat/src/main.go`) resolves `DATABASE_URL` at startup.
- `postgres://` or `postgresql://` selects PostgreSQL driver.
- `sqlite://` selects SQLite driver.
- 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.
- Use GORM or sqlx (Go) or Prisma (if Node.js fallback) for ORM/migrations.
- Store large chat history in a separate table with retention policy (e.g., 30 days).
- 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/`.
- Media metadata is persisted in the same database as messages and moderation logs.
- Physical media files remain on the shared filesystem (`/app/media`, mapped from `data/media`).
- The database is the source of truth for media library metadata and future runtime analytics.
## Rationale
- SQLite aligns with ADR-003's emphasis on operational simplicity and low resource usage for the initial deployment on a single Hetzner VPS.
- It eliminates the need for a separate database container in `docker-compose.yml` during early development.
- The admin interface can provide visibility and basic maintenance operations, fulfilling the requirement from the previous TODO plan.
- PostgreSQL can be introduced later with a migration script when scale demands it (replication, advanced querying, full-text search on chat).
- The repository already operationalizes PostgreSQL in both `docker-compose.yml` and `docker-compose.prod.yml` with persistent storage.
- Default environment documentation (`.env.example`, setup docs) already points to PostgreSQL.
- This aligns architecture documentation with implemented behavior and avoids drift.
- Keeping SQLite support preserves local flexibility while avoiding a hard breaking change.
## Consequences
**Positive**:
- Zero-config database for quick local development and small-scale production.
- Simple backups (just copy the `.db` file).
- Admin UI can directly manage and monitor storage without external tools.
- Better write concurrency and query flexibility for chat + admin workloads.
- Clear operational model for containerized deployments (health checks, named volume, service dependency).
- Easier extension for admin analytics and media metadata features.
**Negative / Risks**:
- SQLite has limited concurrency compared to PostgreSQL (WAL mode helps but is not a full replacement).
- Large chat history or analytics queries may become slow – plan retention policy and archiving.
- Migration to PostgreSQL will require downtime or dual-write strategy (documented in this ADR).
- Additional service to operate versus embedded-only SQLite.
- Misconfigured `DATABASE_URL` can still route to SQLite fallback unintentionally if left empty outside compose.
- 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).
- Pure key-value store (e.g., BoltDB) – rejected because SQL is better for audit logs and complex moderation queries.
- Managed database (Neon, Supabase) – rejected to maintain full self-hosted independence per ADR-001.
- SQLite-first in production: rejected for current implementation because compose and deployment already run PostgreSQL.
- PostgreSQL-only with no fallback: rejected to preserve local dev and troubleshooting flexibility.
- Managed external PostgreSQL: deferred; self-hosted compose remains the preferred operational model.
## Implementation Plan
## Implementation Status
1. Create this ADR-006 (done).
2. Add SQLite support in `backend/shared/db/` with GORM migrations.
3. Implement admin UI pages for storage monitoring and configuration (linked to ADR-005).
4. Add configuration reload capability without full restart where possible.
5. Write migration script from SQLite to PostgreSQL.
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).
1. ADR updated to match implemented state. Done.
2. Compose includes PostgreSQL service and persistent volume. Done.
3. Chat backend supports PostgreSQL and SQLite based on `DATABASE_URL`. Done.
4. Media metadata persistence model documented here for Admin UI track. In progress (implementation ongoing).
5. Admin storage observability and operations UI (vacuum/analyze/backup flows). Planned.
## References
- ADR-001: Chat persistence, moderation log, monitoring requirements.
- 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.
- arc42 documentation: Cross-cutting Concepts (data management, monitoring) and Risks & Technical Debts.
This ADR ensures all data storage decisions are documented before code is written, maintaining full traceability.
- ADR-005: Admin interface for user management, moderation, and analytics.
- ADR-007: Redis usage for presence, rate limiting, pub/sub.
- `docker-compose.yml` and `docker-compose.prod.yml` database service definitions.
+295 -760
View File
File diff suppressed because it is too large. Load diff
+19 -22
View File
@@ -5,30 +5,27 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Linear Void Admin Login</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="../../src/style.css" />
</head>
<body class="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center px-6">
<main class="w-full max-w-md bg-slate-900 border border-slate-800 rounded-[2rem] shadow-2xl p-8">
<div class="mb-8">
<div class="text-xs uppercase tracking-[0.3em] text-slate-500 mb-3">Linear Void</div>
<h1 class="text-4xl font-semibold text-white mb-2">Admin Login</h1>
<p class="text-sm text-slate-400">Authenticate to access protected admin controls.</p>
</div>
<body class="auth-body">
<main class="auth-shell" aria-label="Admin authentication">
<section class="auth-card" aria-labelledby="auth-title">
<p class="auth-kicker">Linear Void</p>
<h1 id="auth-title">Admin Login</h1>
<p class="auth-subtitle">Authenticate to access protected admin controls.</p>
<form id="login-form" class="space-y-4">
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Username</span>
<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 class="block text-sm">
<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 />
</label>
<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 id="login-form" class="form-stack" novalidate>
<label for="username">Username</label>
<input id="username" type="text" class="text-input" autocomplete="username" required />
<label for="password">Password</label>
<input id="password" type="password" class="text-input" autocomplete="current-password" required />
<button id="submit" type="submit" class="btn btn-primary btn-block">Sign in</button>
<p id="status" class="status-line" aria-live="polite"></p>
</form>
</section>
</main>
<script>
@@ -42,7 +39,7 @@
event.preventDefault();
submit.disabled = true;
status.textContent = 'Signing in...';
status.className = 'text-sm text-slate-400 min-h-6';
status.className = 'status-line';
try {
const response = await fetch('/api/admin/login', {
@@ -59,7 +56,7 @@
} catch (error) {
console.error('Failed to sign in', error);
status.textContent = 'Login failed. Check your credentials.';
status.className = 'text-sm text-rose-400 min-h-6';
status.className = 'status-line status-error';
} finally {
submit.disabled = false;
}
+43 -50
View File
@@ -5,76 +5,69 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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" />
</head>
<body class="bg-slate-950 text-slate-200">
<div class="flex h-screen">
<!-- Video Player -->
<div class="flex-1 flex flex-col">
<div class="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">LV</div>
<body class="lv-body">
<main class="viewer-shell" aria-label="Live player and chat">
<section class="viewer-main" aria-label="Video stream panel">
<header class="viewer-header">
<div class="viewer-brand-group">
<div class="brand-badge" aria-hidden="true">LV</div>
<div>
<h1 class="text-2xl font-bold text-white">Linear Void</h1>
<p class="text-xs text-emerald-400">24/7 Synchronized Channel • Live</p>
<h1 class="viewer-title">Linear Void</h1>
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
</div>
</div>
<div class="now-playing px-4 py-1 rounded-full text-sm font-medium flex items-center gap-2">
<div class="w-2 h-2 bg-emerald-400 rounded-full animate-pulse"></div>
<div class="now-playing-pill" aria-live="polite">
<span class="live-dot" aria-hidden="true"></span>
<span id="now-playing">Loading schedule...</span>
</div>
</div>
</header>
<div class="flex-1 flex items-center justify-center p-8 bg-black relative">
<video id="player" class="max-h-full rounded-xl shadow-2xl" 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 class="flex items-center gap-3">
<div class="text-emerald-400">● LIVE</div>
<div id="overlay-title" class="font-medium"></div>
<section class="player-stage" aria-label="Video stage">
<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>
</div>
</section>
<div class="p-4 bg-slate-900 border-t border-slate-800 text-xs text-slate-400 flex justify-between">
<div>Latency: ~25s • Synced for all viewers (per ADR-001)</div>
<div class="flex gap-6">
<a href="#" onclick="switchRoom('main')" class="hover:text-white">Main Channel</a>
<a href="#" onclick="switchRoom('chill')" class="hover:text-white">Chill Room</a>
<a href="#" onclick="switchRoom('talk')" class="hover:text-white">Talk Room</a>
</div>
</div>
</div>
<footer class="viewer-footer">
<div>Latency: ~25s • Synced</div>
<nav class="room-links" aria-label="Quick room switch">
<a href="#" onclick="switchRoom('main'); return false;">Main Channel</a>
<a href="#" onclick="switchRoom('chill'); return false;">Chill Room</a>
<a href="#" onclick="switchRoom('talk'); return false;">Talk Room</a>
</nav>
</footer>
</section>
<!-- Chat Sidebar -->
<div class="w-96 flex flex-col border-l border-slate-800 bg-slate-900 chat-sidebar">
<div class="p-4 border-b border-slate-700 font-medium flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-emerald-400">●</span>
<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 onclick="togglePrivateDM()" class="text-xs px-3 py-1 bg-slate-700 hover:bg-slate-600 rounded-lg">New DM</button>
</div>
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
</header>
<!-- Messages -->
<div id="messages" class="flex-1 p-4 overflow-y-auto space-y-4 text-sm">
<!-- Messages populated by JS -->
</div>
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
<!-- Input -->
<div class="p-4 border-t border-slate-700">
<div class="flex gap-2">
<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()">
<button onclick="sendMessage()" class="bg-blue-600 hover:bg-blue-500 px-6 rounded-2xl text-sm font-medium">Send</button>
<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>
<div class="text-[10px] text-slate-500 mt-2 text-center">
<p class="chat-note">
Synced with video • Moderation active
</div>
</p>
</div>
</div>
</div>
</aside>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
+5 -209
View File
@@ -8,11 +8,7 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"alpinejs": "^3.15.12",
"autoprefixer": "^10.5.4",
"hls.js": "^1.6.16",
"postcss": "^8.5.25",
"tailwindcss": "^4.3.3"
"hls.js": "^1.6.16"
},
"devDependencies": {
"typescript": "~6.0.2",
@@ -375,131 +371,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.1.5"
}
},
"node_modules/@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"license": "MIT"
},
"node_modules/alpinejs": {
"version": "3.15.12",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
"integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
},
"node_modules/autoprefixer": {
"version": "10.5.4",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
"integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.28.6",
"caniuse-lite": "^1.0.30001806",
"fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
"bin": {
"autoprefixer": "bin/autoprefixer"
},
"engines": {
"node": "^10 || ^12 || >=14"
},
"peerDependencies": {
"postcss": "^8.1.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.10",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz",
"integrity": "sha512-35JEvJ5/KKlbCHjMCsONI2w6HE88STjVdHk+C7d8LtcFxUjZR1KeLP9izofn2qs0KUxX5r4z73bwH/rd+JHacw==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/browserslist": {
"version": "4.28.7",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
"integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.44",
"caniuse-lite": "^1.0.30001806",
"electron-to-chromium": "^1.5.393",
"node-releases": "^2.0.51",
"update-browserslist-db": "^1.2.3"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001806",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -510,21 +381,6 @@
"node": ">=8"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.399",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz",
"integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==",
"license": "ISC"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -543,19 +399,6 @@
}
}
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -854,6 +697,7 @@
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [
{
"type": "github",
@@ -868,19 +712,11 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-releases": {
"version": "2.0.51",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -900,6 +736,7 @@
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -924,12 +761,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz",
@@ -968,17 +799,12 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1018,36 +844,6 @@
"node": ">=14.17"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
},
"peerDependencies": {
"browserslist": ">= 4.21.0"
}
},
"node_modules/vite": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
+1 -5
View File
@@ -13,10 +13,6 @@
"vite": "^8.2.0"
},
"dependencies": {
"alpinejs": "^3.15.12",
"autoprefixer": "^10.5.4",
"hls.js": "^1.6.16",
"postcss": "^8.5.25",
"tailwindcss": "^4.3.3"
"hls.js": "^1.6.16"
}
}
+604
View File
@@ -0,0 +1,604 @@
import "./style.css";
type ServiceHealth = {
name: string;
status: string;
detail: string;
checked: string;
};
type MediaEntry = {
id?: number;
name: string;
title?: string;
status?: string;
size?: number;
runtime?: string;
modifiedAt?: string;
tags?: string;
};
const bodyDataset = document.body.dataset;
const moderationLogLimit = Number.parseInt(
bodyDataset.adminModerationLogLimit || "",
10,
);
const adminConfig = {
defaultModerator: bodyDataset.adminDefaultModerator || "admin",
defaultRoom: bodyDataset.adminDefaultRoom || "main",
moderationLogLimit:
Number.isFinite(moderationLogLimit) && moderationLogLimit > 0
? moderationLogLimit
: 50,
};
const state = {
mediaUploading: false,
moderationSubmitting: false,
playlistSaving: false,
};
const setText = (id: string, text: string) => {
const el = document.getElementById(id);
if (el) el.textContent = text;
};
const setStatus = (id: string, text: string, isError = false) => {
const el = document.getElementById(id);
if (!el) return;
el.textContent = text;
el.classList.toggle("status-error", isError);
};
const fmtSize = (value?: number) => {
if (!value || value <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let size = value;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
};
function switchTab(tabName: string) {
const triggers = document.querySelectorAll<HTMLButtonElement>(".tab-trigger");
const panels = document.querySelectorAll<HTMLElement>(".tab-panel");
triggers.forEach((trigger) => {
const active = trigger.dataset.tab === tabName;
trigger.classList.toggle("is-active", active);
trigger.setAttribute("aria-selected", String(active));
});
panels.forEach((panel) => {
const active = panel.id === `tab-${tabName}`;
panel.hidden = !active;
panel.classList.toggle("is-active", active);
});
if (tabName === "upload") refreshMediaLibrary();
if (tabName === "playlist") refreshPlaylist();
if (tabName === "schedule") refreshSchedule();
if (tabName === "moderation") refreshModerationLogs();
if (tabName === "users") refreshUsers();
if (tabName === "analytics") refreshAnalytics();
if (tabName === "system") refreshSystemSummary();
}
async function refreshOverview() {
try {
const response = await fetch("/api/admin/overview");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
setText("stat-viewers", String(payload.viewers ?? 0));
setText("stat-stored-messages", String(payload.storedMessages ?? 0));
setText("stat-moderation-actions", String(payload.moderationActions ?? 0));
setText("stat-media-runtime", payload.mediaTotalRuntime || "00:00:00");
setText("stat-media-count", `${payload.mediaCount || 0} files`);
setText("system-stored-messages", String(payload.storedMessages ?? 0));
setText("system-redis-clients", String(payload.redisClients ?? 0));
setText("system-redis-enabled", payload.redisEnabled ? "yes" : "no");
setText("system-redis-memory", payload.redisMemoryUsed || "unavailable");
setText("system-db-driver", payload.databaseDriver || "unavailable");
setText("system-generated-at", payload.generatedAt || "unavailable");
setStatus("overview-status", `Live data loaded at ${payload.generatedAt}`);
} catch (error) {
console.error("Failed to load admin overview", error);
setStatus("overview-status", "Failed to load live admin data.", true);
}
}
async function refreshAnalytics() {
setStatus("analytics-status", "Loading analytics...");
try {
const response = await fetch("/api/admin/analytics");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
setText("analytics-total-messages", String(payload.totalMessages ?? 0));
setText("analytics-messages-24h", String(payload.messagesLast24Hours ?? 0));
setText("analytics-distinct-rooms", String(payload.distinctRooms ?? 0));
setText(
"analytics-moderation-actions",
String(payload.moderationActions ?? 0),
);
setText("analytics-generated-at", payload.generatedAt || "unavailable");
const tbody = document.getElementById("analytics-table-body");
if (!tbody) return;
tbody.innerHTML = "";
const rows = (payload.topRooms || []) as Array<{
room: string;
messageCount: number;
}>;
if (rows.length === 0) {
tbody.innerHTML = `<tr><td colspan="2">No room analytics available yet.</td></tr>`;
} else {
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${row.room}</td><td>${row.messageCount}</td>`;
tbody.appendChild(tr);
});
}
setStatus("analytics-status", "Analytics loaded.");
} catch (error) {
console.error("Failed to load analytics", error);
setStatus("analytics-status", "Failed to load analytics.", true);
}
}
async function refreshPlaylist() {
setStatus("playlist-status", "Loading playlist...");
try {
const response = await fetch("/api/admin/playlist");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
const textarea = document.getElementById(
"playlist-content",
) as HTMLTextAreaElement | null;
if (textarea) textarea.value = payload.content || "";
setStatus("playlist-status", "Playlist loaded.");
} catch (error) {
console.error("Failed to load playlist", error);
setStatus("playlist-status", "Failed to load playlist.", true);
}
}
async function savePlaylist() {
if (state.playlistSaving) return;
state.playlistSaving = true;
setStatus("playlist-status", "Saving playlist...");
try {
const textarea = document.getElementById(
"playlist-content",
) as HTMLTextAreaElement | null;
const content = textarea?.value || "";
const response = await fetch("/api/admin/playlist", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setStatus("playlist-status", "Playlist saved.");
} catch (error) {
console.error("Failed to save playlist", error);
setStatus("playlist-status", "Failed to save playlist.", true);
} finally {
state.playlistSaving = false;
}
}
async function refreshSchedule() {
setStatus("schedule-status", "Loading schedule...");
try {
const response = await fetch("/api/admin/schedule");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
setText("schedule-total-entries", String(payload.totalEntries ?? 0));
setText("schedule-matched-entries", String(payload.matchedEntries ?? 0));
setText("schedule-total-runtime", payload.totalRuntime || "00:00:00");
const tbody = document.getElementById("schedule-table-body");
if (!tbody) return;
tbody.innerHTML = "";
const entries = payload.entries || [];
if (entries.length === 0) {
tbody.innerHTML = `<tr><td colspan="5">No schedule entries found.</td></tr>`;
} else {
entries.forEach((entry: any) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${entry.index}</td><td>${entry.label}</td><td>${entry.runtime || "00:00:00"}</td><td>${entry.status || "missing"}</td><td>${entry.path}</td>`;
tbody.appendChild(tr);
});
}
setStatus(
"schedule-status",
`Schedule loaded. Total runtime ${payload.totalRuntime || "00:00:00"}.`,
);
} catch (error) {
console.error("Failed to load schedule", error);
setStatus("schedule-status", "Failed to load schedule.", true);
}
}
async function refreshSystemSummary() {
setStatus("system-status", "Loading system summary...");
try {
const response = await fetch("/api/admin/system");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
setText("system-db-driver", payload.databaseDriver || "unavailable");
setText("system-playlist-path", payload.playlistPath || "unavailable");
setText("system-playlist-present", payload.playlistExists ? "yes" : "no");
setText("system-redis-enabled", payload.redisEnabled ? "yes" : "no");
setText("system-redis-memory", payload.redisMemoryUsed || "unavailable");
setText("system-generated-at", payload.generatedAt || "unavailable");
const servicesEl = document.getElementById("system-services");
if (servicesEl) {
servicesEl.innerHTML = "";
const services = (payload.services || []) as ServiceHealth[];
services.forEach((service) => {
const row = document.createElement("article");
row.className = "service-row";
row.innerHTML = `
<div>
<h3>${service.name}</h3>
<p class="small-muted">${service.checked}</p>
</div>
<div class="service-row-right">
<p class="service-status service-status-${service.status}">${service.status}</p>
<p>${service.detail}</p>
</div>
`;
servicesEl.appendChild(row);
});
}
setStatus("system-status", "System summary loaded.");
} catch (error) {
console.error("Failed to load system summary", error);
setStatus("system-status", "Failed to load system summary.", true);
}
}
function renderMediaRows(files: MediaEntry[]) {
const tbody = document.getElementById("media-table-body");
if (!tbody) return;
tbody.innerHTML = "";
if (files.length === 0) {
tbody.innerHTML = `<tr><td colspan="7">No media files available.</td></tr>`;
return;
}
files.forEach((file) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${file.name}</td>
<td>${file.title || "-"}</td>
<td>${file.runtime || "00:00:00"}</td>
<td>${file.status || "unknown"}</td>
<td>${fmtSize(file.size)}</td>
<td>${file.modifiedAt || "-"}</td>
<td class="action-cell">
<button type="button" class="btn btn-secondary btn-small" data-action="edit" data-id="${file.id || ""}">Edit</button>
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
</td>
`;
tr.querySelector('[data-action="edit"]')?.addEventListener("click", () =>
editMedia(file),
);
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () =>
deleteMedia(file),
);
tbody.appendChild(tr);
});
}
async function refreshMediaLibrary() {
setStatus("media-status", "Loading media library...");
try {
const response = await fetch("/api/admin/media");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
renderMediaRows(payload.files || []);
setText("media-generated-at", payload.generatedAt || "unavailable");
setStatus("media-status", "Media library loaded.");
} catch (error) {
console.error("Failed to load media library", error);
setStatus("media-status", "Failed to load media library.", true);
}
}
async function uploadVideo(file: File) {
if (state.mediaUploading) return;
state.mediaUploading = true;
setStatus("media-status", `Uploading ${file.name}...`);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/admin/media", {
method: "POST",
body: formData,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
renderMediaRows(payload.files || []);
setText("media-generated-at", payload.generatedAt || "unavailable");
setStatus("media-status", `${file.name} uploaded.`);
await refreshOverview();
await refreshSchedule();
} catch (error) {
console.error("Failed to upload media", error);
setStatus("media-status", "Failed to upload media file.", true);
} finally {
state.mediaUploading = false;
}
}
async function editMedia(file: MediaEntry) {
if (!file.id) {
setStatus("media-status", "Cannot edit media item without id.", true);
return;
}
const title = window.prompt("Title", file.title || file.name || "");
if (title === null) return;
const status = window.prompt("Status", file.status || "ready");
if (status === null) return;
const tags = window.prompt("Tags (comma-separated)", file.tags || "");
if (tags === null) return;
setStatus("media-status", `Saving metadata for ${file.name}...`);
try {
const response = await fetch(`/api/admin/media/${file.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, status, tags }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setStatus("media-status", "Media metadata updated.");
await refreshMediaLibrary();
await refreshOverview();
await refreshSchedule();
} catch (error) {
console.error("Failed to update media metadata", error);
setStatus("media-status", "Failed to update media metadata.", true);
}
}
async function deleteMedia(file: MediaEntry) {
if (!file.id) {
setStatus("media-status", "Cannot delete media item without id.", true);
return;
}
if (!window.confirm(`Delete ${file.name}? This removes file and metadata.`)) {
return;
}
setStatus("media-status", `Deleting ${file.name}...`);
try {
const response = await fetch(`/api/admin/media/${file.id}`, {
method: "DELETE",
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setStatus("media-status", `${file.name} deleted.`);
await refreshMediaLibrary();
await refreshOverview();
await refreshSchedule();
} catch (error) {
console.error("Failed to delete media", error);
setStatus("media-status", "Failed to delete media file.", true);
}
}
async function refreshModerationLogs() {
setStatus("moderation-status", "Loading moderation logs...");
try {
const response = await fetch(
`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`,
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
const tbody = document.getElementById("moderation-table-body");
if (!tbody) return;
tbody.innerHTML = "";
const rows = payload.logs || [];
if (rows.length === 0) {
tbody.innerHTML = `<tr><td colspan="5">No moderation actions logged yet.</td></tr>`;
} else {
rows.forEach((entry: any) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${entry.timestamp}</td><td>${entry.action}</td><td>${entry.target}</td><td>${entry.moderator}</td><td>${entry.reason}</td>`;
tbody.appendChild(tr);
});
}
setStatus("moderation-status", "Moderation logs loaded.");
} catch (error) {
console.error("Failed to load moderation logs", error);
setStatus("moderation-status", "Failed to load moderation logs.", true);
}
}
async function submitModerationAction(event: Event) {
event.preventDefault();
if (state.moderationSubmitting) return;
state.moderationSubmitting = true;
setStatus("moderation-status", "Submitting moderation action...");
const payload = {
action: (document.getElementById("mod-action") as HTMLSelectElement).value,
target: (document.getElementById("mod-target") as HTMLInputElement).value,
moderator: (document.getElementById("mod-moderator") as HTMLInputElement)
.value,
reason: (document.getElementById("mod-reason") as HTMLTextAreaElement)
.value,
room: (document.getElementById("mod-room") as HTMLInputElement).value,
};
try {
const response = await fetch("/api/admin/moderation/actions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setStatus("moderation-status", "Moderation action recorded.");
(document.getElementById("mod-target") as HTMLInputElement).value = "";
(document.getElementById("mod-reason") as HTMLTextAreaElement).value = "";
await refreshModerationLogs();
await refreshOverview();
} catch (error) {
console.error("Failed to submit moderation action", error);
setStatus("moderation-status", "Failed to submit moderation action.", true);
} finally {
state.moderationSubmitting = false;
}
}
async function refreshUsers() {
setStatus("users-status", "Loading active users...");
try {
const response = await fetch("/api/admin/users");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
const tbody = document.getElementById("users-table-body");
if (!tbody) return;
tbody.innerHTML = "";
const users = payload.users || [];
if (users.length === 0) {
tbody.innerHTML = `<tr><td colspan="3">No active users reported.</td></tr>`;
} else {
users.forEach((user: any) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${user.username}</td><td>${user.room}</td><td>${user.online ? "yes" : "no"}</td>`;
tbody.appendChild(tr);
});
}
setStatus(
"users-status",
payload.redisEnabled
? "Active users loaded."
: "Redis disabled; no live presence data available.",
);
} catch (error) {
console.error("Failed to load users", error);
setStatus("users-status", "Failed to load active users.", true);
}
}
function bindEvents() {
document
.querySelectorAll<HTMLButtonElement>(".tab-trigger")
.forEach((button) => {
button.addEventListener("click", () => {
const tab = button.dataset.tab;
if (tab) switchTab(tab);
});
});
document.getElementById("logout-btn")?.addEventListener("click", async () => {
try {
await fetch("/api/admin/logout", { method: "POST" });
} finally {
window.location.href = "/admin/login/";
}
});
document
.getElementById("reload-media-btn")
?.addEventListener("click", refreshMediaLibrary);
document
.getElementById("reload-schedule-btn")
?.addEventListener("click", refreshSchedule);
document
.getElementById("reload-playlist-btn")
?.addEventListener("click", refreshPlaylist);
document
.getElementById("save-playlist-btn")
?.addEventListener("click", savePlaylist);
document
.getElementById("reload-moderation-btn")
?.addEventListener("click", refreshModerationLogs);
document
.getElementById("reload-analytics-btn")
?.addEventListener("click", refreshAnalytics);
document
.getElementById("reload-users-btn")
?.addEventListener("click", refreshUsers);
document
.getElementById("reload-system-btn")
?.addEventListener("click", refreshSystemSummary);
const uploadInput = document.getElementById(
"video-upload",
) as HTMLInputElement | null;
document
.getElementById("upload-trigger-btn")
?.addEventListener("click", () => uploadInput?.click());
uploadInput?.addEventListener("change", async (event) => {
const target = event.target as HTMLInputElement;
if (!target.files || target.files.length === 0) return;
await uploadVideo(target.files[0]);
target.value = "";
});
document
.getElementById("moderation-form")
?.addEventListener("submit", submitModerationAction);
(document.getElementById("mod-moderator") as HTMLInputElement).value =
adminConfig.defaultModerator;
(document.getElementById("mod-room") as HTMLInputElement).value =
adminConfig.defaultRoom;
}
async function init() {
bindEvents();
await Promise.all([
refreshOverview(),
refreshAnalytics(),
refreshMediaLibrary(),
refreshPlaylist(),
refreshSchedule(),
refreshModerationLogs(),
refreshSystemSummary(),
refreshUsers(),
]);
}
document.addEventListener("DOMContentLoaded", () => {
init().catch((error) => console.error("Admin initialization failed", error));
});
+58 -66
View File
@@ -1,17 +1,16 @@
import Hls from "hls.js";
import "./style.css";
// Main application state with Alpine.js
type HlsClass = typeof import("hls.js")["default"];
declare global {
interface Window {
app: () => any;
switchRoom: (room: string) => void;
sendMessage: () => void;
togglePrivateDM: () => void;
}
}
let hls: Hls | null = null;
let hls: InstanceType<HlsClass> | null = null;
let ws: WebSocket | null = null;
let currentRoom = "main";
let username = "viewer" + Math.floor(Math.random() * 1000);
@@ -89,17 +88,21 @@ function addMessage(
) {
const messages = document.getElementById("messages")!;
const div = document.createElement("div");
div.className = `message flex ${type === "system" ? "justify-center" : sender === username ? "justify-end" : "justify-start"}`;
div.className = "message-row";
if (type === "system") {
div.innerHTML = `<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 {
div.classList.add(
sender === username ? "message-self-row" : "message-other-row",
);
div.innerHTML = `
<div class="${sender === username ? "bg-blue-600" : "bg-slate-700"} px-4 py-2 rounded-3xl max-w-[75%]">
${sender && sender !== username ? `<div class="text-xs text-blue-300">${sender}</div>` : ""}
<div>${text}</div>
<div class="text-[10px] text-slate-400 mt-1">${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
</div>
<article class="message-bubble ${sender === username ? "message-self" : "message-other"}">
${sender && sender !== username ? `<div class="message-sender">${sender}</div>` : ""}
<div class="message-text">${text}</div>
<time class="message-time">${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</time>
</article>
`;
}
@@ -107,6 +110,49 @@ function addMessage(
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() {
const input = document.getElementById("message-input") as HTMLInputElement;
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.sendMessage = sendMessage;
window.togglePrivateDM = togglePrivateDM;
// Auto initialize when loaded
document.addEventListener("DOMContentLoaded", () => {
// @ts-ignore
window.app().init();
void initPlayerAndChat();
});
+814 -289
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({
envDir: "..",
build: {
chunkSizeWarningLimit: 600,
rollupOptions: {
input: {
main: "index.html",