Compare commits
4 Commits
1a7448fd3d
...
94cab1ea7c
| Author | SHA1 | Date | |
|---|---|---|---|
| 94cab1ea7c | |||
| 3dde0d13db | |||
| 1c81d7768c | |||
| 07df6fe207 |
12
README.md
12
README.md
@@ -47,7 +47,7 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 0–5 rating and optional watch medium (cinema, streaming, TV, physical media, download, media server)
|
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 1–5 rating and optional watch medium (cinema, streaming, TV, physical media, download, media server)
|
||||||
- Edit reviews after the fact — update rating, comment, date, or watch medium via partial PATCH; each watch is still a separate record (re-watches tracked)
|
- Edit reviews after the fact — update rating, comment, date, or watch medium via partial PATCH; each watch is still a separate record (re-watches tracked)
|
||||||
- Background poster fetching and storage (local filesystem or S3-compatible)
|
- Background poster fetching and storage (local filesystem or S3-compatible)
|
||||||
- Movie enrichment via TMDb — full cast, crew, genres, keywords, runtime, budget/revenue, ratings; fetched automatically on movie discovery and refreshed every 30 days; exposed via `GET /api/v1/movies/{id}/profile`
|
- Movie enrichment via TMDb — full cast, crew, genres, keywords, runtime, budget/revenue, ratings; fetched automatically on movie discovery and refreshed every 30 days; exposed via `GET /api/v1/movies/{id}/profile`
|
||||||
@@ -60,8 +60,9 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
|
|||||||
- Watchlist — add movies to watch later, per-user; federated watchlist entries visible for remote actors
|
- Watchlist — add movies to watch later, per-user; federated watchlist entries visible for remote actors
|
||||||
- User profiles — display name, bio, avatar, banner, custom profile fields; editable via HTML settings page or REST API; account deletion broadcasts AP `Delete` actor activity; `alsoKnownAs` change triggers AP `Move` for account migration
|
- User profiles — display name, bio, avatar, banner, custom profile fields; editable via HTML settings page or REST API; account deletion broadcasts AP `Delete` actor activity; `alsoKnownAs` change triggers AP `Move` for account migration
|
||||||
- Jellyfin/Plex auto-import — media server sends a webhook on playback stop, movies land in a watch queue; review and confirm with a rating to create diary entries; per-user webhook tokens with SHA-256 auth; setup UI at `/settings/integrations`
|
- Jellyfin/Plex auto-import — media server sends a webhook on playback stop, movies land in a watch queue; review and confirm with a rating to create diary entries; per-user webhook tokens with SHA-256 auth; setup UI at `/settings/integrations`
|
||||||
- Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, rewatches, budget analysis), shareable HTML page at `/wrapups/{user_id}/{year}`; admin-triggered or auto-generated in January
|
- Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, watch medium breakdown, rewatches, budget analysis); directors/actors filtered by minimum watch count for statistical relevance; shareable HTML page at `/wrapups/{user_id}/{year}`; admin-triggered or auto-generated in January
|
||||||
- Goals — set a "watch N movies in YEAR" target with a progress bar; progress computed from existing reviews (backwards compatible); per-user federation toggle in settings; displayed on profile (SPA: interactive with create/edit/delete, classic HTML: read-only glassmorphic card)
|
- Goals — set a "watch N movies in YEAR" target with a progress bar; progress computed from existing reviews (backwards compatible); per-user federation toggle in settings; displayed on profile (SPA: interactive with create/edit/delete, classic HTML: read-only glassmorphic card)
|
||||||
|
- Profile trends — top directors, genre breakdown, rating distribution histogram, watch medium breakdown, monthly activity chart; all computed from the user's review history
|
||||||
- CSV and JSON diary export
|
- CSV and JSON diary export
|
||||||
- File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports
|
- File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports
|
||||||
- REST API v1 (`/api/v1/`) with full feature parity with the HTML interface
|
- REST API v1 (`/api/v1/`) with full feature parity with the HTML interface
|
||||||
@@ -90,8 +91,8 @@ Hexagonal (Ports & Adapters) with Domain-Driven Design:
|
|||||||
```
|
```
|
||||||
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by presentation, tui, and template adapters
|
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by presentation, tui, and template adapters
|
||||||
infra-wiring — shared infrastructure types (DbPool, EventBusBackend, AppConfig) used by both presentation and worker binaries
|
infra-wiring — shared infrastructure types (DbPool, EventBusBackend, AppConfig) used by both presentation and worker binaries
|
||||||
domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, ImageFetcher, RssFeedRenderer), no external deps except serde
|
domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, SocialCommand/SocialQuery, ImageFetcher, RssFeedRenderer), no external deps except serde
|
||||||
application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic
|
application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic; modules: auth, diary, goals, import, integrations, movies, person, search, social, users, watchlist, wrapup
|
||||||
presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving, composition root for the HTTP process
|
presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving, composition root for the HTTP process
|
||||||
worker — standalone worker binary (event consumer, poster sync, federation)
|
worker — standalone worker binary (event consumer, poster sync, federation)
|
||||||
adapters/
|
adapters/
|
||||||
@@ -101,7 +102,7 @@ adapters/
|
|||||||
postgres — PostgreSQL repository + connection factory
|
postgres — PostgreSQL repository + connection factory
|
||||||
metadata — TMDB / OMDb HTTP client
|
metadata — TMDB / OMDb HTTP client
|
||||||
poster-fetcher — downloads poster images
|
poster-fetcher — downloads poster images
|
||||||
image-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage
|
object-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage
|
||||||
poster-sync — event handler: triggers poster fetch+store on MovieDiscovered
|
poster-sync — event handler: triggers poster fetch+store on MovieDiscovered
|
||||||
image-converter — optional background worker: converts stored images to AVIF or WebP; backfills existing images via a 24h periodic job
|
image-converter — optional background worker: converts stored images to AVIF or WebP; backfills existing images via a 24h periodic job
|
||||||
tmdb-enrichment — TMDb HTTP client implementing MovieEnrichmentClient and PersonEnrichmentClient; event handlers (MovieEnrichmentHandler, PersonEnrichmentHandler) live in the application layer
|
tmdb-enrichment — TMDb HTTP client implementing MovieEnrichmentClient and PersonEnrichmentClient; event handlers (MovieEnrichmentHandler, PersonEnrichmentHandler) live in the application layer
|
||||||
@@ -114,6 +115,7 @@ adapters/
|
|||||||
event-payload — shared event serialization DTOs (used by all event bus adapters)
|
event-payload — shared event serialization DTOs (used by all event bus adapters)
|
||||||
sqlite-event-queue — durable polling event queue backed by SQLite
|
sqlite-event-queue — durable polling event queue backed by SQLite
|
||||||
postgres-event-queue — durable polling event queue backed by PostgreSQL
|
postgres-event-queue — durable polling event queue backed by PostgreSQL
|
||||||
|
event-publisher — in-memory event channel (used in tests)
|
||||||
nats — NATS Core / JetStream event publisher and consumer
|
nats — NATS Core / JetStream event publisher and consumer
|
||||||
event-publisher — in-memory event channel (used in tests)
|
event-publisher — in-memory event channel (used in tests)
|
||||||
activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals
|
activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ graph TB
|
|||||||
UC_INTEGRATIONS["integrations<br/>webhooks, watch_queue,<br/>confirm, dismiss"]
|
UC_INTEGRATIONS["integrations<br/>webhooks, watch_queue,<br/>confirm, dismiss"]
|
||||||
UC_SEARCH["search<br/>execute"]
|
UC_SEARCH["search<br/>execute"]
|
||||||
UC_PERSON["person<br/>get, get_credits"]
|
UC_PERSON["person<br/>get, get_credits"]
|
||||||
|
UC_SOCIAL["social<br/>follow, unfollow,<br/>accept, reject, block"]
|
||||||
end
|
end
|
||||||
subgraph EventHandlers["Event Handlers"]
|
subgraph EventHandlers["Event Handlers"]
|
||||||
EH_MOVIE["MovieEnrichmentHandler<br/><i>on MovieEnrichmentRequested</i>"]
|
EH_MOVIE["MovieEnrichmentHandler<br/><i>on MovieEnrichmentRequested</i>"]
|
||||||
@@ -59,10 +60,10 @@ graph TB
|
|||||||
M_SEARCH["SearchQuery,<br/>SearchResults"]
|
M_SEARCH["SearchQuery,<br/>SearchResults"]
|
||||||
end
|
end
|
||||||
subgraph Ports["Port Traits (Interfaces)"]
|
subgraph Ports["Port Traits (Interfaces)"]
|
||||||
P_REPOS["MovieCommand / MovieQuery<br/>ReviewRepository<br/>DiaryQuery / StatsRepository<br/>UserRepository<br/>WatchlistRepository<br/>WatchEventCommand / WatchEventQuery<br/>WebhookTokenRepository<br/>ImportSessionRepository<br/>MovieProfileRepository<br/>WrapUpRepository<br/>GoalCommand / GoalQuery<br/>UserSettingsRepository<br/>MovieDeduplicator"]
|
P_REPOS["MovieCommand / MovieQuery<br/>ReviewRepository<br/>DiaryQuery / StatsRepository<br/>UserRepository / UserProfileFieldsRepository<br/>WatchlistRepository<br/>WatchEventCommand / WatchEventQuery<br/>WebhookTokenRepository<br/>ImportSessionRepository / ImportProfileRepository<br/>MovieProfileRepository<br/>WrapUpRepository / WrapUpStatsQuery<br/>GoalCommand / GoalQuery<br/>UserSettingsRepository / RefreshSessionRepository<br/>MovieDeduplicator"]
|
||||||
P_SERVICES["AuthService<br/>MetadataClient<br/>PosterFetcherClient<br/>ImageFetcher<br/>ObjectStorage<br/>EventPublisher<br/>EventConsumer<br/>PasswordHasher<br/>DiaryExporter<br/>DocumentParser<br/>RssFeedRenderer"]
|
P_SERVICES["AuthService<br/>MetadataClient / MovieEnrichmentClient<br/>PersonEnrichmentClient<br/>PosterFetcherClient<br/>ImageFetcher / ObjectStorage<br/>EventPublisher / EventConsumer<br/>PasswordHasher<br/>DiaryExporter / DocumentParser<br/>RssFeedRenderer / MediaServerParser"]
|
||||||
P_SEARCH["SearchPort<br/>SearchCommand<br/>PersonQuery<br/>PersonCommand"]
|
P_SEARCH["SearchPort / SearchCommand<br/>PersonQuery / PersonCommand<br/>FederatedProfileQuery"]
|
||||||
P_FEDERATION["SocialQueryPort<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"]
|
P_FEDERATION["SocialCommand / SocialQuery<br/>FederationAdminQuery<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"]
|
||||||
end
|
end
|
||||||
subgraph DomainServices["Services (pure, no I/O)"]
|
subgraph DomainServices["Services (pure, no I/O)"]
|
||||||
DS_WRAPUP["WrapUpAnalyzer<br/><i>build_report, compute_*</i>"]
|
DS_WRAPUP["WrapUpAnalyzer<br/><i>build_report, compute_*</i>"]
|
||||||
@@ -92,7 +93,9 @@ graph TB
|
|||||||
end
|
end
|
||||||
subgraph Messaging["Messaging"]
|
subgraph Messaging["Messaging"]
|
||||||
A_NATS["nats<br/><i>JetStream / Core</i>"]
|
A_NATS["nats<br/><i>JetStream / Core</i>"]
|
||||||
|
A_SQLITE_QUEUE["sqlite-event-queue<br/><i>Polling, dead-letter</i>"]
|
||||||
A_PG_QUEUE["postgres-event-queue<br/><i>Polling, dead-letter</i>"]
|
A_PG_QUEUE["postgres-event-queue<br/><i>Polling, dead-letter</i>"]
|
||||||
|
A_EVT_PUB["event-publisher<br/><i>In-memory (tests)</i>"]
|
||||||
A_PAYLOAD["event-payload<br/><i>Serde (de)serialization</i>"]
|
A_PAYLOAD["event-payload<br/><i>Serde (de)serialization</i>"]
|
||||||
end
|
end
|
||||||
subgraph External["External Services"]
|
subgraph External["External Services"]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
|||||||
import { timeAgo } from "@/lib/date"
|
import { timeAgo } from "@/lib/date"
|
||||||
import type { SocialReviewDto } from "@/features/movies"
|
import type { SocialReviewDto } from "@/features/movies"
|
||||||
|
|
||||||
export function CommunityReviews({ reviews }: { reviews: { items: SocialReviewDto[] } }) {
|
export function CommunityReviews({ reviews, onShowDetail }: { reviews: { items: SocialReviewDto[] }; onShowDetail?: (review: SocialReviewDto) => void }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +36,13 @@ export function CommunityReviews({ reviews }: { reviews: { items: SocialReviewDt
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
{r.comment && (
|
{r.comment && (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-xs text-muted-foreground">{r.comment}</p>
|
<p
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
role={onShowDetail ? "button" : undefined}
|
||||||
|
tabIndex={onShowDetail ? 0 : undefined}
|
||||||
|
onClick={onShowDetail ? () => onShowDetail(r) : undefined}
|
||||||
|
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail(r) : undefined}
|
||||||
|
>{r.comment}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ type MovieCardProps = {
|
|||||||
subtitle?: React.ReactNode
|
subtitle?: React.ReactNode
|
||||||
variant?: "compact" | "full"
|
variant?: "compact" | "full"
|
||||||
action?: React.ReactNode
|
action?: React.ReactNode
|
||||||
|
onShowDetail?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MovieCard({ movie, rating, comment, subtitle, variant = "full", action }: MovieCardProps) {
|
export function MovieCard({ movie, rating, comment, subtitle, variant = "full", action, onShowDetail }: MovieCardProps) {
|
||||||
if (variant === "compact") {
|
if (variant === "compact") {
|
||||||
return (
|
return (
|
||||||
<Link to="/movies/$id" params={{ id: movie.id }} className="glass flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors active:bg-muted/50">
|
<Link to="/movies/$id" params={{ id: movie.id }} className="glass flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors active:bg-muted/50">
|
||||||
@@ -23,7 +24,15 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-semibold">{movie.title}</p>
|
<p className="truncate text-sm font-semibold">{movie.title}</p>
|
||||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||||
{comment && <p className="truncate text-xs text-muted-foreground/70">{comment}</p>}
|
{comment && (
|
||||||
|
<p
|
||||||
|
className="truncate text-xs text-muted-foreground/70"
|
||||||
|
role={onShowDetail ? "button" : undefined}
|
||||||
|
tabIndex={onShowDetail ? 0 : undefined}
|
||||||
|
onClick={onShowDetail ? (e) => { e.preventDefault(); onShowDetail() } : undefined}
|
||||||
|
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail() : undefined}
|
||||||
|
>{comment}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{rating != null && <StarDisplay rating={rating} size="xs" />}
|
{rating != null && <StarDisplay rating={rating} size="xs" />}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -41,7 +50,15 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
|
|||||||
<p className="font-semibold">{movie.title}</p>
|
<p className="font-semibold">{movie.title}</p>
|
||||||
<p className="text-xs text-muted-foreground">{movie.release_year}{movie.director && ` · ${movie.director}`}</p>
|
<p className="text-xs text-muted-foreground">{movie.release_year}{movie.director && ` · ${movie.director}`}</p>
|
||||||
{rating != null && <div className="mt-1"><StarDisplay rating={rating} /></div>}
|
{rating != null && <div className="mt-1"><StarDisplay rating={rating} /></div>}
|
||||||
{comment && <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{comment}</p>}
|
{comment && (
|
||||||
|
<p
|
||||||
|
className="mt-1 line-clamp-2 text-xs text-muted-foreground"
|
||||||
|
role={onShowDetail ? "button" : undefined}
|
||||||
|
tabIndex={onShowDetail ? 0 : undefined}
|
||||||
|
onClick={onShowDetail ? (e) => { e.preventDefault(); onShowDetail() } : undefined}
|
||||||
|
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail() : undefined}
|
||||||
|
>{comment}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{action && <div className="flex items-center" onClick={(e) => e.preventDefault()}>{action}</div>}
|
{action && <div className="flex items-center" onClick={(e) => e.preventDefault()}>{action}</div>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from "@tanstack/react-router"
|
import { Link } from "@tanstack/react-router"
|
||||||
import { useCallback } from "react"
|
import { useCallback, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Bar, BarChart, XAxis, YAxis } from "recharts"
|
import { Bar, BarChart, XAxis, YAxis } from "recharts"
|
||||||
import { Globe, Search, User } from "lucide-react"
|
import { Globe, Search, User } from "lucide-react"
|
||||||
@@ -16,6 +16,8 @@ import { VirtualList } from "@/components/virtual-list"
|
|||||||
import { useInfiniteDiary } from "@/features/diary"
|
import { useInfiniteDiary } from "@/features/diary"
|
||||||
import { TimeAgo } from "@/components/time-ago"
|
import { TimeAgo } from "@/components/time-ago"
|
||||||
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
||||||
|
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
|
||||||
|
import type { DiaryEntryDto } from "@/lib/api/common"
|
||||||
import type { UserProfileResponse } from "@/features/users"
|
import type { UserProfileResponse } from "@/features/users"
|
||||||
|
|
||||||
type ProfileViewProps = {
|
type ProfileViewProps = {
|
||||||
@@ -154,27 +156,39 @@ function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string;
|
|||||||
)
|
)
|
||||||
: items
|
: items
|
||||||
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
|
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
|
||||||
|
const [detailEntry, setDetailEntry] = useState<DiaryEntryDto | null>(null)
|
||||||
|
|
||||||
if (isPending) return <Skeleton className="h-40 w-full rounded-xl" />
|
if (isPending) return <Skeleton className="h-40 w-full rounded-xl" />
|
||||||
if (!filtered.length) return <EmptyState icon={User} title={t("profile.noEntries")} />
|
if (!filtered.length) return <EmptyState icon={User} title={t("profile.noEntries")} />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtualList
|
<>
|
||||||
items={filtered}
|
<VirtualList
|
||||||
estimateSize={52}
|
items={filtered}
|
||||||
hasMore={!!hasNextPage}
|
estimateSize={52}
|
||||||
isFetching={isFetchingNextPage}
|
hasMore={!!hasNextPage}
|
||||||
onLoadMore={loadMore}
|
isFetching={isFetchingNextPage}
|
||||||
renderItem={(e) => (
|
onLoadMore={loadMore}
|
||||||
<MovieCard
|
renderItem={(e) => (
|
||||||
movie={e.movie}
|
<MovieCard
|
||||||
rating={e.review.rating}
|
movie={e.movie}
|
||||||
comment={e.review.comment}
|
rating={e.review.rating}
|
||||||
subtitle={<><TimeAgo date={e.review.watched_at} /></>}
|
comment={e.review.comment}
|
||||||
variant="compact"
|
subtitle={<><TimeAgo date={e.review.watched_at} /></>}
|
||||||
|
variant="compact"
|
||||||
|
onShowDetail={e.review.comment ? () => setDetailEntry(e) : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{detailEntry && (
|
||||||
|
<ReviewDetailSheet
|
||||||
|
open={!!detailEntry}
|
||||||
|
onOpenChange={(open) => !open && setDetailEntry(null)}
|
||||||
|
movie={detailEntry.movie}
|
||||||
|
review={detailEntry.review}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Download, Share2, X } from "lucide-react"
|
import { Download, Share2, X } from "lucide-react"
|
||||||
import html2canvas from "html2canvas-pro"
|
import html2canvas from "html2canvas-pro"
|
||||||
@@ -18,6 +18,11 @@ export function WrapUpShareCard({ report, onClose }: Props) {
|
|||||||
const cardRef = useRef<HTMLDivElement>(null)
|
const cardRef = useRef<HTMLDivElement>(null)
|
||||||
const [exporting, setExporting] = useState(false)
|
const [exporting, setExporting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = "hidden"
|
||||||
|
return () => { document.body.style.overflow = "" }
|
||||||
|
}, [])
|
||||||
|
|
||||||
const watchHours = Math.round(report.total_watch_time_minutes / 60)
|
const watchHours = Math.round(report.total_watch_time_minutes / 60)
|
||||||
const topGenre = report.top_genres[0]?.genre
|
const topGenre = report.top_genres[0]?.genre
|
||||||
const topDirector = report.top_directors[0]?.name
|
const topDirector = report.top_directors[0]?.name
|
||||||
@@ -66,7 +71,7 @@ export function WrapUpShareCard({ report, onClose }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[75vh] overflow-y-auto rounded-2xl">
|
<div className="max-h-[75vh] rounded-2xl">
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
className="relative w-[360px] overflow-hidden rounded-2xl"
|
className="relative w-[360px] overflow-hidden rounded-2xl"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"
|
|||||||
import { BookOpen, ChevronLeft, ChevronRight, Pencil } from "lucide-react"
|
import { BookOpen, ChevronLeft, ChevronRight, Pencil } from "lucide-react"
|
||||||
import { format, startOfMonth, subMonths } from "date-fns"
|
import { format, startOfMonth, subMonths } from "date-fns"
|
||||||
import { ReviewSheet } from "@/components/review-sheet"
|
import { ReviewSheet } from "@/components/review-sheet"
|
||||||
|
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
|
||||||
import { EditableContextMenu } from "@/components/editable-context-menu"
|
import { EditableContextMenu } from "@/components/editable-context-menu"
|
||||||
import { MovieCard } from "@/components/movie-card"
|
import { MovieCard } from "@/components/movie-card"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
import { EmptyState } from "@/components/empty-state"
|
||||||
@@ -39,6 +40,7 @@ function DiaryPage() {
|
|||||||
useInfiniteDiary({ sort_by: "desc", user_id: auth?.user_id })
|
useInfiniteDiary({ sort_by: "desc", user_id: auth?.user_id })
|
||||||
const deleteReview = useDeleteReview()
|
const deleteReview = useDeleteReview()
|
||||||
const [editingEntry, setEditingEntry] = useState<DiaryEntryDto | null>(null)
|
const [editingEntry, setEditingEntry] = useState<DiaryEntryDto | null>(null)
|
||||||
|
const [detailEntry, setDetailEntry] = useState<DiaryEntryDto | null>(null)
|
||||||
|
|
||||||
const monthLabel = format(month, "MMMM yyyy")
|
const monthLabel = format(month, "MMMM yyyy")
|
||||||
const monthStr = format(month, "yyyy-MM")
|
const monthStr = format(month, "yyyy-MM")
|
||||||
@@ -121,6 +123,7 @@ function DiaryPage() {
|
|||||||
rating={item.entry.review.rating}
|
rating={item.entry.review.rating}
|
||||||
comment={item.entry.review.comment}
|
comment={item.entry.review.comment}
|
||||||
variant="full"
|
variant="full"
|
||||||
|
onShowDetail={item.entry.review.comment ? () => setDetailEntry(item.entry) : undefined}
|
||||||
action={
|
action={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{item.entry.review.watch_medium && (
|
{item.entry.review.watch_medium && (
|
||||||
@@ -154,6 +157,15 @@ function DiaryPage() {
|
|||||||
review={editingEntry.review}
|
review={editingEntry.review}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{detailEntry && (
|
||||||
|
<ReviewDetailSheet
|
||||||
|
open={!!detailEntry}
|
||||||
|
onOpenChange={(open) => !open && setDetailEntry(null)}
|
||||||
|
movie={detailEntry.movie}
|
||||||
|
review={detailEntry.review}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||||
|
import { useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Bookmark, BookmarkCheck, Star, User } from "lucide-react"
|
import { Bookmark, BookmarkCheck, Star, User } from "lucide-react"
|
||||||
import { BackButton } from "@/components/back-button"
|
import { BackButton } from "@/components/back-button"
|
||||||
import { CommunityReviews } from "@/components/community-reviews"
|
import { CommunityReviews } from "@/components/community-reviews"
|
||||||
|
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
|
||||||
import { ViewingHistory } from "@/components/viewing-history"
|
import { ViewingHistory } from "@/components/viewing-history"
|
||||||
import { RatingHistogram } from "@/components/rating-histogram"
|
import { RatingHistogram } from "@/components/rating-histogram"
|
||||||
import { HorizontalStrip } from "@/components/horizontal-strip"
|
import { HorizontalStrip } from "@/components/horizontal-strip"
|
||||||
@@ -17,7 +19,7 @@ import {
|
|||||||
useAddToWatchlist,
|
useAddToWatchlist,
|
||||||
useRemoveFromWatchlist,
|
useRemoveFromWatchlist,
|
||||||
} from "@/features/watchlist"
|
} from "@/features/watchlist"
|
||||||
import type { CastMemberDto, CrewMemberDto } from "@/features/movies"
|
import type { CastMemberDto, CrewMemberDto, SocialReviewDto } from "@/features/movies"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/movies/$id")({
|
export const Route = createFileRoute("/_app/movies/$id")({
|
||||||
component: MovieDetailPage,
|
component: MovieDetailPage,
|
||||||
@@ -30,6 +32,7 @@ function MovieDetailPage() {
|
|||||||
const { data: profile } = useMovieProfile(id)
|
const { data: profile } = useMovieProfile(id)
|
||||||
const { data: history } = useMovieHistory(id)
|
const { data: history } = useMovieHistory(id)
|
||||||
useDocumentTitle(data?.movie.title)
|
useDocumentTitle(data?.movie.title)
|
||||||
|
const [detailReview, setDetailReview] = useState<SocialReviewDto | null>(null)
|
||||||
|
|
||||||
if (isPending) return <DetailSkeleton />
|
if (isPending) return <DetailSkeleton />
|
||||||
if (!data) return null
|
if (!data) return null
|
||||||
@@ -102,9 +105,19 @@ function MovieDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CommunityReviews reviews={reviews} />
|
<CommunityReviews reviews={reviews} onShowDetail={setDetailReview} />
|
||||||
|
|
||||||
{history && <ViewingHistory history={history} />}
|
{history && <ViewingHistory history={history} />}
|
||||||
|
|
||||||
|
{detailReview && (
|
||||||
|
<ReviewDetailSheet
|
||||||
|
open={!!detailReview}
|
||||||
|
onOpenChange={(open) => !open && setDetailReview(null)}
|
||||||
|
movie={movie}
|
||||||
|
review={{ id: "", rating: detailReview.rating, comment: detailReview.comment, watched_at: detailReview.watched_at, watch_medium: detailReview.watch_medium }}
|
||||||
|
userName={detailReview.user_display}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { ArrowLeft, ChevronRight, Sparkles, Trash2 } from "lucide-react"
|
import { ArrowLeft, ChevronRight, Sparkles, Trash2 } from "lucide-react"
|
||||||
|
import { format, subMonths, startOfYear, endOfYear } from "date-fns"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
@@ -125,6 +126,7 @@ function WrapupPage() {
|
|||||||
<DrawerTitle>{t("wrapup.generateWrapUp")}</DrawerTitle>
|
<DrawerTitle>{t("wrapup.generateWrapUp")}</DrawerTitle>
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
<div className="space-y-3 p-4 pb-8">
|
<div className="space-y-3 p-4 pb-8">
|
||||||
|
<PeriodPresets onSelect={(s, e) => { setStartDate(s); setEndDate(e) }} />
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>{t("wrapup.startDate")}</Label>
|
<Label>{t("wrapup.startDate")}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -173,3 +175,33 @@ function WrapupPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PeriodPresets({ onSelect }: { onSelect: (start: string, end: string) => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const now = new Date()
|
||||||
|
const fmt = (d: Date) => format(d, "yyyy-MM-dd")
|
||||||
|
|
||||||
|
const currentYear = now.getFullYear()
|
||||||
|
const presets = [
|
||||||
|
{ label: String(currentYear), start: fmt(startOfYear(now)), end: fmt(endOfYear(now)) },
|
||||||
|
{ label: String(currentYear - 1), start: fmt(startOfYear(new Date(currentYear - 1, 0))), end: fmt(endOfYear(new Date(currentYear - 1, 0))) },
|
||||||
|
{ label: t("wrapup.last12Months", { defaultValue: "Last 12 months" }), start: fmt(subMonths(now, 12)), end: fmt(now) },
|
||||||
|
{ label: t("wrapup.last6Months", { defaultValue: "Last 6 months" }), start: fmt(subMonths(now, 6)), end: fmt(now) },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{presets.map((p) => (
|
||||||
|
<Button
|
||||||
|
key={p.label}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onSelect(p.start, p.end)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ function WrapUpReportPage() {
|
|||||||
{Icon && <Icon className="size-4 text-muted-foreground" />}
|
{Icon && <Icon className="size-4 text-muted-foreground" />}
|
||||||
{def ? t(def.labelKey) : wm.medium}
|
{def ? t(def.labelKey) : wm.medium}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">{wm.count} {t("common.films", { count: wm.count })}</span>
|
<span className="text-muted-foreground">{t("common.films", { count: wm.count })}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user