feat: implement configuration management and enhance user registration flow

This commit is contained in:
2026-03-11 22:26:16 +01:00
parent 62549faffa
commit 0f1b9c11fe
12 changed files with 370 additions and 95 deletions

View File

@@ -4,86 +4,43 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
- SESSION_SECRET=dev_secret_key_12345 # Server
- DATABASE_URL=sqlite:///app/data/notes.db
- CORS_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5173
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=3000 - 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_MAX_CONNECTIONS=5
- DB_MIN_CONNECTIONS=1 - 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: volumes:
- ./data:/app/data - ./data:/app/data # SQLite database + any other persistent files
restart: unless-stopped
# nats: # ── Optional: PostgreSQL ────────────────────────────────────────────────────
# image: nats:alpine # 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: # ports:
# - "4222:4222" # - "5432:5432"
# - "6222:6222" # volumes:
# - "8222:8222" # - db_data:/var/lib/postgresql/data
# restart: unless-stopped # 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:

View File

@@ -3,11 +3,13 @@
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { useLogin } from "@/hooks/use-auth"; import { useLogin } from "@/hooks/use-auth";
import { useConfig } from "@/hooks/use-channels";
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const { mutate: login, isPending, error } = useLogin(); const { mutate: login, isPending, error } = useLogin();
const { data: config } = useConfig();
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -63,12 +65,14 @@ export default function LoginPage() {
</button> </button>
</form> </form>
{config?.allow_registration !== false && (
<p className="text-center text-xs text-zinc-500"> <p className="text-center text-xs text-zinc-500">
No account?{" "} No account?{" "}
<Link href="/register" className="text-zinc-300 hover:text-white"> <Link href="/register" className="text-zinc-300 hover:text-white">
Create one Create one
</Link> </Link>
</p> </p>
)}
</div> </div>
); );
} }

View File

@@ -3,11 +3,27 @@
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { useRegister } from "@/hooks/use-auth"; import { useRegister } from "@/hooks/use-auth";
import { useConfig } from "@/hooks/use-channels";
export default function RegisterPage() { export default function RegisterPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const { mutate: register, isPending, error } = useRegister(); const { mutate: register, isPending, error } = useRegister();
const { data: config } = useConfig();
if (config && !config.allow_registration) {
return (
<div className="w-full max-w-sm space-y-4 text-center">
<h1 className="text-xl font-semibold text-zinc-100">Registration disabled</h1>
<p className="text-sm text-zinc-500">
The administrator has disabled new account registration.
</p>
<Link href="/login" className="inline-block text-sm text-zinc-300 hover:text-white">
Sign in instead
</Link>
</div>
);
}
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();

View File

@@ -1,28 +1,64 @@
"use client";
import Link from "next/link"; 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 { Button } from "@/components/ui/button";
import { useActiveSchedule } from "@/hooks/use-channels";
import type { ChannelResponse } from "@/lib/types"; import type { ChannelResponse } from "@/lib/types";
interface ChannelCardProps { interface ChannelCardProps {
channel: ChannelResponse; channel: ChannelResponse;
isGenerating: boolean; isGenerating: boolean;
isFirst: boolean;
isLast: boolean;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onGenerateSchedule: () => void; onGenerateSchedule: () => void;
onViewSchedule: () => void; onViewSchedule: () => void;
onExport: () => 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({ export function ChannelCard({
channel, channel,
isGenerating, isGenerating,
isFirst,
isLast,
onEdit, onEdit,
onDelete, onDelete,
onGenerateSchedule, onGenerateSchedule,
onViewSchedule, onViewSchedule,
onExport, onExport,
onMoveUp,
onMoveDown,
}: ChannelCardProps) { }: ChannelCardProps) {
const blockCount = channel.schedule_config.blocks.length; 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 ( return (
<div className="flex flex-col gap-4 rounded-xl border border-zinc-800 bg-zinc-900 p-5 transition-colors hover:border-zinc-700"> <div className="flex flex-col gap-4 rounded-xl border border-zinc-800 bg-zinc-900 p-5 transition-colors hover:border-zinc-700">
@@ -40,6 +76,26 @@ export function ChannelCard({
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{/* Order controls */}
<div className="flex flex-col">
<button
onClick={onMoveUp}
disabled={isFirst}
title="Move up"
className="rounded p-0.5 text-zinc-600 transition-colors hover:text-zinc-300 disabled:opacity-20 disabled:cursor-not-allowed"
>
<ChevronUp className="size-3.5" />
</button>
<button
onClick={onMoveDown}
disabled={isLast}
title="Move down"
className="rounded p-0.5 text-zinc-600 transition-colors hover:text-zinc-300 disabled:opacity-20 disabled:cursor-not-allowed"
>
<ChevronDown className="size-3.5" />
</button>
</div>
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
@@ -71,12 +127,13 @@ export function ChannelCard({
{/* Meta */} {/* Meta */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-zinc-500"> <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-zinc-500">
<span>
<span className="text-zinc-400">{channel.timezone}</span> <span className="text-zinc-400">{channel.timezone}</span>
</span>
<span> <span>
{blockCount} {blockCount === 1 ? "block" : "blocks"} {blockCount} {blockCount === 1 ? "block" : "blocks"}
</span> </span>
{label && (
<span className={scheduleColor}>{label}</span>
)}
</div> </div>
{/* Actions */} {/* Actions */}
@@ -85,7 +142,7 @@ export function ChannelCard({
size="sm" size="sm"
onClick={onGenerateSchedule} onClick={onGenerateSchedule}
disabled={isGenerating} 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" : ""}`}
> >
<RefreshCw className={`size-3.5 ${isGenerating ? "animate-spin" : ""}`} /> <RefreshCw className={`size-3.5 ${isGenerating ? "animate-spin" : ""}`} />
{isGenerating ? "Generating…" : "Generate schedule"} {isGenerating ? "Generating…" : "Generate schedule"}

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useActiveSchedule } from "@/hooks/use-channels"; import { useActiveSchedule } from "@/hooks/use-channels";
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types"; import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
@@ -21,11 +22,13 @@ interface DayRowProps {
dayStart: Date; dayStart: Date;
slots: ScheduledSlotResponse[]; slots: ScheduledSlotResponse[];
colorMap: Map<string, string>; colorMap: Map<string, string>;
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 DAY_MS = 24 * 60 * 60 * 1000;
const dayEnd = new Date(dayStart.getTime() + DAY_MS); const dayEnd = new Date(dayStart.getTime() + DAY_MS);
const nowPct = ((now.getTime() - dayStart.getTime()) / DAY_MS) * 100;
// Only include slots that overlap this day // Only include slots that overlap this day
const daySlots = slots.filter((s) => { const daySlots = slots.filter((s) => {
@@ -46,6 +49,15 @@ function DayRow({ label, dayStart, slots, colorMap }: DayRowProps) {
style={{ left: `${(i / 24) * 100}%` }} style={{ left: `${(i / 24) * 100}%` }}
/> />
))} ))}
{/* Current time marker */}
{nowPct >= 0 && nowPct <= 100 && (
<div
className="absolute inset-y-0 z-10 w-0.5 bg-red-500"
style={{ left: `${nowPct}%` }}
>
<div className="absolute -top-0.5 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full bg-red-500" />
</div>
)}
{daySlots.map((slot) => { {daySlots.map((slot) => {
const slotStart = new Date(slot.start_at); const slotStart = new Date(slot.start_at);
const slotEnd = new Date(slot.end_at); const slotEnd = new Date(slot.end_at);
@@ -102,6 +114,13 @@ interface ScheduleSheetProps {
export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProps) { export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProps) {
const { data: schedule, isLoading, error } = useActiveSchedule(channel?.id ?? ""); 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(); const colorMap = schedule ? makeColorMap(schedule.slots) : new Map();
// Build day rows from valid_from to valid_until // Build day rows from valid_from to valid_until
@@ -172,6 +191,7 @@ export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProp
dayStart={dayStart} dayStart={dayStart}
slots={schedule.slots} slots={schedule.slots}
colorMap={colorMap} colorMap={colorMap}
now={now}
/> />
))} ))}
</div> </div>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { Plus, Upload } from "lucide-react"; import { Plus, Upload, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
useChannels, useChannels,
@@ -12,6 +12,7 @@ import {
} from "@/hooks/use-channels"; } from "@/hooks/use-channels";
import { useAuthContext } from "@/context/auth-context"; import { useAuthContext } from "@/context/auth-context";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { ChannelCard } from "./components/channel-card"; import { ChannelCard } from "./components/channel-card";
import { CreateChannelDialog } from "./components/create-channel-dialog"; import { CreateChannelDialog } from "./components/create-channel-dialog";
@@ -31,6 +32,69 @@ export default function DashboardPage() {
const deleteChannel = useDeleteChannel(); const deleteChannel = useDeleteChannel();
const generateSchedule = useGenerateSchedule(); const generateSchedule = useGenerateSchedule();
// Channel ordering — persisted to localStorage
const [channelOrder, setChannelOrder] = useState<string[]>([]);
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 [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [importPending, setImportPending] = useState(false); const [importPending, setImportPending] = useState(false);
@@ -124,6 +188,18 @@ export default function DashboardPage() {
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{channels && channels.length > 0 && (
<Button
variant="outline"
onClick={handleRegenerateAll}
disabled={isRegeneratingAll}
title="Regenerate schedules for all channels"
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
>
<RefreshCw className={`size-4 ${isRegeneratingAll ? "animate-spin" : ""}`} />
Regenerate all
</Button>
)}
<Button variant="outline" onClick={() => setImportOpen(true)} className="border-zinc-700 text-zinc-300 hover:text-zinc-100"> <Button variant="outline" onClick={() => setImportOpen(true)} className="border-zinc-700 text-zinc-300 hover:text-zinc-100">
<Upload className="size-4" /> <Upload className="size-4" />
Import Import
@@ -148,7 +224,7 @@ export default function DashboardPage() {
</div> </div>
)} )}
{channels && channels.length === 0 && ( {!isLoading && channels && channels.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-zinc-800 py-20 text-center"> <div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-zinc-800 py-20 text-center">
<p className="text-sm text-zinc-500">No channels yet</p> <p className="text-sm text-zinc-500">No channels yet</p>
<Button variant="outline" onClick={() => setCreateOpen(true)}> <Button variant="outline" onClick={() => setCreateOpen(true)}>
@@ -158,9 +234,9 @@ export default function DashboardPage() {
</div> </div>
)} )}
{channels && channels.length > 0 && ( {sortedChannels.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{channels.map((channel) => ( {sortedChannels.map((channel, idx) => (
<ChannelCard <ChannelCard
key={channel.id} key={channel.id}
channel={channel} channel={channel}
@@ -168,11 +244,15 @@ export default function DashboardPage() {
generateSchedule.isPending && generateSchedule.isPending &&
generateSchedule.variables === channel.id generateSchedule.variables === channel.id
} }
isFirst={idx === 0}
isLast={idx === sortedChannels.length - 1}
onEdit={() => setEditChannel(channel)} onEdit={() => setEditChannel(channel)}
onDelete={() => setDeleteTarget(channel)} onDelete={() => setDeleteTarget(channel)}
onGenerateSchedule={() => generateSchedule.mutate(channel.id)} onGenerateSchedule={() => generateSchedule.mutate(channel.id)}
onViewSchedule={() => setScheduleChannel(channel)} onViewSchedule={() => setScheduleChannel(channel)}
onExport={() => handleExport(channel)} onExport={() => handleExport(channel)}
onMoveUp={() => handleMoveUp(channel.id)}
onMoveDown={() => handleMoveDown(channel.id)}
/> />
))} ))}
</div> </div>

View File

@@ -758,7 +758,10 @@ Output only valid JSON matching this structure:
rows={[ rows={[
["Arrow Up / Page Up", "Next channel"], ["Arrow Up / Page Up", "Next channel"],
["Arrow Down / Page Down", "Previous channel"], ["Arrow Down / Page Down", "Previous channel"],
["09", "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"], ["G", "Toggle the program guide"],
["M", "Mute / unmute"],
["F", "Toggle fullscreen"],
]} ]}
/> />

View File

@@ -11,6 +11,7 @@ import {
NoSignal, NoSignal,
} from "./components"; } from "./components";
import type { SubtitleTrack } from "./components/video-player"; import type { SubtitleTrack } from "./components/video-player";
import { Maximize2, Minimize2, Volume2, VolumeX } from "lucide-react";
import { useAuthContext } from "@/context/auth-context"; import { useAuthContext } from "@/context/auth-context";
import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels"; import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels";
import { import {
@@ -59,6 +60,36 @@ export default function TvPage() {
const [subtitleTracks, setSubtitleTracks] = useState<SubtitleTrack[]>([]); const [subtitleTracks, setSubtitleTracks] = useState<SubtitleTrack[]>([]);
const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1); const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1);
const [showSubtitlePicker, setShowSubtitlePicker] = useState(false); 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<ReturnType<typeof setTimeout> | null>(null);
// Touch-swipe state
const touchStartY = useRef<number | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Tick for live progress calculation (every 30 s is fine for the progress bar) // Tick for live progress calculation (every 30 s is fine for the progress bar)
@@ -169,12 +200,59 @@ export default function TvPage() {
case "G": case "G":
toggleSchedule(); toggleSchedule();
break; 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); window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey); return () => {
}, [nextChannel, prevChannel, toggleSchedule]); 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 // Stream error recovery
@@ -258,6 +336,8 @@ export default function TvPage() {
style={{ cursor: showOverlays ? "default" : "none" }} style={{ cursor: showOverlays ? "default" : "none" }}
onMouseMove={resetIdle} onMouseMove={resetIdle}
onClick={resetIdle} onClick={resetIdle}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
> >
{/* ── Base layer ─────────────────────────────────────────────── */} {/* ── Base layer ─────────────────────────────────────────────── */}
<div className="absolute inset-0">{renderBase()}</div> <div className="absolute inset-0">{renderBase()}</div>
@@ -315,6 +395,26 @@ export default function TvPage() {
</div> </div>
)} )}
<button
className="pointer-events-auto rounded-md bg-black/50 p-1.5 text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
onClick={toggleMute}
title={isMuted ? "Unmute [M]" : "Mute [M]"}
>
{isMuted
? <VolumeX className="h-4 w-4" />
: <Volume2 className="h-4 w-4" />}
</button>
<button
className="pointer-events-auto rounded-md bg-black/50 p-1.5 text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
onClick={toggleFullscreen}
title={isFullscreen ? "Exit fullscreen [F]" : "Fullscreen [F]"}
>
{isFullscreen
? <Minimize2 className="h-4 w-4" />
: <Maximize2 className="h-4 w-4" />}
</button>
<button <button
className="pointer-events-auto rounded-md bg-black/50 px-3 py-1.5 text-xs text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white" className="pointer-events-auto rounded-md bg-black/50 px-3 py-1.5 text-xs text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
onClick={toggleSchedule} onClick={toggleSchedule}
@@ -369,6 +469,14 @@ export default function TvPage() {
</div> </div>
</div> </div>
{/* Channel number input overlay */}
{channelInput && (
<div className="pointer-events-none absolute left-1/2 top-1/2 z-30 -translate-x-1/2 -translate-y-1/2 rounded-xl bg-black/80 px-8 py-5 text-center backdrop-blur">
<p className="mb-1 text-[10px] uppercase tracking-widest text-zinc-500">Channel</p>
<p className="font-mono text-5xl font-bold text-white">{channelInput}</p>
</div>
)}
{/* Schedule overlay — outside the fading div so it has its own visibility */} {/* Schedule overlay — outside the fading div so it has its own visibility */}
{showOverlays && showSchedule && ( {showOverlays && showSchedule && (
<div className="absolute bottom-4 right-4 top-14 z-20 w-80"> <div className="absolute bottom-4 right-4 top-14 z-20 w-80">

View File

@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react"; import { useState } from "react";
import { AuthProvider } from "@/context/auth-context"; import { AuthProvider } from "@/context/auth-context";
import { Toaster } from "@/components/ui/sonner";
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState( const [queryClient] = useState(
@@ -21,6 +22,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
<AuthProvider> <AuthProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{children} {children}
<Toaster position="bottom-right" richColors />
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider> </QueryClientProvider>
</AuthProvider> </AuthProvider>

View File

@@ -1,10 +1,19 @@
"use client"; "use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAuthContext } from "@/context/auth-context"; import { useAuthContext } from "@/context/auth-context";
import type { CreateChannelRequest, UpdateChannelRequest } from "@/lib/types"; import type { CreateChannelRequest, UpdateChannelRequest } from "@/lib/types";
export function useConfig() {
return useQuery({
queryKey: ["config"],
queryFn: () => api.config.get(),
staleTime: Infinity, // config doesn't change at runtime
});
}
export function useChannels() { export function useChannels() {
const { token } = useAuthContext(); const { token } = useAuthContext();
return useQuery({ return useQuery({
@@ -29,9 +38,11 @@ export function useCreateChannel() {
return useMutation({ return useMutation({
mutationFn: (data: CreateChannelRequest) => mutationFn: (data: CreateChannelRequest) =>
api.channels.create(data, token!), api.channels.create(data, token!),
onSuccess: () => { onSuccess: (channel) => {
queryClient.invalidateQueries({ queryKey: ["channels"] }); queryClient.invalidateQueries({ queryKey: ["channels"] });
toast.success(`Channel "${channel.name}" created`);
}, },
onError: (e: Error) => toast.error(e.message),
}); });
} }
@@ -44,7 +55,9 @@ export function useUpdateChannel() {
onSuccess: (updated) => { onSuccess: (updated) => {
queryClient.invalidateQueries({ queryKey: ["channels"] }); queryClient.invalidateQueries({ queryKey: ["channels"] });
queryClient.invalidateQueries({ queryKey: ["channel", updated.id] }); queryClient.invalidateQueries({ queryKey: ["channel", updated.id] });
toast.success(`Channel "${updated.name}" saved`);
}, },
onError: (e: Error) => toast.error(e.message),
}); });
} }
@@ -55,7 +68,9 @@ export function useDeleteChannel() {
mutationFn: (id: string) => api.channels.delete(id, token!), mutationFn: (id: string) => api.channels.delete(id, token!),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["channels"] }); queryClient.invalidateQueries({ queryKey: ["channels"] });
toast.success("Channel deleted");
}, },
onError: (e: Error) => toast.error(e.message),
}); });
} }
@@ -67,7 +82,9 @@ export function useGenerateSchedule() {
api.schedule.generate(channelId, token!), api.schedule.generate(channelId, token!),
onSuccess: (_, channelId) => { onSuccess: (_, channelId) => {
queryClient.invalidateQueries({ queryKey: ["schedule", channelId] }); queryClient.invalidateQueries({ queryKey: ["schedule", channelId] });
toast.success("Schedule generated");
}, },
onError: (e: Error) => toast.error(`Schedule failed: ${e.message}`),
}); });
} }

View File

@@ -1,6 +1,7 @@
import type { import type {
TokenResponse, TokenResponse,
UserResponse, UserResponse,
ConfigResponse,
ChannelResponse, ChannelResponse,
CreateChannelRequest, CreateChannelRequest,
UpdateChannelRequest, UpdateChannelRequest,
@@ -54,6 +55,10 @@ async function request<T>(
} }
export const api = { export const api = {
config: {
get: () => request<ConfigResponse>("/config"),
},
auth: { auth: {
register: (email: string, password: string) => register: (email: string, password: string) =>
request<TokenResponse>("/auth/register", { request<TokenResponse>("/auth/register", {

View File

@@ -37,6 +37,12 @@ export interface ScheduleConfig {
blocks: ProgrammingBlock[]; blocks: ProgrammingBlock[];
} }
// Config
export interface ConfigResponse {
allow_registration: boolean;
}
// Auth // Auth
export interface TokenResponse { export interface TokenResponse {