Files
movies-diary/spa/src/features/movies.ts
Gabriel Kaszewski bb2001b9cc Add Spoiler Flag with veil, AP content-warning encoding, and full-stack plumbing
Authors can flag a review's comment as containing spoilers; the comment is
blurred behind a reveal control for every viewer except the author, omitted
from RSS, and federates as containsSpoilers plus an AP summary content
warning (ADR-0011) so Mastodon-class clients also collapse it. Flag follows
the general-edit path (ADR-0001) across domain, both DB backends, REST,
HTML templates, SPA (shadcn), TUI fixtures, and exports.
2026-10-17 21:12:56 +02:00

174 lines
4.9 KiB
TypeScript

import { z } from "zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import type { Paginated } from "@/lib/api/common"
import { movieDtoSchema, paginatedSchema, reviewDtoSchema } from "@/lib/api/common"
import { get, post } from "@/lib/api/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(),
user_id: z.string(),
rating: z.number(),
comment: z.string().optional(),
watched_at: z.string(),
is_federated: z.boolean(),
watch_medium: z.string().optional(),
spoilers: z.boolean().optional(),
})
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({
person_id: z.string(),
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({
person_id: z.string(),
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>
function getMovies(params?: MoviesQueryParams) {
return get<MoviesResponse>("/movies", params)
}
function getMovie(id: string) {
return get<MovieDetailResponse>(`/movies/${id}`)
}
function getMovieHistory(id: string) {
return get<ReviewHistoryResponse>(`/movies/${id}/history`)
}
function getMovieProfile(id: string) {
return get<MovieProfileResponse>(`/movies/${id}/profile`)
}
function syncPoster(id: string) {
return post(`/movies/${id}/sync-poster`)
}
export const movieKeys = {
all: ["movies"] as const,
list: (params?: MoviesQueryParams) => [...movieKeys.all, params] as const,
detail: (id: string) => [...movieKeys.all, id] as const,
history: (id: string) => [...movieKeys.all, id, "history"] as const,
profile: (id: string) => [...movieKeys.all, id, "profile"] as const,
}
export function useMovies(params?: MoviesQueryParams) {
return useQuery({
queryKey: movieKeys.list(params),
queryFn: () => getMovies(params),
})
}
export function useMovie(id: string) {
return useQuery({
queryKey: movieKeys.detail(id),
queryFn: () => getMovie(id),
enabled: !!id,
})
}
export function useMovieHistory(id: string) {
return useQuery({
queryKey: movieKeys.history(id),
queryFn: () => getMovieHistory(id),
enabled: !!id,
})
}
export function useMovieProfile(id: string) {
return useQuery({
queryKey: movieKeys.profile(id),
queryFn: () => getMovieProfile(id),
enabled: !!id,
})
}
export function useSyncPoster() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => syncPoster(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: movieKeys.all })
},
})
}