Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | 38x 38x 38x 38x 38x 38x 38x 38x 38x 38x 38x 38x 10x 38x 38x 38x 38x 38x | import { useEffect, useRef, useState } from 'react'
import type { MouseEvent, RefObject } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import {
BookmarkIcon as BookmarkOutline,
TrashIcon as TrashOutline,
XMarkIcon,
} from '@heroicons/react/24/outline'
import {
BookmarkIcon as BookmarkSolid,
TrashIcon as TrashSolid,
} from '@heroicons/react/24/solid'
import type { components } from '../api'
import { errorMessage } from '../apiError'
import { SessionExpiredError, useApi } from '../useApi'
type RecipeInput = components['schemas']['RecipeInput']
type RecipeCreated = components['schemas']['RecipeCreated']
export function RecipeSaveButton({
recipe,
recipeId,
onSavedIdChange,
onDeleted,
confirmContainer,
variant = 'icon',
className = '',
}: {
recipe: RecipeInput
recipeId?: number
onSavedIdChange?: (id: number | undefined) => void
onDeleted?: () => void
// When set, the confirm dialog is scoped (absolute) to this element instead of covering the whole viewport
confirmContainer?: RefObject<HTMLElement | null>
variant?: 'icon' | 'text'
className?: string
}) {
const { t } = useTranslation()
const apiFetch = useApi()
const [savedId, setSavedId] = useState<number | undefined>(recipeId)
const [saving, setSaving] = useState(false)
const [failed, setFailed] = useState(false)
const [error, setError] = useState<string | null>(null)
const [confirming, setConfirming] = useState(false)
const [suppressTrash, setSuppressTrash] = useState(false)
const hoveredRef = useRef(false)
const saved = savedId != null
// suppress trash icon until mouse left the bookmark icon after click
const showTrash = saved && !suppressTrash
// auto-dismiss the error toast after a few seconds
useEffect(() => {
Eif (!error) return
const timer = setTimeout(() => setError(null), 5000)
return () => clearTimeout(timer)
}, [error])
function handleClick(e: MouseEvent) {
e.stopPropagation()
if (saving) return
if (savedId != null) {
setConfirming(true)
return
}
void runSave()
}
async function runSave() {
setSaving(true)
setFailed(false)
setError(null)
try {
if (savedId != null) {
const res = await apiFetch(`/recipes/${savedId}`, { method: 'DELETE' })
if (!res.ok) throw new Error(await errorMessage(res))
// mark as non-stored if in the generated-recipes list
const stored = sessionStorage.getItem('generated_recipes')
if (stored) {
const recipes = JSON.parse(stored) as { id?: number }[]
const next = recipes.map((r) => (r.id === savedId ? { ...r, id: undefined } : r))
sessionStorage.setItem('generated_recipes', JSON.stringify(next))
}
setSavedId(undefined)
onSavedIdChange?.(undefined)
onDeleted?.()
} else {
const body: RecipeInput = {
title: recipe.title,
ingredients: recipe.ingredients,
instructions: recipe.instructions,
portions: recipe.portions,
nutrients: recipe.nutrients,
}
const res = await apiFetch('/recipes', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(await errorMessage(res))
const { id } = (await res.json()) as RecipeCreated
setSavedId(id)
onSavedIdChange?.(id)
if (hoveredRef.current) setSuppressTrash(true)
}
} catch (e) {
if (e instanceof SessionExpiredError) return
setFailed(true)
setError(e instanceof Error ? e.message : t('save.genericError'))
} finally {
setSaving(false)
}
}
const Outline = showTrash ? TrashOutline : BookmarkOutline
const Solid = showTrash ? TrashSolid : BookmarkSolid
const fillColor = showTrash ? 'text-red-500' : 'text-orange-500'
const title = failed
? t('save.retryTitle')
: saved
? t('save.savedTitle')
: t('save.saveTitle')
function confirmDelete(e: MouseEvent) {
e.stopPropagation()
setConfirming(false)
void runSave()
}
function cancelDelete(e: MouseEvent) {
e.stopPropagation()
setConfirming(false)
}
return (
<>
{variant === 'text' ? (
<button
type="button"
onClick={handleClick}
disabled={saving}
title={title}
className={`flex items-center gap-1 cursor-pointer transition-colors disabled:cursor-default disabled:opacity-60 ${
saved
? 'text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300'
: 'text-orange-600 dark:text-orange-400 hover:text-orange-700 dark:hover:text-orange-300'
} ${className}`}
>
{saved ? <TrashOutline className="h-5 w-5" /> : <BookmarkOutline className="h-5 w-5" />}
{saved ? t('common.delete') : t('save.toLibrary')}
</button>
) : (
<button
type="button"
onClick={handleClick}
onMouseEnter={() => {
hoveredRef.current = true
}}
onMouseLeave={() => {
hoveredRef.current = false
setSuppressTrash(false)
}}
disabled={saving}
aria-label={saved ? t('save.removeAria') : t('save.saveTitle')}
title={title}
className={`group relative flex h-9 w-9 items-center justify-center rounded-full cursor-pointer disabled:cursor-default ${className}`}
>
<span className={`relative h-6 w-6 ${saving ? 'animate-pulse' : ''}`}>
<Outline
className={`absolute inset-0 h-6 w-6 transition-colors duration-300 ease-out group-hover:text-white ${failed || showTrash ? 'text-red-500 dark:text-red-400' : 'text-gray-400 dark:text-neutral-500'}`}
/>
<Solid
className={`absolute inset-0 h-6 w-6 ${fillColor} [clip-path:inset(100%_0_0_0)] transition-[clip-path] duration-300 ease-out group-hover:[clip-path:inset(0_0_0_0)]`}
/>
</span>
</button>
)}
{confirming &&
createPortal(
<div
role="dialog"
aria-modal="true"
aria-labelledby="recipe-delete-title"
onClick={cancelDelete}
className={`z-50 flex items-center justify-center bg-black/40 p-4 ${
confirmContainer ? 'absolute inset-0' : 'fixed inset-0'
}`}
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-sm rounded-lg bg-white dark:bg-neutral-800 p-6 shadow-xl"
>
<h2 id="recipe-delete-title" className="text-lg font-medium text-gray-900 dark:text-neutral-100">
{t('save.confirmTitle')}
</h2>
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
onClick={cancelDelete}
className="px-4 py-2 rounded text-gray-700 dark:text-neutral-200 cursor-pointer hover:bg-gray-100 dark:hover:bg-neutral-800"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={confirmDelete}
className="px-4 py-2 rounded bg-red-500 text-white cursor-pointer hover:bg-red-600"
>
{t('common.delete')}
</button>
</div>
</div>
</div>,
confirmContainer?.current ?? document.body,
)}
{error &&
createPortal(
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
<div
role="alert"
className="pointer-events-auto flex max-w-md items-start gap-3 rounded-lg bg-red-600 px-4 py-3 text-sm text-white shadow-lg"
>
<span>{error}</span>
<button
type="button"
onClick={() => setError(null)}
aria-label={t('common.dismiss')}
className="-mr-1 shrink-0 cursor-pointer text-white/80 hover:text-white"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
</div>,
document.body,
)}
</>
)
}
|