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:
100
spa/src/features/auth.ts
Normal file
100
spa/src/features/auth.ts
Normal 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
177
spa/src/features/diary.ts
Normal 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
135
spa/src/features/goals.ts
Normal 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
188
spa/src/features/imports.ts
Normal 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
171
spa/src/features/movies.ts
Normal 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
145
spa/src/features/search.ts
Normal 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
262
spa/src/features/social.ts
Normal 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
206
spa/src/features/users.ts
Normal 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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
105
spa/src/features/watchlist.ts
Normal file
105
spa/src/features/watchlist.ts
Normal 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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
144
spa/src/features/webhooks.ts
Normal file
144
spa/src/features/webhooks.ts
Normal 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
174
spa/src/features/wrapup.ts
Normal 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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user