fix: disable vaul repositionInputs to fix iOS keyboard in drawers
Some checks failed
CI / Check / Test (push) Failing after 6m35s
Some checks failed
CI / Check / Test (push) Failing after 6m35s
This commit is contained in:
17
spa/src/components/back-button.tsx
Normal file
17
spa/src/components/back-button.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useRouter } from "@tanstack/react-router"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
export function BackButton() {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => router.history.back()}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" /> {t("common.back")}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { MovieSelection } from "@/components/search-overlay"
|
||||
import { useLogReview } from "@/hooks/use-diary"
|
||||
import { toast } from "sonner"
|
||||
import { posterUrl } from "@/lib/api/client"
|
||||
import { hapticMedium } from "@/lib/haptics"
|
||||
|
||||
type LogSheetProps = {
|
||||
open: boolean
|
||||
@@ -48,6 +49,7 @@ export function LogSheet({ open, onOpenChange }: LogSheetProps) {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
hapticMedium()
|
||||
toast.success(t("logReview.logged", { title: movie.title }))
|
||||
handleClose()
|
||||
},
|
||||
|
||||
@@ -10,9 +10,10 @@ type MovieCardProps = {
|
||||
comment?: string
|
||||
subtitle?: string
|
||||
variant?: "compact" | "full"
|
||||
action?: React.ReactNode
|
||||
}
|
||||
|
||||
export function MovieCard({ movie, rating, comment, subtitle, variant = "full" }: MovieCardProps) {
|
||||
export function MovieCard({ movie, rating, comment, subtitle, variant = "full", action }: MovieCardProps) {
|
||||
if (variant === "compact") {
|
||||
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">
|
||||
@@ -41,6 +42,7 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full" }
|
||||
{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>}
|
||||
</div>
|
||||
{action && <div className="flex items-center" onClick={(e) => e.preventDefault()}>{action}</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { Globe } from "lucide-react"
|
||||
import { timeAgo } from "@/lib/date"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { posterUrl } from "@/lib/api/client"
|
||||
@@ -32,7 +33,7 @@ export function ReviewCard({ movie, review, userName, userId, isFederated }: Rev
|
||||
)}
|
||||
{isFederated && <Globe className="size-3 text-muted-foreground/60" />}
|
||||
<span>·</span>
|
||||
<span>{review.watched_at.slice(0, 10)}</span>
|
||||
<span>{timeAgo(review.watched_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
<Link to="/movies/$id" params={{ id: movie.id }} className="font-semibold hover:underline">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Star } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { hapticLight } from "@/lib/haptics"
|
||||
|
||||
type StarRatingProps = {
|
||||
value: number
|
||||
@@ -16,7 +17,7 @@ export function StarRating({ value, onChange, size = "lg" }: StarRatingProps) {
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => onChange(star)}
|
||||
onClick={() => { hapticLight(); onChange(star) }}
|
||||
className="transition-transform active:scale-90"
|
||||
>
|
||||
<Star
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useDrag } from "@use-gesture/react"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||||
import { hapticMedium } from "@/lib/haptics"
|
||||
|
||||
type SwipeToDeleteProps = {
|
||||
onDelete: () => void
|
||||
@@ -31,6 +32,7 @@ export function SwipeToDelete({
|
||||
setOffsetX(Math.min(0, Math.max(mx, -100)))
|
||||
} else {
|
||||
if (mx < -60) {
|
||||
hapticMedium()
|
||||
setRevealed(true)
|
||||
setOffsetX(-80)
|
||||
} else {
|
||||
|
||||
@@ -4,9 +4,10 @@ import { Drawer as DrawerPrimitive } from "vaul"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Drawer({
|
||||
repositionInputs = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
return <DrawerPrimitive.Root data-slot="drawer" repositionInputs={repositionInputs} {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
|
||||
19
spa/src/lib/date.ts
Normal file
19
spa/src/lib/date.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { formatDistanceToNow, parseISO, format } from "date-fns"
|
||||
|
||||
export function timeAgo(dateStr: string): string {
|
||||
try {
|
||||
const date = dateStr.includes("T") ? parseISO(dateStr) : new Date(dateStr.replace(" ", "T"))
|
||||
return formatDistanceToNow(date, { addSuffix: true })
|
||||
} catch {
|
||||
return dateStr.slice(0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
export function shortDate(dateStr: string): string {
|
||||
try {
|
||||
const date = dateStr.includes("T") ? parseISO(dateStr) : new Date(dateStr.replace(" ", "T"))
|
||||
return format(date, "MMM d, yyyy")
|
||||
} catch {
|
||||
return dateStr.slice(0, 10)
|
||||
}
|
||||
}
|
||||
7
spa/src/lib/haptics.ts
Normal file
7
spa/src/lib/haptics.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function hapticLight() {
|
||||
navigator?.vibrate?.(10)
|
||||
}
|
||||
|
||||
export function hapticMedium() {
|
||||
navigator?.vibrate?.(20)
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { QueryClient } from "@tanstack/react-query"
|
||||
import { QueryClient, type Mutation } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { ApiError } from "@/lib/api/client"
|
||||
|
||||
function onMutationError(error: Error, _vars: unknown, _ctx: unknown, mutation: Mutation) {
|
||||
if (mutation.options.onError) return
|
||||
const msg = error instanceof ApiError ? `Error ${error.status}` : "Something went wrong"
|
||||
toast.error(msg)
|
||||
}
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@@ -13,5 +20,8 @@ export const queryClient = new QueryClient({
|
||||
return failureCount < 2
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
onError: onMutationError as never,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -67,7 +67,8 @@
|
||||
"removeFromWatchlist": "Remove from watchlist?",
|
||||
"queueEmpty": "Queue empty",
|
||||
"queueEmptyDesc": "Movies from Jellyfin/Plex appear here",
|
||||
"deleteReview": "Delete this review?"
|
||||
"deleteReview": "Delete this review?",
|
||||
"addedToWatchlist": "Added to watchlist"
|
||||
},
|
||||
"diary": {
|
||||
"title": "Diary",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useCallback, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Clapperboard, Film, Inbox, Plus } from "lucide-react"
|
||||
import { Clapperboard, Film, Inbox, Plus, RefreshCw } from "lucide-react"
|
||||
import { ReviewCard } from "@/components/review-card"
|
||||
import { MovieCard } from "@/components/movie-card"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
@@ -13,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StarRating } from "@/components/star-rating"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useInfiniteActivityFeed, useDeleteReview } from "@/hooks/use-diary"
|
||||
import { SearchOverlay } from "@/components/search-overlay"
|
||||
import type { MovieSelection } from "@/components/search-overlay"
|
||||
@@ -52,6 +53,8 @@ function HomePage() {
|
||||
function FeedTab() {
|
||||
const { t } = useTranslation()
|
||||
const { auth } = useAuth()
|
||||
const qc = useQueryClient()
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [sortBy, setSortBy] = useState("date")
|
||||
const feedSortOptions = [
|
||||
{ value: "date", label: t("feed.sortLatest") },
|
||||
@@ -67,7 +70,19 @@ function FeedTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={async () => {
|
||||
setRefreshing(true)
|
||||
await qc.refetchQueries({ queryKey: ["activity-feed"] })
|
||||
setRefreshing(false)
|
||||
}}
|
||||
>
|
||||
<RefreshCw className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ArrowLeft, Bookmark, BookmarkCheck, Globe, Star, TrendingUp, User, Users } from "lucide-react"
|
||||
import { Bookmark, BookmarkCheck, Globe, Star, TrendingUp, User, Users } from "lucide-react"
|
||||
import { BackButton } from "@/components/back-button"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { RatingHistogram } from "@/components/rating-histogram"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
@@ -9,6 +10,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { posterUrl, tmdbProfileUrl } from "@/lib/api/client"
|
||||
import { timeAgo, shortDate } from "@/lib/date"
|
||||
import { useMovie, useMovieHistory, useMovieProfile } from "@/hooks/use-movies"
|
||||
import {
|
||||
useWatchlistStatus,
|
||||
@@ -36,9 +38,7 @@ function MovieDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5 p-4">
|
||||
<Link to="/" className="inline-flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<ArrowLeft className="size-4" /> {t("common.back")}
|
||||
</Link>
|
||||
<BackButton />
|
||||
|
||||
<HeroSection movie={movie} stats={stats} movieId={id} tagline={profile?.tagline} />
|
||||
|
||||
@@ -116,7 +116,7 @@ function MovieDetailPage() {
|
||||
{r.user_display}
|
||||
{r.is_federated && <Globe className="size-3 text-muted-foreground/60" />}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[10px]">{r.watched_at.slice(0, 10)}</CardDescription>
|
||||
<CardDescription className="text-[10px]">{timeAgo(r.watched_at)}</CardDescription>
|
||||
</div>
|
||||
<StarDisplay rating={r.rating} size="xs" />
|
||||
</div>
|
||||
@@ -145,7 +145,7 @@ function MovieDetailPage() {
|
||||
{history.viewings.map((v) => (
|
||||
<div key={v.id} className="flex items-center justify-between rounded-xl bg-card p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{v.watched_at}</p>
|
||||
<p className="text-sm font-medium">{shortDate(v.watched_at)}</p>
|
||||
{v.comment && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">{v.comment}</p>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ArrowLeft, Film, User } from "lucide-react"
|
||||
import { Film, User } from "lucide-react"
|
||||
import { BackButton } from "@/components/back-button"
|
||||
import { MovieCard } from "@/components/movie-card"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -23,9 +24,7 @@ function PersonDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<Link to="/" className="inline-flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<ArrowLeft className="size-4" /> {t("common.back")}
|
||||
</Link>
|
||||
<BackButton />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="size-16 flex-shrink-0 overflow-hidden rounded-full bg-muted">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useCallback, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Search as SearchIcon, Film, Users } from "lucide-react"
|
||||
import { Bookmark, Search as SearchIcon, Film, Users } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { MovieCard } from "@/components/movie-card"
|
||||
import { PersonRow } from "@/components/person-row"
|
||||
@@ -10,6 +11,8 @@ import { InfiniteScroll } from "@/components/infinite-scroll"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useInfiniteSearch } from "@/hooks/use-search"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { useAddToWatchlist } from "@/hooks/use-watchlist"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export const Route = createFileRoute("/_app/search")({
|
||||
component: SearchPage,
|
||||
@@ -17,6 +20,7 @@ export const Route = createFileRoute("/_app/search")({
|
||||
|
||||
function SearchPage() {
|
||||
const { t } = useTranslation()
|
||||
const addToWatchlist = useAddToWatchlist()
|
||||
const [query, setQuery] = useState("")
|
||||
const debouncedQuery = useDebounce(query, 300)
|
||||
const {
|
||||
@@ -87,6 +91,21 @@ function SearchPage() {
|
||||
genres: hit.genres,
|
||||
}}
|
||||
variant="full"
|
||||
action={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground"
|
||||
onClick={() => {
|
||||
addToWatchlist.mutate(
|
||||
{ movie_id: hit.movie_id },
|
||||
{ onSuccess: () => toast.success(t("feed.addedToWatchlist")) },
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Bookmark className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ArrowLeft, UserCheck, UserPlus } from "lucide-react"
|
||||
import { UserCheck, UserPlus } from "lucide-react"
|
||||
import { BackButton } from "@/components/back-button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
@@ -28,9 +29,7 @@ function UserProfilePage() {
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Link to="/" className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<ArrowLeft className="size-4" /> {t("common.back")}
|
||||
</Link>
|
||||
<div className="mb-4"><BackButton /></div>
|
||||
|
||||
<ProfileView
|
||||
data={data}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ArrowLeft, Star, Users } from "lucide-react"
|
||||
import { Star, Users } from "lucide-react"
|
||||
import { BackButton } from "@/components/back-button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -26,9 +27,7 @@ function WrapUpReportPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<Link to="/profile" className="inline-flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<ArrowLeft className="size-4" /> {t("profile.title")}
|
||||
</Link>
|
||||
<BackButton />
|
||||
|
||||
{/* Hero */}
|
||||
<Card>
|
||||
|
||||
Reference in New Issue
Block a user