feat: implement authentication context and hooks for user management

- Add AuthContext to manage user authentication state and token storage.
- Create hooks for login, registration, and logout functionalities.
- Implement dashboard layout with authentication check and loading state.
- Enhance dashboard page with channel management features including create, edit, and delete channels.
- Integrate API calls for channel operations and current broadcast retrieval.
- Add stream URL resolution via server-side API route to handle redirects.
- Update TV page to utilize new hooks for channel and broadcast management.
- Refactor components for better organization and user experience.
- Update application metadata for improved branding.
This commit is contained in:
2026-03-11 19:32:49 +01:00
parent 01108aa23e
commit 8d8d320a02
22 changed files with 2118 additions and 173 deletions

132
k-tv-frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,132 @@
import type {
TokenResponse,
UserResponse,
ChannelResponse,
CreateChannelRequest,
UpdateChannelRequest,
ScheduleResponse,
ScheduledSlotResponse,
CurrentBroadcastResponse,
} from "@/lib/types";
const API_BASE =
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000/api/v1";
export class ApiRequestError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
this.name = "ApiRequestError";
}
}
async function request<T>(
path: string,
options: RequestInit & { token?: string } = {},
): Promise<T> {
const { token, ...init } = options;
const headers = new Headers(init.headers);
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
if (init.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
if (!res.ok) {
let message = res.statusText;
try {
const body = await res.json();
message = body.message ?? body.error ?? message;
} catch {
// ignore parse error, use statusText
}
throw new ApiRequestError(res.status, message);
}
if (res.status === 204) return null as T;
return res.json() as Promise<T>;
}
export const api = {
auth: {
register: (email: string, password: string) =>
request<TokenResponse>("/auth/register", {
method: "POST",
body: JSON.stringify({ email, password }),
}),
login: (email: string, password: string) =>
request<TokenResponse>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
}),
logout: (token: string) =>
request<void>("/auth/logout", { method: "POST", token }),
me: (token: string) => request<UserResponse>("/auth/me", { token }),
},
channels: {
list: (token: string) =>
request<ChannelResponse[]>("/channels", { token }),
get: (id: string, token: string) =>
request<ChannelResponse>(`/channels/${id}`, { token }),
create: (data: CreateChannelRequest, token: string) =>
request<ChannelResponse>("/channels", {
method: "POST",
body: JSON.stringify(data),
token,
}),
update: (id: string, data: UpdateChannelRequest, token: string) =>
request<ChannelResponse>(`/channels/${id}`, {
method: "PUT",
body: JSON.stringify(data),
token,
}),
delete: (id: string, token: string) =>
request<void>(`/channels/${id}`, { method: "DELETE", token }),
},
schedule: {
generate: (channelId: string, token: string) =>
request<ScheduleResponse>(`/channels/${channelId}/schedule`, {
method: "POST",
token,
}),
getActive: (channelId: string, token: string) =>
request<ScheduleResponse>(`/channels/${channelId}/schedule`, { token }),
getCurrentBroadcast: (channelId: string, token: string) =>
request<CurrentBroadcastResponse | null>(`/channels/${channelId}/now`, {
token,
}),
getEpg: (
channelId: string,
token: string,
from?: string,
until?: string,
) => {
const params = new URLSearchParams();
if (from) params.set("from", from);
if (until) params.set("until", until);
const qs = params.toString();
return request<ScheduledSlotResponse[]>(
`/channels/${channelId}/epg${qs ? `?${qs}` : ""}`,
{ token },
);
},
},
};

118
k-tv-frontend/lib/types.ts Normal file
View File

@@ -0,0 +1,118 @@
// API response and request types matching the backend DTOs
export type ContentType = "movie" | "episode" | "short";
export type FillStrategy = "best_fit" | "sequential" | "random";
export interface MediaFilter {
content_type?: ContentType | null;
genres: string[];
decade?: number | null;
tags: string[];
min_duration_secs?: number | null;
max_duration_secs?: number | null;
collections: string[];
}
export interface RecyclePolicy {
cooldown_days?: number | null;
cooldown_generations?: number | null;
min_available_ratio: number;
}
export type BlockContent =
| { type: "algorithmic"; filter: MediaFilter; strategy: FillStrategy }
| { type: "manual"; items: string[] };
export interface ProgrammingBlock {
id: string;
name: string;
/** "HH:MM:SS" */
start_time: string;
duration_mins: number;
content: BlockContent;
}
export interface ScheduleConfig {
blocks: ProgrammingBlock[];
}
// Auth
export interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
}
export interface UserResponse {
id: string;
email: string;
created_at: string;
}
// Channels
export interface ChannelResponse {
id: string;
owner_id: string;
name: string;
description?: string | null;
timezone: string;
schedule_config: ScheduleConfig;
recycle_policy: RecyclePolicy;
created_at: string;
updated_at: string;
}
export interface CreateChannelRequest {
name: string;
timezone: string;
description?: string;
}
export interface UpdateChannelRequest {
name?: string;
description?: string;
timezone?: string;
schedule_config?: ScheduleConfig;
recycle_policy?: RecyclePolicy;
}
// Media & Schedule
export interface MediaItemResponse {
id: string;
title: string;
content_type: ContentType;
duration_secs: number;
description?: string | null;
genres: string[];
tags: string[];
year?: number | null;
}
export interface ScheduledSlotResponse {
id: string;
block_id: string;
item: MediaItemResponse;
/** RFC3339 */
start_at: string;
/** RFC3339 */
end_at: string;
}
export interface ScheduleResponse {
id: string;
channel_id: string;
generation: number;
generated_at: string;
valid_from: string;
valid_until: string;
slots: ScheduledSlotResponse[];
}
export interface CurrentBroadcastResponse {
slot: ScheduledSlotResponse;
offset_secs: number;
}