A personal media tracker built on the AT Protocol opnshelf.xyz
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: unify media cards with M3 UI

Replace movie, show, shelf, and list card variants with
`MediaPosterCard`

Move search and shelf actions onto shared M3 buttons,
dialogs, and date picker flows

Remove legacy shadcn-style UI pieces and `react-day-picker`
while aligning theme tokens with Material 3

+898 -1784
-1
apps/web/package.json
··· 34 34 "posthog-js": "^1.356.1", 35 35 "radix-ui": "^1.4.3", 36 36 "react": "^19.2.0", 37 - "react-day-picker": "^9.13.1", 38 37 "react-dom": "^19.2.0", 39 38 "sonner": "^2.0.7", 40 39 "tailwind-merge": "^3.0.2",
+1 -1
apps/web/src/components/AddToListModal.tsx
··· 100 100 101 101 return ( 102 102 <Dialog open={open} onOpenChange={onOpenChange}> 103 - <DialogContent className="bg-(--md-sys-color-surface-container-high) border-(--md-sys-color-outline) text-(--md-sys-color-on-surface) max-w-md rounded-[1.75rem]"> 103 + <DialogContent className="bg-(--md-sys-color-surface-container-high) border-(--md-sys-color-outline) text-(--md-sys-color-on-surface) max-w-md rounded-xl"> 104 104 <DialogHeader> 105 105 <DialogTitle className="text-(--md-sys-color-on-surface)"> 106 106 Manage Lists
+2 -2
apps/web/src/components/AddToShelfButton.tsx
··· 33 33 type="button" 34 34 onClick={onClick} 35 35 disabled={disabled || isPending} 36 - className={`w-full rounded-xl m3-label-large transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-70 ${sizeClasses} ${className}`} 36 + className={`w-full rounded-full m3-label-large transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-70 ${sizeClasses} ${className}`} 37 37 style={{ 38 38 background: `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 39 - boxShadow: `0 15px 35px -10px ${colors.primary}60`, 39 + boxShadow: `0 15px 35px -10px color-mix(in srgb, ${colors.primary} 38%, transparent)`, 40 40 color: "var(--md-sys-color-on-primary)", 41 41 }} 42 42 >
+1 -1
apps/web/src/components/ConfirmDialog.tsx
··· 38 38 39 39 return ( 40 40 <Dialog open={open} onOpenChange={onOpenChange}> 41 - <DialogContent className="bg-(--md-sys-color-surface-container-high) border-(--md-sys-color-outline) text-(--md-sys-color-on-surface) rounded-[1.75rem]"> 41 + <DialogContent className="bg-(--md-sys-color-surface-container-high) border-(--md-sys-color-outline) text-(--md-sys-color-on-surface) rounded-xl"> 42 42 <DialogHeader> 43 43 <DialogTitle className="text-(--md-sys-color-on-surface)"> 44 44 {title}
+6 -10
apps/web/src/components/DatePickerModal.tsx
··· 12 12 import { X } from "lucide-react"; 13 13 import { useEffect, useState } from "react"; 14 14 import { toast } from "sonner"; 15 - import { LoadingButton } from "@/components/ui/loading-button"; 16 15 import { M3Button } from "@/components/ui/m3-button"; 17 16 import { MaterialDatePicker } from "@/components/ui/material-date-picker"; 18 17 import { TimePicker } from "@/components/ui/time-picker"; ··· 244 243 245 244 return ( 246 245 <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"> 247 - <div className="bg-(--md-sys-color-surface-container-high) rounded-[1.75rem] p-6 max-w-sm w-full"> 246 + <div className="bg-(--md-sys-color-surface-container-high) rounded-xl p-6 max-w-sm w-full"> 248 247 <div className="flex justify-between items-center mb-6"> 249 248 <h3 className="text-xl font-semibold text-(--md-sys-color-on-surface)"> 250 249 {modalTitle || "Select date"} ··· 274 273 > 275 274 Cancel 276 275 </M3Button> 277 - <LoadingButton 276 + <M3Button 278 277 type="button" 278 + variant="filled" 279 279 onClick={handleSubmit} 280 - disabled={ 281 - !customDate || 282 - markMutation.isPending || 283 - markEpisodeMutation.isPending 284 - } 285 - className="flex-1 bg-(--md-sys-color-primary) hover:bg-(--md-sys-color-primary)/90" 280 + disabled={!customDate} 286 281 isLoading={ 287 282 markMutation.isPending || markEpisodeMutation.isPending 288 283 } 284 + className="flex-1" 289 285 > 290 286 Add Watch 291 - </LoadingButton> 287 + </M3Button> 292 288 </div> 293 289 </div> 294 290 </div>
+2 -2
apps/web/src/components/GenresSection.tsx
··· 25 25 key={genre.id} 26 26 className="px-4 py-2 rounded-full text-sm font-medium" 27 27 style={{ 28 - backgroundColor: `${colors.primary}20`, 28 + backgroundColor: `color-mix(in srgb, ${colors.primary} 12%, transparent)`, 29 29 color: colors.accent, 30 - border: `1px solid ${colors.primary}40`, 30 + border: `1px solid color-mix(in srgb, ${colors.primary} 25%, transparent)`, 31 31 }} 32 32 > 33 33 {genre.name}
+6 -6
apps/web/src/components/Header.tsx
··· 87 87 backgroundColor: "var(--md-sys-color-surface)", 88 88 borderColor: "var(--md-sys-color-outline-variant)", 89 89 boxShadow: 90 - "0 18px 40px rgba(0, 0, 0, 0.28), inset 0 -1px 0 rgba(255, 255, 255, 0.02)", 90 + "0 18px 40px color-mix(in srgb, var(--md-sys-color-scrim) 28%, transparent), inset 0 -1px 0 color-mix(in srgb, var(--md-sys-color-on-surface) 2%, transparent)", 91 91 }} 92 92 > 93 93 <div ··· 138 138 return ( 139 139 <Link to="/" className="group flex items-center gap-3"> 140 140 <div 141 - className="flex size-10 items-center justify-center rounded-[18px] border transition-transform duration-300 group-hover:scale-[1.04]" 141 + className="flex size-10 items-center justify-center rounded-lg border transition-transform duration-300 group-hover:scale-[1.04]" 142 142 style={{ 143 143 backgroundColor: "var(--md-sys-color-surface-container-high)", 144 144 borderColor: "var(--md-sys-color-outline-variant)", ··· 297 297 <PopoverContent 298 298 align="end" 299 299 sideOffset={10} 300 - className="w-[20rem] rounded-[24px] border p-2" 300 + className="w-[20rem] rounded-xl border p-2" 301 301 style={{ 302 302 backgroundColor: "var(--md-sys-color-surface-container-high)", 303 303 borderColor: "var(--md-sys-color-outline-variant)", 304 304 }} 305 305 > 306 306 <div 307 - className="mb-2 flex items-center gap-3 rounded-[18px] border px-3 py-3" 307 + className="mb-2 flex items-center gap-3 rounded-lg border px-3 py-3" 308 308 style={{ 309 309 backgroundColor: "var(--md-sys-color-surface-container-low)", 310 310 borderColor: "var(--md-sys-color-outline-variant)", ··· 365 365 type="button" 366 366 onClick={onLogout} 367 367 disabled={isLoggingOut} 368 - className="flex w-full items-center gap-3 rounded-[18px] px-3 py-3 text-left transition-colors hover:bg-(--md-sys-color-surface-container-low) disabled:opacity-60" 368 + className="flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-(--md-sys-color-surface-container-low) disabled:opacity-60" 369 369 style={{ color: "var(--md-sys-color-on-surface)" }} 370 370 > 371 371 <LogOut className="size-4" /> ··· 392 392 <Link 393 393 {...target} 394 394 onClick={onSelect} 395 - className="flex items-center gap-3 rounded-[18px] px-3 py-3 transition-colors hover:bg-(--md-sys-color-surface-container-low)" 395 + className="flex items-center gap-3 rounded-lg px-3 py-3 transition-colors hover:bg-(--md-sys-color-surface-container-low)" 396 396 style={{ color: "var(--md-sys-color-on-surface)" }} 397 397 > 398 398 <Icon className="size-4" />
+180
apps/web/src/components/MediaPosterCard.tsx
··· 1 + import type { UserDto } from "@opnshelf/api"; 2 + import { Link } from "@tanstack/react-router"; 3 + import { Bookmark, Check, Plus, Trash2, X } from "lucide-react"; 4 + import { useState } from "react"; 5 + import { AddToListModal } from "@/components/AddToListModal"; 6 + import { M3Button } from "@/components/ui/m3-button"; 7 + import { cn, getTmdbPosterUrl } from "@/lib/utils"; 8 + 9 + interface MediaPosterCardProps { 10 + posterPath?: string | null; 11 + title: string; 12 + subtitle?: string; 13 + badge?: string; 14 + 15 + to: string; 16 + params: Record<string, string>; 17 + 18 + isOnShelf?: boolean; 19 + onToggleShelf?: () => void; 20 + isShelfPending?: boolean; 21 + 22 + listMedia?: { type: "movie" | "show"; id: string; title: string }; 23 + 24 + onRemove?: () => void; 25 + isRemoving?: boolean; 26 + removeIcon?: "trash" | "x"; 27 + 28 + readOnly?: boolean; 29 + user?: UserDto | null; 30 + className?: string; 31 + } 32 + 33 + const HOVER_REVEAL = 34 + "[@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 focus-visible:opacity-100 group-focus-within:opacity-100 transition-opacity"; 35 + 36 + export function MediaPosterCard({ 37 + posterPath, 38 + title, 39 + subtitle, 40 + badge, 41 + to, 42 + params, 43 + isOnShelf, 44 + onToggleShelf, 45 + isShelfPending = false, 46 + listMedia, 47 + onRemove, 48 + isRemoving = false, 49 + removeIcon = "trash", 50 + readOnly = false, 51 + user, 52 + className, 53 + }: MediaPosterCardProps) { 54 + const [listModalOpen, setListModalOpen] = useState(false); 55 + const posterUrl = getTmdbPosterUrl(posterPath); 56 + const showActions = !readOnly && !!user; 57 + const RemoveIcon = removeIcon === "x" ? X : Trash2; 58 + 59 + return ( 60 + <div className={cn("group", className)}> 61 + <Link 62 + to={to as never} 63 + params={params as never} 64 + className="block relative aspect-2/3 rounded-lg overflow-hidden mb-2" 65 + style={{ 66 + backgroundColor: "var(--md-sys-color-surface-container-high)", 67 + }} 68 + > 69 + {posterUrl ? ( 70 + <img 71 + src={posterUrl} 72 + alt={title} 73 + className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105" 74 + /> 75 + ) : ( 76 + <div 77 + className="w-full h-full flex items-center justify-center md-body-medium" 78 + style={{ color: "var(--md-sys-color-on-surface-variant)" }} 79 + > 80 + No poster 81 + </div> 82 + )} 83 + 84 + {badge && ( 85 + <div className="absolute inset-x-0 bottom-0 bg-linear-to-t from-black/80 to-transparent p-3"> 86 + <div className="text-white text-sm font-medium">{badge}</div> 87 + </div> 88 + )} 89 + 90 + {showActions && ( 91 + <div className="absolute top-2 right-2 z-10 flex items-center gap-1.5"> 92 + {onToggleShelf && ( 93 + <M3Button 94 + type="button" 95 + size="icon" 96 + variant="filled" 97 + onClick={(e) => { 98 + e.preventDefault(); 99 + e.stopPropagation(); 100 + onToggleShelf(); 101 + }} 102 + isLoading={isShelfPending} 103 + className={cn( 104 + "shadow-lg ring-1 ring-black/10", 105 + isOnShelf 106 + ? "bg-(--md-sys-color-tertiary) hover:bg-(--md-sys-color-error)" 107 + : HOVER_REVEAL, 108 + )} 109 + > 110 + {isOnShelf ? ( 111 + <Check className="size-5" /> 112 + ) : ( 113 + <Plus className="size-5" /> 114 + )} 115 + </M3Button> 116 + )} 117 + 118 + {listMedia && ( 119 + <M3Button 120 + type="button" 121 + size="icon" 122 + variant="filled-tonal" 123 + onClick={(e) => { 124 + e.preventDefault(); 125 + e.stopPropagation(); 126 + setListModalOpen(true); 127 + }} 128 + className={cn("shadow-lg ring-1 ring-black/10", HOVER_REVEAL)} 129 + > 130 + <Bookmark className="size-5" /> 131 + </M3Button> 132 + )} 133 + 134 + {onRemove && ( 135 + <M3Button 136 + type="button" 137 + size="icon" 138 + variant="destructive" 139 + onClick={(e) => { 140 + e.preventDefault(); 141 + e.stopPropagation(); 142 + onRemove(); 143 + }} 144 + isLoading={isRemoving} 145 + className={cn("shadow-lg ring-1 ring-black/10", HOVER_REVEAL)} 146 + > 147 + <RemoveIcon className="size-5" /> 148 + </M3Button> 149 + )} 150 + </div> 151 + )} 152 + </Link> 153 + 154 + <Link to={to as never} params={params as never} className="block"> 155 + <h3 className="font-semibold text-sm line-clamp-2 mb-1 transition-colors hover:text-(--md-sys-color-primary)"> 156 + {title} 157 + </h3> 158 + {subtitle && ( 159 + <p 160 + className="text-sm" 161 + style={{ color: "var(--md-sys-color-on-surface-variant)" }} 162 + > 163 + {subtitle} 164 + </p> 165 + )} 166 + </Link> 167 + 168 + {listMedia && user && ( 169 + <AddToListModal 170 + open={listModalOpen} 171 + onOpenChange={setListModalOpen} 172 + mediaType={listMedia.type} 173 + mediaId={listMedia.id} 174 + mediaTitle={listMedia.title} 175 + user={user} 176 + /> 177 + )} 178 + </div> 179 + ); 180 + }
-186
apps/web/src/components/MovieCard.tsx
··· 1 - import { 2 - moviesControllerGetUserMoviesQueryKey, 3 - moviesControllerMarkWatchedMutation, 4 - moviesControllerUnmarkWatchedMutation, 5 - type UserDto, 6 - } from "@opnshelf/api"; 7 - import { useMutation, useQueryClient } from "@tanstack/react-query"; 8 - import { Link } from "@tanstack/react-router"; 9 - import { Check, Loader2, Plus } from "lucide-react"; 10 - import { toast } from "sonner"; 11 - import { Button } from "@/components/ui/button"; 12 - import { 13 - Tooltip, 14 - TooltipContent, 15 - TooltipProvider, 16 - TooltipTrigger, 17 - } from "@/components/ui/tooltip"; 18 - import { invalidateUserShelfQueries } from "@/lib/invalidate-shelf"; 19 - import { createTitleSlug, getTmdbPosterUrl } from "@/lib/utils"; 20 - 21 - export interface MovieCardData { 22 - id: number; 23 - title: string; 24 - poster_path?: string | null; 25 - release_date?: string | null; 26 - releaseYear?: number | null; 27 - } 28 - 29 - interface MovieCardProps { 30 - movie: MovieCardData; 31 - user: UserDto | null | undefined; 32 - isWatched: boolean; 33 - showActions?: boolean; 34 - } 35 - 36 - export function MovieCard({ 37 - movie, 38 - user, 39 - isWatched, 40 - showActions = true, 41 - }: MovieCardProps) { 42 - const queryClient = useQueryClient(); 43 - const movieId = movie.id.toString(); 44 - 45 - const markMutation = useMutation({ 46 - mutationKey: ["movies", movieId, "markWatched"], 47 - ...moviesControllerMarkWatchedMutation(), 48 - onSuccess: () => { 49 - queryClient.invalidateQueries({ 50 - queryKey: moviesControllerGetUserMoviesQueryKey({ 51 - path: { userDid: user?.did || "" }, 52 - }), 53 - }); 54 - invalidateUserShelfQueries(queryClient, user?.did); 55 - toast.success("Added to your shelf"); 56 - }, 57 - onError: () => { 58 - toast.error("Failed to update. Please try again."); 59 - }, 60 - }); 61 - 62 - const unmarkMutation = useMutation({ 63 - mutationKey: ["movies", movieId, "unmarkWatched"], 64 - ...moviesControllerUnmarkWatchedMutation(), 65 - onSuccess: () => { 66 - queryClient.invalidateQueries({ 67 - queryKey: moviesControllerGetUserMoviesQueryKey({ 68 - path: { userDid: user?.did || "" }, 69 - }), 70 - }); 71 - invalidateUserShelfQueries(queryClient, user?.did); 72 - toast.success("Removed from your shelf"); 73 - }, 74 - onError: () => { 75 - toast.error("Failed to update. Please try again."); 76 - }, 77 - }); 78 - 79 - const isMarkPending = 80 - markMutation.isPending && markMutation.variables?.body?.movieId === movieId; 81 - const isUnmarkPending = 82 - unmarkMutation.isPending && 83 - unmarkMutation.variables?.path?.movieId === movieId; 84 - const isPending = isMarkPending || isUnmarkPending; 85 - 86 - const posterUrl = getTmdbPosterUrl(movie.poster_path); 87 - const releaseYear = movie.release_date 88 - ? movie.release_date.split("-")[0] 89 - : movie.releaseYear; 90 - 91 - return ( 92 - <div className="group"> 93 - <Link 94 - to="/movies/$movieId/$title" 95 - params={{ 96 - movieId: movieId, 97 - title: createTitleSlug(movie.title), 98 - }} 99 - className="block relative aspect-2/3 rounded-lg overflow-hidden mb-2" 100 - style={{ 101 - backgroundColor: "var(--md-sys-color-surface-container-high)", 102 - }} 103 - > 104 - {posterUrl ? ( 105 - <img 106 - src={posterUrl} 107 - alt={movie.title} 108 - className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105" 109 - /> 110 - ) : ( 111 - <div 112 - className="w-full h-full flex items-center justify-center md-body-medium" 113 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 114 - > 115 - No poster 116 - </div> 117 - )} 118 - {showActions && user && ( 119 - <div className="absolute top-2 right-2 z-10"> 120 - <TooltipProvider> 121 - <Tooltip> 122 - <TooltipTrigger asChild> 123 - <Button 124 - type="button" 125 - size="icon" 126 - variant="default" 127 - onClick={(e) => { 128 - e.preventDefault(); 129 - e.stopPropagation(); 130 - if (isWatched) { 131 - unmarkMutation.mutate({ 132 - path: { movieId }, 133 - }); 134 - } else { 135 - markMutation.mutate({ 136 - body: { movieId }, 137 - }); 138 - } 139 - }} 140 - disabled={isPending} 141 - className={`${ 142 - isWatched 143 - ? "bg-green-600 hover:bg-red-600" 144 - : "bg-primary hover:bg-primary/80 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100" 145 - } transition-opacity`} 146 - > 147 - {isPending ? ( 148 - <Loader2 className="w-4 h-4 animate-spin" /> 149 - ) : isWatched ? ( 150 - <Check className="w-4 h-4" /> 151 - ) : ( 152 - <Plus className="w-4 h-4" /> 153 - )} 154 - </Button> 155 - </TooltipTrigger> 156 - <TooltipContent> 157 - <p>{isWatched ? "Remove from shelf" : "Mark as watched"}</p> 158 - </TooltipContent> 159 - </Tooltip> 160 - </TooltipProvider> 161 - </div> 162 - )} 163 - </Link> 164 - <Link 165 - to="/movies/$movieId/$title" 166 - params={{ 167 - movieId: movieId, 168 - title: createTitleSlug(movie.title), 169 - }} 170 - className="block" 171 - > 172 - <h3 className="font-semibold text-sm line-clamp-2 mb-1 transition-colors hover:text-(--md-sys-color-primary)"> 173 - {movie.title} 174 - </h3> 175 - {releaseYear && ( 176 - <p 177 - className="text-sm" 178 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 179 - > 180 - {releaseYear} 181 - </p> 182 - )} 183 - </Link> 184 - </div> 185 - ); 186 - }
+2 -41
apps/web/src/components/MovieGrid.tsx
··· 1 - import type { UserDto } from "@opnshelf/api"; 2 1 import { Skeleton } from "@/components/ui/skeleton"; 3 - import { cn } from "@/lib/utils"; 4 - import type { MovieCardData } from "./MovieCard"; 5 - import { MovieCard } from "./MovieCard"; 6 2 7 - interface MovieGridProps { 8 - movies: MovieCardData[]; 9 - user: UserDto | null | undefined; 10 - watchedMovieIds: Set<string>; 11 - showActions?: boolean; 12 - gridClassName?: string; 13 - } 14 - 15 - export function MovieGrid({ 16 - movies, 17 - user, 18 - watchedMovieIds, 19 - showActions = true, 20 - gridClassName, 21 - }: MovieGridProps) { 22 - return ( 23 - <div 24 - className={cn( 25 - "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4", 26 - gridClassName, 27 - )} 28 - > 29 - {movies.map((movie) => ( 30 - <MovieCard 31 - key={movie.id} 32 - movie={movie} 33 - user={user} 34 - isWatched={watchedMovieIds.has(movie.id.toString())} 35 - showActions={showActions} 36 - /> 37 - ))} 38 - </div> 39 - ); 40 - } 41 - 42 - interface MovieGridSkeletonProps { 3 + interface PosterGridSkeletonProps { 43 4 count?: number; 44 5 } 45 6 46 - export function MovieGridSkeleton({ count = 10 }: MovieGridSkeletonProps) { 7 + export function PosterGridSkeleton({ count = 10 }: PosterGridSkeletonProps) { 47 8 return ( 48 9 <div className="grid grid-cols-2 sm:grid-cols-3 gap-4"> 49 10 {Array.from({ length: count }, (_, i) => i).map((index) => (
+10 -10
apps/web/src/components/MovieHero.tsx
··· 24 24 : null; 25 25 26 26 const colors = movie?.colors || { 27 - primary: "#F59E0B", 28 - secondary: "#D97706", 29 - accent: "#FBBF24", 30 - muted: "#92400E", 27 + primary: "var(--md-sys-color-primary)", 28 + secondary: "var(--md-sys-color-primary-container)", 29 + accent: "var(--md-sys-color-on-primary-container)", 30 + muted: "var(--md-sys-color-surface-container)", 31 31 }; 32 32 33 33 if (isLoading || !movie) { ··· 36 36 <div 37 37 className="w-full h-full animate-pulse" 38 38 style={{ 39 - background: `linear-gradient(135deg, #1a1a2e 0%, #0f0f1a 100%)`, 39 + background: `linear-gradient(135deg, var(--md-sys-color-surface-container) 0%, var(--md-sys-color-surface) 100%)`, 40 40 }} 41 41 /> 42 42 <div className="absolute bottom-0 left-0 right-0 p-4 md:p-8"> ··· 67 67 <div 68 68 className="absolute inset-0" 69 69 style={{ 70 - background: `linear-gradient(to bottom, transparent 0%, rgba(3, 7, 18, 0.6) 60%, rgb(3, 7, 18) 100%)`, 70 + background: `linear-gradient(to bottom, transparent 0%, color-mix(in srgb, var(--md-sys-color-surface) 60%, transparent) 60%, var(--md-sys-color-surface) 100%)`, 71 71 }} 72 72 /> 73 73 <div 74 74 className="absolute inset-0" 75 75 style={{ 76 - background: `linear-gradient(to right, rgba(3, 7, 18, 0.8) 0%, transparent 50%)`, 76 + background: `linear-gradient(to right, color-mix(in srgb, var(--md-sys-color-surface) 80%, transparent) 0%, transparent 50%)`, 77 77 }} 78 78 /> 79 79 </> ··· 81 81 <div 82 82 className="w-full h-full" 83 83 style={{ 84 - background: `linear-gradient(135deg, ${colors.muted} 0%, rgb(3, 7, 18) 100%)`, 84 + background: `linear-gradient(135deg, ${colors.muted} 0%, var(--md-sys-color-surface) 100%)`, 85 85 }} 86 86 /> 87 87 )} ··· 101 101 <div 102 102 className="w-28 md:w-48 lg:w-64 rounded-lg overflow-hidden shadow-2xl" 103 103 style={{ 104 - boxShadow: `0 25px 50px -12px ${colors.primary}40`, 104 + boxShadow: `0 25px 50px -12px color-mix(in srgb, ${colors.primary} 25%, transparent)`, 105 105 }} 106 106 > 107 107 {posterUrl ? ( ··· 129 129 <h1 130 130 className="text-2xl md:text-5xl lg:text-6xl font-bold mb-2" 131 131 style={{ 132 - textShadow: `0 4px 30px ${colors.primary}60`, 132 + textShadow: `0 4px 30px color-mix(in srgb, ${colors.primary} 38%, transparent)`, 133 133 }} 134 134 > 135 135 {movie?.title}
+13 -9
apps/web/src/components/NotFoundPage.tsx
··· 24 24 className="pointer-events-none absolute inset-0" 25 25 style={{ 26 26 background: 27 - "radial-gradient(circle at top left, rgba(243, 188, 0, 0.16), transparent 34%), radial-gradient(circle at bottom right, rgba(243, 188, 0, 0.1), transparent 28%)", 27 + "radial-gradient(circle at top left, color-mix(in srgb, var(--md-sys-color-primary) 16%, transparent), transparent 34%), radial-gradient(circle at bottom right, color-mix(in srgb, var(--md-sys-color-primary) 10%, transparent), transparent 28%)", 28 28 }} 29 29 /> 30 30 ··· 34 34 <div 35 35 className="mb-5 inline-flex items-center gap-2 rounded-full border px-4 py-2" 36 36 style={{ 37 - backgroundColor: "rgba(243, 188, 0, 0.1)", 38 - borderColor: "rgba(243, 188, 0, 0.24)", 37 + backgroundColor: 38 + "color-mix(in srgb, var(--md-sys-color-primary) 10%, transparent)", 39 + borderColor: 40 + "color-mix(in srgb, var(--md-sys-color-primary) 24%, transparent)", 39 41 color: "var(--md-sys-color-primary)", 40 42 }} 41 43 > ··· 107 109 108 110 <M3Card 109 111 variant="elevated" 110 - className="rounded-[28px] border" 112 + className="rounded-xl border" 111 113 style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 112 114 > 113 115 <M3CardHeader className="gap-3 p-6 pb-4"> ··· 131 133 132 134 <M3CardContent className="space-y-4 p-6 pt-0"> 133 135 <div 134 - className="rounded-[24px] border p-4" 136 + className="rounded-xl border p-4" 135 137 style={{ 136 138 backgroundColor: "var(--md-sys-color-surface-container)", 137 139 borderColor: "var(--md-sys-color-outline-variant)", ··· 146 148 </div> 147 149 148 150 <div 149 - className="rounded-[24px] border p-4" 151 + className="rounded-xl border p-4" 150 152 style={{ 151 153 backgroundColor: "var(--md-sys-color-surface-container)", 152 154 borderColor: "var(--md-sys-color-outline-variant)", ··· 162 164 </div> 163 165 164 166 <div 165 - className="rounded-[24px] border p-4" 167 + className="rounded-xl border p-4" 166 168 style={{ 167 - backgroundColor: "rgba(243, 188, 0, 0.1)", 168 - borderColor: "rgba(243, 188, 0, 0.24)", 169 + backgroundColor: 170 + "color-mix(in srgb, var(--md-sys-color-primary) 10%, transparent)", 171 + borderColor: 172 + "color-mix(in srgb, var(--md-sys-color-primary) 24%, transparent)", 169 173 }} 170 174 > 171 175 <p className="mb-1 text-sm font-semibold text-(--md-sys-color-on-surface)">
+1 -1
apps/web/src/components/PaginationControls.tsx
··· 22 22 23 23 return ( 24 24 <div 25 - className="grid gap-3 rounded-[28px] border px-4 py-4 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:items-center" 25 + className="grid gap-3 rounded-xl border px-4 py-4 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:items-center" 26 26 style={{ 27 27 backgroundColor: "var(--md-sys-color-surface-container)", 28 28 borderColor: "var(--md-sys-color-outline-variant)",
-174
apps/web/src/components/ShelfEpisodeCard.tsx
··· 1 - import { 2 - showsControllerDeleteEpisodeWatchHistoryEntryMutation, 3 - type UserDto, 4 - } from "@opnshelf/api"; 5 - import { useMutation, useQueryClient } from "@tanstack/react-query"; 6 - import { Link } from "@tanstack/react-router"; 7 - import { Loader2, Trash2 } from "lucide-react"; 8 - import { useMemo } from "react"; 9 - import { toast } from "sonner"; 10 - import { Button } from "@/components/ui/button"; 11 - import { useFormattedDate } from "@/hooks/useFormattedDate"; 12 - import { 13 - invalidateUserShelfQueries, 14 - invalidateUserUpNextQueries, 15 - } from "@/lib/invalidate-shelf"; 16 - import { createTitleSlug, getTmdbPosterUrl } from "@/lib/utils"; 17 - 18 - export interface ShelfEpisodeItem { 19 - id: string; 20 - type: "episode"; 21 - showId: string; 22 - showTitle: string; 23 - seasonNumber: number; 24 - episodeNumber: number; 25 - posterPath?: string; 26 - backdropPath?: string; 27 - firstAirYear?: number; 28 - overview?: string; 29 - colors?: unknown; 30 - watchedDate?: string; 31 - createdAt: string; 32 - } 33 - 34 - interface ShelfEpisodeCardProps { 35 - tracked: ShelfEpisodeItem; 36 - user: UserDto | undefined; 37 - readOnly?: boolean; 38 - } 39 - 40 - export function ShelfEpisodeCard({ 41 - tracked, 42 - user, 43 - readOnly = false, 44 - }: ShelfEpisodeCardProps) { 45 - const queryClient = useQueryClient(); 46 - const { formatDate } = useFormattedDate(); 47 - 48 - const deleteMutation = useMutation({ 49 - mutationKey: [ 50 - "shows", 51 - tracked.showId, 52 - "episodes", 53 - tracked.episodeNumber, 54 - "deleteWatchEntry", 55 - ], 56 - ...showsControllerDeleteEpisodeWatchHistoryEntryMutation(), 57 - onSuccess: () => { 58 - const userDid = user?.did; 59 - if (userDid) { 60 - invalidateUserShelfQueries(queryClient, userDid); 61 - invalidateUserUpNextQueries(queryClient, userDid); 62 - } 63 - toast.success("Episode removed from history"); 64 - }, 65 - onError: () => { 66 - toast.error("Failed to remove episode. Please try again."); 67 - }, 68 - }); 69 - 70 - const posterUrl = getTmdbPosterUrl(tracked.posterPath); 71 - const formattedDate = useMemo(() => { 72 - if (!tracked.watchedDate) return null; 73 - return formatDate(tracked.watchedDate); 74 - }, [tracked.watchedDate, formatDate]); 75 - 76 - return ( 77 - <div 78 - className="group rounded-[24px] border p-3 transition-transform duration-200 hover:-translate-y-1" 79 - style={{ 80 - backgroundColor: "var(--md-sys-color-surface-container-low)", 81 - borderColor: "var(--md-sys-color-outline-variant)", 82 - }} 83 - > 84 - <Link 85 - to="/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 86 - params={{ 87 - showId: tracked.showId, 88 - title: createTitleSlug(tracked.showTitle), 89 - seasonNumber: String(tracked.seasonNumber), 90 - episodeNumber: String(tracked.episodeNumber), 91 - }} 92 - className="block relative mb-3 overflow-hidden rounded-[20px]" 93 - > 94 - <div 95 - className="aspect-2/3" 96 - style={{ 97 - backgroundColor: "var(--md-sys-color-surface-container-highest)", 98 - }} 99 - > 100 - {posterUrl ? ( 101 - <img 102 - src={posterUrl} 103 - alt={tracked.showTitle} 104 - className="h-full w-full object-cover" 105 - /> 106 - ) : ( 107 - <div 108 - className="flex h-full w-full items-center justify-center px-4 text-center text-sm" 109 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 110 - > 111 - No poster available 112 - </div> 113 - )} 114 - </div> 115 - <div className="absolute inset-x-0 bottom-0 bg-linear-to-t from-black/80 to-transparent p-3"> 116 - <div className="text-white text-sm font-medium"> 117 - S{tracked.seasonNumber} E{tracked.episodeNumber} 118 - </div> 119 - </div> 120 - {readOnly ? null : ( 121 - <Button 122 - type="button" 123 - size="icon" 124 - variant="destructive" 125 - onClick={(e) => { 126 - e.preventDefault(); 127 - e.stopPropagation(); 128 - deleteMutation.mutate({ 129 - path: { trackedEpisodeId: tracked.id }, 130 - }); 131 - }} 132 - disabled={deleteMutation.isPending} 133 - className="absolute top-2 right-2 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity" 134 - title="Remove from history" 135 - > 136 - {deleteMutation.isPending ? ( 137 - <Loader2 className="w-4 h-4 animate-spin" /> 138 - ) : ( 139 - <Trash2 className="w-4 h-4" /> 140 - )} 141 - </Button> 142 - )} 143 - </Link> 144 - <Link 145 - to="/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 146 - params={{ 147 - showId: tracked.showId, 148 - title: createTitleSlug(tracked.showTitle), 149 - seasonNumber: String(tracked.seasonNumber), 150 - episodeNumber: String(tracked.episodeNumber), 151 - }} 152 - className="block rounded-[20px] px-1 pb-1" 153 - > 154 - <h3 className="mb-1 line-clamp-2 text-sm font-semibold transition-colors hover:text-(--md-sys-color-primary)"> 155 - {tracked.showTitle} 156 - </h3> 157 - <p 158 - className="text-sm" 159 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 160 - > 161 - S{tracked.seasonNumber} E{tracked.episodeNumber} 162 - </p> 163 - {formattedDate && ( 164 - <p 165 - className="mt-2 text-xs" 166 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 167 - > 168 - Watched {formattedDate} 169 - </p> 170 - )} 171 - </Link> 172 - </div> 173 - ); 174 - }
-159
apps/web/src/components/ShelfMovieCard.tsx
··· 1 - import { 2 - moviesControllerUnmarkWatchedMutation, 3 - type UserDto, 4 - } from "@opnshelf/api"; 5 - import { useMutation, useQueryClient } from "@tanstack/react-query"; 6 - import { Link } from "@tanstack/react-router"; 7 - import { Loader2, Trash2 } from "lucide-react"; 8 - import { useMemo } from "react"; 9 - import { toast } from "sonner"; 10 - import { Button } from "@/components/ui/button"; 11 - import { useFormattedDate } from "@/hooks/useFormattedDate"; 12 - import { invalidateUserShelfQueries } from "@/lib/invalidate-shelf"; 13 - import { createTitleSlug, getTmdbPosterUrl } from "@/lib/utils"; 14 - 15 - export interface ShelfMovieItem { 16 - id: string; 17 - type: "movie"; 18 - movieId: string; 19 - title: string; 20 - posterPath?: string; 21 - backdropPath?: string; 22 - releaseYear?: number; 23 - overview?: string; 24 - colors?: unknown; 25 - watchedDate?: string; 26 - createdAt: string; 27 - } 28 - 29 - interface ShelfMovieCardProps { 30 - tracked: ShelfMovieItem; 31 - user: UserDto | undefined; 32 - readOnly?: boolean; 33 - } 34 - 35 - export function ShelfMovieCard({ 36 - tracked, 37 - user, 38 - readOnly = false, 39 - }: ShelfMovieCardProps) { 40 - const queryClient = useQueryClient(); 41 - const { formatDate } = useFormattedDate(); 42 - 43 - const unmarkMutation = useMutation({ 44 - mutationKey: ["movies", tracked.movieId, "unmarkWatched"], 45 - ...moviesControllerUnmarkWatchedMutation(), 46 - onSuccess: () => { 47 - const userDid = user?.did; 48 - if (userDid) { 49 - invalidateUserShelfQueries(queryClient, userDid); 50 - } 51 - toast.success("Removed from your shelf"); 52 - }, 53 - onError: () => { 54 - toast.error("Failed to update. Please try again."); 55 - }, 56 - }); 57 - 58 - const posterUrl = getTmdbPosterUrl(tracked.posterPath); 59 - const formattedDate = useMemo(() => { 60 - if (!tracked.watchedDate) return null; 61 - return formatDate(tracked.watchedDate); 62 - }, [tracked.watchedDate, formatDate]); 63 - 64 - return ( 65 - <div 66 - className="group rounded-[24px] border p-3 transition-transform duration-200 hover:-translate-y-1" 67 - style={{ 68 - backgroundColor: "var(--md-sys-color-surface-container-low)", 69 - borderColor: "var(--md-sys-color-outline-variant)", 70 - }} 71 - > 72 - <Link 73 - to="/movies/$movieId/$title" 74 - params={{ 75 - movieId: tracked.movieId, 76 - title: createTitleSlug(tracked.title), 77 - }} 78 - className="block relative mb-3 overflow-hidden rounded-[20px]" 79 - > 80 - <div 81 - className="aspect-2/3" 82 - style={{ 83 - backgroundColor: "var(--md-sys-color-surface-container-highest)", 84 - }} 85 - > 86 - {posterUrl ? ( 87 - <img 88 - src={posterUrl} 89 - alt={tracked.title} 90 - className="h-full w-full object-cover" 91 - /> 92 - ) : ( 93 - <div 94 - className="flex h-full w-full items-center justify-center px-4 text-center text-sm" 95 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 96 - > 97 - No poster available 98 - </div> 99 - )} 100 - </div> 101 - {readOnly ? null : ( 102 - <Button 103 - type="button" 104 - size="icon" 105 - variant="destructive" 106 - onClick={(e) => { 107 - e.preventDefault(); 108 - e.stopPropagation(); 109 - unmarkMutation.mutate({ 110 - path: { movieId: tracked.movieId }, 111 - }); 112 - }} 113 - disabled={ 114 - unmarkMutation.isPending && 115 - unmarkMutation.variables?.path?.movieId === tracked.movieId 116 - } 117 - className="absolute top-2 right-2 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity" 118 - title="Remove from shelf" 119 - > 120 - {unmarkMutation.isPending && 121 - unmarkMutation.variables?.path?.movieId === tracked.movieId ? ( 122 - <Loader2 className="w-4 h-4 animate-spin" /> 123 - ) : ( 124 - <Trash2 className="w-4 h-4" /> 125 - )} 126 - </Button> 127 - )} 128 - </Link> 129 - <Link 130 - to="/movies/$movieId/$title" 131 - params={{ 132 - movieId: tracked.movieId, 133 - title: createTitleSlug(tracked.title), 134 - }} 135 - className="block rounded-[20px] px-1 pb-1" 136 - > 137 - <h3 className="mb-1 line-clamp-2 text-sm font-semibold transition-colors hover:text-(--md-sys-color-primary)"> 138 - {tracked.title} 139 - </h3> 140 - {tracked.releaseYear && ( 141 - <p 142 - className="text-sm" 143 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 144 - > 145 - {tracked.releaseYear} 146 - </p> 147 - )} 148 - {formattedDate && ( 149 - <p 150 - className="mt-2 text-xs" 151 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 152 - > 153 - Watched {formattedDate} 154 - </p> 155 - )} 156 - </Link> 157 - </div> 158 - ); 159 - }
-179
apps/web/src/components/ShowCard.tsx
··· 1 - import { 2 - showsControllerGetUserShowsQueryKey, 3 - showsControllerMarkShowWatchedMutation, 4 - showsControllerUnmarkWatchedMutation, 5 - type TmdbShowResultDto, 6 - type UserDto, 7 - } from "@opnshelf/api"; 8 - import { useMutation, useQueryClient } from "@tanstack/react-query"; 9 - import { Link } from "@tanstack/react-router"; 10 - import { Check, Loader2, Plus } from "lucide-react"; 11 - import { toast } from "sonner"; 12 - import { Button } from "@/components/ui/button"; 13 - import { 14 - Tooltip, 15 - TooltipContent, 16 - TooltipProvider, 17 - TooltipTrigger, 18 - } from "@/components/ui/tooltip"; 19 - import { invalidateUserShelfQueries } from "@/lib/invalidate-shelf"; 20 - import { createTitleSlug, getTmdbPosterUrl } from "@/lib/utils"; 21 - 22 - interface ShowCardProps { 23 - show: TmdbShowResultDto; 24 - user: UserDto | null | undefined; 25 - isWatched: boolean; 26 - showActions?: boolean; 27 - } 28 - 29 - export function ShowCard({ 30 - show, 31 - user, 32 - isWatched, 33 - showActions = true, 34 - }: ShowCardProps) { 35 - const queryClient = useQueryClient(); 36 - const showId = show.id.toString(); 37 - 38 - const markMutation = useMutation({ 39 - mutationKey: ["shows", showId, "markShowWatched"], 40 - ...showsControllerMarkShowWatchedMutation(), 41 - onSuccess: () => { 42 - queryClient.invalidateQueries({ 43 - queryKey: showsControllerGetUserShowsQueryKey({ 44 - path: { userDid: user?.did || "" }, 45 - }), 46 - }); 47 - invalidateUserShelfQueries(queryClient, user?.did); 48 - toast.success("Added to your shelf"); 49 - }, 50 - onError: () => { 51 - toast.error("Failed to update. Please try again."); 52 - }, 53 - }); 54 - 55 - const unmarkMutation = useMutation({ 56 - mutationKey: ["shows", showId, "unmarkWatched"], 57 - ...showsControllerUnmarkWatchedMutation(), 58 - onSuccess: () => { 59 - queryClient.invalidateQueries({ 60 - queryKey: showsControllerGetUserShowsQueryKey({ 61 - path: { userDid: user?.did || "" }, 62 - }), 63 - }); 64 - invalidateUserShelfQueries(queryClient, user?.did); 65 - toast.success("Removed from your shelf"); 66 - }, 67 - onError: () => { 68 - toast.error("Failed to update. Please try again."); 69 - }, 70 - }); 71 - 72 - const isMarkPending = 73 - markMutation.isPending && markMutation.variables?.body?.showId === showId; 74 - const isUnmarkPending = 75 - unmarkMutation.isPending && 76 - unmarkMutation.variables?.path?.showId === showId; 77 - const isPending = isMarkPending || isUnmarkPending; 78 - 79 - const compatShow = show as TmdbShowResultDto & { 80 - posterPath?: string | null; 81 - firstAirDate?: string | null; 82 - }; 83 - const posterUrl = getTmdbPosterUrl( 84 - show.poster_path ?? compatShow.posterPath ?? null, 85 - ); 86 - const firstAirDate = show.first_air_date ?? compatShow.firstAirDate ?? null; 87 - const year = firstAirDate ? firstAirDate.split("-")[0] : undefined; 88 - 89 - return ( 90 - <div className="group"> 91 - <Link 92 - to="/shows/$showId/$title" 93 - params={{ showId, title: createTitleSlug(show.name) }} 94 - className="block relative aspect-2/3 rounded-lg overflow-hidden mb-2" 95 - style={{ 96 - backgroundColor: "var(--md-sys-color-surface-container-high)", 97 - }} 98 - > 99 - {posterUrl ? ( 100 - <img 101 - src={posterUrl} 102 - alt={show.name} 103 - className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105" 104 - /> 105 - ) : ( 106 - <div 107 - className="w-full h-full flex items-center justify-center md-body-medium" 108 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 109 - > 110 - No poster 111 - </div> 112 - )} 113 - {showActions && user && ( 114 - <div className="absolute top-2 right-2 z-10"> 115 - <TooltipProvider> 116 - <Tooltip> 117 - <TooltipTrigger asChild> 118 - <Button 119 - type="button" 120 - size="icon" 121 - variant="default" 122 - onClick={(e) => { 123 - e.preventDefault(); 124 - e.stopPropagation(); 125 - if (isWatched) { 126 - unmarkMutation.mutate({ 127 - path: { showId }, 128 - query: { mode: "all" }, 129 - }); 130 - } else { 131 - markMutation.mutate({ 132 - body: { showId }, 133 - }); 134 - } 135 - }} 136 - disabled={isPending} 137 - className={`${ 138 - isWatched 139 - ? "bg-green-600 hover:bg-red-600" 140 - : "bg-primary hover:bg-primary/80 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100" 141 - } transition-opacity`} 142 - > 143 - {isPending ? ( 144 - <Loader2 className="w-4 h-4 animate-spin" /> 145 - ) : isWatched ? ( 146 - <Check className="w-4 h-4" /> 147 - ) : ( 148 - <Plus className="w-4 h-4" /> 149 - )} 150 - </Button> 151 - </TooltipTrigger> 152 - <TooltipContent> 153 - <p>{isWatched ? "Remove from shelf" : "Mark as watched"}</p> 154 - </TooltipContent> 155 - </Tooltip> 156 - </TooltipProvider> 157 - </div> 158 - )} 159 - </Link> 160 - <Link 161 - to="/shows/$showId/$title" 162 - params={{ showId, title: createTitleSlug(show.name) }} 163 - className="block" 164 - > 165 - <h3 className="font-semibold text-sm line-clamp-2 mb-1 transition-colors hover:text-(--md-sys-color-primary)"> 166 - {show.name} 167 - </h3> 168 - {year && ( 169 - <p 170 - className="text-sm" 171 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 172 - > 173 - {year} 174 - </p> 175 - )} 176 - </Link> 177 - </div> 178 - ); 179 - }
-35
apps/web/src/components/ShowGrid.tsx
··· 1 - import type { TmdbShowResultDto, UserDto } from "@opnshelf/api"; 2 - import { cn } from "@/lib/utils"; 3 - import { ShowCard } from "./ShowCard"; 4 - 5 - interface ShowGridProps { 6 - shows: TmdbShowResultDto[]; 7 - user: UserDto | null | undefined; 8 - watchedShowIds: Set<string>; 9 - gridClassName?: string; 10 - } 11 - 12 - export function ShowGrid({ 13 - shows, 14 - user, 15 - watchedShowIds, 16 - gridClassName, 17 - }: ShowGridProps) { 18 - return ( 19 - <div 20 - className={cn( 21 - "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4", 22 - gridClassName, 23 - )} 24 - > 25 - {shows.map((show) => ( 26 - <ShowCard 27 - key={show.id} 28 - show={show} 29 - user={user} 30 - isWatched={watchedShowIds.has(show.id.toString())} 31 - /> 32 - ))} 33 - </div> 34 - ); 35 - }
+1 -1
apps/web/src/components/UnauthenticatedState.tsx
··· 31 31 }} 32 32 > 33 33 <div className="container mx-auto px-4 py-16 max-w-4xl"> 34 - <M3Card variant="filled" className="rounded-[28px] text-center"> 34 + <M3Card variant="filled" className="rounded-xl text-center"> 35 35 <M3CardHeader className="items-center px-6 pt-8"> 36 36 <Icon 37 37 className="size-16 mb-4"
+33 -66
apps/web/src/components/detail/DetailActions.tsx
··· 3 3 import { useState } from "react"; 4 4 import { toast } from "sonner"; 5 5 import { AddToShelfButton } from "@/components/AddToShelfButton"; 6 - import { ActionButton } from "@/components/ui/action-button"; 6 + import { M3Button } from "@/components/ui/m3-button"; 7 7 import { TrackedStatusCard } from "./TrackedStatusCard"; 8 8 import type { ColorTheme } from "./types"; 9 9 ··· 95 95 className="w-full py-4 px-6 rounded-xl m3-label-large text-center transition-all duration-200 hover:scale-[1.02]" 96 96 style={{ 97 97 background: `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 98 - boxShadow: `0 15px 35px -10px ${colors.primary}60`, 98 + boxShadow: `0 15px 35px -10px color-mix(in srgb, ${colors.primary} 38%, transparent)`, 99 99 color: "var(--md-sys-color-on-primary)", 100 100 }} 101 101 onClick={onLogin} 102 102 > 103 103 Sign in to Track 104 104 </button> 105 - <ActionButton 106 - icon={<Share2 className="w-4 h-4" />} 107 - label="Share" 108 - onClick={handleShare} 109 - /> 105 + <M3Button variant="outlined" onClick={handleShare} className="w-full"> 106 + <Share2 className="w-4 h-4" /> 107 + Share 108 + </M3Button> 110 109 </div> 111 110 ); 112 111 } ··· 134 133 size="compact" 135 134 className="flex-1" 136 135 /> 137 - <button 138 - type="button" 136 + <M3Button 137 + variant="outlined" 138 + size="icon" 139 139 onClick={onShowDatePicker} 140 140 title={`Watch ${mediaType}`} 141 - className="p-3 rounded-xl border transition-all duration-200 flex items-center justify-center group" 142 - style={{ 143 - backgroundColor: "transparent", 144 - borderColor: "var(--md-sys-color-outline)", 145 - }} 146 - onMouseEnter={(e) => { 147 - e.currentTarget.style.backgroundColor = 148 - "var(--md-sys-color-surface-container)"; 149 - e.currentTarget.style.borderColor = 150 - "var(--md-sys-color-primary)"; 151 - }} 152 - onMouseLeave={(e) => { 153 - e.currentTarget.style.backgroundColor = "transparent"; 154 - e.currentTarget.style.borderColor = 155 - "var(--md-sys-color-outline)"; 156 - }} 157 141 > 158 - <Calendar className="w-5 h-5 text-(--md-sys-color-on-surface-variant) group-hover:text-(--md-sys-color-primary) transition-colors" /> 159 - </button> 142 + <Calendar className="w-5 h-5" /> 143 + </M3Button> 160 144 </div> 161 145 </> 162 146 ) : ( ··· 169 153 colors={colors} 170 154 className="flex-1" 171 155 /> 172 - <button 173 - type="button" 156 + <M3Button 157 + variant="outlined" 158 + size="icon" 174 159 onClick={onShowDatePicker} 175 160 title={`Watch ${mediaType}`} 176 - className="p-3 rounded-xl border transition-all duration-200 flex items-center justify-center group" 177 - style={{ 178 - backgroundColor: "transparent", 179 - borderColor: "var(--md-sys-color-outline)", 180 - }} 181 - onMouseEnter={(e) => { 182 - e.currentTarget.style.backgroundColor = 183 - "var(--md-sys-color-surface-container)"; 184 - e.currentTarget.style.borderColor = "var(--md-sys-color-primary)"; 185 - }} 186 - onMouseLeave={(e) => { 187 - e.currentTarget.style.backgroundColor = "transparent"; 188 - e.currentTarget.style.borderColor = "var(--md-sys-color-outline)"; 189 - }} 190 161 > 191 - <Calendar className="w-5 h-5 text-(--md-sys-color-on-surface-variant) group-hover:text-(--md-sys-color-primary) transition-colors" /> 192 - </button> 162 + <Calendar className="w-5 h-5" /> 163 + </M3Button> 193 164 </div> 194 165 )} 195 166 196 167 {onShowListModal && ( 197 - <ActionButton 198 - icon={ 199 - isInAnyList ? ( 200 - <Check className="w-4 h-4" /> 201 - ) : ( 202 - <ListPlus className="w-4 h-4" /> 203 - ) 204 - } 205 - label={ 206 - isInAnyList 207 - ? `In ${listsCount} list${listsCount > 1 ? "s" : ""}` 208 - : "Add to List" 209 - } 168 + <M3Button 169 + variant={isInAnyList ? "filled-tonal" : "outlined"} 210 170 onClick={handleShowListModal} 211 - isActive={isInAnyList} 212 - activeColor={colors.primary} 213 - /> 171 + className="w-full" 172 + > 173 + {isInAnyList ? ( 174 + <Check className="w-4 h-4" /> 175 + ) : ( 176 + <ListPlus className="w-4 h-4" /> 177 + )} 178 + {isInAnyList 179 + ? `In ${listsCount} list${listsCount > 1 ? "s" : ""}` 180 + : "Add to List"} 181 + </M3Button> 214 182 )} 215 183 216 - <ActionButton 217 - icon={<Share2 className="w-4 h-4" />} 218 - label={copied ? "Copied!" : "Share"} 219 - onClick={handleShare} 220 - /> 184 + <M3Button variant="outlined" onClick={handleShare} className="w-full"> 185 + <Share2 className="w-4 h-4" /> 186 + {copied ? "Copied!" : "Share"} 187 + </M3Button> 221 188 </div> 222 189 ); 223 190 }
+6 -6
apps/web/src/components/detail/DetailHero.tsx
··· 32 32 <div 33 33 className="w-full h-full animate-pulse" 34 34 style={{ 35 - background: `linear-gradient(135deg, #1a1a2e 0%, #0f0f1a 100%)`, 35 + background: `linear-gradient(135deg, var(--md-sys-color-surface-container) 0%, var(--md-sys-color-surface) 100%)`, 36 36 }} 37 37 /> 38 38 <div className="absolute bottom-0 left-0 right-0 p-4 md:p-8"> ··· 81 81 <div 82 82 className="absolute inset-0" 83 83 style={{ 84 - background: `linear-gradient(to bottom, transparent 0%, rgba(3, 7, 18, 0.6) 60%, rgb(3, 7, 18) 100%)`, 84 + background: `linear-gradient(to bottom, transparent 0%, color-mix(in srgb, var(--md-sys-color-surface) 60%, transparent) 60%, var(--md-sys-color-surface) 100%)`, 85 85 }} 86 86 /> 87 87 <div 88 88 className="absolute inset-0" 89 89 style={{ 90 - background: `linear-gradient(to right, rgba(3, 7, 18, 0.8) 0%, transparent 50%)`, 90 + background: `linear-gradient(to right, color-mix(in srgb, var(--md-sys-color-surface) 80%, transparent) 0%, transparent 50%)`, 91 91 }} 92 92 /> 93 93 </> ··· 95 95 <div 96 96 className="w-full h-full" 97 97 style={{ 98 - background: `linear-gradient(135deg, ${colors.muted} 0%, rgb(3, 7, 18) 100%)`, 98 + background: `linear-gradient(135deg, ${colors.muted} 0%, var(--md-sys-color-surface) 100%)`, 99 99 }} 100 100 /> 101 101 )} ··· 115 115 <div 116 116 className="w-28 md:w-48 lg:w-64 rounded-lg overflow-hidden shadow-2xl" 117 117 style={{ 118 - boxShadow: `0 25px 50px -12px ${colors.primary}40`, 118 + boxShadow: `0 25px 50px -12px color-mix(in srgb, ${colors.primary} 25%, transparent)`, 119 119 }} 120 120 > 121 121 {posterLinkTo ? ( ··· 136 136 <h1 137 137 className="text-2xl md:text-5xl lg:text-6xl font-bold mb-2" 138 138 style={{ 139 - textShadow: `0 4px 30px ${colors.primary}60`, 139 + textShadow: `0 4px 30px color-mix(in srgb, ${colors.primary} 38%, transparent)`, 140 140 }} 141 141 > 142 142 {title}
+2 -2
apps/web/src/components/detail/EpisodeCard.tsx
··· 132 132 style={{ 133 133 borderColor: 134 134 watchedCount > 0 135 - ? `${colors.primary}40` 135 + ? `color-mix(in srgb, ${colors.primary} 25%, transparent)` 136 136 : "var(--md-sys-color-outline)", 137 137 }} 138 138 > ··· 179 179 type="button" 180 180 onClick={handleToggleWatched} 181 181 disabled={isPending} 182 - className={`flex items-center gap-1 px-4 py-2 rounded-md text-xs font-medium transition-all mt-2 ${watchedCount > 0 ? "bg-red-500/20 text-red-500" : "bg-green-500/20 text-green-500"}`} 182 + className={`flex items-center gap-1 px-4 py-2 rounded-sm text-xs font-medium transition-all mt-2 ${watchedCount > 0 ? "bg-(--md-sys-color-error)/20 text-(--md-sys-color-error)" : "bg-(--md-sys-color-tertiary)/20 text-(--md-sys-color-tertiary)"}`} 183 183 title="Add to Shelf" 184 184 > 185 185 {isPending ? (
+1 -1
apps/web/src/components/detail/EpisodeNav.tsx
··· 175 175 style={ 176 176 slot.highlighted 177 177 ? { 178 - backgroundColor: `${colors.primary}15`, 178 + backgroundColor: `color-mix(in srgb, ${colors.primary} 8%, transparent)`, 179 179 } 180 180 : {} 181 181 }
+1 -1
apps/web/src/components/detail/SeasonCard.tsx
··· 202 202 type="button" 203 203 onClick={handleToggleWatched} 204 204 disabled={isPending} 205 - className={`flex items-center gap-1 px-4 py-2 rounded-md text-xs font-medium transition-all mt-2 ${hasWatchedEpisodes ? "bg-red-500/20 text-red-500" : "bg-green-500/20 text-green-500"}`} 205 + className={`flex items-center gap-1 px-4 py-2 rounded-sm text-xs font-medium transition-all mt-2 ${hasWatchedEpisodes ? "bg-(--md-sys-color-error)/20 text-(--md-sys-color-error)" : "bg-(--md-sys-color-tertiary)/20 text-(--md-sys-color-tertiary)"}`} 206 206 title="Add to Shelf" 207 207 > 208 208 {isPending ? (
+1 -1
apps/web/src/components/detail/TrailerSection.tsx
··· 43 43 {isFallback ? <Badge variant="outline">From show</Badge> : null} 44 44 </div> 45 45 46 - <div className="max-w-3xl overflow-hidden rounded-[1.25rem] border border-white/8 bg-black/20"> 46 + <div className="max-w-3xl overflow-hidden rounded-xl border border-white/8 bg-black/20"> 47 47 <div className="aspect-video w-full bg-black"> 48 48 {isPlaying ? ( 49 49 <iframe
+123 -21
apps/web/src/components/home/DashboardHomePage.tsx
··· 1 1 import { 2 2 listsControllerGetUserListsOptions, 3 + moviesControllerUnmarkWatchedMutation, 3 4 type ShelfActivityBucketDto, 4 5 type ShelfActivitySummaryDto, 5 6 shelfControllerGetUserActivitySummaryOptions, 6 7 shelfControllerGetUserShelfOptions, 8 + showsControllerDeleteEpisodeWatchHistoryEntryMutation, 7 9 showsControllerGetUserUpNextOptions, 8 10 type UserDto, 9 11 } from "@opnshelf/api"; 10 - import { useQuery } from "@tanstack/react-query"; 12 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 11 13 import { Link } from "@tanstack/react-router"; 12 14 import { LayoutDashboard, Search } from "lucide-react"; 13 15 import { useMemo, useState } from "react"; ··· 15 17 import { FriendsActivitySection } from "@/components/home/FriendsActivitySection"; 16 18 import { UpNextSection } from "@/components/home/UpNextSection"; 17 19 import { ListCard } from "@/components/ListCard"; 18 - import { ShelfEpisodeCard } from "@/components/ShelfEpisodeCard"; 19 - import { ShelfMovieCard } from "@/components/ShelfMovieCard"; 20 + import { MediaPosterCard } from "@/components/MediaPosterCard"; 20 21 import { getSocialDisplayName } from "@/components/social/social-display"; 21 22 import { M3Button } from "@/components/ui/m3-button"; 22 23 import { ··· 26 27 M3CardHeader, 27 28 M3CardTitle, 28 29 } from "@/components/ui/m3-card"; 30 + import { 31 + invalidateUserShelfQueries, 32 + invalidateUserUpNextQueries, 33 + } from "@/lib/invalidate-shelf"; 29 34 import { getProfileRoute } from "@/lib/profile-routes"; 35 + import { createTitleSlug } from "@/lib/utils"; 30 36 31 37 type DashboardRange = "week" | "month"; 32 38 ··· 69 75 enabled: !!user.did, 70 76 }); 71 77 78 + const queryClient = useQueryClient(); 79 + 80 + const unmarkMovieMutation = useMutation({ 81 + mutationKey: ["dashboard", "movies", "unmarkWatched"], 82 + ...moviesControllerUnmarkWatchedMutation(), 83 + onSuccess: () => { 84 + invalidateUserShelfQueries(queryClient, user.did); 85 + }, 86 + }); 87 + 88 + const deleteEpisodeMutation = useMutation({ 89 + mutationKey: ["dashboard", "episodes", "deleteWatchEntry"], 90 + ...showsControllerDeleteEpisodeWatchHistoryEntryMutation(), 91 + onSuccess: () => { 92 + invalidateUserShelfQueries(queryClient, user.did); 93 + invalidateUserUpNextQueries(queryClient, user.did); 94 + }, 95 + }); 96 + 72 97 const { recentWatched } = useMemo(() => { 73 98 const items = shelfData?.items ?? []; 74 99 ··· 108 133 return ( 109 134 <div className="container mx-auto max-w-7xl px-4 py-8 md:py-10"> 110 135 <div 111 - className="mb-8 rounded-[28px] border p-5 md:p-6" 136 + className="mb-8 rounded-xl border p-5 md:p-6" 112 137 style={{ 113 138 backgroundColor: "var(--md-sys-color-surface-container-high)", 114 139 borderColor: "var(--md-sys-color-outline-variant)", ··· 161 186 <div className="lg:col-span-2"> 162 187 <M3Card 163 188 variant="elevated" 164 - className="h-full rounded-[28px] border" 189 + className="h-full rounded-xl border" 165 190 style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 166 191 > 167 192 <M3CardHeader> ··· 196 221 </M3Button> 197 222 </div> 198 223 <div 199 - className="rounded-[24px] border p-4" 224 + className="rounded-xl border p-4" 200 225 style={{ 201 226 backgroundColor: "var(--md-sys-color-surface-container)", 202 227 borderColor: "var(--md-sys-color-outline-variant)", ··· 281 306 </div> 282 307 ) : recentWatched.length > 0 ? ( 283 308 <div className="grid grid-cols-2 sm:grid-cols-3 gap-4"> 284 - {recentWatched.map((item) => 285 - item.type === "movie" ? ( 286 - <ShelfMovieCard 287 - key={item.id} 288 - tracked={item as never} 289 - user={user} 290 - /> 291 - ) : ( 292 - <ShelfEpisodeCard 293 - key={item.id} 294 - tracked={item as never} 309 + {recentWatched.map((item) => { 310 + const shelfItem = item as { 311 + id: string; 312 + type: string; 313 + movieId?: string; 314 + title?: string; 315 + showId?: string; 316 + showTitle?: string; 317 + seasonNumber?: number; 318 + episodeNumber?: number; 319 + posterPath?: string; 320 + releaseYear?: number; 321 + }; 322 + const isMovie = shelfItem.type === "movie"; 323 + const title = isMovie 324 + ? (shelfItem.title ?? "Untitled") 325 + : (shelfItem.showTitle ?? "Untitled"); 326 + 327 + return ( 328 + <MediaPosterCard 329 + key={shelfItem.id} 330 + posterPath={shelfItem.posterPath} 331 + title={title} 332 + subtitle={ 333 + isMovie ? shelfItem.releaseYear?.toString() : undefined 334 + } 335 + badge={ 336 + !isMovie && shelfItem.seasonNumber != null 337 + ? `S${shelfItem.seasonNumber} E${shelfItem.episodeNumber}` 338 + : undefined 339 + } 340 + to={ 341 + isMovie 342 + ? "/movies/$movieId/$title" 343 + : "/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 344 + } 345 + params={ 346 + isMovie 347 + ? { 348 + movieId: shelfItem.movieId ?? "", 349 + title: createTitleSlug(title), 350 + } 351 + : { 352 + showId: shelfItem.showId ?? "", 353 + title: createTitleSlug(title), 354 + seasonNumber: String(shelfItem.seasonNumber ?? 1), 355 + episodeNumber: String(shelfItem.episodeNumber ?? 1), 356 + } 357 + } 295 358 user={user} 359 + listMedia={ 360 + isMovie && shelfItem.movieId 361 + ? { 362 + type: "movie", 363 + id: shelfItem.movieId, 364 + title, 365 + } 366 + : shelfItem.showId 367 + ? { 368 + type: "show", 369 + id: shelfItem.showId, 370 + title, 371 + } 372 + : undefined 373 + } 374 + onRemove={() => { 375 + if (isMovie) { 376 + unmarkMovieMutation.mutate({ 377 + path: { 378 + movieId: shelfItem.movieId ?? "", 379 + }, 380 + }); 381 + } else { 382 + deleteEpisodeMutation.mutate({ 383 + path: { 384 + trackedEpisodeId: shelfItem.id, 385 + }, 386 + }); 387 + } 388 + }} 389 + isRemoving={ 390 + isMovie 391 + ? unmarkMovieMutation.isPending && 392 + unmarkMovieMutation.variables?.path?.movieId === 393 + shelfItem.movieId 394 + : deleteEpisodeMutation.isPending && 395 + deleteEpisodeMutation.variables?.path 396 + ?.trackedEpisodeId === shelfItem.id 397 + } 296 398 /> 297 - ), 298 - )} 399 + ); 400 + })} 299 401 </div> 300 402 ) : ( 301 403 <M3Card 302 404 variant="elevated" 303 - className="rounded-[28px] border" 405 + className="rounded-xl border" 304 406 style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 305 407 > 306 408 <M3CardHeader> ··· 356 458 ) : ( 357 459 <M3Card 358 460 variant="elevated" 359 - className="rounded-[28px] border" 461 + className="rounded-xl border" 360 462 style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 361 463 > 362 464 <M3CardHeader>
+9 -9
apps/web/src/components/home/FriendsActivitySection.tsx
··· 52 52 <FriendsActivitySectionHeader userHandle={userHandle} /> 53 53 <M3Card 54 54 variant="elevated" 55 - className="rounded-[28px] border" 55 + className="rounded-xl border" 56 56 style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 57 57 > 58 58 <M3CardHeader> ··· 77 77 <FriendsActivitySectionHeader userHandle={userHandle} /> 78 78 <M3Card 79 79 variant="elevated" 80 - className="rounded-[28px] border" 80 + className="rounded-xl border" 81 81 style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 82 82 > 83 83 <M3CardHeader> ··· 140 140 {Array.from({ length: 5 }, (_, index) => ( 141 141 <div 142 142 key={`friends-activity-skeleton-${index + 1}`} 143 - className="w-[min(11rem,70vw)] shrink-0 rounded-[24px] border p-3 sm:w-46 lg:w-48" 143 + className="w-[min(11rem,70vw)] shrink-0 rounded-xl border p-3 sm:w-46 lg:w-48" 144 144 style={{ 145 145 backgroundColor: "var(--md-sys-color-surface-container-low)", 146 146 borderColor: "var(--md-sys-color-outline-variant)", 147 147 }} 148 148 > 149 - <Skeleton className="mb-3 aspect-2/3 w-full rounded-[20px] bg-(--md-sys-color-surface-container-highest)" /> 149 + <Skeleton className="mb-3 aspect-2/3 w-full rounded-xl bg-(--md-sys-color-surface-container-highest)" /> 150 150 <div className="space-y-2 px-1 pb-1"> 151 151 <Skeleton className="h-5 w-4/5 rounded-full bg-(--md-sys-color-surface-container-highest)" /> 152 152 <Skeleton className="h-4 w-2/3 rounded-full bg-(--md-sys-color-surface-container-highest)" /> ··· 229 229 230 230 return ( 231 231 <div 232 - className="group flex h-full w-[min(11rem,70vw)] shrink-0 flex-col rounded-[24px] border p-3 transition-transform duration-200 hover:-translate-y-1 sm:w-46 lg:w-48" 232 + className="group flex h-full w-[min(11rem,70vw)] shrink-0 flex-col rounded-xl border p-3 transition-transform duration-200 hover:-translate-y-1 sm:w-46 lg:w-48" 233 233 style={{ 234 234 backgroundColor: "var(--md-sys-color-surface-container-low)", 235 235 borderColor: "var(--md-sys-color-outline-variant)", ··· 238 238 {mediaTarget ? ( 239 239 <Link 240 240 {...mediaTarget} 241 - className="relative mb-3 block overflow-hidden rounded-[20px]" 241 + className="relative mb-3 block overflow-hidden rounded-xl" 242 242 > 243 243 <div 244 244 className="aspect-2/3" ··· 264 264 </Link> 265 265 ) : ( 266 266 <div 267 - className="mb-3 flex aspect-2/3 items-center justify-center rounded-[20px] px-4 text-center text-sm" 267 + className="mb-3 flex aspect-2/3 items-center justify-center rounded-xl px-4 text-center text-sm" 268 268 style={{ 269 269 backgroundColor: "var(--md-sys-color-surface-container-highest)", 270 270 color: "var(--md-sys-color-on-surface-variant)", ··· 277 277 <div className="flex min-h-35 flex-1 flex-col px-1 pb-1"> 278 278 <div> 279 279 {mediaTarget ? ( 280 - <Link {...mediaTarget} className="block rounded-[20px]"> 280 + <Link {...mediaTarget} className="block rounded-xl"> 281 281 <h3 className="mb-1 line-clamp-2 text-sm font-semibold transition-colors hover:text-(--md-sys-color-primary)"> 282 282 {mediaTitle} 283 283 </h3> ··· 290 290 </div> 291 291 292 292 <div 293 - className="mt-auto flex min-h-16 items-center gap-2 rounded-[18px] border px-2.5 py-2" 293 + className="mt-auto flex min-h-16 items-center gap-2 rounded-lg border px-2.5 py-2" 294 294 style={{ 295 295 backgroundColor: "var(--md-sys-color-surface-container)", 296 296 borderColor: "var(--md-sys-color-outline-variant)",
+15 -12
apps/web/src/components/home/LandingHomePage.tsx
··· 82 82 className="absolute inset-0 opacity-90" 83 83 style={{ 84 84 background: 85 - "radial-gradient(circle at top left, rgba(243, 188, 0, 0.18), transparent 35%), radial-gradient(circle at 85% 20%, rgba(176, 207, 186, 0.12), transparent 30%), linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0))", 85 + "radial-gradient(circle at top left, color-mix(in srgb, var(--md-sys-color-primary) 18%, transparent), transparent 35%), radial-gradient(circle at 85% 20%, color-mix(in srgb, var(--md-sys-color-tertiary-container) 12%, transparent), transparent 30%), linear-gradient(180deg, color-mix(in srgb, var(--md-sys-color-on-surface) 2%, transparent), transparent)", 86 86 }} 87 87 /> 88 88 <div className="container relative mx-auto max-w-7xl px-4 py-16 md:py-24"> ··· 91 91 <img 92 92 src="/icon.png" 93 93 alt="OpnShelf" 94 - className="size-14 rounded-2xl shadow-[0_12px_30px_rgba(0,0,0,0.28)]" 94 + className="size-14 rounded-2xl shadow-[0_12px_30px_color-mix(in_srgb,var(--md-sys-color-scrim)_28%,transparent)]" 95 95 /> 96 96 <span 97 97 className="md-label-large rounded-full px-4 py-1.5" ··· 150 150 </div> 151 151 </section> 152 152 153 - <section className="border-b border-(--md-sys-color-outline-variant) bg-[rgba(255,255,255,0.02)]"> 153 + <section className="border-b border-(--md-sys-color-outline-variant) bg-(--md-sys-color-on-surface)/2"> 154 154 <div className="container mx-auto max-w-7xl px-4 py-10 md:py-14"> 155 155 <div className="mb-8 max-w-2xl"> 156 156 <p ··· 168 168 {comparisonItems.map((item) => ( 169 169 <div 170 170 key={item.current} 171 - className="rounded-[24px] border p-6" 171 + className="rounded-xl border p-6" 172 172 style={{ 173 173 borderColor: "var(--md-sys-color-outline-variant)", 174 - backgroundColor: "rgba(255, 255, 255, 0.025)", 174 + backgroundColor: 175 + "color-mix(in srgb, var(--md-sys-color-on-surface) 2.5%, transparent)", 175 176 }} 176 177 > 177 178 <p ··· 209 210 <M3Card 210 211 key={section.title} 211 212 variant="elevated" 212 - className="rounded-[28px] border border-(--md-sys-color-outline-variant)" 213 + className="rounded-xl border border-(--md-sys-color-outline-variant)" 213 214 > 214 215 <M3CardHeader className="px-6 pt-6"> 215 216 <div ··· 254 255 255 256 <section className="container mx-auto max-w-7xl px-4 pb-16"> 256 257 <div 257 - className="grid gap-6 overflow-hidden rounded-[32px] border px-6 py-8 md:px-10 md:py-10 lg:grid-cols-[1fr_auto] lg:items-center" 258 + className="grid gap-6 overflow-hidden rounded-xl border px-6 py-8 md:px-10 md:py-10 lg:grid-cols-[1fr_auto] lg:items-center" 258 259 style={{ 259 - borderColor: "rgba(243, 188, 0, 0.34)", 260 + borderColor: 261 + "color-mix(in srgb, var(--md-sys-color-primary) 34%, transparent)", 260 262 background: 261 - "linear-gradient(135deg, rgba(92, 69, 0, 0.7), rgba(33, 31, 38, 0.95) 48%, rgba(50, 75, 59, 0.7))", 262 - boxShadow: "0 24px 60px rgba(0, 0, 0, 0.28)", 263 + "linear-gradient(135deg, color-mix(in srgb, var(--md-sys-color-primary-container) 70%, transparent), color-mix(in srgb, var(--md-sys-color-surface-container) 95%, transparent) 48%, color-mix(in srgb, var(--md-sys-color-tertiary-container) 70%, transparent))", 264 + boxShadow: 265 + "0 24px 60px color-mix(in srgb, var(--md-sys-color-scrim) 28%, transparent)", 263 266 }} 264 267 > 265 268 <div className="max-w-3xl"> ··· 306 309 </div> 307 310 308 311 <div 309 - className="rounded-[28px] border p-6 md:p-8" 312 + className="rounded-xl border p-6 md:p-8" 310 313 style={{ 311 314 borderColor: "var(--md-sys-color-outline-variant)", 312 315 backgroundColor: "var(--md-sys-color-surface-container-low)", ··· 337 340 338 341 <section className="container mx-auto max-w-7xl px-4 pb-20"> 339 342 <div 340 - className="rounded-[32px] border px-6 py-10 text-center md:px-10" 343 + className="rounded-xl border px-6 py-10 text-center md:px-10" 341 344 style={{ 342 345 borderColor: "var(--md-sys-color-outline-variant)", 343 346 backgroundColor: "var(--md-sys-color-surface-container)",
-173
apps/web/src/components/lists/ListMediaCard.tsx
··· 1 - import type { MediaInListDto } from "@opnshelf/api"; 2 - import { Link } from "@tanstack/react-router"; 3 - import { Check, Loader2, X } from "lucide-react"; 4 - import { useTheme } from "@/components/theme-provider"; 5 - import { Button } from "@/components/ui/button"; 6 - import { getTmdbPosterUrl, parseScopedShowMediaId } from "@/lib/utils"; 7 - 8 - interface ListMediaCardProps { 9 - item: MediaInListDto; 10 - readOnly?: boolean; 11 - onWatch?: () => void; 12 - onRemove?: (item: { mediaType: "movie" | "show"; mediaId: string }) => void; 13 - isWatching?: boolean; 14 - isRemoving?: boolean; 15 - } 16 - 17 - export function ListMediaCard({ 18 - item, 19 - readOnly = false, 20 - onWatch, 21 - onRemove, 22 - isWatching = false, 23 - isRemoving = false, 24 - }: ListMediaCardProps) { 25 - const media = item.media as { 26 - title?: string; 27 - posterPath?: string | null; 28 - releaseYear?: number | null; 29 - showId?: string; 30 - }; 31 - const mediaType: "movie" | "show" = 32 - item.mediaType === "show" ? "show" : "movie"; 33 - const scopedShow = 34 - mediaType === "show" ? parseScopedShowMediaId(item.mediaId) : null; 35 - const showIdForNav = media.showId ?? scopedShow?.showId ?? item.mediaId; 36 - const seasonNumber = scopedShow?.seasonNumber; 37 - const episodeNumber = scopedShow?.episodeNumber; 38 - const mediaTitle = media.title ?? "Untitled"; 39 - const mediaSlug = mediaTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-"); 40 - const isMovie = mediaType === "movie"; 41 - const listContext = 42 - typeof seasonNumber === "number" && typeof episodeNumber === "number" 43 - ? `S${seasonNumber} E${episodeNumber}` 44 - : typeof seasonNumber === "number" 45 - ? `Season ${seasonNumber}` 46 - : null; 47 - const linkTo = isMovie 48 - ? "/movies/$movieId/$title" 49 - : typeof seasonNumber === "number" && typeof episodeNumber === "number" 50 - ? "/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 51 - : typeof seasonNumber === "number" 52 - ? "/shows/$showId/$title/seasons/$seasonNumber" 53 - : "/shows/$showId/$title"; 54 - const linkParams = isMovie 55 - ? { movieId: item.mediaId, title: mediaSlug } 56 - : typeof seasonNumber === "number" && typeof episodeNumber === "number" 57 - ? { 58 - showId: showIdForNav, 59 - title: mediaSlug, 60 - seasonNumber: String(seasonNumber), 61 - episodeNumber: String(episodeNumber), 62 - } 63 - : typeof seasonNumber === "number" 64 - ? { 65 - showId: showIdForNav, 66 - title: mediaSlug, 67 - seasonNumber: String(seasonNumber), 68 - } 69 - : { showId: showIdForNav, title: mediaSlug }; 70 - const posterUrl = getTmdbPosterUrl(media.posterPath ?? null); 71 - const releaseYear = media.releaseYear; 72 - const { seedColor } = useTheme(); 73 - 74 - return ( 75 - <div className="group"> 76 - <Link 77 - to={linkTo as never} 78 - params={linkParams as never} 79 - className="block relative aspect-2/3 rounded-lg overflow-hidden mb-2" 80 - style={{ 81 - backgroundColor: "var(--md-sys-color-surface-container-highest)", 82 - }} 83 - > 84 - {posterUrl ? ( 85 - <img 86 - src={posterUrl} 87 - alt={mediaTitle} 88 - className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105" 89 - /> 90 - ) : ( 91 - <div 92 - className="w-full h-full flex items-center justify-center" 93 - style={{ color: "var(--md-sys-color-outline)" }} 94 - > 95 - No poster 96 - </div> 97 - )} 98 - {!readOnly && onWatch && onRemove ? ( 99 - <div className="absolute top-2 right-2 z-10 flex items-center gap-2"> 100 - <Button 101 - type="button" 102 - size="icon-sm" 103 - variant="default" 104 - onClick={(e) => { 105 - e.preventDefault(); 106 - e.stopPropagation(); 107 - onWatch(); 108 - }} 109 - disabled={isWatching} 110 - className="bg-primary hover:bg-primary/80 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity focus-visible:opacity-100 group-focus-within:opacity-100" 111 - title="Mark as watched" 112 - > 113 - {isWatching ? ( 114 - <Loader2 className="w-4 h-4 animate-spin" /> 115 - ) : ( 116 - <Check className="w-4 h-4" /> 117 - )} 118 - </Button> 119 - <Button 120 - type="button" 121 - size="icon-sm" 122 - variant="destructive" 123 - onClick={(e) => { 124 - e.preventDefault(); 125 - e.stopPropagation(); 126 - onRemove({ mediaType, mediaId: item.mediaId }); 127 - }} 128 - disabled={isRemoving} 129 - className="[@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity focus-visible:opacity-100 group-focus-within:opacity-100" 130 - title="Remove from list" 131 - > 132 - {isRemoving ? ( 133 - <Loader2 className="w-4 h-4 animate-spin" /> 134 - ) : ( 135 - <X className="w-4 h-4" /> 136 - )} 137 - </Button> 138 - </div> 139 - ) : null} 140 - </Link> 141 - <Link to={linkTo as never} params={linkParams as never} className="block"> 142 - <h3 143 - className="font-semibold text-sm line-clamp-2 mb-1 transition-colors" 144 - style={{ color: "var(--md-sys-color-on-surface)" }} 145 - onMouseEnter={(e) => { 146 - e.currentTarget.style.color = seedColor; 147 - }} 148 - onMouseLeave={(e) => { 149 - e.currentTarget.style.color = "var(--md-sys-color-on-surface)"; 150 - }} 151 - > 152 - {mediaTitle} 153 - </h3> 154 - {releaseYear ? ( 155 - <p 156 - className="text-sm" 157 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 158 - > 159 - {releaseYear} 160 - </p> 161 - ) : null} 162 - {listContext ? ( 163 - <p 164 - className="text-sm" 165 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 166 - > 167 - {listContext} 168 - </p> 169 - ) : null} 170 - </Link> 171 - </div> 172 - ); 173 - }
+1 -1
apps/web/src/components/social/SocialUserCard.tsx
··· 28 28 return ( 29 29 <M3Card 30 30 variant="elevated" 31 - className="rounded-[28px] border" 31 + className="rounded-xl border" 32 32 style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 33 33 > 34 34 <M3CardContent className="flex flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between">
-59
apps/web/src/components/ui/action-button.tsx
··· 1 - import type { ReactNode } from "react"; 2 - 3 - interface ActionButtonProps { 4 - icon: ReactNode; 5 - label: string; 6 - onClick?: () => void; 7 - isActive?: boolean; 8 - activeColor?: string; 9 - disabled?: boolean; 10 - className?: string; 11 - } 12 - 13 - export function ActionButton({ 14 - icon, 15 - label, 16 - onClick, 17 - isActive = false, 18 - activeColor, 19 - disabled = false, 20 - className = "", 21 - }: ActionButtonProps) { 22 - const color = activeColor || "var(--md-sys-color-primary)"; 23 - const hoverBg = isActive 24 - ? `${color}15` 25 - : "var(--md-sys-color-surface-container)"; 26 - 27 - return ( 28 - <button 29 - type="button" 30 - onClick={onClick} 31 - disabled={disabled} 32 - className={`w-full py-3 px-6 rounded-xl m3-label-large transition-all duration-200 flex items-center justify-center gap-2 border focus:outline-none focus:ring-2 focus:ring-(--md-sys-color-primary)/50 ${className}`} 33 - style={ 34 - isActive 35 - ? { 36 - backgroundColor: `${color}20`, 37 - borderColor: color, 38 - color: color, 39 - } 40 - : { 41 - backgroundColor: "transparent", 42 - color: "var(--md-sys-color-on-surface-variant)", 43 - borderColor: "var(--md-sys-color-outline)", 44 - } 45 - } 46 - onMouseEnter={(e) => { 47 - e.currentTarget.style.backgroundColor = hoverBg; 48 - }} 49 - onMouseLeave={(e) => { 50 - e.currentTarget.style.backgroundColor = isActive 51 - ? `${color}20` 52 - : "transparent"; 53 - }} 54 - > 55 - {icon} 56 - {label} 57 - </button> 58 - ); 59 - }
-65
apps/web/src/components/ui/button.tsx
··· 1 - import { cva, type VariantProps } from "class-variance-authority"; 2 - import { Slot } from "radix-ui"; 3 - import type * as React from "react"; 4 - 5 - import { cn } from "@/lib/utils"; 6 - 7 - const buttonVariants = cva( 8 - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-(--md-sys-color-primary)/50", 9 - { 10 - variants: { 11 - variant: { 12 - default: 13 - "bg-(--md-sys-color-primary) text-(--md-sys-color-on-primary) hover:brightness-110", 14 - destructive: 15 - "bg-(--md-sys-color-error) text-(--md-sys-color-on-error) hover:brightness-110", 16 - outline: 17 - "border border-(--md-sys-color-outline-variant) bg-(--md-sys-color-surface-container) text-(--md-sys-color-on-surface) hover:bg-(--md-sys-color-surface-container-high)", 18 - secondary: 19 - "bg-(--md-sys-color-surface-container-high) text-(--md-sys-color-on-surface) hover:bg-(--md-sys-color-surface-container-highest)", 20 - ghost: 21 - "text-(--md-sys-color-on-surface-variant) hover:text-(--md-sys-color-on-surface) hover:bg-(--md-sys-color-surface-container-high)/50", 22 - link: "text-(--md-sys-color-primary) underline-offset-4 hover:underline hover:brightness-110", 23 - }, 24 - size: { 25 - default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 - xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", 27 - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 28 - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 29 - icon: "size-9", 30 - "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", 31 - "icon-sm": "size-8", 32 - "icon-lg": "size-10", 33 - }, 34 - }, 35 - defaultVariants: { 36 - variant: "default", 37 - size: "default", 38 - }, 39 - }, 40 - ); 41 - 42 - function Button({ 43 - className, 44 - variant = "default", 45 - size = "default", 46 - asChild = false, 47 - ...props 48 - }: React.ComponentProps<"button"> & 49 - VariantProps<typeof buttonVariants> & { 50 - asChild?: boolean; 51 - }) { 52 - const Comp = asChild ? Slot.Root : "button"; 53 - 54 - return ( 55 - <Comp 56 - data-slot="button" 57 - data-variant={variant} 58 - data-size={size} 59 - className={cn(buttonVariants({ variant, size, className }))} 60 - {...props} 61 - /> 62 - ); 63 - } 64 - 65 - export { Button, buttonVariants };
-246
apps/web/src/components/ui/calendar.tsx
··· 1 - import { 2 - ChevronDownIcon, 3 - ChevronLeftIcon, 4 - ChevronRightIcon, 5 - } from "lucide-react"; 6 - import * as React from "react"; 7 - import { 8 - type CustomComponents, 9 - type DayButton, 10 - DayPicker, 11 - getDefaultClassNames, 12 - } from "react-day-picker"; 13 - import { Button, buttonVariants } from "@/components/ui/button"; 14 - import { cn } from "@/lib/utils"; 15 - 16 - function CalendarRoot({ 17 - className, 18 - rootRef, 19 - ...props 20 - }: { 21 - className?: string; 22 - rootRef?: React.Ref<HTMLDivElement>; 23 - children?: React.ReactNode; 24 - }) { 25 - return ( 26 - <div 27 - data-slot="calendar" 28 - ref={rootRef} 29 - className={cn(className)} 30 - {...props} 31 - /> 32 - ); 33 - } 34 - 35 - function CalendarChevron({ 36 - className, 37 - orientation, 38 - ...props 39 - }: { 40 - className?: string; 41 - orientation?: "left" | "right" | "down" | "up"; 42 - children?: React.ReactNode; 43 - }) { 44 - if (orientation === "left") { 45 - return <ChevronLeftIcon className={cn("size-5", className)} {...props} />; 46 - } 47 - 48 - if (orientation === "right") { 49 - return <ChevronRightIcon className={cn("size-5", className)} {...props} />; 50 - } 51 - 52 - return <ChevronDownIcon className={cn("size-4", className)} {...props} />; 53 - } 54 - 55 - function CalendarWeekNumber({ 56 - children, 57 - className, 58 - ...props 59 - }: { 60 - children?: React.ReactNode; 61 - className?: string; 62 - }) { 63 - return ( 64 - <td className={className} {...props}> 65 - <div className="flex size-(--cell-size) items-center justify-center text-center"> 66 - {children} 67 - </div> 68 - </td> 69 - ); 70 - } 71 - 72 - function Calendar({ 73 - className, 74 - classNames, 75 - showOutsideDays = true, 76 - captionLayout = "label", 77 - buttonVariant = "ghost", 78 - formatters, 79 - components, 80 - ...props 81 - }: React.ComponentProps<typeof DayPicker> & { 82 - buttonVariant?: React.ComponentProps<typeof Button>["variant"]; 83 - }) { 84 - const defaultClassNames = getDefaultClassNames(); 85 - 86 - return ( 87 - <DayPicker 88 - showOutsideDays={showOutsideDays} 89 - weekStartsOn={0} 90 - className={cn( 91 - "bg-background group/calendar p-0 in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent font-normal", 92 - String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, 93 - String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, 94 - className, 95 - )} 96 - captionLayout={captionLayout} 97 - formatters={{ 98 - formatMonthDropdown: (date: Date) => 99 - date.toLocaleString("default", { month: "short" }), 100 - formatWeekdayName: (weekday: Date) => 101 - weekday.toLocaleString("default", { weekday: "narrow" }), 102 - ...formatters, 103 - }} 104 - classNames={{ 105 - root: cn("w-fit", defaultClassNames.root), 106 - months: cn("flex flex-col gap-0", defaultClassNames.months), 107 - month: cn("flex flex-col w-full gap-0", defaultClassNames.month), 108 - nav: cn( 109 - "flex items-center justify-between w-full px-1 py-1", 110 - defaultClassNames.nav, 111 - ), 112 - button_previous: cn( 113 - buttonVariants({ variant: buttonVariant }), 114 - "size-8 aria-disabled:opacity-50 p-0 rounded-full select-none hover:bg-muted", 115 - defaultClassNames.button_previous, 116 - ), 117 - button_next: cn( 118 - buttonVariants({ variant: buttonVariant }), 119 - "size-8 aria-disabled:opacity-50 p-0 rounded-full select-none hover:bg-muted", 120 - defaultClassNames.button_next, 121 - ), 122 - month_caption: cn( 123 - "flex items-center justify-center h-10 w-full", 124 - defaultClassNames.month_caption, 125 - ), 126 - dropdowns: cn( 127 - "flex items-center text-sm font-medium justify-center h-10 gap-1", 128 - defaultClassNames.dropdowns, 129 - ), 130 - dropdown_root: cn("relative", defaultClassNames.dropdown_root), 131 - dropdown: cn( 132 - "absolute bg-popover inset-0 opacity-0", 133 - defaultClassNames.dropdown, 134 - ), 135 - caption_label: cn( 136 - "select-none font-medium text-foreground text-base", 137 - captionLayout === "label" 138 - ? "text-base font-medium" 139 - : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", 140 - defaultClassNames.caption_label, 141 - ), 142 - table: "w-full border-collapse", 143 - weekdays: cn("grid grid-cols-7 w-full", defaultClassNames.weekdays), 144 - weekday: cn( 145 - "text-muted-foreground font-normal text-xs select-none h-9 flex items-center justify-center", 146 - defaultClassNames.weekday, 147 - ), 148 - week: cn("grid grid-cols-7 w-full", defaultClassNames.week), 149 - week_number_header: cn( 150 - "select-none w-8", 151 - defaultClassNames.week_number_header, 152 - ), 153 - week_number: cn( 154 - "text-xs select-none text-muted-foreground", 155 - defaultClassNames.week_number, 156 - ), 157 - day: cn( 158 - "relative w-full h-full p-0 text-center group/day aspect-square select-none", 159 - props.showWeekNumber 160 - ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-full" 161 - : "[&:first-child[data-selected=true]_button]:rounded-l-full", 162 - defaultClassNames.day, 163 - ), 164 - range_start: cn( 165 - "rounded-l-full rounded-r-none bg-primary text-primary-foreground", 166 - defaultClassNames.range_start, 167 - ), 168 - range_middle: cn( 169 - "rounded-none bg-primary/10 text-foreground", 170 - defaultClassNames.range_middle, 171 - ), 172 - range_end: cn( 173 - "rounded-r-full rounded-l-none bg-primary text-primary-foreground", 174 - defaultClassNames.range_end, 175 - ), 176 - today: cn( 177 - "border-2 border-primary rounded-full font-semibold text-foreground", 178 - defaultClassNames.today, 179 - ), 180 - selected: cn( 181 - "bg-primary text-primary-foreground rounded-full font-semibold", 182 - defaultClassNames.selected, 183 - ), 184 - outside: cn( 185 - "text-muted-foreground/50 aria-selected:text-muted-foreground", 186 - defaultClassNames.outside, 187 - ), 188 - disabled: cn( 189 - "text-muted-foreground/30 opacity-50", 190 - defaultClassNames.disabled, 191 - ), 192 - hidden: cn("invisible", defaultClassNames.hidden), 193 - ...classNames, 194 - }} 195 - components={{ 196 - Root: CalendarRoot as CustomComponents["Root"], 197 - Chevron: CalendarChevron as CustomComponents["Chevron"], 198 - DayButton: CalendarDayButton, 199 - WeekNumber: CalendarWeekNumber as CustomComponents["WeekNumber"], 200 - ...components, 201 - }} 202 - {...props} 203 - /> 204 - ); 205 - } 206 - 207 - function CalendarDayButton({ 208 - className, 209 - day, 210 - modifiers, 211 - ...props 212 - }: React.ComponentProps<typeof DayButton>) { 213 - const defaultClassNames = getDefaultClassNames(); 214 - 215 - const ref = React.useRef<HTMLButtonElement>(null); 216 - React.useEffect(() => { 217 - if (modifiers.focused) ref.current?.focus(); 218 - }, [modifiers.focused]); 219 - 220 - return ( 221 - <Button 222 - ref={ref} 223 - variant="ghost" 224 - size="icon" 225 - data-day={day.date.toLocaleDateString()} 226 - data-selected-single={ 227 - modifiers.selected && 228 - !modifiers.range_start && 229 - !modifiers.range_end && 230 - !modifiers.range_middle 231 - } 232 - data-range-start={modifiers.range_start} 233 - data-range-end={modifiers.range_end} 234 - data-range-middle={modifiers.range_middle} 235 - data-today={modifiers.today} 236 - className={cn( 237 - "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-primary/10 data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[today=true]:border-2 data-[today=true]:border-primary data-[today=true]:rounded-full group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square w-full h-full min-w-0 min-h-0 p-0 leading-none font-normal justify-center items-center rounded-full hover:bg-muted group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-2 group-data-[focused=true]/day:ring-ring", 238 - defaultClassNames.day, 239 - className, 240 - )} 241 - {...props} 242 - /> 243 - ); 244 - } 245 - 246 - export { Calendar };
-64
apps/web/src/components/ui/card.tsx
··· 1 - import type * as React from "react"; 2 - 3 - import { cn } from "@/lib/utils"; 4 - 5 - function Card({ className, ...props }: React.ComponentProps<"div">) { 6 - return ( 7 - <div 8 - data-slot="card" 9 - className={cn( 10 - "bg-(--md-sys-color-surface-container) text-(--md-sys-color-on-surface) flex flex-col gap-6 rounded-xl border border-(--md-sys-color-outline-variant) py-6 shadow-sm", 11 - className, 12 - )} 13 - {...props} 14 - /> 15 - ); 16 - } 17 - 18 - function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 - return ( 20 - <div 21 - data-slot="card-header" 22 - className={cn( 23 - "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", 24 - className, 25 - )} 26 - {...props} 27 - /> 28 - ); 29 - } 30 - 31 - function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 - return ( 33 - <div 34 - data-slot="card-title" 35 - className={cn("leading-none font-semibold", className)} 36 - {...props} 37 - /> 38 - ); 39 - } 40 - 41 - function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 - return ( 43 - <div 44 - data-slot="card-description" 45 - className={cn( 46 - "text-(--md-sys-color-on-surface-variant) text-sm", 47 - className, 48 - )} 49 - {...props} 50 - /> 51 - ); 52 - } 53 - 54 - function CardContent({ className, ...props }: React.ComponentProps<"div">) { 55 - return ( 56 - <div 57 - data-slot="card-content" 58 - className={cn("px-6", className)} 59 - {...props} 60 - /> 61 - ); 62 - } 63 - 64 - export { Card, CardHeader, CardTitle, CardDescription, CardContent };
+3 -3
apps/web/src/components/ui/dialog.tsx
··· 1 1 import { XIcon } from "lucide-react"; 2 2 import { Dialog as DialogPrimitive } from "radix-ui"; 3 3 import type * as React from "react"; 4 - import { Button } from "@/components/ui/button"; 4 + import { M3Button } from "@/components/ui/m3-button"; 5 5 import { cn } from "@/lib/utils"; 6 6 7 7 function Dialog({ ··· 52 52 <DialogPrimitive.Content 53 53 data-slot="dialog-content" 54 54 className={cn( 55 - "bg-(--md-sys-color-surface-container-high) text-(--md-sys-color-on-surface) data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[1.75rem] border border-(--md-sys-color-outline) p-6 shadow-lg duration-200 outline-none sm:max-w-lg", 55 + "bg-(--md-sys-color-surface-container-high) text-(--md-sys-color-on-surface) data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border border-(--md-sys-color-outline) p-6 shadow-lg duration-200 outline-none sm:max-w-lg", 56 56 className, 57 57 )} 58 58 {...props} ··· 102 102 {children} 103 103 {showCloseButton && ( 104 104 <DialogPrimitive.Close asChild> 105 - <Button variant="outline">Close</Button> 105 + <M3Button variant="outlined">Close</M3Button> 106 106 </DialogPrimitive.Close> 107 107 )} 108 108 </div>
-36
apps/web/src/components/ui/loading-button.tsx
··· 1 - import type { VariantProps } from "class-variance-authority"; 2 - import { Loader2 } from "lucide-react"; 3 - 4 - import { Button, buttonVariants } from "@/components/ui/button"; 5 - import { cn } from "@/lib/utils"; 6 - 7 - interface LoadingButtonProps 8 - extends React.ButtonHTMLAttributes<HTMLButtonElement>, 9 - VariantProps<typeof buttonVariants> { 10 - isLoading?: boolean; 11 - } 12 - 13 - function LoadingButton({ 14 - isLoading = false, 15 - children, 16 - disabled, 17 - className, 18 - variant, 19 - size, 20 - ...props 21 - }: LoadingButtonProps) { 22 - return ( 23 - <Button 24 - disabled={disabled || isLoading} 25 - className={cn(buttonVariants({ variant, size, className }))} 26 - variant={variant} 27 - size={size} 28 - {...props} 29 - > 30 - {isLoading && <Loader2 className="w-4 h-4 animate-spin" />} 31 - {isLoading ? "Loading" : children} 32 - </Button> 33 - ); 34 - } 35 - 36 - export { LoadingButton };
+40 -4
apps/web/src/components/ui/m3-button.tsx
··· 1 1 import { cva, type VariantProps } from "class-variance-authority"; 2 + import { Loader2 } from "lucide-react"; 2 3 import { Slot } from "radix-ui"; 3 4 import type * as React from "react"; 4 5 import { cn } from "@/lib/utils"; ··· 16 17 17 18 const m3ButtonVariants = cva( 18 19 [ 19 - "inline-flex items-center justify-center gap-2 whitespace-nowrap", 20 + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full", 20 21 "transition-all duration-200", 21 22 "disabled:pointer-events-none disabled:opacity-38", 22 23 "[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-[18px] shrink-0 [&_svg]:shrink-0", ··· 90 91 "active:bg-(--md-sys-color-primary-container)/20", 91 92 "px-3", 92 93 ].join(" "), 94 + 95 + /** 96 + * Destructive Button 97 + * - Error/danger actions (delete, remove) 98 + * - Error color background 99 + */ 100 + destructive: [ 101 + "bg-(--md-sys-color-error)", 102 + "text-(--md-sys-color-on-error)", 103 + "hover:brightness-110", 104 + "active:brightness-95", 105 + ].join(" "), 106 + 107 + /** 108 + * Ghost Button 109 + * - Minimal emphasis, no container 110 + * - Surface-variant text, subtle hover 111 + */ 112 + ghost: [ 113 + "bg-transparent", 114 + "text-(--md-sys-color-on-surface-variant)", 115 + "hover:text-(--md-sys-color-on-surface)", 116 + "hover:bg-(--md-sys-color-surface-container-high)/50", 117 + "active:bg-(--md-sys-color-surface-container-high)/70", 118 + ].join(" "), 93 119 }, 94 120 size: { 95 121 default: "h-10 px-6 py-2", 96 122 sm: "h-8 px-4 py-1.5", 123 + xs: "h-6 gap-1 px-2 text-xs [&_svg:not([class*='size-'])]:size-3", 97 124 lg: "h-12 px-8 py-3", 98 125 icon: "size-10", 126 + "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3", 99 127 "icon-sm": "size-8", 100 128 "icon-lg": "size-12", 101 129 }, ··· 111 139 extends React.ComponentProps<"button">, 112 140 VariantProps<typeof m3ButtonVariants> { 113 141 asChild?: boolean; 142 + isLoading?: boolean; 114 143 } 115 144 116 145 function M3Button({ ··· 118 147 variant = "filled", 119 148 size = "default", 120 149 asChild = false, 150 + isLoading = false, 151 + disabled, 152 + children, 121 153 ...props 122 154 }: M3ButtonProps) { 123 - const Comp = asChild ? Slot.Root : "button"; 155 + const useSlot = asChild && !isLoading; 156 + const Comp = useSlot ? Slot.Root : "button"; 124 157 125 158 return ( 126 159 <Comp 127 160 data-slot="m3-button" 128 161 data-variant={variant} 129 162 data-size={size} 163 + disabled={disabled || isLoading} 130 164 className={cn(m3ButtonVariants({ variant, size, className }))} 131 165 {...props} 132 - /> 166 + > 167 + {isLoading ? <Loader2 className="size-4 animate-spin" /> : children} 168 + </Comp> 133 169 ); 134 170 } 135 171 136 - export { M3Button }; 172 + export { M3Button, m3ButtonVariants };
+2 -2
apps/web/src/components/ui/material-date-picker.tsx
··· 111 111 return ( 112 112 <div 113 113 className={cn( 114 - "w-[328px] bg-(--md-sys-color-surface-container-high) rounded-[28px] p-0 overflow-hidden", 114 + "w-[328px] bg-(--md-sys-color-surface-container-high) rounded-xl p-0 overflow-hidden", 115 115 className, 116 116 )} 117 117 > ··· 171 171 {/* Year/Month Picker Overlay */} 172 172 {selectingYear && ( 173 173 <div className="px-4 pb-4"> 174 - <div className="bg-(--md-sys-color-surface-container) rounded-[16px] p-4 max-h-[280px] overflow-y-auto"> 174 + <div className="bg-(--md-sys-color-surface-container) rounded-lg p-4 max-h-[280px] overflow-y-auto"> 175 175 <div className="grid grid-cols-3 gap-2"> 176 176 {months.map((month, index) => ( 177 177 <button
+1 -1
apps/web/src/components/ui/tooltip.tsx
··· 42 42 data-slot="tooltip-content" 43 43 sideOffset={sideOffset} 44 44 className={cn( 45 - "bg-(--md-sys-color-inverse-surface) text-(--md-sys-color-inverse-on-surface) animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", 45 + "bg-(--md-sys-color-inverse-surface) text-(--md-sys-color-on-inverse-surface) animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", 46 46 className, 47 47 )} 48 48 {...props}
+5 -9
apps/web/src/routes/login.tsx
··· 11 11 import { toast } from "sonner"; 12 12 import { z } from "zod"; 13 13 import { useTheme } from "@/components/theme-provider"; 14 - import { LoadingButton } from "@/components/ui/loading-button"; 14 + import { M3Button } from "@/components/ui/m3-button"; 15 15 import { M3TextField } from "@/components/ui/m3-text-field"; 16 16 17 17 const OAUTH_PENDING_KEY = "oauth_pending"; ··· 154 154 className="mb-6 p-4 rounded-lg border" 155 155 style={{ 156 156 backgroundColor: 157 - "rgba(var(--md-sys-color-tertiary-container), 0.5)", 157 + "color-mix(in srgb, var(--md-sys-color-tertiary-container) 50%, transparent)", 158 158 borderColor: "var(--md-sys-color-tertiary)", 159 159 }} 160 160 > ··· 248 248 )} 249 249 </div> 250 250 251 - <LoadingButton 251 + <M3Button 252 252 type="submit" 253 - disabled={isSubmitting} 253 + variant="filled" 254 254 isLoading={isSubmitting} 255 255 className="w-full flex items-center justify-center px-4 py-3 font-semibold rounded-(--md-sys-shape-corner-large) transition-colors" 256 - style={{ 257 - backgroundColor: "var(--md-sys-color-primary)", 258 - color: "var(--md-sys-color-on-primary)", 259 - }} 260 256 > 261 257 Connect 262 - </LoadingButton> 258 + </M3Button> 263 259 264 260 <button 265 261 type="button"
+7 -7
apps/web/src/routes/profile.$handle.calendar.tsx
··· 274 274 <div className="space-y-5"> 275 275 {selectedMonthView ? ( 276 276 <section 277 - className="overflow-hidden rounded-[30px] border" 277 + className="overflow-hidden rounded-xl border" 278 278 style={{ 279 279 backgroundColor: "var(--md-sys-color-surface-container-low)", 280 280 borderColor: "var(--md-sys-color-outline-variant)", ··· 426 426 <div className="space-y-5 lg:hidden"> 427 427 {selectedMonthEvents.length === 0 && selectedMonthView ? ( 428 428 <div 429 - className="rounded-[24px] border px-4 py-5" 429 + className="rounded-xl border px-4 py-5" 430 430 style={{ 431 431 backgroundColor: 432 432 "var(--md-sys-color-surface-container-low)", ··· 440 440 {daySections.map((section) => ( 441 441 <section 442 442 key={section.dayKey} 443 - className="rounded-[28px] border p-4 md:p-5" 443 + className="rounded-xl border p-4 md:p-5" 444 444 style={{ 445 445 backgroundColor: 446 446 "var(--md-sys-color-surface-container-low)", 447 447 borderColor: "var(--md-sys-color-outline-variant)", 448 448 }} 449 449 > 450 - <div className="mb-4 flex flex-col gap-2 rounded-[22px] border px-4 py-3 md:flex-row md:items-end md:justify-between"> 450 + <div className="mb-4 flex flex-col gap-2 rounded-xl border px-4 py-3 md:flex-row md:items-end md:justify-between"> 451 451 <div> 452 452 <p className="md-title-large">{section.label}</p> 453 453 <p ··· 497 497 className="block" 498 498 > 499 499 <div 500 - className="group flex h-full gap-3 rounded-[24px] border p-3 transition-transform hover:-translate-y-0.5" 500 + className="group flex h-full gap-3 rounded-xl border p-3 transition-transform hover:-translate-y-0.5" 501 501 style={{ 502 502 backgroundColor: "var(--md-sys-color-surface-container-highest)", 503 503 borderColor: "var(--md-sys-color-outline-variant)", ··· 505 505 }} 506 506 > 507 507 <div 508 - className="relative h-24 w-16 shrink-0 overflow-hidden rounded-[18px]" 508 + className="relative h-24 w-16 shrink-0 overflow-hidden rounded-lg" 509 509 style={{ 510 510 backgroundColor: "var(--md-sys-color-surface-container-high)", 511 511 }} ··· 574 574 <Link 575 575 to={event.to as never} 576 576 params={event.params as never} 577 - className="block rounded-[18px] border px-3 py-3 transition-transform hover:-translate-y-0.5" 577 + className="block rounded-lg border px-3 py-3 transition-transform hover:-translate-y-0.5" 578 578 style={{ 579 579 backgroundColor: "var(--md-sys-color-surface-container-highest)", 580 580 borderColor: "var(--md-sys-color-outline-variant)",
+2 -2
apps/web/src/routes/profile.$handle.followers.tsx
··· 78 78 return ( 79 79 <M3Card 80 80 variant="elevated" 81 - className="mx-auto max-w-xl rounded-[28px] border" 81 + className="mx-auto max-w-xl rounded-xl border" 82 82 style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 83 83 > 84 84 <M3CardHeader> ··· 124 124 {users.length === 0 ? ( 125 125 <M3Card 126 126 variant="elevated" 127 - className="rounded-[28px] border" 127 + className="rounded-xl border" 128 128 style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 129 129 > 130 130 <M3CardHeader>
+2 -2
apps/web/src/routes/profile.$handle.following.tsx
··· 78 78 return ( 79 79 <M3Card 80 80 variant="elevated" 81 - className="mx-auto max-w-xl rounded-[28px] border" 81 + className="mx-auto max-w-xl rounded-xl border" 82 82 style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 83 83 > 84 84 <M3CardHeader> ··· 124 124 {users.length === 0 ? ( 125 125 <M3Card 126 126 variant="elevated" 127 - className="rounded-[28px] border" 127 + className="rounded-xl border" 128 128 style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 129 129 > 130 130 <M3CardHeader>
+105 -30
apps/web/src/routes/profile.$handle.list.$slug.tsx
··· 17 17 import { useEffect, useMemo, useState } from "react"; 18 18 import { toast } from "sonner"; 19 19 import { ConfirmDialog } from "@/components/ConfirmDialog"; 20 - import { ListMediaCard } from "@/components/lists/ListMediaCard"; 21 - import { MovieGridSkeleton } from "@/components/MovieGrid"; 20 + import { MediaPosterCard } from "@/components/MediaPosterCard"; 21 + import { PosterGridSkeleton } from "@/components/MovieGrid"; 22 22 import { PaginationControls } from "@/components/PaginationControls"; 23 23 import { useTheme } from "@/components/theme-provider"; 24 24 import { M3Button } from "@/components/ui/m3-button"; ··· 36 36 } from "@/lib/invalidate-shelf"; 37 37 import { getVisiblePages, parsePageNumber } from "@/lib/pagination"; 38 38 import { getProfileRoute } from "@/lib/profile-routes"; 39 - import { parseScopedShowMediaId } from "@/lib/utils"; 39 + import { createTitleSlug, parseScopedShowMediaId } from "@/lib/utils"; 40 40 41 41 const PAGE_SIZE = 24; 42 42 ··· 184 184 }); 185 185 186 186 if (isLoading) { 187 - return <MovieGridSkeleton />; 187 + return <PosterGridSkeleton />; 188 188 } 189 189 190 190 if (!profile) { ··· 192 192 } 193 193 194 194 if (isListLoading) { 195 - return <MovieGridSkeleton />; 195 + return <PosterGridSkeleton />; 196 196 } 197 197 198 198 if (!list) { ··· 369 369 {totalItems} item{totalItems !== 1 ? "s" : ""} 370 370 </p> 371 371 <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"> 372 - {items.map((item) => ( 373 - <ListMediaCard 374 - key={item.id} 375 - item={item} 376 - readOnly={!isOwner} 377 - onWatch={isOwner ? () => handleQuickWatch(item) : undefined} 378 - onRemove={ 379 - isOwner 380 - ? ({ mediaType, mediaId }) => { 381 - removeMutation.mutate({ 382 - path: { slug, mediaType, mediaId }, 383 - }); 384 - } 385 - : undefined 386 - } 387 - isWatching={isOwner ? isQuickWatchPending(item) : false} 388 - isRemoving={ 389 - isOwner && 390 - removeMutation.isPending && 391 - removeMutation.variables?.path?.mediaType === 392 - item.mediaType && 393 - removeMutation.variables?.path?.mediaId === item.mediaId 394 - } 395 - /> 396 - ))} 372 + {items.map((item) => { 373 + const media = item.media as { 374 + title?: string; 375 + posterPath?: string | null; 376 + releaseYear?: number | null; 377 + showId?: string; 378 + }; 379 + const mediaType: "movie" | "show" = 380 + item.mediaType === "show" ? "show" : "movie"; 381 + const scopedShow = 382 + mediaType === "show" 383 + ? parseScopedShowMediaId(item.mediaId) 384 + : null; 385 + const showIdForNav = 386 + media.showId ?? scopedShow?.showId ?? item.mediaId; 387 + const seasonNumber = scopedShow?.seasonNumber; 388 + const episodeNumber = scopedShow?.episodeNumber; 389 + const mediaTitle = media.title ?? "Untitled"; 390 + const titleSlug = createTitleSlug(mediaTitle); 391 + const isMovie = mediaType === "movie"; 392 + const listContext = 393 + typeof seasonNumber === "number" && 394 + typeof episodeNumber === "number" 395 + ? `S${seasonNumber} E${episodeNumber}` 396 + : typeof seasonNumber === "number" 397 + ? `Season ${seasonNumber}` 398 + : null; 399 + 400 + let linkTo: string; 401 + let linkParams: Record<string, string>; 402 + if (isMovie) { 403 + linkTo = "/movies/$movieId/$title"; 404 + linkParams = { movieId: item.mediaId, title: titleSlug }; 405 + } else if ( 406 + typeof seasonNumber === "number" && 407 + typeof episodeNumber === "number" 408 + ) { 409 + linkTo = 410 + "/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber"; 411 + linkParams = { 412 + showId: showIdForNav, 413 + title: titleSlug, 414 + seasonNumber: String(seasonNumber), 415 + episodeNumber: String(episodeNumber), 416 + }; 417 + } else if (typeof seasonNumber === "number") { 418 + linkTo = "/shows/$showId/$title/seasons/$seasonNumber"; 419 + linkParams = { 420 + showId: showIdForNav, 421 + title: titleSlug, 422 + seasonNumber: String(seasonNumber), 423 + }; 424 + } else { 425 + linkTo = "/shows/$showId/$title"; 426 + linkParams = { showId: showIdForNav, title: titleSlug }; 427 + } 428 + 429 + return ( 430 + <MediaPosterCard 431 + key={item.id} 432 + posterPath={media.posterPath} 433 + title={mediaTitle} 434 + subtitle={ 435 + [media.releaseYear?.toString(), listContext] 436 + .filter(Boolean) 437 + .join(" · ") || undefined 438 + } 439 + to={linkTo} 440 + params={linkParams} 441 + user={isOwner ? currentUser : undefined} 442 + readOnly={!isOwner} 443 + isOnShelf={false} 444 + onToggleShelf={ 445 + isOwner ? () => handleQuickWatch(item) : undefined 446 + } 447 + isShelfPending={isOwner ? isQuickWatchPending(item) : false} 448 + onRemove={ 449 + isOwner 450 + ? () => { 451 + removeMutation.mutate({ 452 + path: { 453 + slug, 454 + mediaType, 455 + mediaId: item.mediaId, 456 + }, 457 + }); 458 + } 459 + : undefined 460 + } 461 + isRemoving={ 462 + isOwner && 463 + removeMutation.isPending && 464 + removeMutation.variables?.path?.mediaType === 465 + item.mediaType && 466 + removeMutation.variables?.path?.mediaId === item.mediaId 467 + } 468 + removeIcon="x" 469 + /> 470 + ); 471 + })} 397 472 </div> 398 473 <div className="mt-6"> 399 474 <PaginationControls
+4 -4
apps/web/src/routes/profile.$handle.people.tsx
··· 218 218 return ( 219 219 <M3Card 220 220 variant="elevated" 221 - className="mx-auto max-w-xl rounded-[28px] border" 221 + className="mx-auto max-w-xl rounded-xl border" 222 222 style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 223 223 > 224 224 <M3CardHeader> ··· 246 246 return ( 247 247 <M3Card 248 248 variant="elevated" 249 - className="rounded-[28px] border" 249 + className="rounded-xl border" 250 250 style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 251 251 > 252 252 <M3CardHeader> ··· 503 503 return ( 504 504 <M3Card 505 505 variant="elevated" 506 - className="rounded-[28px] border" 506 + className="rounded-xl border" 507 507 style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 508 508 > 509 509 <M3CardHeader> ··· 574 574 return ( 575 575 <M3Card 576 576 variant="elevated" 577 - className="rounded-[28px] border" 577 + className="rounded-xl border" 578 578 style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 579 579 > 580 580 <M3CardHeader>
+8 -4
apps/web/src/routes/profile.$handle.settings.tsx
··· 837 837 <div 838 838 className="p-3 rounded-lg border" 839 839 style={{ 840 - backgroundColor: "rgba(var(--md-sys-color-error), 0.1)", 841 - borderColor: "rgba(var(--md-sys-color-error), 0.2)", 840 + backgroundColor: 841 + "color-mix(in srgb, var(--md-sys-color-error) 10%, transparent)", 842 + borderColor: 843 + "color-mix(in srgb, var(--md-sys-color-error) 20%, transparent)", 842 844 }} 843 845 > 844 846 <p className="md-body-medium text-(--md-sys-color-error)"> ··· 979 981 <div 980 982 className="rounded-lg border p-3" 981 983 style={{ 982 - backgroundColor: "rgba(var(--md-sys-color-error), 0.1)", 983 - borderColor: "rgba(var(--md-sys-color-error), 0.2)", 984 + backgroundColor: 985 + "color-mix(in srgb, var(--md-sys-color-error) 10%, transparent)", 986 + borderColor: 987 + "color-mix(in srgb, var(--md-sys-color-error) 20%, transparent)", 984 988 }} 985 989 > 986 990 <p className="md-body-medium text-(--md-sys-color-error)">
+137 -20
apps/web/src/routes/profile.$handle.shelf.tsx
··· 1 1 import { 2 + moviesControllerUnmarkWatchedMutation, 2 3 shelfControllerGetUserShelfOptions, 4 + showsControllerDeleteEpisodeWatchHistoryEntryMutation, 3 5 usersControllerGetMySettingsOptions, 4 6 } from "@opnshelf/api"; 5 - import { useQuery } from "@tanstack/react-query"; 7 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 6 8 import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 7 9 import { BookOpen, Loader2 } from "lucide-react"; 8 10 import { useEffect, useMemo } from "react"; 11 + import { MediaPosterCard } from "@/components/MediaPosterCard"; 9 12 import { PaginationControls } from "@/components/PaginationControls"; 10 - import { ShelfEpisodeCard } from "@/components/ShelfEpisodeCard"; 11 - import { ShelfMovieCard } from "@/components/ShelfMovieCard"; 12 13 import { M3Button } from "@/components/ui/m3-button"; 13 14 import { 14 15 M3Card, ··· 17 18 M3CardHeader, 18 19 M3CardTitle, 19 20 } from "@/components/ui/m3-card"; 21 + import { useFormattedDate } from "@/hooks/useFormattedDate"; 20 22 import { useProfileRouteState } from "@/hooks/useProfileRouteState"; 23 + import { 24 + invalidateUserShelfQueries, 25 + invalidateUserUpNextQueries, 26 + } from "@/lib/invalidate-shelf"; 21 27 import { getVisiblePages, parsePageNumber } from "@/lib/pagination"; 22 - import { getDayKeyInTimezone, getShelfDayLabel } from "@/lib/utils"; 28 + import { 29 + createTitleSlug, 30 + getDayKeyInTimezone, 31 + getShelfDayLabel, 32 + } from "@/lib/utils"; 23 33 24 34 const PAGE_SIZE = 24; 25 35 ··· 57 67 query: { page, pageSize: PAGE_SIZE }, 58 68 }), 59 69 enabled: !!userDid, 70 + }); 71 + 72 + const queryClient = useQueryClient(); 73 + const { formatDate } = useFormattedDate(); 74 + 75 + const unmarkMovieMutation = useMutation({ 76 + mutationKey: ["shelf", "movies", "unmarkWatched"], 77 + ...moviesControllerUnmarkWatchedMutation(), 78 + onSuccess: () => { 79 + invalidateUserShelfQueries(queryClient, userDid); 80 + }, 81 + }); 82 + 83 + const deleteEpisodeMutation = useMutation({ 84 + mutationKey: ["shelf", "episodes", "deleteWatchEntry"], 85 + ...showsControllerDeleteEpisodeWatchHistoryEntryMutation(), 86 + onSuccess: () => { 87 + invalidateUserShelfQueries(queryClient, userDid); 88 + invalidateUserUpNextQueries(queryClient, userDid); 89 + }, 60 90 }); 61 91 62 92 const items = shelfQuery.data?.items ?? []; ··· 179 209 {daySections.map((section) => ( 180 210 <section 181 211 key={section.dayKey} 182 - className="rounded-[28px] border p-4 md:p-5" 212 + className="rounded-xl border p-4 md:p-5" 183 213 style={{ 184 214 backgroundColor: "var(--md-sys-color-surface-container-low)", 185 215 borderColor: "var(--md-sys-color-outline-variant)", 186 216 }} 187 217 > 188 218 <div 189 - className="mb-4 flex flex-col gap-1 rounded-[22px] border px-4 py-3 md:flex-row md:items-center md:justify-between md:gap-3" 219 + className="mb-4 flex flex-col gap-1 rounded-xl border px-4 py-3 md:flex-row md:items-center md:justify-between md:gap-3" 190 220 style={{ 191 221 backgroundColor: 192 222 "var(--md-sys-color-surface-container-highest)", ··· 204 234 </div> 205 235 206 236 <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"> 207 - {section.items.map((item) => 208 - item.type === "movie" ? ( 209 - <ShelfMovieCard 210 - key={item.id} 211 - tracked={item as never} 237 + {section.items.map((item) => { 238 + const shelfItem = item as { 239 + id: string; 240 + type: string; 241 + movieId?: string; 242 + title?: string; 243 + showId?: string; 244 + showTitle?: string; 245 + seasonNumber?: number; 246 + episodeNumber?: number; 247 + posterPath?: string; 248 + releaseYear?: number; 249 + watchedDate?: string; 250 + }; 251 + const isMovie = shelfItem.type === "movie"; 252 + const title = isMovie 253 + ? (shelfItem.title ?? "Untitled") 254 + : (shelfItem.showTitle ?? "Untitled"); 255 + const watchedLabel = shelfItem.watchedDate 256 + ? `Watched ${formatDate(shelfItem.watchedDate, { includeTime: false })}` 257 + : undefined; 258 + const subtitle = isMovie 259 + ? [shelfItem.releaseYear?.toString(), watchedLabel] 260 + .filter(Boolean) 261 + .join(" · ") 262 + : watchedLabel; 263 + 264 + return ( 265 + <MediaPosterCard 266 + key={shelfItem.id} 267 + posterPath={shelfItem.posterPath} 268 + title={title} 269 + subtitle={subtitle || undefined} 270 + badge={ 271 + !isMovie && shelfItem.seasonNumber != null 272 + ? `S${shelfItem.seasonNumber} E${shelfItem.episodeNumber}` 273 + : undefined 274 + } 275 + to={ 276 + isMovie 277 + ? "/movies/$movieId/$title" 278 + : "/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 279 + } 280 + params={ 281 + isMovie 282 + ? { 283 + movieId: shelfItem.movieId ?? "", 284 + title: createTitleSlug(title), 285 + } 286 + : { 287 + showId: shelfItem.showId ?? "", 288 + title: createTitleSlug(title), 289 + seasonNumber: String(shelfItem.seasonNumber ?? 1), 290 + episodeNumber: String(shelfItem.episodeNumber ?? 1), 291 + } 292 + } 212 293 user={isOwner ? (currentUser ?? undefined) : undefined} 213 294 readOnly={!isOwner} 214 - /> 215 - ) : ( 216 - <ShelfEpisodeCard 217 - key={item.id} 218 - tracked={item as never} 219 - user={isOwner ? (currentUser ?? undefined) : undefined} 220 - readOnly={!isOwner} 295 + listMedia={ 296 + isMovie && shelfItem.movieId 297 + ? { 298 + type: "movie", 299 + id: shelfItem.movieId, 300 + title, 301 + } 302 + : shelfItem.showId 303 + ? { 304 + type: "show", 305 + id: shelfItem.showId, 306 + title, 307 + } 308 + : undefined 309 + } 310 + onRemove={ 311 + isOwner 312 + ? () => { 313 + if (isMovie) { 314 + unmarkMovieMutation.mutate({ 315 + path: { 316 + movieId: shelfItem.movieId ?? "", 317 + }, 318 + }); 319 + } else { 320 + deleteEpisodeMutation.mutate({ 321 + path: { 322 + trackedEpisodeId: shelfItem.id, 323 + }, 324 + }); 325 + } 326 + } 327 + : undefined 328 + } 329 + isRemoving={ 330 + isMovie 331 + ? unmarkMovieMutation.isPending && 332 + unmarkMovieMutation.variables?.path?.movieId === 333 + shelfItem.movieId 334 + : deleteEpisodeMutation.isPending && 335 + deleteEpisodeMutation.variables?.path 336 + ?.trackedEpisodeId === shelfItem.id 337 + } 221 338 /> 222 - ), 223 - )} 339 + ); 340 + })} 224 341 </div> 225 342 </section> 226 343 ))}
+139 -39
apps/web/src/routes/search.tsx
··· 1 1 import { 2 2 authControllerMeOptions, 3 3 moviesControllerGetUserMoviesOptions, 4 + moviesControllerGetUserMoviesQueryKey, 5 + moviesControllerMarkWatchedMutation, 6 + moviesControllerUnmarkWatchedMutation, 4 7 searchControllerDiscoverAllOptions, 5 8 searchControllerSearchAllOptions, 6 9 showsControllerGetUserShowsOptions, 7 - type TmdbMovieResultDto, 10 + showsControllerGetUserShowsQueryKey, 11 + showsControllerMarkShowWatchedMutation, 12 + showsControllerUnmarkWatchedMutation, 8 13 type UnifiedSearchResultDto, 9 14 } from "@opnshelf/api"; 10 15 import { usePostHog } from "@posthog/react"; 11 - import { useQuery } from "@tanstack/react-query"; 16 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 12 17 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 13 18 import { Search, X } from "lucide-react"; 14 19 import { useEffect, useMemo, useRef, useState } from "react"; 15 - import { MovieCard } from "@/components/MovieCard"; 16 - import { ShowCard } from "@/components/ShowCard"; 20 + import { MediaPosterCard } from "@/components/MediaPosterCard"; 17 21 import { M3TextField } from "@/components/ui/m3-text-field"; 22 + import { invalidateUserShelfQueries } from "@/lib/invalidate-shelf"; 23 + import { createTitleSlug } from "@/lib/utils"; 18 24 19 25 export const Route = createFileRoute("/search")({ 20 26 component: SearchPage, ··· 66 72 if (!trackedShows) return new Set<string>(); 67 73 return new Set(trackedShows.map((s: { showId: string }) => s.showId)); 68 74 }, [trackedShows]); 75 + 76 + const queryClient = useQueryClient(); 77 + 78 + const markMovieMutation = useMutation({ 79 + mutationKey: ["search", "movies", "markWatched"], 80 + ...moviesControllerMarkWatchedMutation(), 81 + onSuccess: () => { 82 + queryClient.invalidateQueries({ 83 + queryKey: moviesControllerGetUserMoviesQueryKey({ 84 + path: { userDid: user?.did ?? "" }, 85 + }), 86 + }); 87 + invalidateUserShelfQueries(queryClient, user?.did); 88 + }, 89 + }); 90 + 91 + const unmarkMovieMutation = useMutation({ 92 + mutationKey: ["search", "movies", "unmarkWatched"], 93 + ...moviesControllerUnmarkWatchedMutation(), 94 + onSuccess: () => { 95 + queryClient.invalidateQueries({ 96 + queryKey: moviesControllerGetUserMoviesQueryKey({ 97 + path: { userDid: user?.did ?? "" }, 98 + }), 99 + }); 100 + invalidateUserShelfQueries(queryClient, user?.did); 101 + }, 102 + }); 103 + 104 + const markShowMutation = useMutation({ 105 + mutationKey: ["search", "shows", "markShowWatched"], 106 + ...showsControllerMarkShowWatchedMutation(), 107 + onSuccess: () => { 108 + queryClient.invalidateQueries({ 109 + queryKey: showsControllerGetUserShowsQueryKey({ 110 + path: { userDid: user?.did ?? "" }, 111 + }), 112 + }); 113 + invalidateUserShelfQueries(queryClient, user?.did); 114 + }, 115 + }); 116 + 117 + const unmarkShowMutation = useMutation({ 118 + mutationKey: ["search", "shows", "unmarkWatched"], 119 + ...showsControllerUnmarkWatchedMutation(), 120 + onSuccess: () => { 121 + queryClient.invalidateQueries({ 122 + queryKey: showsControllerGetUserShowsQueryKey({ 123 + path: { userDid: user?.did ?? "" }, 124 + }), 125 + }); 126 + invalidateUserShelfQueries(queryClient, user?.did); 127 + }, 128 + }); 69 129 70 130 useEffect(() => { 71 131 if (searchQuery !== lastNavigatedQueryRef.current) { ··· 267 327 </div> 268 328 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> 269 329 {combinedResults.map((item) => { 270 - if (item.media_type === "movie") { 271 - const movie: TmdbMovieResultDto = { 272 - id: item.id, 273 - title: item.title ?? "", 274 - poster_path: item.poster_path, 275 - backdrop_path: item.backdrop_path, 276 - release_date: item.release_date, 277 - overview: item.overview, 278 - }; 279 - return ( 280 - <MovieCard 281 - key={`movie-${item.id}`} 282 - movie={movie} 283 - user={user} 284 - isWatched={watchedMovieIds.has(item.id.toString())} 285 - /> 286 - ); 287 - } else { 288 - const show = { 289 - id: item.id, 290 - name: item.name ?? "", 291 - poster_path: item.poster_path, 292 - backdrop_path: item.backdrop_path, 293 - first_air_date: item.first_air_date, 294 - overview: item.overview, 295 - }; 296 - return ( 297 - <ShowCard 298 - key={`show-${item.id}`} 299 - show={show} 300 - user={user} 301 - isWatched={watchedShowIds.has(item.id.toString())} 302 - /> 303 - ); 304 - } 330 + const isMovie = item.media_type === "movie"; 331 + const id = item.id.toString(); 332 + const title = isMovie ? (item.title ?? "") : (item.name ?? ""); 333 + const year = isMovie 334 + ? item.release_date?.split("-")[0] 335 + : item.first_air_date?.split("-")[0]; 336 + const isWatched = isMovie 337 + ? watchedMovieIds.has(id) 338 + : watchedShowIds.has(id); 339 + const isShelfPending = isMovie 340 + ? (markMovieMutation.isPending && 341 + markMovieMutation.variables?.body?.movieId === id) || 342 + (unmarkMovieMutation.isPending && 343 + unmarkMovieMutation.variables?.path?.movieId === id) 344 + : (markShowMutation.isPending && 345 + markShowMutation.variables?.body?.showId === id) || 346 + (unmarkShowMutation.isPending && 347 + unmarkShowMutation.variables?.path?.showId === id); 348 + 349 + return ( 350 + <MediaPosterCard 351 + key={`${item.media_type}-${item.id}`} 352 + posterPath={item.poster_path} 353 + title={title} 354 + subtitle={year} 355 + to={ 356 + isMovie 357 + ? "/movies/$movieId/$title" 358 + : "/shows/$showId/$title" 359 + } 360 + params={ 361 + isMovie 362 + ? { 363 + movieId: id, 364 + title: createTitleSlug(title), 365 + } 366 + : { 367 + showId: id, 368 + title: createTitleSlug(title), 369 + } 370 + } 371 + user={user} 372 + isOnShelf={isWatched} 373 + isShelfPending={isShelfPending} 374 + onToggleShelf={() => { 375 + if (isMovie) { 376 + if (isWatched) { 377 + unmarkMovieMutation.mutate({ 378 + path: { movieId: id }, 379 + }); 380 + } else { 381 + markMovieMutation.mutate({ 382 + body: { movieId: id }, 383 + }); 384 + } 385 + } else { 386 + if (isWatched) { 387 + unmarkShowMutation.mutate({ 388 + path: { showId: id }, 389 + query: { mode: "all" }, 390 + }); 391 + } else { 392 + markShowMutation.mutate({ 393 + body: { showId: id }, 394 + }); 395 + } 396 + } 397 + }} 398 + listMedia={{ 399 + type: isMovie ? "movie" : "show", 400 + id, 401 + title, 402 + }} 403 + /> 404 + ); 305 405 })} 306 406 </div> 307 407 </>
+5 -4
apps/web/src/routes/shows.$showId.$title.seasons.$seasonNumber.episodes.$episodeNumber.tsx
··· 296 296 }); 297 297 298 298 const colors: ColorTheme = { 299 - primary: show?.colors?.primary || "#F59E0B", 300 - secondary: show?.colors?.secondary || "#D97706", 301 - accent: show?.colors?.accent || "#FBBF24", 302 - muted: show?.colors?.muted || "#6b7280", 299 + primary: show?.colors?.primary || "var(--md-sys-color-primary)", 300 + secondary: 301 + show?.colors?.secondary || "var(--md-sys-color-primary-container)", 302 + accent: show?.colors?.accent || "var(--md-sys-color-on-primary-container)", 303 + muted: show?.colors?.muted || "var(--md-sys-color-surface-container)", 303 304 }; 304 305 305 306 const backdropUrl = getTmdbBackdropUrl(show?.backdrop_path);
+1 -1
apps/web/src/routes/shows.$showId.$title.seasons.$seasonNumber.tsx
··· 188 188 primary: show?.colors?.primary || seedColor, 189 189 secondary: show?.colors?.secondary || seedColor, 190 190 accent: show?.colors?.accent || seedColor, 191 - muted: show?.colors?.muted || "#6b7280", 191 + muted: show?.colors?.muted || "var(--md-sys-color-surface-container)", 192 192 }; 193 193 194 194 const backdropUrl = getTmdbBackdropUrl(show?.backdrop_path);
+1 -1
apps/web/src/routes/shows.$showId.$title.tsx
··· 155 155 primary: show?.colors?.primary || seedColor, 156 156 secondary: show?.colors?.secondary || seedColor, 157 157 accent: show?.colors?.accent || seedColor, 158 - muted: show?.colors?.muted || "#6b7280", 158 + muted: show?.colors?.muted || "var(--md-sys-color-surface-container)", 159 159 }; 160 160 161 161 const backdropUrl = getTmdbBackdropUrl(show?.backdrop_path);
+19 -44
apps/web/src/styles.css
··· 8 8 @custom-variant dark (&:is(.dark *)); 9 9 10 10 @theme { 11 - --color-background: var(--background); 12 - --color-foreground: var(--foreground); 13 - --color-card: var(--card); 14 - --color-card-foreground: var(--card-foreground); 15 - --color-popover: var(--popover); 16 - --color-popover-foreground: var(--popover-foreground); 17 - --color-primary: var(--primary); 18 - --color-primary-foreground: var(--primary-foreground); 19 - --color-secondary: var(--secondary); 20 - --color-secondary-foreground: var(--secondary-foreground); 21 - --color-muted: var(--muted); 22 - --color-muted-foreground: var(--muted-foreground); 23 - --color-accent: var(--accent); 24 - --color-accent-foreground: var(--accent-foreground); 25 - --color-destructive: var(--destructive); 26 - --color-destructive-foreground: var(--destructive-foreground); 27 - --color-border: var(--border); 28 - --color-input: var(--input); 29 - --color-ring: var(--ring); 11 + --color-background: var(--md-sys-color-surface); 12 + --color-foreground: var(--md-sys-color-on-surface); 13 + --color-popover: var(--md-sys-color-surface-container); 14 + --color-popover-foreground: var(--md-sys-color-on-surface); 15 + --color-muted-foreground: var(--md-sys-color-on-surface-variant); 16 + --color-accent: var(--md-sys-color-tertiary-container); 17 + --color-accent-foreground: var(--md-sys-color-on-tertiary-container); 18 + --color-destructive: var(--md-sys-color-error); 19 + --color-border: var(--md-sys-color-outline-variant); 20 + --color-input: var(--md-sys-color-surface-container-highest); 21 + --color-ring: var(--md-sys-color-primary); 22 + 23 + --radius-xs: var(--md-sys-shape-corner-extra-small); 24 + --radius-sm: var(--md-sys-shape-corner-small); 25 + --radius-md: var(--md-sys-shape-corner-medium); 26 + --radius-lg: var(--md-sys-shape-corner-large); 27 + --radius-xl: var(--md-sys-shape-corner-extra-large); 28 + --radius-full: var(--md-sys-shape-corner-full); 30 29 } 31 30 32 31 /* ============================================ ··· 219 218 --md-sys-motion-duration-long3: 550ms; 220 219 --md-sys-motion-duration-long4: 600ms; 221 220 222 - /* Legacy shadcn/ui compatibility */ 223 - --background: var(--md-sys-color-surface); 224 - --foreground: var(--md-sys-color-on-surface); 225 - --card: var(--md-sys-color-surface-container-low); 226 - --card-foreground: var(--md-sys-color-on-surface); 227 - --popover: var(--md-sys-color-surface-container); 228 - --popover-foreground: var(--md-sys-color-on-surface); 229 - --primary: var(--md-sys-color-primary); 230 - --primary-foreground: var(--md-sys-color-on-primary); 231 - --secondary: var(--md-sys-color-secondary-container); 232 - --secondary-foreground: var(--md-sys-color-on-secondary-container); 233 - --muted: var(--md-sys-color-surface-container-highest); 234 - --muted-foreground: var(--md-sys-color-on-surface-variant); 235 - --accent: var(--md-sys-color-tertiary-container); 236 - --accent-foreground: var(--md-sys-color-on-tertiary-container); 237 - --destructive: var(--md-sys-color-error); 238 - --destructive-foreground: var(--md-sys-color-on-error); 239 - --border: var(--md-sys-color-outline-variant); 240 - --input: var(--md-sys-color-surface-container-highest); 241 - --ring: var(--md-sys-color-primary); 242 - --radius: 12px; 243 221 } 244 222 245 223 /* ============================================ ··· 500 478 501 479 @layer base { 502 480 * { 503 - @apply border-border; 504 - } 505 - body { 506 - @apply bg-background text-foreground; 481 + border-color: var(--md-sys-color-outline-variant); 507 482 } 508 483 }
-26
pnpm-lock.yaml
··· 243 243 react: 244 244 specifier: ^19.2.0 245 245 version: 19.2.4 246 - react-day-picker: 247 - specifier: ^9.13.1 248 - version: 9.13.1(react@19.2.4) 249 246 react-dom: 250 247 specifier: ^19.2.0 251 248 version: 19.2.4(react@19.2.4) ··· 1316 1313 '@csstools/css-tokenizer@3.0.4': 1317 1314 resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} 1318 1315 engines: {node: '>=18'} 1319 - 1320 - '@date-fns/tz@1.4.1': 1321 - resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} 1322 1316 1323 1317 '@egjs/hammerjs@2.0.17': 1324 1318 resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} ··· 4985 4979 resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} 4986 4980 engines: {node: '>=20'} 4987 4981 4988 - date-fns-jalali@4.1.0-0: 4989 - resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} 4990 - 4991 4982 date-fns@4.1.0: 4992 4983 resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} 4993 4984 ··· 7477 7468 resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} 7478 7469 hasBin: true 7479 7470 7480 - react-day-picker@9.13.1: 7481 - resolution: {integrity: sha512-9nx2lBBJ0VZw5jJekId3DishwnJLiqY1Me1JvCrIyqbWwcflBTVaEkiK+w1bre5oMNWYo722eu+8UAMXWMqktw==} 7482 - engines: {node: '>=18'} 7483 - peerDependencies: 7484 - react: '>=16.8.0' 7485 - 7486 7471 react-devtools-core@6.1.5: 7487 7472 resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} 7488 7473 ··· 10289 10274 '@csstools/css-syntax-patches-for-csstree@1.0.26': {} 10290 10275 10291 10276 '@csstools/css-tokenizer@3.0.4': {} 10292 - 10293 - '@date-fns/tz@1.4.1': {} 10294 10277 10295 10278 '@egjs/hammerjs@2.0.17': 10296 10279 dependencies: ··· 14749 14732 whatwg-mimetype: 5.0.0 14750 14733 whatwg-url: 15.1.0 14751 14734 14752 - date-fns-jalali@4.1.0-0: {} 14753 - 14754 14735 date-fns@4.1.0: {} 14755 14736 14756 14737 db0@0.3.4(@electric-sql/pglite@0.3.15)(better-sqlite3@12.8.0)(mysql2@3.15.3): ··· 17610 17591 ini: 1.3.8 17611 17592 minimist: 1.2.8 17612 17593 strip-json-comments: 2.0.1 17613 - 17614 - react-day-picker@9.13.1(react@19.2.4): 17615 - dependencies: 17616 - '@date-fns/tz': 1.4.1 17617 - date-fns: 4.1.0 17618 - date-fns-jalali: 4.1.0-0 17619 - react: 19.2.4 17620 17594 17621 17595 react-devtools-core@6.1.5: 17622 17596 dependencies: