feat: add WatchMedium field, general review editing, configurable deploy
Some checks failed
CI / Check / Test (push) Failing after 27m0s
Some checks failed
CI / Check / Test (push) Failing after 27m0s
- WatchMedium enum (cinema/streaming/tv/physical_media/download/media_server/other) - PATCH /api/v1/reviews/:id partial update (rating, comment, watched_at, watch_medium) - edit_review use case w/ ownership + remote review guard, best-effort AP Update broadcast - SPA: icon picker, edit sheet (long-press mobile / pencil desktop), watch medium badge - shared ReviewFormFields, EditableContextMenu, parse_watched_at/format_watched_at - deploy.sh parameterized (--features, --tag), CORS allows PATCH - CONTEXT.md glossary, ADR-0001 general review editing
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useCallback, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { BookOpen, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { BookOpen, ChevronLeft, ChevronRight, Pencil } from "lucide-react"
|
||||
import { format, startOfMonth, subMonths } from "date-fns"
|
||||
import { EditReviewSheet } from "@/components/edit-review-sheet"
|
||||
import { EditableContextMenu } from "@/components/editable-context-menu"
|
||||
import { MovieCard } from "@/components/movie-card"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { SwipeToDelete } from "@/components/swipe-to-delete"
|
||||
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||
import { VirtualList } from "@/components/virtual-list"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -33,6 +36,7 @@ function DiaryPage() {
|
||||
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteDiary({ sort_by: "desc" })
|
||||
const deleteReview = useDeleteReview()
|
||||
const [editingEntry, setEditingEntry] = useState<DiaryEntryDto | null>(null)
|
||||
|
||||
const monthLabel = format(month, "MMMM yyyy")
|
||||
const monthStr = format(month, "yyyy-MM")
|
||||
@@ -109,17 +113,44 @@ function DiaryPage() {
|
||||
confirmTitle={t("diary.deleteReview")}
|
||||
confirmDescription={`${item.entry.movie.title} — ${item.entry.review.watched_at.slice(0, 10)}`}
|
||||
>
|
||||
<MovieCard
|
||||
movie={item.entry.movie}
|
||||
rating={item.entry.review.rating}
|
||||
comment={item.entry.review.comment}
|
||||
variant="full"
|
||||
/>
|
||||
<EditableContextMenu onEdit={() => setEditingEntry(item.entry)}>
|
||||
<MovieCard
|
||||
movie={item.entry.movie}
|
||||
rating={item.entry.review.rating}
|
||||
comment={item.entry.review.comment}
|
||||
variant="full"
|
||||
action={
|
||||
<div className="flex items-center gap-1">
|
||||
{item.entry.review.watch_medium && (
|
||||
<WatchMediumBadge medium={item.entry.review.watch_medium} />
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden size-7 md:inline-flex"
|
||||
onClick={(e) => { e.preventDefault(); setEditingEntry(item.entry) }}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</EditableContextMenu>
|
||||
</SwipeToDelete>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingEntry && (
|
||||
<EditReviewSheet
|
||||
key={editingEntry.review.id}
|
||||
open={!!editingEntry}
|
||||
onOpenChange={(open) => !open && setEditingEntry(null)}
|
||||
movie={editingEntry.movie}
|
||||
review={editingEntry.review}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import { StarRating } from "@/components/star-rating"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { EditReviewSheet } from "@/components/edit-review-sheet"
|
||||
import { useInfiniteActivityFeed, useDeleteReview } from "@/hooks/use-diary"
|
||||
import type { FeedEntryDto } from "@/lib/api/diary"
|
||||
import { SearchOverlay } from "@/components/search-overlay"
|
||||
import type { MovieSelection } from "@/components/search-overlay"
|
||||
import { useInfiniteWatchlist, useAddToWatchlist, useRemoveFromWatchlist } from "@/hooks/use-watchlist"
|
||||
@@ -66,6 +68,7 @@ function FeedTab() {
|
||||
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteActivityFeed({ sort_by: sortBy })
|
||||
const deleteReview = useDeleteReview()
|
||||
const [editingEntry, setEditingEntry] = useState<FeedEntryDto | null>(null)
|
||||
const items = data?.pages.flatMap((p) => p.items) ?? []
|
||||
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
|
||||
|
||||
@@ -110,6 +113,7 @@ function FeedTab() {
|
||||
isFetching={isFetchingNextPage}
|
||||
onLoadMore={loadMore}
|
||||
renderItem={(entry) => {
|
||||
const isOwn = entry.user_id === auth?.user_id
|
||||
const card = (
|
||||
<ReviewCard
|
||||
movie={entry.movie}
|
||||
@@ -118,9 +122,10 @@ function FeedTab() {
|
||||
userId={entry.user_id}
|
||||
isFederated={entry.is_federated}
|
||||
actorUrl={entry.actor_url}
|
||||
onEdit={isOwn ? () => setEditingEntry(entry) : undefined}
|
||||
/>
|
||||
)
|
||||
return entry.user_id === auth?.user_id ? (
|
||||
return isOwn ? (
|
||||
<SwipeToDelete
|
||||
onDelete={() => deleteReview.mutate(entry.review.id)}
|
||||
confirmTitle={t("feed.deleteReview")}
|
||||
@@ -134,6 +139,16 @@ function FeedTab() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingEntry && (
|
||||
<EditReviewSheet
|
||||
key={editingEntry.review.id}
|
||||
open={!!editingEntry}
|
||||
onOpenChange={(open) => !open && setEditingEntry(null)}
|
||||
movie={editingEntry.movie}
|
||||
review={editingEntry.review}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"
|
||||
import { Bookmark, BookmarkCheck, Globe, Star, TrendingUp, User, Users } from "lucide-react"
|
||||
import { BackButton } from "@/components/back-button"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||
import { RatingHistogram } from "@/components/rating-histogram"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { HorizontalStrip } from "@/components/horizontal-strip"
|
||||
@@ -121,7 +122,10 @@ function MovieDetailPage() {
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[10px]">{timeAgo(r.watched_at)}</CardDescription>
|
||||
</div>
|
||||
<StarDisplay rating={r.rating} size="xs" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StarDisplay rating={r.rating} size="xs" />
|
||||
{r.watch_medium && <WatchMediumBadge medium={r.watch_medium} />}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{r.comment && (
|
||||
|
||||
@@ -113,15 +113,14 @@ function SettingsPage() {
|
||||
|
||||
{isAdmin && <AdminActions />}
|
||||
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleLogout}
|
||||
className="w-full rounded-xl bg-card p-3 text-sm font-medium text-red-400"
|
||||
className="w-full justify-start gap-3 rounded-xl bg-card p-3 text-red-400 hover:text-red-300"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<LogOut className="size-4" />
|
||||
{t("settings.logOut")}
|
||||
</div>
|
||||
</button>
|
||||
<LogOut className="size-4" />
|
||||
{t("settings.logOut")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,9 +68,9 @@ function WebhooksPage() {
|
||||
</Link>
|
||||
<h1 className="text-lg font-bold">{t("webhooks.title")}</h1>
|
||||
</div>
|
||||
<button onClick={() => setOpen(true)} className="text-primary">
|
||||
<Button variant="ghost" size="icon" onClick={() => setOpen(true)} className="text-primary">
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
@@ -97,12 +97,14 @@ function WebhooksPage() {
|
||||
{new Date(t.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove.mutate(t.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user