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:
60
k-tv-frontend/hooks/use-auth.ts
Normal file
60
k-tv-frontend/hooks/use-auth.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuthContext } from "@/context/auth-context";
|
||||
|
||||
export function useCurrentUser() {
|
||||
const { token } = useAuthContext();
|
||||
return useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.auth.me(token!),
|
||||
enabled: !!token,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogin() {
|
||||
const { setToken } = useAuthContext();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ email, password }: { email: string; password: string }) =>
|
||||
api.auth.login(email, password),
|
||||
onSuccess: (data) => {
|
||||
setToken(data.access_token);
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
router.push("/dashboard");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRegister() {
|
||||
const { setToken } = useAuthContext();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ email, password }: { email: string; password: string }) =>
|
||||
api.auth.register(email, password),
|
||||
onSuccess: (data) => {
|
||||
setToken(data.access_token);
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
router.push("/dashboard");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const { token, setToken } = useAuthContext();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => (token ? api.auth.logout(token) : Promise.resolve()),
|
||||
onSettled: () => {
|
||||
setToken(null);
|
||||
queryClient.clear();
|
||||
router.push("/login");
|
||||
},
|
||||
});
|
||||
}
|
||||
102
k-tv-frontend/hooks/use-channels.ts
Normal file
102
k-tv-frontend/hooks/use-channels.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuthContext } from "@/context/auth-context";
|
||||
import type { CreateChannelRequest, UpdateChannelRequest } from "@/lib/types";
|
||||
|
||||
export function useChannels() {
|
||||
const { token } = useAuthContext();
|
||||
return useQuery({
|
||||
queryKey: ["channels"],
|
||||
queryFn: () => api.channels.list(token!),
|
||||
enabled: !!token,
|
||||
});
|
||||
}
|
||||
|
||||
export function useChannel(id: string) {
|
||||
const { token } = useAuthContext();
|
||||
return useQuery({
|
||||
queryKey: ["channel", id],
|
||||
queryFn: () => api.channels.get(id, token!),
|
||||
enabled: !!token && !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateChannel() {
|
||||
const { token } = useAuthContext();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateChannelRequest) =>
|
||||
api.channels.create(data, token!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["channels"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateChannel() {
|
||||
const { token } = useAuthContext();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateChannelRequest }) =>
|
||||
api.channels.update(id, data, token!),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["channels"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["channel", updated.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteChannel() {
|
||||
const { token } = useAuthContext();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.channels.delete(id, token!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["channels"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateSchedule() {
|
||||
const { token } = useAuthContext();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (channelId: string) =>
|
||||
api.schedule.generate(channelId, token!),
|
||||
onSuccess: (_, channelId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["schedule", channelId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useActiveSchedule(channelId: string) {
|
||||
const { token } = useAuthContext();
|
||||
return useQuery({
|
||||
queryKey: ["schedule", channelId],
|
||||
queryFn: () => api.schedule.getActive(channelId, token!),
|
||||
enabled: !!token && !!channelId,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCurrentBroadcast(channelId: string) {
|
||||
const { token } = useAuthContext();
|
||||
return useQuery({
|
||||
queryKey: ["broadcast", channelId],
|
||||
queryFn: () => api.schedule.getCurrentBroadcast(channelId, token!),
|
||||
enabled: !!token && !!channelId,
|
||||
refetchInterval: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEpg(channelId: string, from?: string, until?: string) {
|
||||
const { token } = useAuthContext();
|
||||
return useQuery({
|
||||
queryKey: ["epg", channelId, from, until],
|
||||
queryFn: () => api.schedule.getEpg(channelId, token!, from, until),
|
||||
enabled: !!token && !!channelId,
|
||||
});
|
||||
}
|
||||
94
k-tv-frontend/hooks/use-tv.ts
Normal file
94
k-tv-frontend/hooks/use-tv.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ScheduleSlot } from "@/app/(main)/tv/components";
|
||||
import type { ScheduledSlotResponse } from "@/lib/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure transformation utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Format an ISO-8601 string to "HH:MM" in the user's local timezone. */
|
||||
export function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Progress percentage through a slot based on wall-clock time. */
|
||||
export function calcProgress(startAt: string, durationSecs: number): number {
|
||||
if (durationSecs <= 0) return 0;
|
||||
const elapsedSecs = (Date.now() - new Date(startAt).getTime()) / 1000;
|
||||
return Math.min(100, Math.max(0, Math.round((elapsedSecs / durationSecs) * 100)));
|
||||
}
|
||||
|
||||
/** Minutes until a future timestamp (rounded, minimum 0). */
|
||||
export function minutesUntil(iso: string): number {
|
||||
return Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 60_000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map EPG slots to the shape expected by ScheduleOverlay.
|
||||
* Marks the slot matching currentSlotId as current.
|
||||
*/
|
||||
export function toScheduleSlots(
|
||||
slots: ScheduledSlotResponse[],
|
||||
currentSlotId?: string,
|
||||
): ScheduleSlot[] {
|
||||
return slots.map((slot) => ({
|
||||
id: slot.id,
|
||||
title: slot.item.title,
|
||||
startTime: fmtTime(slot.start_at),
|
||||
endTime: fmtTime(slot.end_at),
|
||||
isCurrent: slot.id === currentSlotId,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the slot immediately after the current one.
|
||||
* Returns null if current slot is last or not found.
|
||||
*/
|
||||
export function findNextSlot(
|
||||
slots: ScheduledSlotResponse[],
|
||||
currentSlotId?: string,
|
||||
): ScheduledSlotResponse | null {
|
||||
if (!currentSlotId || slots.length === 0) return null;
|
||||
const idx = slots.findIndex((s) => s.id === currentSlotId);
|
||||
if (idx === -1 || idx === slots.length - 1) return null;
|
||||
return slots[idx + 1];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useStreamUrl — resolves the 307 stream redirect via a Next.js API route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves the live stream URL for a channel.
|
||||
*
|
||||
* The backend's GET /channels/:id/stream endpoint returns a 307 redirect to
|
||||
* the Jellyfin stream URL. Since browsers can't read redirect Location headers
|
||||
* from fetch(), we proxy through /api/stream/[channelId] (a Next.js route that
|
||||
* runs server-side) and return the final URL as JSON.
|
||||
*
|
||||
* Returns null when the channel is in a gap (no-signal / 204).
|
||||
*/
|
||||
export function useStreamUrl(channelId: string | undefined, token: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["stream-url", channelId],
|
||||
queryFn: async (): Promise<string | null> => {
|
||||
const res = await fetch(
|
||||
`/api/stream/${channelId}?token=${encodeURIComponent(token!)}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
if (res.status === 204) return null;
|
||||
if (!res.ok) throw new Error(`Stream resolve failed: ${res.status}`);
|
||||
const { url } = await res.json();
|
||||
return url as string;
|
||||
},
|
||||
enabled: !!channelId && !!token,
|
||||
refetchInterval: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user