@@ -1,8 +1,8 @@
"use client" ;
import { useState , useCallback } from "react" ;
import { useState , useCallback , useEffect } from "react" ;
import { useTranslations } from "next-intl" ;
import { Trash2 , Globe , Lock , Link2 , X , Check , ListChecks } from "lucide-react" ;
import { Trash2 , Globe , Lock , Link2 , X , Check , ListChecks , LayoutGrid , List , Rows3 } from "lucide-react" ;
import { Button , buttonVariants } from "@/components/ui/button" ;
import {
DropdownMenu ,
@@ -10,8 +10,10 @@ import {
DropdownMenuItem ,
DropdownMenuTrigger ,
} from "@/components/ui/dropdown-menu" ;
import { Tooltip , TooltipContent , TooltipProvider , TooltipTrigger } from "@/components/ui/tooltip" ;
import { toast } from "sonner" ;
import { getPublicUrl } from "@/lib/storage" ;
import { useLocale } from "@/lib/i18n/provider" ;
import { Badge } from "@/components/ui/badge" ;
import { Clock , Users } from "lucide-react" ;
import Link from "next/link" ;
@@ -31,26 +33,50 @@ type Recipe = {
photos? : Array < { storageKey : string ; isCover : boolean } > ;
} ;
type ViewMode = "grid" | "list" | "compact" ;
const VIEW_STORAGE_KEY = "epicure-recipes-view" ;
const DIFFICULTY_COLOR = { easy : "default" , medium : "secondary" , hard : "destructive" } as const ;
const VISIBILITY_ICON = { private : Lock , unlisted : Link2 , public : Globe } ;
function SelectableRecipeCard ( {
recipe ,
selected ,
selectMode ,
onToggle ,
} : {
recipe : Recipe ;
selected : boolean ;
selectMode : boolean ;
onToggle : ( id : string ) = > void ;
} ) {
const t = useTranslations ( "recipe" ) ;
function RecipeThumb ( { recipe , selectMode , selected , className } : { recipe : Recipe ; selectMode : boolean ; selected : boolean ; className? : string } ) {
const cover = recipe . photos ? . find ( ( p ) = > p . isCover ) ? ? recipe . photos ? . [ 0 ] ;
return (
< div className = { cn ( "relative overflow-hidden bg-muted shrink-0" , className ) } >
{ cover ? (
< img
src = { getPublicUrl ( cover . storageKey ) }
alt = { recipe . title }
className = { cn (
"w-full h-full object-cover transition-transform duration-300" ,
! selectMode && "group-hover:scale-105" ,
selectMode && selected && "brightness-75"
) }
/ >
) : (
< div className = { cn ( "w-full h-full flex items-center justify-center text-2xl" , selectMode && ! selected && "opacity-60" ) } >
🍽 ️
< / div >
) }
{ selectMode && selected && < div className = "absolute inset-0 bg-primary/20" / > }
{ selectMode && (
< div className = { cn (
"absolute top-1.5 left-1.5 h-5 w-5 rounded-full border-2 flex items-center justify-center transition-all duration-150 shadow-sm" ,
selected ? "bg-primary border-primary" : "bg-black/30 border-white/70 group-hover:border-white"
) } >
{ selected && < Check className = "h-3 w-3 text-primary-foreground stroke-[3]" / > }
< / div >
) }
< / div >
) ;
}
function GridCard ( { recipe , selected , selectMode } : { recipe : Recipe ; selected : boolean ; selectMode : boolean } ) {
const t = useTranslations ( "recipe" ) ;
const totalMins = ( recipe . prepMins ? ? 0 ) + ( recipe . cookMins ? ? 0 ) ;
const VisibilityIcon = VISIBILITY_ICON [ recipe . visibility ] ;
const inner = (
return (
< div
className = { cn (
"group relative overflow-hidden rounded-xl border bg-card flex flex-col h-full transition-all duration-200" ,
@@ -62,57 +88,13 @@ function SelectableRecipeCard({
: "hover:shadow-md hover:border-muted-foreground/30"
) }
>
{ /* Cover image */ }
< div className = "relative aspect-video overflow-hidden bg-muted shrink-0" >
{ cover ? (
< img
src = { getPublicUrl ( cover . storageKey ) }
alt = { recipe . title }
className = { cn (
"w-full h-full object-cover transition-transform duration-300" ,
! selectMode && "group-hover:scale-105" ,
selectMode && selected && "brightness-75"
) }
/ >
) : (
< div className = { cn (
"w-full h-full flex items-center justify-center text-4xl transition-opacity" ,
selectMode && ! selected && "opacity-60"
) } >
🍽 ️
< / div >
) }
{ /* Selection overlay tint */ }
{ selectMode && selected && (
< div className = "absolute inset-0 bg-primary/20" / >
) }
{ /* Checkbox */ }
{ selectMode ? (
< div className = { cn (
"absolute top-2.5 left-2.5 h-6 w-6 rounded-full border-2 flex items-center justify-center transition-all duration-150 shadow-sm" ,
selected
? "bg-primary border-primary"
: "bg-black/30 border-white/70 group-hover:border-white"
) } >
{ selected && < Check className = "h-3.5 w-3.5 text-primary-foreground stroke-[3]" / > }
< / div >
) : null }
< / div >
{ /* Body */ }
< RecipeThumb recipe = { recipe } selectMode = { selectMode } selected = { selected } className = "aspect-video" / >
< div className = "flex flex-col flex-1 p-3 gap-1" >
< h3 className = { cn (
"font-semibold leading-tight line-clamp-2 text-sm transition-colors" ,
! selectMode && "group-hover:text-primary"
) } >
< h3 className = { cn ( "font-semibold leading-tight line-clamp-2 text-sm transition-colors" , ! selectMode && "group-hover:text-primary" ) } >
{ recipe . title }
< / h3 >
{ recipe . description && (
< p className = "text-xs text-muted-foreground line-clamp-2 leading-relaxed" >
{ recipe . description }
< / p >
< p className = "text-xs text-muted-foreground line-clamp-2 leading-relaxed" > { recipe . description } < / p >
) }
{ recipe . tags . length > 0 && (
< div className = "flex flex-wrap gap-1 mt-1" >
@@ -122,46 +104,110 @@ function SelectableRecipeCard({
< / span >
) ) }
{ recipe . tags . length > 3 && (
< span className = "inline-flex items-center px-1.5 py-0.5 rounded text-xs text-muted-foreground" >
+ { recipe . tags . length - 3 }
< / span >
< span className = "inline-flex items-center px-1.5 py-0.5 rounded text-xs text-muted-foreground" > + { recipe . tags . length - 3 } < / span >
) }
< / div >
) }
< / div >
{ /* Footer */ }
< div className = "px-3 pb-3 flex items-center justify-between text-xs text-muted-foreground" >
< div className = "flex items-center gap-2.5" >
< span className = "flex items-center gap-1" >
< Users className = "h-3 w-3" / >
{ recipe . baseServings }
< / span >
{ totalMins > 0 && (
< span className = "flex items-center gap-1" >
< Clock className = "h-3 w-3" / >
{ t ( "total" , { mins : totalMins } ) }
< / span >
) }
< span className = "flex items-center gap-1" > < Users className = "h-3 w-3" / > { recipe . baseServings } < / span >
{ totalMins > 0 && < span className = "flex items-center gap-1" > < Clock className = "h-3 w-3" / > { t ( "total" , { mins : totalMins } ) } < / span > }
{ recipe . difficulty && (
< Badge variant = { DIFFICULTY_COLOR [ recipe . difficulty ] } className = "text-xs h-4 px-1.5" >
{ t ( ` difficulty. ${ recipe . difficulty } ` ) }
< / Badge >
< Badge variant = { DIFFICULTY_COLOR [ recipe . difficulty ] } className = "text-xs h-4 px-1.5" > { t ( ` difficulty. ${ recipe . difficulty } ` ) } < / Badge >
) }
< / div >
< VisibilityIcon className = "h-3 w-3 shrink-0" / >
< / div >
< / div >
) ;
}
function ListRow ( { recipe , selected , selectMode } : { recipe : Recipe ; selected : boolean ; selectMode : boolean } ) {
const t = useTranslations ( "recipe" ) ;
const { locale } = useLocale ( ) ;
const totalMins = ( recipe . prepMins ? ? 0 ) + ( recipe . cookMins ? ? 0 ) ;
const VisibilityIcon = VISIBILITY_ICON [ recipe . visibility ] ;
return (
< div
className = { cn (
"group flex gap-4 p-3 rounded-xl border bg-card transition-all duration-200" ,
selectMode && "cursor-pointer select-none" ,
selected ? "ring-2 ring-primary border-primary shadow-sm shadow-primary/10" : selectMode ? "hover:border-muted-foreground/40" : "hover:shadow-md hover:border-muted-foreground/30"
) }
>
< RecipeThumb recipe = { recipe } selectMode = { selectMode } selected = { selected } className = "w-28 h-20 rounded-lg" / >
< div className = "flex-1 min-w-0 flex flex-col gap-1" >
< div className = "flex items-start justify-between gap-2" >
< h3 className = { cn ( "font-semibold leading-tight line-clamp-1 text-sm" , ! selectMode && "group-hover:text-primary" ) } > { recipe . title } < / h3 >
< VisibilityIcon className = "h-3.5 w-3.5 shrink-0 text-muted-foreground mt-0.5" / >
< / div >
{ recipe . description && < p className = "text-xs text-muted-foreground line-clamp-2 leading-relaxed" > { recipe . description } < / p > }
< div className = "flex items-center gap-2.5 text-xs text-muted-foreground mt-auto flex-wrap" >
< span className = "flex items-center gap-1" > < Users className = "h-3 w-3" / > { recipe . baseServings } < / span >
{ totalMins > 0 && < span className = "flex items-center gap-1" > < Clock className = "h-3 w-3" / > { t ( "total" , { mins : totalMins } ) } < / span > }
{ recipe . difficulty && < Badge variant = { DIFFICULTY_COLOR [ recipe . difficulty ] } className = "text-xs h-4 px-1.5" > { t ( ` difficulty. ${ recipe . difficulty } ` ) } < / Badge > }
{ recipe . tags . slice ( 0 , 2 ) . map ( ( tag ) = > (
< span key = { tag } className = "px-1.5 py-0.5 rounded bg-muted" > { tag } < / span >
) ) }
< span className = "ml-auto shrink-0" > { t ( "updatedLabel" , { date : recipe.updatedAt.toLocaleDateString ( locale , { month : "short" , day : "numeric" } ) } ) } < / span >
< / div >
< / div >
< / div >
) ;
}
function CompactRow ( { recipe , selected , selectMode } : { recipe : Recipe ; selected : boolean ; selectMode : boolean } ) {
const t = useTranslations ( "recipe" ) ;
const { locale } = useLocale ( ) ;
const totalMins = ( recipe . prepMins ? ? 0 ) + ( recipe . cookMins ? ? 0 ) ;
const VisibilityIcon = VISIBILITY_ICON [ recipe . visibility ] ;
return (
< div
className = { cn (
"group flex items-center gap-3 px-3 py-2 rounded-lg border bg-card transition-all duration-150" ,
selectMode && "cursor-pointer select-none" ,
selected ? "ring-2 ring-primary border-primary" : selectMode ? "hover:border-muted-foreground/40" : "hover:bg-muted/40"
) }
>
< RecipeThumb recipe = { recipe } selectMode = { selectMode } selected = { selected } className = "w-10 h-10 rounded-md shrink-0" / >
< h3 className = { cn ( "font-medium text-sm truncate flex-1 min-w-0" , ! selectMode && "group-hover:text-primary" ) } > { recipe . title } < / h3 >
< span className = "hidden sm:flex items-center gap-1 text-xs text-muted-foreground shrink-0" > < Users className = "h-3 w-3" / > { recipe . baseServings } < / span >
{ totalMins > 0 && (
< span className = "hidden sm:flex items-center gap-1 text-xs text-muted-foreground shrink-0" > < Clock className = "h-3 w-3" / > { t ( "total" , { mins : totalMins } ) } < / span >
) }
{ recipe . difficulty && (
< Badge variant = { DIFFICULTY_COLOR [ recipe . difficulty ] } className = "hidden sm:inline-flex text-xs h-4 px-1.5 shrink-0" > { t ( ` difficulty. ${ recipe . difficulty } ` ) } < / Badge >
) }
< span className = "hidden md:inline text-xs text-muted-foreground shrink-0 w-16 text-right" >
{ recipe . updatedAt . toLocaleDateString ( locale , { month : "short" , day : "numeric" } ) }
< / span >
< VisibilityIcon className = "h-3.5 w-3.5 shrink-0 text-muted-foreground" / >
< / div >
) ;
}
function SelectableRecipe ( {
recipe ,
selected ,
selectMode ,
viewMode ,
onToggle ,
} : {
recipe : Recipe ;
selected : boolean ;
selectMode : boolean ;
viewMode : ViewMode ;
onToggle : ( id : string ) = > void ;
} ) {
const Tile = viewMode === "grid" ? GridCard : viewMode === "list" ? ListRow : CompactRow ;
const inner = < Tile recipe = { recipe } selected = { selected } selectMode = { selectMode } / > ;
if ( selectMode ) {
return (
< div
onClick = { ( ) = > onToggle ( recipe . id ) }
role = "checkbox"
aria-checked = { selected }
className = "h-full"
>
< div onClick = { ( ) = > onToggle ( recipe . id ) } role = "checkbox" aria-checked = { selected } className = "h-full" >
{ inner }
< / div >
) ;
@@ -174,6 +220,40 @@ function SelectableRecipeCard({
) ;
}
function ViewToggle ( { value , onChange } : { value : ViewMode ; onChange : ( v : ViewMode ) = > void } ) {
const t = useTranslations ( "recipe" ) ;
const options : { mode : ViewMode ; icon : typeof LayoutGrid ; label : string } [ ] = [
{ mode : "grid" , icon : LayoutGrid , label : t ( "viewGrid" ) } ,
{ mode : "list" , icon : List , label : t ( "viewList" ) } ,
{ mode : "compact" , icon : Rows3 , label : t ( "viewCompact" ) } ,
] ;
return (
< TooltipProvider >
< div className = "inline-flex items-center rounded-lg border p-0.5 gap-0.5" >
{ options . map ( ( { mode , icon : Icon , label } ) = > (
< Tooltip key = { mode } >
< TooltipTrigger render = {
< button
onClick = { ( ) = > onChange ( mode ) }
aria-label = { label }
aria-pressed = { value === mode }
className = { cn (
"flex items-center justify-center h-7 w-7 rounded-md transition-colors" ,
value === mode ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground hover:bg-accent/50"
) }
/ >
} >
< Icon className = "h-4 w-4" / >
< / TooltipTrigger >
< TooltipContent > { label } < / TooltipContent >
< / Tooltip >
) ) }
< / div >
< / TooltipProvider >
) ;
}
export function RecipesGrid ( { recipes : initialRecipes } : { recipes : Recipe [ ] } ) {
const t = useTranslations ( "recipe" ) ;
const tCommon = useTranslations ( "common" ) ;
@@ -181,6 +261,17 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
const [ selectMode , setSelectMode ] = useState ( false ) ;
const [ selected , setSelected ] = useState < Set < string > > ( new Set ( ) ) ;
const [ busy , setBusy ] = useState ( false ) ;
const [ viewMode , setViewMode ] = useState < ViewMode > ( "grid" ) ;
useEffect ( ( ) = > {
const stored = localStorage . getItem ( VIEW_STORAGE_KEY ) as ViewMode | null ;
if ( stored === "grid" || stored === "list" || stored === "compact" ) setViewMode ( stored ) ;
} , [ ] ) ;
function changeViewMode ( mode : ViewMode ) {
setViewMode ( mode ) ;
localStorage . setItem ( VIEW_STORAGE_KEY , mode ) ;
}
const toggleSelect = useCallback ( ( id : string ) = > {
setSelected ( ( prev ) = > {
@@ -191,9 +282,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
} , [ ] ) ;
const toggleAll = ( ) = > {
setSelected ( ( prev ) = >
prev . size === recipes . length ? new Set ( ) : new Set ( recipes . map ( ( r ) = > r . id ) )
) ;
setSelected ( ( prev ) = > ( prev . size === recipes . length ? new Set ( ) : new Set ( recipes . map ( ( r ) = > r . id ) ) ) ) ;
} ;
const exitSelect = ( ) = > {
@@ -230,9 +319,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
body : JSON.stringify ( { ids : [ . . . selected ] , visibility } ) ,
} ) ;
if ( ! res . ok ) throw new Error ( "Update failed" ) ;
setRecipes ( ( prev ) = >
prev . map ( ( r ) = > selected . has ( r . id ) ? { . . . r , visibility } : r )
) ;
setRecipes ( ( prev ) = > prev . map ( ( r ) = > ( selected . has ( r . id ) ? { . . . r , visibility } : r ) ) ) ;
toast . success ( t ( "bulkVisibility" , { count : selected.size , visibility } ) ) ;
exitSelect ( ) ;
} catch {
@@ -249,46 +336,51 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
return (
< div className = "space-y-4" >
{ /* Toolbar */ }
< div className = "flex items-center justify-between min-h-[36px]" >
< div className = "flex items-center justify-between min-h-[36px] gap-3 " >
{ selectMode ? (
< div className = "flex items-center gap-3" >
< button
onClick = { toggleAll }
className = "text-sm font-medium text-primary hover:underline"
>
< button onClick = { toggleAll } className = "text-sm font-medium text-primary hover:underline" >
{ allSelected ? t ( "deselectAll" ) : t ( "selectAll" ) }
< / button >
< span className = "text-sm text-muted-foreground" >
{ selected . size > 0
? t ( "selectedCount" , { count : selected.size } )
: t ( "clickToSelect" ) }
{ selected . size > 0 ? t ( "selectedCount" , { count : selected.size } ) : t ( "clickToSelect" ) }
< / span >
< / div >
) : (
< div / >
) }
< Button
variant = { selectMode ? "ghost" : "ghost" }
size = "sm"
onClick = { selectMode ? exitSelect : ( ) = > setSelectMode ( true ) }
className = { cn ( "gap-1.5" , selectMode && "text-muted-foreground" ) }
>
{ selectMode ? (
< > < X className = "h-4 w-4" / > { tCommon ( "cancel" ) } < / >
) : (
< > < ListChecks className = "h-4 w-4" / > { t ( "select " ) } < / >
) }
< / Button >
< div className = "flex items-center gap-2" >
< ViewToggle value = { viewMode } onChange = { changeViewMode } / >
< Button
variant = "ghost"
size = "sm"
onClick = { selectMode ? exitSelect : ( ) = > setSelectMode ( true ) }
className = { cn ( "gap-1.5" , selectMode && "text-muted-foreground" ) }
>
{ selectMode ? (
< > < X className = "h-4 w-4" / > { tCommon ( "cancel " ) } < / >
) : (
< > < ListChecks className = "h-4 w-4" / > { t ( "select" ) } < / >
) }
< / Button >
< / div >
< / div >
{ /* Grid */ }
< div className = "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 items-start" >
{ /* Recipes */ }
< div
className = { cn (
viewMode === "grid" && "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 items-start" ,
viewMode === "list" && "flex flex-col gap-3" ,
viewMode === "compact" && "flex flex-col gap-1.5"
) }
>
{ recipes . map ( ( recipe ) = > (
< SelectableRecipeCard
< SelectableRecipe
key = { recipe . id }
recipe = { recipe }
selected = { selected . has ( recipe . id ) }
selectMode = { selectMode }
viewMode = { viewMode }
onToggle = { toggleSelect }
/ >
) ) }
@@ -298,17 +390,12 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
{ selectMode && selected . size > 0 && (
< div className = "fixed bottom-8 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-bottom-4 duration-200" >
< div className = "flex items-center gap-2 bg-popover border shadow-2xl rounded-2xl px-5 py-3" >
< span className = "text-sm font-semibold tabular-nums mr-1" >
{ t ( "selectedCount" , { count : selected.size } ) }
< / span >
< span className = "text-sm font-semibold tabular-nums mr-1" > { t ( "selectedCount" , { count : selected.size } ) } < / span >
< div className = "w-px h-5 bg-border mx-1" / >
< DropdownMenu >
< DropdownMenuTrigger
disabled = { busy }
className = { buttonVariants ( { variant : "ghost" , size : "sm" } ) + " gap-1.5" }
>
< DropdownMenuTrigger disabled = { busy } className = { buttonVariants ( { variant : "ghost" , size : "sm" } ) + " gap-1.5" } >
< Globe className = "h-4 w-4" / >
{ t ( "visibility" ) }
{ t ( "visibilityMenuLabel " ) }
< / DropdownMenuTrigger >
< DropdownMenuContent align = "center" side = "top" >
< DropdownMenuItem onClick = { ( ) = > { void bulkSetVisibility ( "public" ) ; } } >
@@ -322,13 +409,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
< / DropdownMenuItem >
< / DropdownMenuContent >
< / DropdownMenu >
< Button
variant = "destructive"
size = "sm"
onClick = { ( ) = > { void bulkDelete ( ) ; } }
disabled = { busy }
className = "gap-1.5"
>
< Button variant = "destructive" size = "sm" onClick = { ( ) = > { void bulkDelete ( ) ; } } disabled = { busy } className = "gap-1.5" >
< Trash2 className = "h-4 w-4" / >
{ tCommon ( "delete" ) }
< / Button >