watch-party/frontend/src/api/watchparty.ts
Nik Afiq 008f8a3cca feat(auth): integrate Firebase authentication and add auth status component
- Added Firebase as a dependency for authentication.
- Created AuthProvider to manage authentication state and user sessions.
- Implemented AuthStatus component to display authentication status in the UI.
- Added verifyFirebaseIdToken function to validate Firebase ID tokens against the backend.
- Updated API endpoints to include Firebase OAuth verification.
- Enhanced ShowsPage to allow authenticated users to delete shows.
- Updated configuration to include Firebase settings and authentication enablement.
- Styled authentication components and added necessary CSS for better UI.
2025-12-10 21:16:14 +09:00

155 lines
5.2 KiB
TypeScript

import { API_ENDPOINT } from "./endpoint";
import { ApiError, apiFetch } from "./client";
import type { DanimeEpisode, ScheduleResponse, ShowItem, TimeResponse } from "./types";
export type { DanimeEpisode, ScheduleResponse, ShowItem, TimeResponse } from "./types";
function asNumber(v: unknown, fallback = 0) {
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : fallback;
}
function asString(v: unknown, fallback = "") {
return typeof v === "string" ? v : fallback;
}
function normalizeSchedule(data: unknown): ScheduleResponse {
if (data == null || typeof data !== "object") {
throw new ApiError("Bad schedule payload", { url: API_ENDPOINT.v1.CURRENT, data });
}
const obj = data as Record<string, unknown>;
return {
ep_num: asNumber(obj.ep_num, 0),
ep_title: asString(obj.ep_title, "未設定"),
season_name: asString(obj.season_name, "未設定"),
start_time: obj.start_time ? asString(obj.start_time) : undefined,
playback_length: obj.playback_length ? asString(obj.playback_length) : undefined,
};
}
function normalizeShows(data: unknown): ShowItem[] {
if (!Array.isArray(data)) {
throw new ApiError("Shows payload is not an array", { url: API_ENDPOINT.v1.SHOWS, data });
}
return data.map((item) => {
if (!item || typeof item !== "object") {
throw new ApiError("Bad show item", { url: API_ENDPOINT.v1.SHOWS, data: item });
}
const obj = item as Record<string, unknown>;
const id = asNumber(obj.id, NaN);
if (!Number.isFinite(id)) {
throw new ApiError("Show item missing id", { url: API_ENDPOINT.v1.SHOWS, data: item });
}
return {
id,
ep_num: asNumber(obj.ep_num, 0),
ep_title: asString(obj.ep_title, "不明"),
season_name: asString(obj.season_name, "不明"),
start_time: asString(obj.start_time, ""),
playback_length: asString(obj.playback_length, ""),
date_created: asString(obj.date_created, ""),
};
});
}
function normalizeDanimeEpisode(data: unknown): DanimeEpisode {
if (!data || typeof data !== "object") {
throw new ApiError("Bad danime payload", { url: API_ENDPOINT.v1.DANIME, data });
}
const obj = data as Record<string, unknown>;
return {
ep_num: asNumber(obj.ep_num, 0),
ep_title: asString(obj.ep_title, ""),
season_name: asString(obj.season_name, ""),
playback_length: asString(obj.playback_length, ""),
};
}
export async function fetchSchedule(signal?: AbortSignal) {
const data = await apiFetch<ScheduleResponse>(API_ENDPOINT.v1.CURRENT, {
signal,
timeoutMs: 12_000,
logLabel: "load schedule",
});
return normalizeSchedule(data);
}
export async function fetchShows(signal?: AbortSignal) {
const data = await apiFetch<ShowItem[]>(API_ENDPOINT.v1.SHOWS, {
signal,
timeoutMs: 12_000,
logLabel: "fetch shows",
});
return normalizeShows(data);
}
export async function postCurrentEpisode(payload: { id: number; start_time?: string }, signal?: AbortSignal) {
await apiFetch<void>(API_ENDPOINT.v1.CURRENT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal,
timeoutMs: 12_000,
expect: "void",
logLabel: "set current episode",
});
}
export async function fetchServerNow(signal?: AbortSignal): Promise<number> {
const data = await apiFetch<TimeResponse>(API_ENDPOINT.v1.TIME, {
signal,
timeoutMs: 8_000,
logLabel: "server time",
});
let n = typeof data?.now === "number" ? data.now : Number(data?.now);
if (!Number.isFinite(n)) {
throw new ApiError("Bad time payload", { url: API_ENDPOINT.v1.TIME, data });
}
if (n < 1e12) n = n * 1000; // seconds → ms
return n;
}
export async function fetchDanimeEpisode(partId: string, signal?: AbortSignal): Promise<DanimeEpisode> {
const url = `${API_ENDPOINT.v1.DANIME}?part_id=${encodeURIComponent(partId)}`;
const data = await apiFetch<DanimeEpisode>(url, {
signal,
timeoutMs: 12_000,
logLabel: "fetch danime episode",
});
return normalizeDanimeEpisode(data);
}
export async function createShow(payload: {
ep_num: number;
ep_title: string;
season_name: string;
playback_length: string;
start_time?: string;
}, signal?: AbortSignal) {
await apiFetch<void>(API_ENDPOINT.v1.SHOWS, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal,
timeoutMs: 12_000,
expect: "void",
logLabel: "create show",
});
}
export async function deleteShow(id: number, idToken: string) {
if (!idToken) {
throw new ApiError("Missing auth token for delete");
}
const url = `${API_ENDPOINT.v1.SHOWS}?id=${encodeURIComponent(id)}`;
await apiFetch<void>(url, {
method: "DELETE",
headers: {
"Authorization": `Bearer ${idToken}`,
},
timeoutMs: 10_000,
expect: "void",
logLabel: "delete show",
});
}