From 0f1b9c11fe66c5b8c9fa5189cecf023358bfba3a Mon Sep 17 00:00:00 2001
From: Gabriel Kaszewski
Date: Wed, 11 Mar 2026 22:26:16 +0100
Subject: [PATCH] feat: implement configuration management and enhance user
registration flow
---
k-tv-backend/compose.yml | 107 +++++------------
k-tv-frontend/app/(auth)/login/page.tsx | 16 ++-
k-tv-frontend/app/(auth)/register/page.tsx | 16 +++
.../dashboard/components/channel-card.tsx | 67 ++++++++++-
.../dashboard/components/schedule-sheet.tsx | 22 +++-
k-tv-frontend/app/(main)/dashboard/page.tsx | 90 +++++++++++++-
k-tv-frontend/app/(main)/docs/page.tsx | 3 +
k-tv-frontend/app/(main)/tv/page.tsx | 112 +++++++++++++++++-
k-tv-frontend/app/providers.tsx | 2 +
k-tv-frontend/hooks/use-channels.ts | 19 ++-
k-tv-frontend/lib/api.ts | 5 +
k-tv-frontend/lib/types.ts | 6 +
12 files changed, 370 insertions(+), 95 deletions(-)
diff --git a/k-tv-backend/compose.yml b/k-tv-backend/compose.yml
index ba6ca5b..616a61f 100644
--- a/k-tv-backend/compose.yml
+++ b/k-tv-backend/compose.yml
@@ -4,86 +4,43 @@ services:
ports:
- "3000:3000"
environment:
- - SESSION_SECRET=dev_secret_key_12345
- - DATABASE_URL=sqlite:///app/data/notes.db
- - CORS_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5173
+ # Server
- HOST=0.0.0.0
- PORT=3000
+ # Database — SQLite by default; swap for a postgres:// URL to use PostgreSQL
+ - DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc
+ # CORS — set to your frontend origin(s), comma-separated
+ - CORS_ALLOWED_ORIGINS=http://localhost:3001
+ # Auth — CHANGE BOTH before going to production
+ # Generate JWT_SECRET with: openssl rand -hex 32
+ # Generate COOKIE_SECRET with: openssl rand -base64 64
+ - JWT_SECRET=change-me-generate-with-openssl-rand-hex-32
+ - COOKIE_SECRET=change-me-must-be-at-least-64-characters-long-for-production!!
+ - JWT_EXPIRY_HOURS=24
+ - SECURE_COOKIE=false # set to true when serving over HTTPS
+ - PRODUCTION=false
+ # Database pool
- DB_MAX_CONNECTIONS=5
- DB_MIN_CONNECTIONS=1
- - SECURE_COOKIE=true
+ # Jellyfin media provider — all three are required to enable schedule generation
+ - JELLYFIN_BASE_URL=http://jellyfin:8096
+ - JELLYFIN_API_KEY=your-jellyfin-api-key-here
+ - JELLYFIN_USER_ID=your-jellyfin-user-id-here
volumes:
- - ./data:/app/data
+ - ./data:/app/data # SQLite database + any other persistent files
+ restart: unless-stopped
- # nats:
- # image: nats:alpine
+ # ── Optional: PostgreSQL ────────────────────────────────────────────────────
+ # Uncomment and set DATABASE_URL=postgres://ktv:password@db:5432/ktv above.
+ #
+ # db:
+ # image: postgres:16-alpine
+ # environment:
+ # POSTGRES_USER: ktv
+ # POSTGRES_PASSWORD: password
+ # POSTGRES_DB: ktv
# ports:
- # - "4222:4222"
- # - "6222:6222"
- # - "8222:8222"
+ # - "5432:5432"
+ # volumes:
+ # - db_data:/var/lib/postgresql/data
# restart: unless-stopped
-
- db:
- image: postgres:15-alpine
- environment:
- POSTGRES_USER: user
- POSTGRES_PASSWORD: password
- POSTGRES_DB: k_template_db
- ports:
- - "5439:5432"
- volumes:
- - db_data:/var/lib/postgresql/data
-
- zitadel-db:
- image: postgres:16-alpine
- container_name: zitadel_db
- environment:
- POSTGRES_USER: zitadel
- POSTGRES_PASSWORD: zitadel_password
- POSTGRES_DB: zitadel
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U zitadel -d zitadel"]
- interval: 10s
- timeout: 5s
- retries: 5
- volumes:
- - zitadel_db_data:/var/lib/postgresql/data
-
- zitadel:
- image: ghcr.io/zitadel/zitadel:latest
- container_name: zitadel_local
- depends_on:
- zitadel-db:
- condition: service_healthy
- ports:
- - "8086:8080"
- # USE start-from-init (Fixes the "relation does not exist" bug)
- command: 'start-from-init --masterkey "MasterkeyNeedsToBeExactly32Bytes"'
- environment:
- # Database Connection
- ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db
- ZITADEL_DATABASE_POSTGRES_PORT: 5432
- ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
-
- # APPLICATION USER (Zitadel uses this to run)
- ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
- ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_password
- ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
-
- # ADMIN USER (Zitadel uses this to create tables/migrations)
- # We use 'zitadel' because it is the owner of the DB in your postgres container.
- ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: zitadel
- ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: zitadel_password
- ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
-
- # General Config
- ZITADEL_EXTERNALDOMAIN: localhost
- ZITADEL_EXTERNALPORT: 8086
- ZITADEL_EXTERNALSECURE: "false"
- ZITADEL_TLS_ENABLED: "false"
-
- ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "false"
-
-volumes:
- db_data:
- zitadel_db_data:
\ No newline at end of file
diff --git a/k-tv-frontend/app/(auth)/login/page.tsx b/k-tv-frontend/app/(auth)/login/page.tsx
index 2c9b133..e069227 100644
--- a/k-tv-frontend/app/(auth)/login/page.tsx
+++ b/k-tv-frontend/app/(auth)/login/page.tsx
@@ -3,11 +3,13 @@
import Link from "next/link";
import { useState } from "react";
import { useLogin } from "@/hooks/use-auth";
+import { useConfig } from "@/hooks/use-channels";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { mutate: login, isPending, error } = useLogin();
+ const { data: config } = useConfig();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -63,12 +65,14 @@ export default function LoginPage() {
-
- No account?{" "}
-
- Create one
-
-
+ {config?.allow_registration !== false && (
+
+ No account?{" "}
+
+ Create one
+
+
+ )}
);
}
diff --git a/k-tv-frontend/app/(auth)/register/page.tsx b/k-tv-frontend/app/(auth)/register/page.tsx
index ccd4f68..63abc71 100644
--- a/k-tv-frontend/app/(auth)/register/page.tsx
+++ b/k-tv-frontend/app/(auth)/register/page.tsx
@@ -3,11 +3,27 @@
import Link from "next/link";
import { useState } from "react";
import { useRegister } from "@/hooks/use-auth";
+import { useConfig } from "@/hooks/use-channels";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { mutate: register, isPending, error } = useRegister();
+ const { data: config } = useConfig();
+
+ if (config && !config.allow_registration) {
+ return (
+
+
Registration disabled
+
+ The administrator has disabled new account registration.
+
+
+ Sign in instead
+
+
+ );
+ }
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
diff --git a/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx b/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx
index 7231e91..e9c6e2e 100644
--- a/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx
+++ b/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx
@@ -1,28 +1,64 @@
+"use client";
+
import Link from "next/link";
-import { Pencil, Trash2, RefreshCw, Tv2, CalendarDays, Download } from "lucide-react";
+import { Pencil, Trash2, RefreshCw, Tv2, CalendarDays, Download, ChevronUp, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { useActiveSchedule } from "@/hooks/use-channels";
import type { ChannelResponse } from "@/lib/types";
interface ChannelCardProps {
channel: ChannelResponse;
isGenerating: boolean;
+ isFirst: boolean;
+ isLast: boolean;
onEdit: () => void;
onDelete: () => void;
onGenerateSchedule: () => void;
onViewSchedule: () => void;
onExport: () => void;
+ onMoveUp: () => void;
+ onMoveDown: () => void;
+}
+
+function useScheduleStatus(channelId: string) {
+ const { data: schedule } = useActiveSchedule(channelId);
+ if (!schedule) return { status: "none" as const, label: null };
+
+ const expiresAt = new Date(schedule.valid_until);
+ const hoursLeft = (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60);
+
+ if (hoursLeft < 0) {
+ return { status: "expired" as const, label: "Schedule expired" };
+ }
+ if (hoursLeft < 6) {
+ const h = Math.ceil(hoursLeft);
+ return { status: "expiring" as const, label: `Expires in ${h}h` };
+ }
+ const fmt = expiresAt.toLocaleDateString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false });
+ return { status: "ok" as const, label: `Until ${fmt}` };
}
export function ChannelCard({
channel,
isGenerating,
+ isFirst,
+ isLast,
onEdit,
onDelete,
onGenerateSchedule,
onViewSchedule,
onExport,
+ onMoveUp,
+ onMoveDown,
}: ChannelCardProps) {
const blockCount = channel.schedule_config.blocks.length;
+ const { status, label } = useScheduleStatus(channel.id);
+
+ const scheduleColor =
+ status === "expired" ? "text-red-400" :
+ status === "expiring" ? "text-amber-400" :
+ status === "ok" ? "text-zinc-500" :
+ "text-zinc-600";
return (
@@ -40,6 +76,26 @@ export function ChannelCard({
+ {/* Order controls */}
+
+
+
+
+
{/* Actions */}
@@ -85,7 +142,7 @@ export function ChannelCard({
size="sm"
onClick={onGenerateSchedule}
disabled={isGenerating}
- className="flex-1"
+ className={`flex-1 ${status === "expired" ? "border border-red-800/50 bg-red-950/30 text-red-300 hover:bg-red-900/40" : ""}`}
>
{isGenerating ? "Generating…" : "Generate schedule"}
diff --git a/k-tv-frontend/app/(main)/dashboard/components/schedule-sheet.tsx b/k-tv-frontend/app/(main)/dashboard/components/schedule-sheet.tsx
index 8f1ccde..37bd604 100644
--- a/k-tv-frontend/app/(main)/dashboard/components/schedule-sheet.tsx
+++ b/k-tv-frontend/app/(main)/dashboard/components/schedule-sheet.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useEffect, useState } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useActiveSchedule } from "@/hooks/use-channels";
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
@@ -21,11 +22,13 @@ interface DayRowProps {
dayStart: Date;
slots: ScheduledSlotResponse[];
colorMap: Map;
+ now: Date;
}
-function DayRow({ label, dayStart, slots, colorMap }: DayRowProps) {
+function DayRow({ label, dayStart, slots, colorMap, now }: DayRowProps) {
const DAY_MS = 24 * 60 * 60 * 1000;
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
+ const nowPct = ((now.getTime() - dayStart.getTime()) / DAY_MS) * 100;
// Only include slots that overlap this day
const daySlots = slots.filter((s) => {
@@ -46,6 +49,15 @@ function DayRow({ label, dayStart, slots, colorMap }: DayRowProps) {
style={{ left: `${(i / 24) * 100}%` }}
/>
))}
+ {/* Current time marker */}
+ {nowPct >= 0 && nowPct <= 100 && (
+
+ )}
{daySlots.map((slot) => {
const slotStart = new Date(slot.start_at);
const slotEnd = new Date(slot.end_at);
@@ -102,6 +114,13 @@ interface ScheduleSheetProps {
export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProps) {
const { data: schedule, isLoading, error } = useActiveSchedule(channel?.id ?? "");
+ // Live clock for the current-time marker — updates every minute
+ const [now, setNow] = useState(() => new Date());
+ useEffect(() => {
+ const id = setInterval(() => setNow(new Date()), 60_000);
+ return () => clearInterval(id);
+ }, []);
+
const colorMap = schedule ? makeColorMap(schedule.slots) : new Map();
// Build day rows from valid_from to valid_until
@@ -172,6 +191,7 @@ export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProp
dayStart={dayStart}
slots={schedule.slots}
colorMap={colorMap}
+ now={now}
/>
))}
diff --git a/k-tv-frontend/app/(main)/dashboard/page.tsx b/k-tv-frontend/app/(main)/dashboard/page.tsx
index 437b1a7..bd08af4 100644
--- a/k-tv-frontend/app/(main)/dashboard/page.tsx
+++ b/k-tv-frontend/app/(main)/dashboard/page.tsx
@@ -1,7 +1,7 @@
"use client";
-import { useState } from "react";
-import { Plus, Upload } from "lucide-react";
+import { useState, useEffect } from "react";
+import { Plus, Upload, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
useChannels,
@@ -12,6 +12,7 @@ import {
} from "@/hooks/use-channels";
import { useAuthContext } from "@/context/auth-context";
import { api } from "@/lib/api";
+import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { ChannelCard } from "./components/channel-card";
import { CreateChannelDialog } from "./components/create-channel-dialog";
@@ -31,6 +32,69 @@ export default function DashboardPage() {
const deleteChannel = useDeleteChannel();
const generateSchedule = useGenerateSchedule();
+ // Channel ordering — persisted to localStorage
+ const [channelOrder, setChannelOrder] = useState([]);
+ useEffect(() => {
+ try {
+ const stored = localStorage.getItem("k-tv-channel-order");
+ if (stored) setChannelOrder(JSON.parse(stored));
+ } catch {}
+ }, []);
+
+ const saveOrder = (order: string[]) => {
+ setChannelOrder(order);
+ try { localStorage.setItem("k-tv-channel-order", JSON.stringify(order)); } catch {}
+ };
+
+ // Sort channels by stored order; new channels appear at the end
+ const sortedChannels = channels
+ ? [...channels].sort((a, b) => {
+ const ai = channelOrder.indexOf(a.id);
+ const bi = channelOrder.indexOf(b.id);
+ if (ai === -1 && bi === -1) return 0;
+ if (ai === -1) return 1;
+ if (bi === -1) return -1;
+ return ai - bi;
+ })
+ : [];
+
+ const handleMoveUp = (channelId: string) => {
+ const ids = sortedChannels.map((c) => c.id);
+ const idx = ids.indexOf(channelId);
+ if (idx <= 0) return;
+ const next = [...ids];
+ [next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
+ saveOrder(next);
+ };
+
+ const handleMoveDown = (channelId: string) => {
+ const ids = sortedChannels.map((c) => c.id);
+ const idx = ids.indexOf(channelId);
+ if (idx === -1 || idx >= ids.length - 1) return;
+ const next = [...ids];
+ [next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
+ saveOrder(next);
+ };
+
+ // Regenerate all channels
+ const [isRegeneratingAll, setIsRegeneratingAll] = useState(false);
+ const handleRegenerateAll = async () => {
+ if (!token || !channels || channels.length === 0) return;
+ setIsRegeneratingAll(true);
+ let failed = 0;
+ for (const ch of channels) {
+ try {
+ await api.schedule.generate(ch.id, token);
+ queryClient.invalidateQueries({ queryKey: ["schedule", ch.id] });
+ } catch {
+ failed++;
+ }
+ }
+ setIsRegeneratingAll(false);
+ if (failed === 0) toast.success(`All ${channels.length} schedules regenerated`);
+ else toast.error(`${failed} schedule(s) failed to generate`);
+ };
+
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importPending, setImportPending] = useState(false);
@@ -124,6 +188,18 @@ export default function DashboardPage() {
+ {channels && channels.length > 0 && (
+
+ )}
)}
- {channels && channels.length === 0 && (
+ {!isLoading && channels && channels.length === 0 && (
No channels yet
)}
- {channels && channels.length > 0 && (
+ {sortedChannels.length > 0 && (
- {channels.map((channel) => (
+ {sortedChannels.map((channel, idx) => (
setEditChannel(channel)}
onDelete={() => setDeleteTarget(channel)}
onGenerateSchedule={() => generateSchedule.mutate(channel.id)}
onViewSchedule={() => setScheduleChannel(channel)}
onExport={() => handleExport(channel)}
+ onMoveUp={() => handleMoveUp(channel.id)}
+ onMoveDown={() => handleMoveDown(channel.id)}
/>
))}
diff --git a/k-tv-frontend/app/(main)/docs/page.tsx b/k-tv-frontend/app/(main)/docs/page.tsx
index 5dde28c..0dccd45 100644
--- a/k-tv-frontend/app/(main)/docs/page.tsx
+++ b/k-tv-frontend/app/(main)/docs/page.tsx
@@ -758,7 +758,10 @@ Output only valid JSON matching this structure:
rows={[
["Arrow Up / Page Up", "Next channel"],
["Arrow Down / Page Down", "Previous channel"],
+ ["0–9", "Type a channel number and jump to it after 1.5 s (e.g. press 1 then 4 → channel 14)"],
["G", "Toggle the program guide"],
+ ["M", "Mute / unmute"],
+ ["F", "Toggle fullscreen"],
]}
/>
diff --git a/k-tv-frontend/app/(main)/tv/page.tsx b/k-tv-frontend/app/(main)/tv/page.tsx
index b69c6d0..82d8845 100644
--- a/k-tv-frontend/app/(main)/tv/page.tsx
+++ b/k-tv-frontend/app/(main)/tv/page.tsx
@@ -11,6 +11,7 @@ import {
NoSignal,
} from "./components";
import type { SubtitleTrack } from "./components/video-player";
+import { Maximize2, Minimize2, Volume2, VolumeX } from "lucide-react";
import { useAuthContext } from "@/context/auth-context";
import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels";
import {
@@ -59,6 +60,36 @@ export default function TvPage() {
const [subtitleTracks, setSubtitleTracks] = useState([]);
const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1);
const [showSubtitlePicker, setShowSubtitlePicker] = useState(false);
+
+ // Fullscreen
+ const [isFullscreen, setIsFullscreen] = useState(false);
+ useEffect(() => {
+ const handler = () => setIsFullscreen(!!document.fullscreenElement);
+ document.addEventListener("fullscreenchange", handler);
+ return () => document.removeEventListener("fullscreenchange", handler);
+ }, []);
+ const toggleFullscreen = useCallback(() => {
+ if (!document.fullscreenElement) {
+ document.documentElement.requestFullscreen().catch(() => {});
+ } else {
+ document.exitFullscreen().catch(() => {});
+ }
+ }, []);
+
+ // Volume / mute
+ const [isMuted, setIsMuted] = useState(false);
+ useEffect(() => {
+ if (videoRef.current) videoRef.current.muted = isMuted;
+ }, [isMuted]);
+ const toggleMute = useCallback(() => setIsMuted((m) => !m), []);
+
+ // Channel jump by number (e.g. press "1","4" → jump to ch 14 after 1.5 s)
+ const [channelInput, setChannelInput] = useState("");
+ const channelInputTimer = useRef | null>(null);
+
+ // Touch-swipe state
+ const touchStartY = useRef(null);
+
const queryClient = useQueryClient();
// Tick for live progress calculation (every 30 s is fine for the progress bar)
@@ -169,12 +200,59 @@ export default function TvPage() {
case "G":
toggleSchedule();
break;
+ case "f":
+ case "F":
+ toggleFullscreen();
+ break;
+ case "m":
+ case "M":
+ toggleMute();
+ break;
+ default: {
+ if (e.key >= "0" && e.key <= "9") {
+ setChannelInput((prev) => {
+ const next = prev + e.key;
+ if (channelInputTimer.current) clearTimeout(channelInputTimer.current);
+ channelInputTimer.current = setTimeout(() => {
+ const num = parseInt(next, 10);
+ if (num >= 1 && num <= Math.max(channelCount, 1)) {
+ setChannelIdx(num - 1);
+ resetIdle();
+ }
+ setChannelInput("");
+ }, 1500);
+ return next;
+ });
+ }
+ }
}
};
window.addEventListener("keydown", handleKey);
- return () => window.removeEventListener("keydown", handleKey);
- }, [nextChannel, prevChannel, toggleSchedule]);
+ return () => {
+ window.removeEventListener("keydown", handleKey);
+ if (channelInputTimer.current) clearTimeout(channelInputTimer.current);
+ };
+ }, [nextChannel, prevChannel, toggleSchedule, toggleFullscreen, toggleMute, channelCount, resetIdle]);
+
+ // ------------------------------------------------------------------
+ // Touch swipe (swipe up = next channel, swipe down = prev channel)
+ // ------------------------------------------------------------------
+
+ const handleTouchStart = useCallback((e: React.TouchEvent) => {
+ touchStartY.current = e.touches[0].clientY;
+ resetIdle();
+ }, [resetIdle]);
+
+ const handleTouchEnd = useCallback((e: React.TouchEvent) => {
+ if (touchStartY.current === null) return;
+ const dy = touchStartY.current - e.changedTouches[0].clientY;
+ touchStartY.current = null;
+ if (Math.abs(dy) > 60) {
+ if (dy > 0) nextChannel();
+ else prevChannel();
+ }
+ }, [nextChannel, prevChannel]);
// ------------------------------------------------------------------
// Stream error recovery
@@ -258,6 +336,8 @@ export default function TvPage() {
style={{ cursor: showOverlays ? "default" : "none" }}
onMouseMove={resetIdle}
onClick={resetIdle}
+ onTouchStart={handleTouchStart}
+ onTouchEnd={handleTouchEnd}
>
{/* ── Base layer ─────────────────────────────────────────────── */}
{renderBase()}
@@ -315,6 +395,26 @@ export default function TvPage() {
)}
+
+
+
+