feat: add SPA, serve at /app/, update Dockerfile and README

- React + TanStack Router + shadcn/ui SPA under spa/
- serve spa/dist at /app/ with index.html fallback for client routing
- Dockerfile: node build stage for SPA, copy dist into runtime image
- README: document SPA, CORS_ORIGINS env var, architecture entry
- vite base set to /app/, manifest.json paths fixed
This commit is contained in:
2026-06-04 04:20:15 +02:00
parent 15dc0e526b
commit b9c0b10740
153 changed files with 24329 additions and 1 deletions

32
spa/src/lib/api/auth.ts Normal file
View File

@@ -0,0 +1,32 @@
import { z } from "zod"
import { post } from "./client"
export const loginRequestSchema = z.object({
email: z.string(),
password: z.string(),
})
export type LoginRequest = z.infer<typeof loginRequestSchema>
export const loginResponseSchema = z.object({
token: z.string(),
user_id: z.string().uuid(),
email: z.string(),
role: z.string(),
expires_at: z.string(),
})
export type LoginResponse = z.infer<typeof loginResponseSchema>
export const registerRequestSchema = z.object({
email: z.string(),
username: z.string(),
password: z.string(),
})
export type RegisterRequest = z.infer<typeof registerRequestSchema>
export function login(data: LoginRequest) {
return post<LoginResponse>("/auth/login", data)
}
export function register(data: RegisterRequest) {
return post("/auth/register", data)
}

120
spa/src/lib/api/client.ts Normal file
View File

@@ -0,0 +1,120 @@
import { clearAuth, getToken } from "@/lib/auth"
export const API_URL = import.meta.env.VITE_API_URL ?? ""
export function posterUrl(path: string | undefined | null): string | undefined {
if (!path) return undefined
const clean = path.startsWith("/") ? path.slice(1) : path
return `${API_URL}/images/${clean}`
}
export function tmdbProfileUrl(path: string | undefined | null): string | undefined {
if (!path) return undefined
return `https://image.tmdb.org/t/p/w185${path}`
}
export class ApiError extends Error {
status: number
body: string
constructor(status: number, body: string) {
super(`API ${status}: ${body}`)
this.status = status
this.body = body
}
}
function authHeaders(): HeadersInit {
const token = getToken()
return token ? { Authorization: `Bearer ${token}` } : {}
}
function buildUrl(
path: string,
params?: Record<string, unknown>,
): string {
const base = `${API_URL}/api/v1${path}`
if (!params) return base
const sp = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v != null) sp.set(k, String(v))
}
const qs = sp.toString()
return qs ? `${base}?${qs}` : base
}
async function request<T = void>(
url: string,
options?: RequestInit,
): Promise<T> {
const res = await fetch(url, {
...options,
headers: {
...authHeaders(),
...options?.headers,
},
})
if (!res.ok) {
if (res.status === 401) {
clearAuth()
window.location.href = "/login"
}
throw new ApiError(res.status, await res.text())
}
const text = await res.text()
return text ? JSON.parse(text) : (undefined as T)
}
export async function get<T>(
path: string,
params?: Record<string, unknown>,
): Promise<T> {
return request<T>(buildUrl(path, params))
}
export async function post<T = void>(
path: string,
body?: unknown,
): Promise<T> {
return request<T>(buildUrl(path), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
})
}
export async function put<T = void>(
path: string,
body?: unknown,
): Promise<T> {
return request<T>(buildUrl(path), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
})
}
export async function putForm<T = void>(
path: string,
form: FormData,
): Promise<T> {
return request<T>(buildUrl(path), {
method: "PUT",
body: form,
})
}
export async function del<T = void>(path: string): Promise<T> {
return request<T>(buildUrl(path), { method: "DELETE" })
}
export async function upload<T>(
path: string,
file: File,
): Promise<T> {
const form = new FormData()
form.append("file", file)
return request<T>(buildUrl(path), {
method: "POST",
body: form,
})
}

44
spa/src/lib/api/common.ts Normal file
View File

@@ -0,0 +1,44 @@
import { z } from "zod"
export const movieDtoSchema = z.object({
id: z.string().uuid(),
title: z.string(),
release_year: z.number(),
director: z.string().optional(),
poster_path: z.string().optional(),
genres: z.array(z.string()),
runtime_minutes: z.number().optional(),
original_language: z.string().optional(),
overview: z.string().optional(),
collection_name: z.string().optional(),
})
export type MovieDto = z.infer<typeof movieDtoSchema>
export const reviewDtoSchema = z.object({
id: z.string().uuid(),
rating: z.number(),
comment: z.string().optional(),
watched_at: z.string(),
})
export type ReviewDto = z.infer<typeof reviewDtoSchema>
export const diaryEntryDtoSchema = z.object({
movie: movieDtoSchema,
review: reviewDtoSchema,
})
export type DiaryEntryDto = z.infer<typeof diaryEntryDtoSchema>
export function paginatedSchema<T extends z.ZodType>(itemSchema: T) {
return z.object({
items: z.array(itemSchema),
total_count: z.number(),
limit: z.number(),
offset: z.number(),
})
}
export type Paginated<T> = {
items: T[]
total_count: number
limit: number
offset: number
}

70
spa/src/lib/api/diary.ts Normal file
View File

@@ -0,0 +1,70 @@
import { z } from "zod"
import type { DiaryEntryDto, Paginated } from "./common"
import { diaryEntryDtoSchema, movieDtoSchema, paginatedSchema, reviewDtoSchema } from "./common"
import { del, get, post } from "./client"
export const diaryQueryParamsSchema = z.object({
limit: z.number().optional(),
offset: z.number().optional(),
sort_by: z.string().optional(),
movie_id: z.string().uuid().optional(),
})
export type DiaryQueryParams = z.infer<typeof diaryQueryParamsSchema>
export const diaryResponseSchema = paginatedSchema(diaryEntryDtoSchema)
export type DiaryResponse = Paginated<DiaryEntryDto>
export const logReviewRequestSchema = z.object({
external_metadata_id: z.string().optional(),
manual_title: z.string().optional(),
manual_release_year: z.number().optional(),
manual_director: z.string().optional(),
rating: z.number(),
comment: z.string().optional(),
watched_at: z.string(),
})
export type LogReviewRequest = z.infer<typeof logReviewRequestSchema>
export const feedEntryDtoSchema = z.object({
movie: movieDtoSchema,
review: reviewDtoSchema,
user_id: z.string().uuid(),
user_email: z.string(),
user_display_name: z.string(),
})
export type FeedEntryDto = z.infer<typeof feedEntryDtoSchema>
export const activityFeedQueryParamsSchema = z.object({
limit: z.number().optional(),
offset: z.number().optional(),
sort_by: z.string().optional(),
})
export type ActivityFeedQueryParams = z.infer<typeof activityFeedQueryParamsSchema>
export const activityFeedResponseSchema = paginatedSchema(feedEntryDtoSchema)
export type ActivityFeedResponse = Paginated<FeedEntryDto>
export const exportQueryParamsSchema = z.object({
format: z.string().optional(),
})
export type ExportQueryParams = z.infer<typeof exportQueryParamsSchema>
export function getDiary(params?: DiaryQueryParams) {
return get<DiaryResponse>("/diary", params)
}
export function logReview(data: LogReviewRequest) {
return post("/reviews", data)
}
export function deleteReview(id: string) {
return del(`/reviews/${id}`)
}
export function getActivityFeed(params?: ActivityFeedQueryParams) {
return get<ActivityFeedResponse>("/activity-feed", params)
}
export function exportDiary(params?: ExportQueryParams) {
return get<Blob>("/diary/export", params)
}

View File

@@ -0,0 +1,89 @@
import { z } from "zod"
import { del, get, post, put, upload } from "./client"
export const sessionCreatedResponseSchema = z.object({
session_id: z.string(),
columns: z.array(z.string()),
sample_rows: z.array(z.array(z.string())),
})
export type SessionCreatedResponse = z.infer<typeof sessionCreatedResponseSchema>
export const sessionStateResponseSchema = z.object({
session_id: z.string(),
columns: z.array(z.string()),
has_mappings: z.boolean(),
row_count: z.number(),
})
export type SessionStateResponse = z.infer<typeof sessionStateResponseSchema>
export const apiFieldMappingSchema = z.object({
source_column: z.string(),
domain_field: z.string(),
rating_scale: z.number().optional(),
date_format: z.string().optional(),
})
export type ApiFieldMapping = z.infer<typeof apiFieldMappingSchema>
export const applyMappingRequestSchema = z.object({
mappings: z.array(apiFieldMappingSchema),
})
export type ApplyMappingRequest = z.infer<typeof applyMappingRequestSchema>
export const confirmRequestSchema = z.object({
confirmed_indices: z.array(z.number()),
})
export type ConfirmRequest = z.infer<typeof confirmRequestSchema>
export const saveProfileRequestSchema = z.object({
session_id: z.string(),
name: z.string(),
})
export type SaveProfileRequest = z.infer<typeof saveProfileRequestSchema>
export function createImportSession(file: File) {
return upload<SessionCreatedResponse>("/import/sessions", file)
}
export function getImportSession(id: string) {
return get<SessionStateResponse>(`/import/sessions/${id}`)
}
export type PreviewRow = {
index: number
status: string
title?: string
release_year?: string
director?: string
rating?: string
watched_at?: string
comment?: string
errors?: string[]
}
export type PreviewResponse = {
rows: PreviewRow[]
}
export function getImportPreview(id: string) {
return get<PreviewResponse>(`/import/sessions/${id}/preview`)
}
export function applyMapping(sessionId: string, data: ApplyMappingRequest) {
return put(`/import/sessions/${sessionId}/mapping`, data)
}
export function confirmImport(sessionId: string, data: ConfirmRequest) {
return post(`/import/sessions/${sessionId}/confirm`, data)
}
export function getImportProfiles() {
return get<unknown[]>("/import/profiles")
}
export function saveImportProfile(data: SaveProfileRequest) {
return post("/import/profiles", data)
}
export function deleteImportProfile(id: string) {
return del(`/import/profiles/${id}`)
}

12
spa/src/lib/api/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export { ApiError } from "./client"
export * from "./common"
export * from "./auth"
export * from "./diary"
export * from "./movies"
export * from "./users"
export * from "./search"
export * from "./watchlist"
export * from "./webhooks"
export * from "./imports"
export * from "./wrapup"
export * from "./social"

118
spa/src/lib/api/movies.ts Normal file
View File

@@ -0,0 +1,118 @@
import { z } from "zod"
import type { Paginated } from "./common"
import { movieDtoSchema, paginatedSchema, reviewDtoSchema } from "./common"
import { get, post } from "./client"
export const moviesQueryParamsSchema = z.object({
limit: z.number().optional(),
offset: z.number().optional(),
search: z.string().optional(),
genre: z.string().optional(),
language: z.string().optional(),
})
export type MoviesQueryParams = z.infer<typeof moviesQueryParamsSchema>
export const moviesResponseSchema = paginatedSchema(movieDtoSchema)
export type MoviesResponse = z.infer<typeof moviesResponseSchema>
export const movieStatsDtoSchema = z.object({
total_count: z.number(),
avg_rating: z.number().optional(),
federated_count: z.number(),
rating_histogram: z.array(z.number()),
})
export type MovieStatsDto = z.infer<typeof movieStatsDtoSchema>
export const socialReviewDtoSchema = z.object({
user_display: z.string(),
rating: z.number(),
comment: z.string().optional(),
watched_at: z.string(),
is_federated: z.boolean(),
})
export type SocialReviewDto = z.infer<typeof socialReviewDtoSchema>
export const socialFeedResponseSchema = paginatedSchema(socialReviewDtoSchema)
export type SocialFeedResponse = Paginated<SocialReviewDto>
export const movieDetailResponseSchema = z.object({
movie: movieDtoSchema,
stats: movieStatsDtoSchema,
reviews: socialFeedResponseSchema,
})
export type MovieDetailResponse = z.infer<typeof movieDetailResponseSchema>
export const reviewHistoryResponseSchema = z.object({
movie: movieDtoSchema,
viewings: z.array(reviewDtoSchema),
trend: z.string(),
})
export type ReviewHistoryResponse = z.infer<typeof reviewHistoryResponseSchema>
export const genreDtoSchema = z.object({
tmdb_id: z.number(),
name: z.string(),
})
export const keywordDtoSchema = z.object({
tmdb_id: z.number(),
name: z.string(),
})
export const castMemberDtoSchema = z.object({
tmdb_person_id: z.number(),
name: z.string(),
character: z.string(),
billing_order: z.number(),
profile_path: z.string().optional(),
})
export type CastMemberDto = z.infer<typeof castMemberDtoSchema>
export const crewMemberDtoSchema = z.object({
tmdb_person_id: z.number(),
name: z.string(),
job: z.string(),
department: z.string(),
profile_path: z.string().optional(),
})
export type CrewMemberDto = z.infer<typeof crewMemberDtoSchema>
export const movieProfileResponseSchema = z.object({
tmdb_id: z.number(),
imdb_id: z.string().optional(),
overview: z.string().optional(),
tagline: z.string().optional(),
runtime_minutes: z.number().optional(),
budget_usd: z.number().optional(),
revenue_usd: z.number().optional(),
vote_average: z.number().optional(),
vote_count: z.number().optional(),
original_language: z.string().optional(),
collection_name: z.string().optional(),
genres: z.array(genreDtoSchema),
keywords: z.array(keywordDtoSchema),
cast: z.array(castMemberDtoSchema),
crew: z.array(crewMemberDtoSchema),
enriched_at: z.string(),
})
export type MovieProfileResponse = z.infer<typeof movieProfileResponseSchema>
export function getMovies(params?: MoviesQueryParams) {
return get<MoviesResponse>("/movies", params)
}
export function getMovie(id: string) {
return get<MovieDetailResponse>(`/movies/${id}`)
}
export function getMovieHistory(id: string) {
return get<ReviewHistoryResponse>(`/movies/${id}/history`)
}
export function getMovieProfile(id: string) {
return get<MovieProfileResponse>(`/movies/${id}/profile`)
}
export function syncPoster(id: string) {
return post(`/movies/${id}/sync-poster`)
}

87
spa/src/lib/api/search.ts Normal file
View File

@@ -0,0 +1,87 @@
import { z } from "zod"
import { paginatedSchema } from "./common"
import { get } from "./client"
export const searchQueryParamsSchema = z.object({
q: z.string().optional(),
genre: z.string().optional(),
year: z.number().optional(),
person_id: z.string().uuid().optional(),
department: z.string().optional(),
language: z.string().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
})
export type SearchQueryParams = z.infer<typeof searchQueryParamsSchema>
export const movieSearchHitDtoSchema = z.object({
movie_id: z.string().uuid(),
title: z.string(),
release_year: z.number().optional(),
director: z.string().optional(),
poster_path: z.string().optional(),
genres: z.array(z.string()),
})
export type MovieSearchHitDto = z.infer<typeof movieSearchHitDtoSchema>
export const personSearchHitDtoSchema = z.object({
person_id: z.string().uuid(),
name: z.string(),
known_for_department: z.string().optional(),
profile_path: z.string().optional(),
known_for_titles: z.array(z.string()),
})
export type PersonSearchHitDto = z.infer<typeof personSearchHitDtoSchema>
export const searchResponseSchema = z.object({
movies: paginatedSchema(movieSearchHitDtoSchema),
people: paginatedSchema(personSearchHitDtoSchema),
})
export type SearchResponse = z.infer<typeof searchResponseSchema>
export const personDtoSchema = z.object({
id: z.string().uuid(),
external_id: z.string(),
name: z.string(),
known_for_department: z.string().optional(),
profile_path: z.string().optional(),
})
export type PersonDto = z.infer<typeof personDtoSchema>
export const castCreditDtoSchema = z.object({
movie_id: z.string().uuid(),
title: z.string(),
release_year: z.number().optional(),
character: z.string(),
poster_path: z.string().optional(),
})
export type CastCreditDto = z.infer<typeof castCreditDtoSchema>
export const crewCreditDtoSchema = z.object({
movie_id: z.string().uuid(),
title: z.string(),
release_year: z.number().optional(),
job: z.string(),
department: z.string(),
poster_path: z.string().optional(),
})
export type CrewCreditDto = z.infer<typeof crewCreditDtoSchema>
export const personCreditsDtoSchema = z.object({
person: personDtoSchema,
cast: z.array(castCreditDtoSchema),
crew: z.array(crewCreditDtoSchema),
})
export type PersonCreditsDto = z.infer<typeof personCreditsDtoSchema>
export function search(params: SearchQueryParams) {
return get<SearchResponse>("/search", params)
}
export function getPerson(id: string) {
return get<PersonDto>(`/people/${id}`)
}
export function getPersonCredits(id: string) {
return get<PersonCreditsDto>(`/people/${id}/credits`)
}

109
spa/src/lib/api/social.ts Normal file
View File

@@ -0,0 +1,109 @@
import { z } from "zod"
import { del, get, post } from "./client"
export const remoteActorDtoSchema = z.object({
handle: z.string(),
display_name: z.string().optional(),
url: z.string(),
})
export type RemoteActorDto = z.infer<typeof remoteActorDtoSchema>
export const actorListResponseSchema = z.object({
actors: z.array(remoteActorDtoSchema),
})
export type ActorListResponse = z.infer<typeof actorListResponseSchema>
export const followRequestSchema = z.object({
handle: z.string(),
})
export type FollowRequest = z.infer<typeof followRequestSchema>
export const actorUrlRequestSchema = z.object({
actor_url: z.string(),
})
export type ActorUrlRequest = z.infer<typeof actorUrlRequestSchema>
export const blockedDomainResponseSchema = z.object({
domain: z.string(),
reason: z.string().optional(),
blocked_at: z.string(),
})
export type BlockedDomainResponse = z.infer<typeof blockedDomainResponseSchema>
export const addBlockedDomainRequestSchema = z.object({
domain: z.string(),
reason: z.string().optional(),
})
export type AddBlockedDomainRequest = z.infer<typeof addBlockedDomainRequestSchema>
export const blockedActorResponseSchema = z.object({
url: z.string(),
handle: z.string(),
display_name: z.string().optional(),
avatar_url: z.string().optional(),
})
export type BlockedActorResponse = z.infer<typeof blockedActorResponseSchema>
export function getFollowing() {
return get<ActorListResponse>("/social/following")
}
export function getFollowers() {
return get<ActorListResponse>("/social/followers")
}
export function getUserFollowing(userId: string) {
return get<ActorListResponse>(`/users/${userId}/following`)
}
export function getUserFollowers(userId: string) {
return get<ActorListResponse>(`/users/${userId}/followers`)
}
export function getPendingFollowers() {
return get<ActorListResponse>("/social/followers/pending")
}
export function follow(data: FollowRequest) {
return post("/social/follow", data)
}
export function unfollow(data: FollowRequest) {
return post("/social/unfollow", data)
}
export function acceptFollower(data: ActorUrlRequest) {
return post("/social/followers/accept", data)
}
export function rejectFollower(data: ActorUrlRequest) {
return post("/social/followers/reject", data)
}
export function removeFollower(data: ActorUrlRequest) {
return post("/social/followers/remove", data)
}
export function getBlockedDomains() {
return get<BlockedDomainResponse[]>("/admin/blocked-domains")
}
export function addBlockedDomain(data: AddBlockedDomainRequest) {
return post("/admin/blocked-domains", data)
}
export function removeBlockedDomain(domain: string) {
return del(`/admin/blocked-domains/${domain}`)
}
export function blockActor(data: ActorUrlRequest) {
return post("/social/block", data)
}
export function unblockActor(data: ActorUrlRequest) {
return post("/social/unblock", data)
}
export function getBlockedActors() {
return get<BlockedActorResponse[]>("/social/blocked")
}

132
spa/src/lib/api/users.ts Normal file
View File

@@ -0,0 +1,132 @@
import { z } from "zod"
import { diaryEntryDtoSchema, paginatedSchema } from "./common"
import { get, put, putForm } from "./client"
export const userSummaryDtoSchema = z.object({
id: z.string().uuid(),
email: z.string(),
total_movies: z.number(),
avg_rating: z.number().optional(),
})
export type UserSummaryDto = z.infer<typeof userSummaryDtoSchema>
export const usersResponseSchema = z.object({
users: z.array(userSummaryDtoSchema),
})
export type UsersResponse = z.infer<typeof usersResponseSchema>
export const userProfileQueryParamsSchema = z.object({
view: z.string().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
})
export type UserProfileQueryParams = z.infer<typeof userProfileQueryParamsSchema>
export const userStatsDtoSchema = z.object({
total_movies: z.number(),
avg_rating: z.number().optional(),
favorite_director: z.string().optional(),
most_active_month: z.string().optional(),
})
export type UserStatsDto = z.infer<typeof userStatsDtoSchema>
export const monthlyRatingDtoSchema = z.object({
year_month: z.string(),
month_label: z.string(),
avg_rating: z.number(),
count: z.number(),
})
export type MonthlyRatingDto = z.infer<typeof monthlyRatingDtoSchema>
export const directorStatDtoSchema = z.object({
director: z.string(),
count: z.number(),
})
export type DirectorStatDto = z.infer<typeof directorStatDtoSchema>
export const userTrendsDtoSchema = z.object({
monthly_ratings: z.array(monthlyRatingDtoSchema),
top_directors: z.array(directorStatDtoSchema),
max_director_count: z.number(),
})
export type UserTrendsDto = z.infer<typeof userTrendsDtoSchema>
export const monthActivityDtoSchema = z.object({
year_month: z.string(),
month_label: z.string(),
count: z.number(),
entries: z.array(diaryEntryDtoSchema),
})
export type MonthActivityDto = z.infer<typeof monthActivityDtoSchema>
const userDiaryResponseSchema = paginatedSchema(diaryEntryDtoSchema)
export const userProfileResponseSchema = z.object({
user_id: z.string().uuid(),
username: z.string(),
avatar_url: z.string().optional(),
banner_url: z.string().optional(),
stats: userStatsDtoSchema,
following_count: z.number(),
followers_count: z.number(),
entries: userDiaryResponseSchema.optional(),
history: z.array(monthActivityDtoSchema).optional(),
trends: userTrendsDtoSchema.optional(),
})
export type UserProfileResponse = z.infer<typeof userProfileResponseSchema>
export const profileFieldDtoSchema = z.object({
name: z.string(),
value: z.string(),
})
export const profileResponseSchema = z.object({
username: z.string(),
display_name: z.string().optional(),
bio: z.string().optional(),
avatar_url: z.string().optional(),
banner_url: z.string().optional(),
also_known_as: z.string().optional(),
fields: z.array(profileFieldDtoSchema),
role: z.string(),
})
export type ProfileResponse = z.infer<typeof profileResponseSchema>
export const updateProfileFieldsRequestSchema = z.object({
fields: z.array(profileFieldDtoSchema),
})
export type UpdateProfileFieldsRequest = z.infer<typeof updateProfileFieldsRequestSchema>
export function getUsers() {
return get<UsersResponse>("/users")
}
export function getUserProfile(id: string, params?: UserProfileQueryParams) {
return get<UserProfileResponse>(`/users/${id}`, params)
}
export function getProfile() {
return get<ProfileResponse>("/profile")
}
export type UpdateProfileData = {
display_name?: string
bio?: string
also_known_as?: string
avatar?: File
banner?: File
}
export function updateProfile(data: UpdateProfileData) {
const form = new FormData()
if (data.display_name != null) form.append("display_name", data.display_name)
if (data.also_known_as != null) form.append("also_known_as", data.also_known_as)
if (data.bio != null) form.append("bio", data.bio)
if (data.avatar) form.append("avatar", data.avatar)
if (data.banner) form.append("banner", data.banner)
return putForm("/profile", form)
}
export function updateProfileFields(data: UpdateProfileFieldsRequest) {
return put("/profile/fields", data)
}

View File

@@ -0,0 +1,43 @@
import { z } from "zod"
import type { Paginated } from "./common"
import { movieDtoSchema, paginatedSchema } from "./common"
import { del, get, post } from "./client"
export const watchlistEntryDtoSchema = z.object({
id: z.string().uuid(),
movie: movieDtoSchema,
added_at: z.string(),
})
export type WatchlistEntryDto = z.infer<typeof watchlistEntryDtoSchema>
export const watchlistResponseSchema = paginatedSchema(watchlistEntryDtoSchema)
export type WatchlistResponse = Paginated<WatchlistEntryDto>
export const addToWatchlistRequestSchema = z.object({
movie_id: z.string().uuid().optional(),
external_metadata_id: z.string().optional(),
manual_title: z.string().optional(),
manual_release_year: z.number().optional(),
})
export type AddToWatchlistRequest = z.infer<typeof addToWatchlistRequestSchema>
export const watchlistStatusResponseSchema = z.object({
on_watchlist: z.boolean(),
})
export type WatchlistStatusResponse = z.infer<typeof watchlistStatusResponseSchema>
export function getWatchlist(params?: { limit?: number; offset?: number }) {
return get<WatchlistResponse>("/watchlist", params)
}
export function getWatchlistStatus(movieId: string) {
return get<WatchlistStatusResponse>(`/watchlist/${movieId}`)
}
export function addToWatchlist(data: AddToWatchlistRequest) {
return post("/watchlist", data)
}
export function removeFromWatchlist(movieId: string) {
return del(`/watchlist/${movieId}`)
}

View File

@@ -0,0 +1,84 @@
import { z } from "zod"
import { del, get, post } from "./client"
export const webhookTokenDtoSchema = z.object({
id: z.string(),
provider: z.string(),
label: z.string().optional(),
created_at: z.string(),
last_used_at: z.string().optional(),
})
export type WebhookTokenDto = z.infer<typeof webhookTokenDtoSchema>
export const generateTokenRequestSchema = z.object({
provider: z.string(),
label: z.string().optional(),
})
export type GenerateTokenRequest = z.infer<typeof generateTokenRequestSchema>
export const generateTokenResponseSchema = z.object({
id: z.string(),
token: z.string(),
webhook_url: z.string(),
})
export type GenerateTokenResponse = z.infer<typeof generateTokenResponseSchema>
export const watchQueueEntryDtoSchema = z.object({
id: z.string(),
title: z.string(),
year: z.number().optional(),
movie_id: z.string().optional(),
source: z.string(),
watched_at: z.string(),
})
export type WatchQueueEntryDto = z.infer<typeof watchQueueEntryDtoSchema>
export const confirmWatchEntrySchema = z.object({
watch_event_id: z.string().uuid(),
rating: z.number(),
comment: z.string().optional(),
})
export const confirmWatchRequestSchema = z.object({
confirmations: z.array(confirmWatchEntrySchema),
})
export type ConfirmWatchRequest = z.infer<typeof confirmWatchRequestSchema>
export const confirmWatchResponseSchema = z.object({
confirmed: z.number(),
})
export type ConfirmWatchResponse = z.infer<typeof confirmWatchResponseSchema>
export const dismissWatchRequestSchema = z.object({
event_ids: z.array(z.string().uuid()),
})
export type DismissWatchRequest = z.infer<typeof dismissWatchRequestSchema>
export const dismissWatchResponseSchema = z.object({
dismissed: z.number(),
})
export type DismissWatchResponse = z.infer<typeof dismissWatchResponseSchema>
export function getWebhookTokens() {
return get<WebhookTokenDto[]>("/settings/webhook-tokens")
}
export function generateToken(data: GenerateTokenRequest) {
return post<GenerateTokenResponse>("/settings/webhook-tokens", data)
}
export function deleteToken(id: string) {
return del(`/settings/webhook-tokens/${id}`)
}
export function getWatchQueue() {
return get<WatchQueueEntryDto[]>("/watch-queue")
}
export function confirmWatch(data: ConfirmWatchRequest) {
return post<ConfirmWatchResponse>("/watch-queue/confirm", data)
}
export function dismissWatch(data: DismissWatchRequest) {
return post<DismissWatchResponse>("/watch-queue/dismiss", data)
}

98
spa/src/lib/api/wrapup.ts Normal file
View File

@@ -0,0 +1,98 @@
import { z } from "zod"
import { del, get, post } from "./client"
export const generateWrapUpRequestSchema = z.object({
start_date: z.string(),
end_date: z.string(),
user_id: z.string().uuid().optional(),
})
export type GenerateWrapUpRequest = z.infer<typeof generateWrapUpRequestSchema>
export const wrapUpGeneratedResponseSchema = z.object({
id: z.string(),
})
export type WrapUpGeneratedResponse = z.infer<typeof wrapUpGeneratedResponseSchema>
export const wrapUpStatusResponseSchema = z.object({
id: z.string(),
user_id: z.string().optional(),
status: z.string(),
start_date: z.string(),
end_date: z.string(),
created_at: z.string(),
completed_at: z.string().optional(),
error_message: z.string().optional(),
})
export type WrapUpStatusResponse = z.infer<typeof wrapUpStatusResponseSchema>
export const wrapUpListResponseSchema = z.object({
items: z.array(wrapUpStatusResponseSchema),
})
export type WrapUpListResponse = z.infer<typeof wrapUpListResponseSchema>
export function generateWrapUp(data: GenerateWrapUpRequest) {
return post<WrapUpGeneratedResponse>("/wrapups/generate", data)
}
export function getWrapUps() {
return get<WrapUpListResponse>("/wrapups")
}
export function getWrapUp(id: string) {
return get<WrapUpStatusResponse>(`/wrapups/${id}`)
}
export function deleteWrapUp(id: string) {
return del(`/wrapups/${id}`)
}
export type MovieRef = {
title: string
year: number
runtime_minutes?: number
poster_path?: string
}
export type PersonStat = {
name: string
count: number
avg_rating: number
}
export type GenreStat = {
genre: string
count: number
avg_rating: number
}
export type WrapUpReport = {
total_movies: number
total_watch_time_minutes: number
busiest_month?: string
busiest_day_of_week?: string
avg_rating?: number
rating_distribution: number[]
longest_movie?: MovieRef
shortest_movie?: MovieRef
top_directors: PersonStat[]
top_actors: PersonStat[]
director_diversity: number
actor_diversity: number
top_genres: GenreStat[]
genre_diversity: number
highest_rated_genre?: string
lowest_rated_genre?: string
oldest_movie?: MovieRef
newest_movie?: MovieRef
total_rewatches: number
most_rewatched_movie?: MovieRef
highest_rated_movie?: MovieRef
lowest_rated_movie?: MovieRef
first_movie_of_period?: MovieRef
last_movie_of_period?: MovieRef
poster_paths: string[]
}
export function getWrapUpReport(id: string) {
return get<WrapUpReport>(`/wrapups/${id}/report`)
}

35
spa/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,35 @@
const AUTH_KEY = "auth_state"
export type AuthState = {
token: string
user_id: string
email: string
role: string
expires_at: string
}
export function getAuth(): AuthState | null {
const raw = localStorage.getItem(AUTH_KEY)
if (!raw) return null
try {
return JSON.parse(raw) as AuthState
} catch {
return null
}
}
export function setAuth(state: AuthState) {
localStorage.setItem(AUTH_KEY, JSON.stringify(state))
}
export function clearAuth() {
localStorage.removeItem(AUTH_KEY)
}
export function getToken(): string | null {
return getAuth()?.token ?? null
}
export function isAdmin(): boolean {
return getAuth()?.role === "admin"
}

View File

@@ -0,0 +1,17 @@
import { QueryClient } from "@tanstack/react-query"
import { ApiError } from "@/lib/api/client"
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status < 500) return false
return failureCount < 2
},
},
},
})

6
spa/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}