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

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

39
spa/src/hooks/use-auth.ts Normal file
View File

@@ -0,0 +1,39 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useAuth } from "@/components/auth-provider"
import { login, register } from "@/lib/api/auth"
import type { LoginRequest, RegisterRequest } from "@/lib/api/auth"
export function useLogin() {
const { login: setAuth } = useAuth()
const qc = useQueryClient()
return useMutation({
mutationFn: (data: LoginRequest) => login(data),
onSuccess: (res) => {
setAuth({
token: res.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 () => {
logout()
qc.clear()
},
})
}

View File

@@ -0,0 +1,12 @@
import { useEffect, useState } from "react"
export function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debounced
}

View File

@@ -0,0 +1,89 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import {
deleteReview,
getActivityFeed,
getDiary,
logReview,
} from "@/lib/api/diary"
import type {
ActivityFeedQueryParams,
DiaryQueryParams,
LogReviewRequest,
} from "@/lib/api/diary"
const PAGE_SIZE = 20
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 })
},
})
}
export function useDeleteReview() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => deleteReview(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: diaryKeys.all })
},
})
}

View File

@@ -0,0 +1,95 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
applyMapping,
confirmImport,
createImportSession,
deleteImportProfile,
getImportPreview,
getImportProfiles,
getImportSession,
saveImportProfile,
} from "@/lib/api/imports"
import type {
ApplyMappingRequest,
ConfirmRequest,
SaveProfileRequest,
} from "@/lib/api/imports"
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 })
},
})
}

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -0,0 +1,58 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
getMovie,
getMovieHistory,
getMovieProfile,
getMovies,
syncPoster,
} from "@/lib/api/movies"
import type { MoviesQueryParams } from "@/lib/api/movies"
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 })
},
})
}

View File

@@ -0,0 +1,52 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { getPerson, getPersonCredits, search } from "@/lib/api/search"
import type { SearchQueryParams } from "@/lib/api/search"
const PAGE_SIZE = 20
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,
})
}

176
spa/src/hooks/use-social.ts Normal file
View File

@@ -0,0 +1,176 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
acceptFollower,
addBlockedDomain,
blockActor,
follow,
getBlockedActors,
getBlockedDomains,
getFollowers,
getFollowing,
getPendingFollowers,
getUserFollowers,
getUserFollowing,
rejectFollower,
removeBlockedDomain,
removeFollower,
unblockActor,
unfollow,
} from "@/lib/api/social"
import type {
ActorUrlRequest,
AddBlockedDomainRequest,
FollowRequest,
} from "@/lib/api/social"
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: FollowRequest) => 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,
})
}

View File

@@ -0,0 +1,63 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
getProfile,
getUserProfile,
getUsers,
updateProfile,
updateProfileFields,
} from "@/lib/api/users"
import type {
UpdateProfileData,
UpdateProfileFieldsRequest,
UserProfileQueryParams,
} from "@/lib/api/users"
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,69 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import {
addToWatchlist,
getWatchlist,
getWatchlistStatus,
removeFromWatchlist,
} from "@/lib/api/watchlist"
import type { AddToWatchlistRequest } from "@/lib/api/watchlist"
const PAGE_SIZE = 20
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,73 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
confirmWatch,
deleteToken,
dismissWatch,
generateToken,
getWatchQueue,
getWebhookTokens,
} from "@/lib/api/webhooks"
import type {
ConfirmWatchRequest,
DismissWatchRequest,
GenerateTokenRequest,
} from "@/lib/api/webhooks"
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 })
},
})
}

View File

@@ -0,0 +1,59 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
deleteWrapUp,
generateWrapUp,
getWrapUp,
getWrapUpReport,
getWrapUps,
} from "@/lib/api/wrapup"
import type { GenerateWrapUpRequest } from "@/lib/api/wrapup"
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 })
},
})
}