refactor(spa): collapse api/hooks telescope, merge review sheets, extract tab+list modules

- merge lib/api/* + hooks/use-* → features/* domain modules
- log-sheet + edit-review-sheet → review-sheet with mode discriminant
- index.tsx → feed-tab, watchlist-tab, queue-tab
- social actor-list pattern → ActorList component
- movie detail inline sections → community-reviews, viewing-history
This commit is contained in:
2026-07-10 17:38:58 +02:00
parent 89045414cf
commit 0ee2e04fe5
61 changed files with 1753 additions and 1810 deletions

100
spa/src/features/auth.ts Normal file
View File

@@ -0,0 +1,100 @@
import { z } from "zod"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useAuth } from "@/components/auth-provider"
import { API_URL, post } from "@/lib/api/client"
import { getRefreshToken } from "@/lib/auth"
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(),
refresh_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 type RefreshResponse = {
token: string
refresh_token: string
expires_at: string
}
function login(data: LoginRequest) {
return post<LoginResponse>("/auth/login", data)
}
function register(data: RegisterRequest) {
return post("/auth/register", data)
}
export async function refreshToken(
refresh_token: string,
): Promise<RefreshResponse> {
const res = await fetch(`${API_URL}/api/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token }),
})
if (!res.ok) throw new Error("refresh failed")
return res.json()
}
function apiLogout(refresh_token: string) {
return post("/auth/logout", { refresh_token })
}
export function useLogin() {
const { login: setAuth } = useAuth()
const qc = useQueryClient()
return useMutation({
mutationFn: (data: LoginRequest) => login(data),
onSuccess: (res) => {
setAuth({
token: res.token,
refresh_token: res.refresh_token,
user_id: res.user_id,
email: res.email,
role: res.role,
expires_at: res.expires_at,
})
qc.clear()
},
})
}
export function useRegister() {
return useMutation({
mutationFn: (data: RegisterRequest) => register(data),
})
}
export function useLogout() {
const { logout } = useAuth()
const qc = useQueryClient()
return useMutation({
mutationFn: async () => {
const rt = getRefreshToken()
if (rt) {
try {
await apiLogout(rt)
} catch {}
}
logout()
qc.clear()
},
})
}

177
spa/src/features/diary.ts Normal file
View File

@@ -0,0 +1,177 @@
import { z } from "zod"
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import type { DiaryEntryDto, Paginated } from "@/lib/api/common"
import { diaryEntryDtoSchema, movieDtoSchema, paginatedSchema, reviewDtoSchema } from "@/lib/api/common"
import { del, get, patch, post } from "@/lib/api/client"
const PAGE_SIZE = 20
export const diaryQueryParamsSchema = z.object({
limit: z.number().optional(),
offset: z.number().optional(),
sort_by: z.string().optional(),
movie_id: z.string().uuid().optional(),
user_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(),
watch_medium: z.string().optional(),
})
export type LogReviewRequest = z.infer<typeof logReviewRequestSchema>
export const editReviewRequestSchema = z.object({
rating: z.number().optional(),
comment: z.string().nullable().optional(),
watched_at: z.string().optional(),
watch_medium: z.string().nullable().optional(),
})
export type EditReviewRequest = z.infer<typeof editReviewRequestSchema>
export const feedEntryDtoSchema = z.object({
movie: movieDtoSchema,
review: reviewDtoSchema,
user_id: z.string().uuid(),
user_display_name: z.string(),
is_federated: z.boolean(),
actor_url: z.string().optional(),
})
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>
function getDiary(params?: DiaryQueryParams) {
return get<DiaryResponse>("/diary", params)
}
export function logReview(data: LogReviewRequest) {
return post("/reviews", data)
}
export function editReview(id: string, data: EditReviewRequest) {
return patch(`/reviews/${id}`, data)
}
function deleteReview(id: string) {
return del(`/reviews/${id}`)
}
function getActivityFeed(params?: ActivityFeedQueryParams) {
return get<ActivityFeedResponse>("/activity-feed", params)
}
export function exportDiary(params?: ExportQueryParams) {
return get<Blob>("/diary/export", params)
}
export const diaryKeys = {
all: ["diary"] as const,
list: (params?: Partial<DiaryQueryParams>) => [...diaryKeys.all, "list", params] as const,
infinite: (params?: Partial<DiaryQueryParams>) => [...diaryKeys.all, "infinite", params] as const,
feed: (params?: ActivityFeedQueryParams) =>
["activity-feed", params] as const,
}
export function useDiary(params?: DiaryQueryParams) {
return useQuery({
queryKey: diaryKeys.list(params),
queryFn: () => getDiary(params),
})
}
export function useInfiniteDiary(params?: Omit<DiaryQueryParams, "limit" | "offset">) {
return useInfiniteQuery({
queryKey: diaryKeys.infinite(params),
queryFn: ({ pageParam = 0 }) =>
getDiary({ ...params, limit: PAGE_SIZE, offset: pageParam }),
initialPageParam: 0,
getNextPageParam: (last) => {
const next = last.offset + last.limit
return next < last.total_count ? next : undefined
},
})
}
export function useActivityFeed(params?: ActivityFeedQueryParams) {
return useQuery({
queryKey: diaryKeys.feed(params),
queryFn: () => getActivityFeed(params),
})
}
export function useInfiniteActivityFeed(
params?: Omit<ActivityFeedQueryParams, "limit" | "offset">,
) {
return useInfiniteQuery({
queryKey: diaryKeys.feed(params),
queryFn: ({ pageParam = 0 }) =>
getActivityFeed({ ...params, limit: PAGE_SIZE, offset: pageParam }),
initialPageParam: 0,
getNextPageParam: (last) => {
const next = last.offset + last.limit
return next < last.total_count ? next : undefined
},
})
}
export function useLogReview() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: LogReviewRequest) => logReview(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: diaryKeys.all })
qc.invalidateQueries({ queryKey: ["activity-feed"] })
},
})
}
export function useEditReview() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: string; data: EditReviewRequest }) =>
editReview(id, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: diaryKeys.all })
qc.invalidateQueries({ queryKey: ["activity-feed"] })
},
})
}
export function useDeleteReview() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => deleteReview(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: diaryKeys.all })
qc.invalidateQueries({ queryKey: ["activity-feed"] })
},
})
}

135
spa/src/features/goals.ts Normal file
View File

@@ -0,0 +1,135 @@
import { z } from "zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { get, post, put, del } from "@/lib/api/client"
import { goalDtoSchema, userKeys } from "@/features/users"
export const goalsResponseSchema = z.object({
goals: z.array(goalDtoSchema),
})
export type GoalsResponse = z.infer<typeof goalsResponseSchema>
export type CreateGoalRequest = {
year: number
target_count: number
}
export type UpdateGoalRequest = {
target_count: number
}
export const userSettingsDtoSchema = z.object({
federate_goals: z.boolean(),
federate_reviews: z.boolean(),
federate_watchlist: z.boolean(),
})
export type UserSettingsDto = z.infer<typeof userSettingsDtoSchema>
export type UpdateUserSettingsRequest = {
federate_goals: boolean
federate_reviews: boolean
federate_watchlist: boolean
}
function getGoals() {
return get<GoalsResponse>("/goals")
}
function getUserGoals(userId: string) {
return get<GoalsResponse>(`/users/${userId}/goals`)
}
function createGoal(data: CreateGoalRequest) {
return post<z.infer<typeof goalDtoSchema>>("/goals", data)
}
function updateGoal(year: number, data: UpdateGoalRequest) {
return put<z.infer<typeof goalDtoSchema>>(`/goals/${year}`, data)
}
function deleteGoal(year: number) {
return del(`/goals/${year}`)
}
function getSettings() {
return get<UserSettingsDto>("/settings")
}
function updateSettings(data: UpdateUserSettingsRequest) {
return put("/settings", data)
}
export const goalKeys = {
all: ["goals"] as const,
list: () => [...goalKeys.all, "list"] as const,
user: (userId: string) => [...goalKeys.all, "user", userId] as const,
}
export const settingsKeys = {
all: ["settings"] as const,
}
export function useGoals() {
return useQuery({
queryKey: goalKeys.list(),
queryFn: getGoals,
})
}
export function useUserGoals(userId: string) {
return useQuery({
queryKey: goalKeys.user(userId),
queryFn: () => getUserGoals(userId),
enabled: !!userId,
})
}
export function useCreateGoal() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: CreateGoalRequest) => createGoal(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: goalKeys.all })
qc.invalidateQueries({ queryKey: userKeys.all })
},
})
}
export function useUpdateGoal() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ year, data }: { year: number; data: UpdateGoalRequest }) =>
updateGoal(year, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: goalKeys.all })
qc.invalidateQueries({ queryKey: userKeys.all })
},
})
}
export function useDeleteGoal() {
const qc = useQueryClient()
return useMutation({
mutationFn: (year: number) => deleteGoal(year),
onSuccess: () => {
qc.invalidateQueries({ queryKey: goalKeys.all })
qc.invalidateQueries({ queryKey: userKeys.all })
},
})
}
export function useSettings() {
return useQuery({
queryKey: settingsKeys.all,
queryFn: getSettings,
})
}
export function useUpdateSettings() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: UpdateUserSettingsRequest) => updateSettings(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: settingsKeys.all })
},
})
}

188
spa/src/features/imports.ts Normal file
View File

@@ -0,0 +1,188 @@
import { z } from "zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { del, get, post, put, uploadWithFields } from "@/lib/api/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 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 type ImportProfile = {
id: string
name: string
created_at: string
}
function createImportSession(file: File) {
const ext = file.name.split(".").pop()?.toLowerCase()
const format = ext === "json" ? "json" : "csv"
return uploadWithFields<SessionCreatedResponse>("/import/sessions", file, { format })
}
function getImportSession(id: string) {
return get<SessionStateResponse>(`/import/sessions/${id}`)
}
function getImportPreview(id: string) {
return get<PreviewResponse>(`/import/sessions/${id}/preview`)
}
function applyMapping(sessionId: string, data: ApplyMappingRequest) {
return put(`/import/sessions/${sessionId}/mapping`, data)
}
function confirmImport(sessionId: string, data: ConfirmRequest) {
return post(`/import/sessions/${sessionId}/confirm`, data)
}
function getImportProfiles() {
return get<ImportProfile[]>("/import/profiles")
}
function saveImportProfile(data: SaveProfileRequest) {
return post<{ id: string }>("/import/profiles", data)
}
function deleteImportProfile(id: string) {
return del(`/import/profiles/${id}`)
}
function applyImportProfile(sessionId: string, profileId: string) {
return put<{ row_count: number }>(`/import/sessions/${sessionId}/profile/${profileId}`)
}
export const importKeys = {
session: (id: string) => ["import-session", id] as const,
preview: (id: string) => ["import-preview", id] as const,
profiles: ["import-profiles"] as const,
}
export function useImportPreview(id: string) {
return useQuery({
queryKey: importKeys.preview(id),
queryFn: () => getImportPreview(id),
enabled: !!id,
})
}
export function useCreateImportSession() {
return useMutation({
mutationFn: (file: File) => createImportSession(file),
})
}
export function useImportSession(id: string) {
return useQuery({
queryKey: importKeys.session(id),
queryFn: () => getImportSession(id),
enabled: !!id,
})
}
export function useApplyMapping() {
return useMutation({
mutationFn: ({
sessionId,
data,
}: {
sessionId: string
data: ApplyMappingRequest
}) => applyMapping(sessionId, data),
})
}
export function useConfirmImport() {
return useMutation({
mutationFn: ({
sessionId,
data,
}: {
sessionId: string
data: ConfirmRequest
}) => confirmImport(sessionId, data),
})
}
export function useImportProfiles() {
return useQuery({
queryKey: importKeys.profiles,
queryFn: getImportProfiles,
})
}
export function useSaveImportProfile() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: SaveProfileRequest) => saveImportProfile(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: importKeys.profiles })
},
})
}
export function useDeleteImportProfile() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => deleteImportProfile(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: importKeys.profiles })
},
})
}
export function useApplyImportProfile() {
return useMutation({
mutationFn: ({ sessionId, profileId }: { sessionId: string; profileId: string }) =>
applyImportProfile(sessionId, profileId),
})
}

171
spa/src/features/movies.ts Normal file
View File

@@ -0,0 +1,171 @@
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(),
rating: z.number(),
comment: z.string().optional(),
watched_at: z.string(),
is_federated: z.boolean(),
watch_medium: z.string().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 })
},
})
}

145
spa/src/features/search.ts Normal file
View File

@@ -0,0 +1,145 @@
import { z } from "zod"
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { paginatedSchema } from "@/lib/api/common"
import { get } from "@/lib/api/client"
const PAGE_SIZE = 20
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(),
biography: z.string().optional(),
birthday: z.string().optional(),
deathday: z.string().optional(),
place_of_birth: z.string().optional(),
also_known_as: z.array(z.string()).default([]),
homepage: z.string().optional(),
imdb_url: z.string().optional(),
enriched: z.boolean().default(false),
})
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>
function search(params: SearchQueryParams) {
return get<SearchResponse>("/search", params)
}
function getPerson(id: string) {
return get<PersonDto>(`/people/${id}`)
}
function getPersonCredits(id: string) {
return get<PersonCreditsDto>(`/people/${id}/credits`)
}
export const searchKeys = {
all: ["search"] as const,
query: (params: SearchQueryParams) => [...searchKeys.all, params] as const,
person: (id: string) => ["people", id] as const,
personCredits: (id: string) => ["people", id, "credits"] as const,
}
export function useSearch(params: SearchQueryParams) {
return useQuery({
queryKey: searchKeys.query(params),
queryFn: () => search(params),
enabled: !!params.q || !!params.genre || !!params.person_id,
})
}
export function useInfiniteSearch(
params: Omit<SearchQueryParams, "limit" | "offset">,
) {
return useInfiniteQuery({
queryKey: searchKeys.query(params),
queryFn: ({ pageParam = 0 }) =>
search({ ...params, limit: PAGE_SIZE, offset: pageParam }),
initialPageParam: 0,
getNextPageParam: (last) => {
const next = last.movies.offset + last.movies.limit
return next < last.movies.total_count ? next : undefined
},
enabled: !!params.q || !!params.genre || !!params.person_id,
})
}
export function usePerson(id: string) {
return useQuery({
queryKey: searchKeys.person(id),
queryFn: () => getPerson(id),
enabled: !!id,
})
}
export function usePersonCredits(id: string) {
return useQuery({
queryKey: searchKeys.personCredits(id),
queryFn: () => getPersonCredits(id),
enabled: !!id,
})
}

262
spa/src/features/social.ts Normal file
View File

@@ -0,0 +1,262 @@
import { z } from "zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { del, get, post } from "@/lib/api/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>
function getFollowing() {
return get<ActorListResponse>("/social/following")
}
function getFollowers() {
return get<ActorListResponse>("/social/followers")
}
function getUserFollowing(userId: string) {
return get<ActorListResponse>(`/users/${userId}/following`)
}
function getUserFollowers(userId: string) {
return get<ActorListResponse>(`/users/${userId}/followers`)
}
function getPendingFollowers() {
return get<ActorListResponse>("/social/followers/pending")
}
function follow(data: FollowRequest) {
return post("/social/follow", data)
}
function unfollow(data: ActorUrlRequest) {
return post("/social/unfollow", data)
}
function acceptFollower(data: ActorUrlRequest) {
return post("/social/followers/accept", data)
}
function rejectFollower(data: ActorUrlRequest) {
return post("/social/followers/reject", data)
}
function removeFollower(data: ActorUrlRequest) {
return post("/social/followers/remove", data)
}
function getBlockedDomains() {
return get<BlockedDomainResponse[]>("/admin/blocked-domains")
}
function addBlockedDomain(data: AddBlockedDomainRequest) {
return post("/admin/blocked-domains", data)
}
function removeBlockedDomain(domain: string) {
return del(`/admin/blocked-domains/${domain}`)
}
function blockActor(data: ActorUrlRequest) {
return post("/social/block", data)
}
function unblockActor(data: ActorUrlRequest) {
return post("/social/unblock", data)
}
function getBlockedActors() {
return get<BlockedActorResponse[]>("/social/blocked")
}
export const socialKeys = {
following: ["following"] as const,
followers: ["followers"] as const,
pending: ["followers-pending"] as const,
userFollowing: (id: string) => ["following", id] as const,
userFollowers: (id: string) => ["followers", id] as const,
blockedDomains: ["blocked-domains"] as const,
blockedActors: ["blocked-actors"] as const,
}
export function useUserFollowing(userId: string) {
return useQuery({
queryKey: socialKeys.userFollowing(userId),
queryFn: () => getUserFollowing(userId),
enabled: !!userId,
})
}
export function useUserFollowers(userId: string) {
return useQuery({
queryKey: socialKeys.userFollowers(userId),
queryFn: () => getUserFollowers(userId),
enabled: !!userId,
})
}
export function useFollowing() {
return useQuery({
queryKey: socialKeys.following,
queryFn: getFollowing,
})
}
export function useFollowers() {
return useQuery({
queryKey: socialKeys.followers,
queryFn: getFollowers,
})
}
export function usePendingFollowers() {
return useQuery({
queryKey: socialKeys.pending,
queryFn: getPendingFollowers,
})
}
export function useFollow() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: FollowRequest) => follow(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.following })
},
})
}
export function useUnfollow() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: ActorUrlRequest) => unfollow(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.following })
},
})
}
export function useAcceptFollower() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: ActorUrlRequest) => acceptFollower(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.pending })
qc.invalidateQueries({ queryKey: socialKeys.followers })
},
})
}
export function useRejectFollower() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: ActorUrlRequest) => rejectFollower(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.pending })
},
})
}
export function useRemoveFollower() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: ActorUrlRequest) => removeFollower(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.followers })
},
})
}
export function useBlockedDomains() {
return useQuery({
queryKey: socialKeys.blockedDomains,
queryFn: getBlockedDomains,
})
}
export function useAddBlockedDomain() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: AddBlockedDomainRequest) => addBlockedDomain(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.blockedDomains })
},
})
}
export function useRemoveBlockedDomain() {
const qc = useQueryClient()
return useMutation({
mutationFn: (domain: string) => removeBlockedDomain(domain),
onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.blockedDomains })
},
})
}
export function useBlockActor() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: ActorUrlRequest) => blockActor(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.blockedActors })
},
})
}
export function useUnblockActor() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: ActorUrlRequest) => unblockActor(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.blockedActors })
},
})
}
export function useBlockedActors() {
return useQuery({
queryKey: socialKeys.blockedActors,
queryFn: getBlockedActors,
})
}

206
spa/src/features/users.ts Normal file
View File

@@ -0,0 +1,206 @@
import { z } from "zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { diaryEntryDtoSchema, paginatedSchema } from "@/lib/api/common"
import { get, post, put, putForm } from "@/lib/api/client"
export const userSummaryDtoSchema = z.object({
id: z.string().uuid(),
email: z.string(),
username: z.string(),
display_name: z.string().optional(),
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(),
search: z.string().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>
export const goalDtoSchema = z.object({
year: z.number(),
target_count: z.number(),
current_count: z.number(),
percentage: z.number(),
is_complete: z.boolean(),
goal_type: z.string(),
})
export type GoalDto = z.infer<typeof goalDtoSchema>
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(),
goals: z.array(goalDtoSchema).optional(),
is_federated: z.boolean().optional().default(false),
handle: z.string().optional(),
display_name: z.string().optional(),
bio: z.string().optional(),
actor_url: z.string().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 type UpdateProfileData = {
display_name?: string
bio?: string
also_known_as?: string
avatar?: File
banner?: File
}
function getUsers() {
return get<UsersResponse>("/users")
}
function getUserProfile(id: string, params?: UserProfileQueryParams) {
return get<UserProfileResponse>(`/users/${id}`, params)
}
function getProfile() {
return get<ProfileResponse>("/profile")
}
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)
}
function updateProfileFields(data: UpdateProfileFieldsRequest) {
return put("/profile/fields", data)
}
export function reindexSearch() {
return post("/admin/reindex-search")
}
export const userKeys = {
all: ["users"] as const,
list: () => [...userKeys.all, "list"] as const,
profile: (id: string, params?: UserProfileQueryParams) =>
[...userKeys.all, id, params] as const,
me: ["profile"] as const,
}
export function useUsers() {
return useQuery({
queryKey: userKeys.list(),
queryFn: getUsers,
})
}
export function useUserProfile(id: string, params?: UserProfileQueryParams) {
return useQuery({
queryKey: userKeys.profile(id, params),
queryFn: () => getUserProfile(id, params),
enabled: !!id,
})
}
export function useProfile() {
return useQuery({
queryKey: userKeys.me,
queryFn: getProfile,
})
}
export function useUpdateProfile() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: UpdateProfileData) => updateProfile(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: userKeys.me })
},
})
}
export function useUpdateProfileFields() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: UpdateProfileFieldsRequest) => updateProfileFields(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: userKeys.me })
},
})
}

View File

@@ -0,0 +1,105 @@
import { z } from "zod"
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import type { Paginated } from "@/lib/api/common"
import { movieDtoSchema, paginatedSchema } from "@/lib/api/common"
import { del, get, post } from "@/lib/api/client"
const PAGE_SIZE = 20
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>
function getWatchlist(params?: { limit?: number; offset?: number }) {
return get<WatchlistResponse>("/watchlist", params)
}
function getWatchlistStatus(movieId: string) {
return get<WatchlistStatusResponse>(`/watchlist/${movieId}`)
}
function addToWatchlist(data: AddToWatchlistRequest) {
return post("/watchlist", data)
}
function removeFromWatchlist(movieId: string) {
return del(`/watchlist/${movieId}`)
}
export const watchlistKeys = {
all: ["watchlist"] as const,
list: () => [...watchlistKeys.all, "list"] as const,
status: (movieId: string) => [...watchlistKeys.all, movieId] as const,
}
export function useWatchlist() {
return useQuery({
queryKey: watchlistKeys.list(),
queryFn: () => getWatchlist(),
})
}
export function useInfiniteWatchlist() {
return useInfiniteQuery({
queryKey: watchlistKeys.list(),
queryFn: ({ pageParam = 0 }) =>
getWatchlist({ limit: PAGE_SIZE, offset: pageParam }),
initialPageParam: 0,
getNextPageParam: (last) => {
const next = last.offset + last.limit
return next < last.total_count ? next : undefined
},
})
}
export function useWatchlistStatus(movieId: string) {
return useQuery({
queryKey: watchlistKeys.status(movieId),
queryFn: () => getWatchlistStatus(movieId),
enabled: !!movieId,
})
}
export function useAddToWatchlist() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: AddToWatchlistRequest) => addToWatchlist(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: watchlistKeys.all })
},
})
}
export function useRemoveFromWatchlist() {
const qc = useQueryClient()
return useMutation({
mutationFn: (movieId: string) => removeFromWatchlist(movieId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: watchlistKeys.all })
},
})
}

View File

@@ -0,0 +1,144 @@
import { z } from "zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { del, get, post } from "@/lib/api/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>
function getWebhookTokens() {
return get<WebhookTokenDto[]>("/settings/webhook-tokens")
}
function generateToken(data: GenerateTokenRequest) {
return post<GenerateTokenResponse>("/settings/webhook-tokens", data)
}
function deleteToken(id: string) {
return del(`/settings/webhook-tokens/${id}`)
}
function getWatchQueue() {
return get<WatchQueueEntryDto[]>("/watch-queue")
}
function confirmWatch(data: ConfirmWatchRequest) {
return post<ConfirmWatchResponse>("/watch-queue/confirm", data)
}
function dismissWatch(data: DismissWatchRequest) {
return post<DismissWatchResponse>("/watch-queue/dismiss", data)
}
export const webhookKeys = {
tokens: ["webhook-tokens"] as const,
queue: ["watch-queue"] as const,
}
export function useWebhookTokens() {
return useQuery({
queryKey: webhookKeys.tokens,
queryFn: getWebhookTokens,
})
}
export function useGenerateToken() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: GenerateTokenRequest) => generateToken(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: webhookKeys.tokens })
},
})
}
export function useDeleteToken() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => deleteToken(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: webhookKeys.tokens })
},
})
}
export function useWatchQueue() {
return useQuery({
queryKey: webhookKeys.queue,
queryFn: getWatchQueue,
})
}
export function useConfirmWatch() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: ConfirmWatchRequest) => confirmWatch(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: webhookKeys.queue })
},
})
}
export function useDismissWatch() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: DismissWatchRequest) => dismissWatch(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: webhookKeys.queue })
},
})
}

174
spa/src/features/wrapup.ts Normal file
View File

@@ -0,0 +1,174 @@
import { z } from "zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { del, get, post } from "@/lib/api/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 type MovieRef = {
movie_id?: string
title: string
year: number
runtime_minutes?: number
poster_path?: string
}
export type PersonStat = {
person_id?: string
name: string
count: number
avg_rating: number
}
export type GenreStat = {
genre: string
count: number
avg_rating: number
}
export type MonthCount = {
year_month: string
label: string
count: number
}
export type KeywordStat = {
keyword: string
count: number
}
export type LangStat = {
language: string
count: number
}
export type WrapUpReport = {
date_range: { start: string; end: string }
total_movies: number
total_watch_time_minutes: number
movies_per_month: MonthCount[]
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
top_keywords: KeywordStat[]
total_budget_watched?: number
avg_budget?: number
language_distribution: LangStat[]
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[]
top_cast_profile_paths: string[]
}
function generateWrapUp(data: GenerateWrapUpRequest) {
return post<WrapUpGeneratedResponse>("/wrapups/generate", data)
}
function getWrapUps() {
return get<WrapUpListResponse>("/wrapups")
}
function getWrapUp(id: string) {
return get<WrapUpStatusResponse>(`/wrapups/${id}`)
}
function deleteWrapUp(id: string) {
return del(`/wrapups/${id}`)
}
function getWrapUpReport(id: string) {
return get<WrapUpReport>(`/wrapups/${id}/report`)
}
export const wrapupKeys = {
all: ["wrapups"] as const,
list: () => [...wrapupKeys.all, "list"] as const,
detail: (id: string) => [...wrapupKeys.all, id] as const,
report: (id: string) => [...wrapupKeys.all, id, "report"] as const,
}
export function useWrapUpReport(id: string) {
return useQuery({
queryKey: wrapupKeys.report(id),
queryFn: () => getWrapUpReport(id),
enabled: !!id,
})
}
export function useWrapUps() {
return useQuery({
queryKey: wrapupKeys.list(),
queryFn: getWrapUps,
})
}
export function useWrapUp(id: string) {
return useQuery({
queryKey: wrapupKeys.detail(id),
queryFn: () => getWrapUp(id),
enabled: !!id,
})
}
export function useGenerateWrapUp() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: GenerateWrapUpRequest) => generateWrapUp(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: wrapupKeys.all })
},
})
}
export function useDeleteWrapUp() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => deleteWrapUp(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: wrapupKeys.all })
},
})
}