feat: auto-grow comment textarea, review detail sheet for full text
- textarea grows as you type instead of fixed 3 rows - tap truncated comment to open detail sheet with full selectable text - review detail sheet shows poster, rating, medium, date, full comment
This commit is contained in:
@@ -17,9 +17,10 @@ type ReviewCardProps = {
|
|||||||
isFederated?: boolean
|
isFederated?: boolean
|
||||||
actorUrl?: string
|
actorUrl?: string
|
||||||
onEdit?: () => void
|
onEdit?: () => void
|
||||||
|
onShowDetail?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewCard({ movie, review, userName, userId, isFederated, actorUrl, onEdit }: ReviewCardProps) {
|
export function ReviewCard({ movie, review, userName, userId, isFederated, actorUrl, onEdit, onShowDetail }: ReviewCardProps) {
|
||||||
const card = (
|
const card = (
|
||||||
<Card size="sm">
|
<Card size="sm">
|
||||||
<CardContent className="flex gap-3">
|
<CardContent className="flex gap-3">
|
||||||
@@ -59,7 +60,17 @@ export function ReviewCard({ movie, review, userName, userId, isFederated, actor
|
|||||||
<StarDisplay rating={review.rating} />
|
<StarDisplay rating={review.rating} />
|
||||||
{review.watch_medium && <WatchMediumBadge medium={review.watch_medium} />}
|
{review.watch_medium && <WatchMediumBadge medium={review.watch_medium} />}
|
||||||
</div>
|
</div>
|
||||||
{review.comment && <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{review.comment}</p>}
|
{review.comment && (
|
||||||
|
<p
|
||||||
|
className="mt-1 line-clamp-2 text-xs text-muted-foreground"
|
||||||
|
role={onShowDetail ? "button" : undefined}
|
||||||
|
tabIndex={onShowDetail ? 0 : undefined}
|
||||||
|
onClick={onShowDetail}
|
||||||
|
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail() : undefined}
|
||||||
|
>
|
||||||
|
{review.comment}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
51
spa/src/components/review-detail-sheet.tsx
Normal file
51
spa/src/components/review-detail-sheet.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { VisuallyHidden } from "radix-ui"
|
||||||
|
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
|
||||||
|
import { StarDisplay } from "@/components/star-display"
|
||||||
|
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||||
|
import { shortDate } from "@/lib/date"
|
||||||
|
import { posterUrl } from "@/lib/api/client"
|
||||||
|
import type { MovieDto, ReviewDto } from "@/lib/api/common"
|
||||||
|
|
||||||
|
type ReviewDetailSheetProps = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
movie: MovieDto
|
||||||
|
review: ReviewDto
|
||||||
|
userName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewDetailSheet({ open, onOpenChange, movie, review, userName }: ReviewDetailSheetProps) {
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DrawerContent className="mx-auto max-w-lg">
|
||||||
|
<VisuallyHidden.Root><DrawerTitle>{movie.title}</DrawerTitle></VisuallyHidden.Root>
|
||||||
|
<div className="p-5 pb-8">
|
||||||
|
<div className="mb-4 flex gap-3">
|
||||||
|
<div className="h-24 w-16 flex-shrink-0 overflow-hidden rounded-lg bg-muted">
|
||||||
|
{movie.poster_path && <img src={posterUrl(movie.poster_path)} alt="" className="size-full object-cover" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold">{movie.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{movie.release_year}{movie.director && ` · ${movie.director}`}
|
||||||
|
</p>
|
||||||
|
{userName && <p className="mt-1 text-xs text-muted-foreground">{userName}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<StarDisplay rating={review.rating} />
|
||||||
|
{review.watch_medium && <WatchMediumBadge medium={review.watch_medium} />}
|
||||||
|
<span className="text-xs text-muted-foreground">{shortDate(review.watched_at)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{review.comment && (
|
||||||
|
<p className="select-text whitespace-pre-wrap text-sm leading-relaxed">
|
||||||
|
{review.comment}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useCallback, useRef } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { CalendarIcon } from "lucide-react"
|
import { CalendarIcon } from "lucide-react"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
@@ -38,7 +39,7 @@ export function ReviewFormFields({
|
|||||||
<div className="flex justify-center"><StarRating value={rating} onChange={onRatingChange} /></div>
|
<div className="flex justify-center"><StarRating value={rating} onChange={onRatingChange} /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Textarea value={comment} onChange={(e) => onCommentChange(e.target.value)} placeholder={t("logReview.commentPlaceholder")} className="mb-5" rows={3} />
|
<AutoGrowTextarea value={comment} onChange={onCommentChange} placeholder={t("logReview.commentPlaceholder")} className="mb-5" />
|
||||||
|
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">{t("logReview.watchedAt")}</p>
|
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">{t("logReview.watchedAt")}</p>
|
||||||
@@ -68,3 +69,38 @@ export function ReviewFormFields({
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AutoGrowTextarea({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const handleInput = useCallback(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = "auto"
|
||||||
|
el.style.height = `${el.scrollHeight}px`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value)
|
||||||
|
handleInput()
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={className}
|
||||||
|
rows={2}
|
||||||
|
style={{ overflow: "hidden", resize: "none" }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { StarRating } from "@/components/star-rating"
|
|||||||
import { useAuth } from "@/components/auth-provider"
|
import { useAuth } from "@/components/auth-provider"
|
||||||
import { useQueryClient } from "@tanstack/react-query"
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
import { EditReviewSheet } from "@/components/edit-review-sheet"
|
import { EditReviewSheet } from "@/components/edit-review-sheet"
|
||||||
|
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
|
||||||
import { useInfiniteActivityFeed, useDeleteReview } from "@/hooks/use-diary"
|
import { useInfiniteActivityFeed, useDeleteReview } from "@/hooks/use-diary"
|
||||||
import type { FeedEntryDto } from "@/lib/api/diary"
|
import type { FeedEntryDto } from "@/lib/api/diary"
|
||||||
import { SearchOverlay } from "@/components/search-overlay"
|
import { SearchOverlay } from "@/components/search-overlay"
|
||||||
@@ -69,6 +70,7 @@ function FeedTab() {
|
|||||||
useInfiniteActivityFeed({ sort_by: sortBy })
|
useInfiniteActivityFeed({ sort_by: sortBy })
|
||||||
const deleteReview = useDeleteReview()
|
const deleteReview = useDeleteReview()
|
||||||
const [editingEntry, setEditingEntry] = useState<FeedEntryDto | null>(null)
|
const [editingEntry, setEditingEntry] = useState<FeedEntryDto | null>(null)
|
||||||
|
const [detailEntry, setDetailEntry] = useState<FeedEntryDto | null>(null)
|
||||||
const items = data?.pages.flatMap((p) => p.items) ?? []
|
const items = data?.pages.flatMap((p) => p.items) ?? []
|
||||||
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
|
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
|
||||||
|
|
||||||
@@ -123,6 +125,7 @@ function FeedTab() {
|
|||||||
isFederated={entry.is_federated}
|
isFederated={entry.is_federated}
|
||||||
actorUrl={entry.actor_url}
|
actorUrl={entry.actor_url}
|
||||||
onEdit={isOwn ? () => setEditingEntry(entry) : undefined}
|
onEdit={isOwn ? () => setEditingEntry(entry) : undefined}
|
||||||
|
onShowDetail={entry.review.comment ? () => setDetailEntry(entry) : undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
return isOwn ? (
|
return isOwn ? (
|
||||||
@@ -149,6 +152,16 @@ function FeedTab() {
|
|||||||
review={editingEntry.review}
|
review={editingEntry.review}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{detailEntry && (
|
||||||
|
<ReviewDetailSheet
|
||||||
|
open={!!detailEntry}
|
||||||
|
onOpenChange={(open) => !open && setDetailEntry(null)}
|
||||||
|
movie={detailEntry.movie}
|
||||||
|
review={detailEntry.review}
|
||||||
|
userName={detailEntry.user_display_name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user