diff --git a/spa/src/components/person-row.tsx b/spa/src/components/person-row.tsx index ee27a84..afa6c68 100644 --- a/spa/src/components/person-row.tsx +++ b/spa/src/components/person-row.tsx @@ -1,7 +1,7 @@ import { Link } from "@tanstack/react-router" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Card, CardContent } from "@/components/ui/card" -import { posterUrl } from "@/lib/api/client" +import { tmdbProfileUrl } from "@/lib/api/client" type PersonRowProps = { id: string @@ -16,7 +16,7 @@ export function PersonRow({ id, name, subtitle, imagePath }: PersonRowProps) { - {imagePath && } + {imagePath && } {name[0]?.toUpperCase()}
diff --git a/spa/src/components/review-card.tsx b/spa/src/components/review-card.tsx index 6cb016e..8d91a05 100644 --- a/spa/src/components/review-card.tsx +++ b/spa/src/components/review-card.tsx @@ -1,4 +1,5 @@ import { Link } from "@tanstack/react-router" +import { Globe } from "lucide-react" import { StarDisplay } from "@/components/star-display" import { Card, CardContent } from "@/components/ui/card" import { posterUrl } from "@/lib/api/client" @@ -9,9 +10,10 @@ type ReviewCardProps = { review: ReviewDto userName?: string userId?: string + isFederated?: boolean } -export function ReviewCard({ movie, review, userName, userId }: ReviewCardProps) { +export function ReviewCard({ movie, review, userName, userId, isFederated }: ReviewCardProps) { return ( @@ -28,6 +30,7 @@ export function ReviewCard({ movie, review, userName, userId }: ReviewCardProps) ) : ( {userName} )} + {isFederated && } · {review.watched_at.slice(0, 10)}
diff --git a/spa/src/hooks/use-diary.ts b/spa/src/hooks/use-diary.ts index 261af1a..02c5a31 100644 --- a/spa/src/hooks/use-diary.ts +++ b/spa/src/hooks/use-diary.ts @@ -74,6 +74,7 @@ export function useLogReview() { mutationFn: (data: LogReviewRequest) => logReview(data), onSuccess: () => { qc.invalidateQueries({ queryKey: diaryKeys.all }) + qc.invalidateQueries({ queryKey: ["activity-feed"] }) }, }) } @@ -84,6 +85,7 @@ export function useDeleteReview() { mutationFn: (id: string) => deleteReview(id), onSuccess: () => { qc.invalidateQueries({ queryKey: diaryKeys.all }) + qc.invalidateQueries({ queryKey: ["activity-feed"] }) }, }) } diff --git a/spa/src/hooks/use-social.ts b/spa/src/hooks/use-social.ts index 53c4aaa..8fcaabf 100644 --- a/spa/src/hooks/use-social.ts +++ b/spa/src/hooks/use-social.ts @@ -83,7 +83,7 @@ export function useFollow() { export function useUnfollow() { const qc = useQueryClient() return useMutation({ - mutationFn: (data: FollowRequest) => unfollow(data), + mutationFn: (data: ActorUrlRequest) => unfollow(data), onSuccess: () => { qc.invalidateQueries({ queryKey: socialKeys.following }) }, diff --git a/spa/src/lib/api/diary.ts b/spa/src/lib/api/diary.ts index 232d475..63d3d1d 100644 --- a/spa/src/lib/api/diary.ts +++ b/spa/src/lib/api/diary.ts @@ -31,6 +31,7 @@ export const feedEntryDtoSchema = z.object({ user_id: z.string().uuid(), user_email: z.string(), user_display_name: z.string(), + is_federated: z.boolean(), }) export type FeedEntryDto = z.infer diff --git a/spa/src/lib/api/movies.ts b/spa/src/lib/api/movies.ts index d0f66b3..eed73ee 100644 --- a/spa/src/lib/api/movies.ts +++ b/spa/src/lib/api/movies.ts @@ -60,6 +60,7 @@ export const keywordDtoSchema = z.object({ }) export const castMemberDtoSchema = z.object({ + person_id: z.string(), tmdb_person_id: z.number(), name: z.string(), character: z.string(), @@ -69,6 +70,7 @@ export const castMemberDtoSchema = z.object({ export type CastMemberDto = z.infer export const crewMemberDtoSchema = z.object({ + person_id: z.string(), tmdb_person_id: z.number(), name: z.string(), job: z.string(), diff --git a/spa/src/lib/api/social.ts b/spa/src/lib/api/social.ts index b030f17..85ad8f8 100644 --- a/spa/src/lib/api/social.ts +++ b/spa/src/lib/api/social.ts @@ -68,7 +68,7 @@ export function follow(data: FollowRequest) { return post("/social/follow", data) } -export function unfollow(data: FollowRequest) { +export function unfollow(data: ActorUrlRequest) { return post("/social/unfollow", data) } diff --git a/spa/src/lib/api/users.ts b/spa/src/lib/api/users.ts index 433ab42..07db2a2 100644 --- a/spa/src/lib/api/users.ts +++ b/spa/src/lib/api/users.ts @@ -1,10 +1,12 @@ import { z } from "zod" import { diaryEntryDtoSchema, paginatedSchema } from "./common" -import { get, put, putForm } from "./client" +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(), }) @@ -130,3 +132,7 @@ export function updateProfile(data: UpdateProfileData) { export function updateProfileFields(data: UpdateProfileFieldsRequest) { return put("/profile/fields", data) } + +export function reindexSearch() { + return post("/admin/reindex-search") +} diff --git a/spa/src/lib/api/wrapup.ts b/spa/src/lib/api/wrapup.ts index 31a7ec1..c67edf7 100644 --- a/spa/src/lib/api/wrapup.ts +++ b/spa/src/lib/api/wrapup.ts @@ -47,6 +47,7 @@ export function deleteWrapUp(id: string) { } export type MovieRef = { + movie_id?: string title: string year: number runtime_minutes?: number @@ -54,6 +55,7 @@ export type MovieRef = { } export type PersonStat = { + person_id?: string name: string count: number avg_rating: number @@ -91,6 +93,7 @@ export type WrapUpReport = { first_movie_of_period?: MovieRef last_movie_of_period?: MovieRef poster_paths: string[] + top_cast_profile_paths: string[] } export function getWrapUpReport(id: string) { diff --git a/spa/src/locales/en.json b/spa/src/locales/en.json index e59cba4..00fd0b0 100644 --- a/spa/src/locales/en.json +++ b/spa/src/locales/en.json @@ -19,6 +19,7 @@ "continue": "Continue", "generate": "Generate", "generating": "Generating...", + "run": "Run", "reviews": "{{count}} reviews", "films": "{{count}} films", "filmsAvg": "{{count}} films, avg {{avg}}" @@ -154,7 +155,11 @@ "account": "Account", "data": "Data", "integrations": "Integrations", - "socialGroup": "Social" + "socialGroup": "Social", + "admin": "Admin", + "rebuildSearch": "Rebuild Search Index", + "rebuildSearchDesc": "Re-index all movies and people", + "rebuildSearchDone": "Reindex queued" }, "editProfile": { "title": "Edit Profile", @@ -201,6 +206,9 @@ "generateWrapUp": "Generate Wrap-Up", "startDate": "Start Date", "endDate": "End Date", + "generateFor": "Generate for", + "forSelf": "Myself", + "forGlobal": "Everyone (global)", "heroSubtitle": "Your Year in Movies", "moviesWatched": "movies watched", "watchHours": "{{hours}} hours of watch time", diff --git a/spa/src/routes/_app/index.tsx b/spa/src/routes/_app/index.tsx index fcc24b2..fddf3cb 100644 --- a/spa/src/routes/_app/index.tsx +++ b/spa/src/routes/_app/index.tsx @@ -100,6 +100,7 @@ function FeedTab() { review={entry.review} userName={entry.user_display_name} userId={entry.user_id} + isFederated={entry.is_federated} /> ) return entry.user_id === auth?.user_id ? ( diff --git a/spa/src/routes/_app/movies.$id.tsx b/spa/src/routes/_app/movies.$id.tsx index 985cd0d..4477f6c 100644 --- a/spa/src/routes/_app/movies.$id.tsx +++ b/spa/src/routes/_app/movies.$id.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Link } from "@tanstack/react-router" import { useTranslation } from "react-i18next" -import { ArrowLeft, Bookmark, BookmarkCheck, Star, TrendingUp, User, Users } from "lucide-react" +import { ArrowLeft, Bookmark, BookmarkCheck, Globe, Star, TrendingUp, User, Users } from "lucide-react" import { StarDisplay } from "@/components/star-display" import { RatingHistogram } from "@/components/rating-histogram" import { EmptyState } from "@/components/empty-state" @@ -112,7 +112,10 @@ function MovieDetailPage() {
- {r.user_display} + + {r.user_display} + {r.is_federated && } + {r.watched_at.slice(0, 10)}
@@ -233,7 +236,7 @@ function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[] : (person as CrewMemberDto).job return ( -
+
{person.profile_path ? ( @@ -245,7 +248,7 @@ function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[]

{person.name}

{subtitle}

-
+ ) })}
diff --git a/spa/src/routes/_app/profile.tsx b/spa/src/routes/_app/profile.tsx index dddb284..fd4e532 100644 --- a/spa/src/routes/_app/profile.tsx +++ b/spa/src/routes/_app/profile.tsx @@ -52,7 +52,7 @@ function ProfilePage() { function WrapUpLink() { const { t } = useTranslation() const { data } = useWrapUps() - const latest = data?.items?.find((w) => w.status === "completed") + const latest = data?.items?.find((w) => w.status === "Ready") if (!latest) return null diff --git a/spa/src/routes/_app/settings/index.tsx b/spa/src/routes/_app/settings/index.tsx index f320a5b..4f3e3ba 100644 --- a/spa/src/routes/_app/settings/index.tsx +++ b/spa/src/routes/_app/settings/index.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" import { useTranslation } from "react-i18next" +import { useMutation } from "@tanstack/react-query" import { ArrowLeft, ChevronRight, @@ -7,11 +8,14 @@ import { Globe, Key, LogOut, + RefreshCw, ShieldBan, Sparkles, User, } from "lucide-react" +import { Button } from "@/components/ui/button" import { useAuth, useIsAdmin } from "@/components/auth-provider" +import { reindexSearch } from "@/lib/api/users" export const Route = createFileRoute("/_app/settings/")({ component: SettingsPage, @@ -100,6 +104,8 @@ function SettingsPage() { + {isAdmin && } + + + + + ) +} + function SettingsGroup({ label, items, diff --git a/spa/src/routes/_app/settings/wrapup.tsx b/spa/src/routes/_app/settings/wrapup.tsx index 875357b..060c89d 100644 --- a/spa/src/routes/_app/settings/wrapup.tsx +++ b/spa/src/routes/_app/settings/wrapup.tsx @@ -15,12 +15,14 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Skeleton } from "@/components/ui/skeleton" import { EmptyState } from "@/components/empty-state" -import { useIsAdmin } from "@/components/auth-provider" +import { useAuth, useIsAdmin } from "@/components/auth-provider" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { useWrapUps, useGenerateWrapUp, useDeleteWrapUp, } from "@/hooks/use-wrapup" +import { useUsers } from "@/hooks/use-users" export const Route = createFileRoute("/_app/settings/wrapup")({ component: WrapupPage, @@ -28,18 +30,22 @@ export const Route = createFileRoute("/_app/settings/wrapup")({ function WrapupPage() { const { t } = useTranslation() + const { auth } = useAuth() const isAdmin = useIsAdmin() const { data, isPending } = useWrapUps() const generate = useGenerateWrapUp() const remove = useDeleteWrapUp() + const { data: usersData } = useUsers() const [open, setOpen] = useState(false) const [startDate, setStartDate] = useState("") const [endDate, setEndDate] = useState("") + const [targetUserId, setTargetUserId] = useState("self") const handleGenerate = () => { + const user_id = targetUserId === "global" ? undefined : targetUserId === "self" ? auth?.user_id : targetUserId generate.mutate( - { start_date: startDate, end_date: endDate }, + { start_date: startDate, end_date: endDate, user_id }, { onSuccess: () => { setOpen(false) @@ -81,7 +87,7 @@ function WrapupPage() { {items.map((w) => ( - {w.status === "completed" ? ( + {w.status === "Ready" ? (

{w.start_date} — {w.end_date}

@@ -133,6 +139,25 @@ function WrapupPage() { onChange={(e) => setEndDate(e.target.value)} />
+ {isAdmin && usersData?.users && ( +
+ + +
+ )}