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

View File

@@ -1,55 +0,0 @@
import { z } from "zod"
import { API_URL, post } from "./client"
export const loginRequestSchema = z.object({
email: z.string(),
password: z.string(),
})
export type LoginRequest = z.infer<typeof loginRequestSchema>
export const loginResponseSchema = z.object({
token: z.string(),
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 function login(data: LoginRequest) {
return post<LoginResponse>("/auth/login", data)
}
export function register(data: RegisterRequest) {
return post("/auth/register", data)
}
export type RefreshResponse = {
token: string
refresh_token: string
expires_at: string
}
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()
}
export function apiLogout(refresh_token: string) {
return post("/auth/logout", { refresh_token })
}

View File

@@ -1,85 +0,0 @@
import { z } from "zod"
import type { DiaryEntryDto, Paginated } from "./common"
import { diaryEntryDtoSchema, movieDtoSchema, paginatedSchema, reviewDtoSchema } from "./common"
import { del, get, patch, post } from "./client"
export const diaryQueryParamsSchema = z.object({
limit: z.number().optional(),
offset: z.number().optional(),
sort_by: z.string().optional(),
movie_id: z.string().uuid().optional(),
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>
export 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)
}
export function deleteReview(id: string) {
return del(`/reviews/${id}`)
}
export function getActivityFeed(params?: ActivityFeedQueryParams) {
return get<ActivityFeedResponse>("/activity-feed", params)
}
export function exportDiary(params?: ExportQueryParams) {
return get<Blob>("/diary/export", params)
}

View File

@@ -1,58 +0,0 @@
import { z } from "zod"
import { get, post, put, del } from "./client"
import { goalDtoSchema } from "./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
}
export function getGoals() {
return get<GoalsResponse>("/goals")
}
export function getUserGoals(userId: string) {
return get<GoalsResponse>(`/users/${userId}/goals`)
}
export function createGoal(data: CreateGoalRequest) {
return post<z.infer<typeof goalDtoSchema>>("/goals", data)
}
export function updateGoal(year: number, data: UpdateGoalRequest) {
return put<z.infer<typeof goalDtoSchema>>(`/goals/${year}`, data)
}
export function deleteGoal(year: number) {
return del(`/goals/${year}`)
}
export function getSettings() {
return get<UserSettingsDto>("/settings")
}
export function updateSettings(data: UpdateUserSettingsRequest) {
return put("/settings", data)
}

View File

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

View File

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

View File

@@ -1,121 +0,0 @@
import { z } from "zod"
import type { Paginated } from "./common"
import { movieDtoSchema, paginatedSchema, reviewDtoSchema } from "./common"
import { get, post } from "./client"
export const moviesQueryParamsSchema = z.object({
limit: z.number().optional(),
offset: z.number().optional(),
search: z.string().optional(),
genre: z.string().optional(),
language: z.string().optional(),
})
export type MoviesQueryParams = z.infer<typeof moviesQueryParamsSchema>
export const moviesResponseSchema = paginatedSchema(movieDtoSchema)
export type MoviesResponse = z.infer<typeof moviesResponseSchema>
export const movieStatsDtoSchema = z.object({
total_count: z.number(),
avg_rating: z.number().optional(),
federated_count: z.number(),
rating_histogram: z.array(z.number()),
})
export type MovieStatsDto = z.infer<typeof movieStatsDtoSchema>
export const socialReviewDtoSchema = z.object({
user_display: z.string(),
rating: z.number(),
comment: z.string().optional(),
watched_at: z.string(),
is_federated: z.boolean(),
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>
export function getMovies(params?: MoviesQueryParams) {
return get<MoviesResponse>("/movies", params)
}
export function getMovie(id: string) {
return get<MovieDetailResponse>(`/movies/${id}`)
}
export function getMovieHistory(id: string) {
return get<ReviewHistoryResponse>(`/movies/${id}/history`)
}
export function getMovieProfile(id: string) {
return get<MovieProfileResponse>(`/movies/${id}/profile`)
}
export function syncPoster(id: string) {
return post(`/movies/${id}/sync-poster`)
}

View File

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

View File

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

View File

@@ -1,155 +0,0 @@
import { z } from "zod"
import { diaryEntryDtoSchema, paginatedSchema } from "./common"
import { get, post, put, putForm } from "./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>
const userDiaryResponseSchema = paginatedSchema(diaryEntryDtoSchema)
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>
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 function getUsers() {
return get<UsersResponse>("/users")
}
export function getUserProfile(id: string, params?: UserProfileQueryParams) {
return get<UserProfileResponse>(`/users/${id}`, params)
}
export function getProfile() {
return get<ProfileResponse>("/profile")
}
export type UpdateProfileData = {
display_name?: string
bio?: string
also_known_as?: string
avatar?: File
banner?: File
}
export function updateProfile(data: UpdateProfileData) {
const form = new FormData()
if (data.display_name != null) form.append("display_name", data.display_name)
if (data.also_known_as != null) form.append("also_known_as", data.also_known_as)
if (data.bio != null) form.append("bio", data.bio)
if (data.avatar) form.append("avatar", data.avatar)
if (data.banner) form.append("banner", data.banner)
return putForm("/profile", form)
}
export function updateProfileFields(data: UpdateProfileFieldsRequest) {
return put("/profile/fields", data)
}
export function reindexSearch() {
return post("/admin/reindex-search")
}

View File

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

View File

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

View File

@@ -1,123 +0,0 @@
import { z } from "zod"
import { del, get, post } from "./client"
export const generateWrapUpRequestSchema = z.object({
start_date: z.string(),
end_date: z.string(),
user_id: z.string().uuid().optional(),
})
export type GenerateWrapUpRequest = z.infer<typeof generateWrapUpRequestSchema>
export const wrapUpGeneratedResponseSchema = z.object({
id: z.string(),
})
export type WrapUpGeneratedResponse = z.infer<typeof wrapUpGeneratedResponseSchema>
export const wrapUpStatusResponseSchema = z.object({
id: z.string(),
user_id: z.string().optional(),
status: z.string(),
start_date: z.string(),
end_date: z.string(),
created_at: z.string(),
completed_at: z.string().optional(),
error_message: z.string().optional(),
})
export type WrapUpStatusResponse = z.infer<typeof wrapUpStatusResponseSchema>
export const wrapUpListResponseSchema = z.object({
items: z.array(wrapUpStatusResponseSchema),
})
export type WrapUpListResponse = z.infer<typeof wrapUpListResponseSchema>
export function generateWrapUp(data: GenerateWrapUpRequest) {
return post<WrapUpGeneratedResponse>("/wrapups/generate", data)
}
export function getWrapUps() {
return get<WrapUpListResponse>("/wrapups")
}
export function getWrapUp(id: string) {
return get<WrapUpStatusResponse>(`/wrapups/${id}`)
}
export function deleteWrapUp(id: string) {
return del(`/wrapups/${id}`)
}
export type MovieRef = {
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[]
}
export function getWrapUpReport(id: string) {
return get<WrapUpReport>(`/wrapups/${id}/report`)
}

View File

@@ -17,3 +17,19 @@ export function shortDate(dateStr: string): string {
return dateStr.slice(0, 10)
}
}
export function parseLocalDate(s: string): Date {
const [datePart, timePart] = s.split("T")
if (!datePart) return new Date()
const [y, m, d] = datePart.split("-").map(Number)
if (timePart) {
const [h, min, sec] = timePart.split(":").map(Number)
return new Date(y!, m! - 1, d!, h, min, sec)
}
return new Date(y!, m! - 1, d!)
}
export function formatLocalDateTime(d: Date): string {
const pad = (n: number) => n.toString().padStart(2, "0")
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}