diff --git a/client/src/components/layout/NewRecipeButton.tsx b/client/src/components/layout/NewRecipeButton.tsx index 1782edf..f263ae1 100644 --- a/client/src/components/layout/NewRecipeButton.tsx +++ b/client/src/components/layout/NewRecipeButton.tsx @@ -1,21 +1,146 @@ -import { useState, type ReactNode } from 'react'; -import { Dialog } from '../ui/Dialog'; -import NewRecipePage from '../../routes/NewRecipePage'; -import { RecipeStepIndicator } from '../new-recipe/RecipeStepIndicator'; -import { useCurrentUser } from '../../lib/useCurrentUser'; -import { LoginRequiredDialog } from './LoginRequiredDialog'; +import { useCallback, useState, type ReactNode } from 'react' +import { useMutation } from '@apollo/client/react' +import { Dialog } from '../ui/Dialog' +import NewRecipePage, { type RecipeFormData } from '../../routes/NewRecipePage' +import { RecipeStepIndicator } from '../new-recipe/RecipeStepIndicator' +import LeaveRecipeDialog from '../new-recipe/LeaveRecipeDialog' +import { useCurrentUser } from '../../lib/useCurrentUser' +import { LoginRequiredDialog } from './LoginRequiredDialog' +import { SAVE_DRAFT, MY_DRAFTS } from '../../graphql/profile' type NewRecipeButtonProps = { - // knappen som vises, er forskjellig på pc og mobil - trigger: ReactNode; -}; + trigger: ReactNode +} -// åpner ny oppskrift eller innloggingspopup hvis man ikke er logget inn export function NewRecipeButton({ trigger }: NewRecipeButtonProps) { - const { user } = useCurrentUser(); - // hvordan steg i ny oppskrift man er på - const [currentStep, setCurrentStep] = useState(1); - const [isOpen, setIsOpen] = useState(false); + const { user } = useCurrentUser() + + const [currentStep, setCurrentStep] = useState(1) + const [isOpen, setIsOpen] = useState(false) + const [showLeaveConfirm, setShowLeaveConfirm] = useState(false) + const [formData, setFormData] = useState(null) + const [formKey, setFormKey] = useState(0) + const [draftError, setDraftError] = useState(null) + + const [saveDraft, { loading: savingDraft }] = useMutation(SAVE_DRAFT, { + refetchQueries: [MY_DRAFTS], + }) + + const handleFormChange = useCallback((data: RecipeFormData) => { + setFormData(data) + }, []) + + const hasChanges = Boolean( + formData && ( + formData.title.trim() || + formData.description.trim() || + formData.imageFile || + formData.tags.length > 0 || + formData.sourceAuthor.trim() || + formData.sourceUrl.trim() || + formData.ingredientNote.trim() || + formData.instructionText.trim() || + formData.ingredients.some((item) => + item.name.trim() || item.amount.trim() + ) || + formData.steps.some((step) => + step.text.trim() || step.minutes.trim() + ) + ) + ) + + const closeAndReset = () => { + setShowLeaveConfirm(false) + setIsOpen(false) + setCurrentStep(1) + setFormData(null) + setDraftError(null) + setFormKey((key) => key + 1) + } + + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen) { + setIsOpen(true) + return + } + + if (showLeaveConfirm || savingDraft) return + + if (hasChanges) { + setDraftError(null) + setShowLeaveConfirm(true) + } else { + closeAndReset() + } + } + + // Brukes både av knappene i stegene og av bekreftelsesdialogen. + // Valgfri data-parameter gjør at et steg kan sende sine nyeste skjemadata direkte. + const handleSaveDraft = async (data?: RecipeFormData) => { + const draftData = data ?? formData + if (!draftData || savingDraft) return + + setDraftError(null) + + try { + const ingredients = draftData.ingredients + .filter((item) => item.name.trim()) + .map((item, index) => ({ + name: item.name.trim(), + amount: Number(item.amount.replace(',', '.')) || 0, + unit: item.unit, + order: index + 1, + })) + + const steps = draftData.instructionMode === 'steps' + ? draftData.steps + .filter((step) => step.text.trim()) + .map((step, index) => ({ + order: index + 1, + instruction: step.text.trim(), + })) + : draftData.instructionText + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((instruction, index) => ({ + order: index + 1, + instruction, + })) + + await saveDraft({ + variables: { + input: { + title: draftData.title.trim() || 'Uten tittel', + description: draftData.description.trim(), + prepTimeMinutes: draftData.prepTime, + servings: draftData.servings, + category: draftData.category, + tagNames: draftData.tags, + ingredients, + steps, + }, + }, + }) + + closeAndReset() + } catch (error) { + console.error('Kunne ikke lagre utkast:', error) + setDraftError( + error instanceof Error + ? error.message + : 'Kunne ikke lagre utkastet. Prøv igjen.', + ) + } + } + + const handleDiscard = () => { + closeAndReset() + } + + const handlePublished = () => { + closeAndReset() + } if (!user) { return ( @@ -23,7 +148,7 @@ export function NewRecipeButton({ trigger }: NewRecipeButtonProps) { trigger={trigger} message="Logg inn eller fortsett som gjest for å lage egne oppskrifter." /> - ); + ) } return ( @@ -31,17 +156,35 @@ export function NewRecipeButton({ trigger }: NewRecipeButtonProps) { trigger={trigger} title="Ny oppskrift" open={isOpen} - onOpenChange={setIsOpen} + onOpenChange={handleOpenChange} headerContent={} > { - setIsOpen(false) - setCurrentStep(1) - }} + onPublished={handlePublished} + onFormChange={handleFormChange} + onSaveDraft={handleSaveDraft} + savingDraft={savingDraft} /> + + {showLeaveConfirm && ( + setShowLeaveConfirm(false)} + onDiscard={handleDiscard} + onSaveDraft={() => void handleSaveDraft()} + /> + )} + + {draftError && ( +

+ Kunne ikke lagre utkast: {draftError} +

+ )} ) } diff --git a/client/src/components/new-recipe/LeaveRecipeDialog.tsx b/client/src/components/new-recipe/LeaveRecipeDialog.tsx new file mode 100644 index 0000000..ebd2fba --- /dev/null +++ b/client/src/components/new-recipe/LeaveRecipeDialog.tsx @@ -0,0 +1,92 @@ + +import * as RadixDialog from '@radix-ui/react-dialog' +import { motion, useReducedMotion } from 'motion/react' + +type LeaveRecipeDialogProps = { + onContinue: () => void + onDiscard: () => void + onSaveDraft: () => void +} + +export default function LeaveRecipeDialog({ + onContinue, + onDiscard, + onSaveDraft, +}: LeaveRecipeDialogProps) { + const reduceMotion = useReducedMotion() + + return ( + { + if (!open) onContinue() + }} + > + + {/* Mørk bakgrunn over oppskriftsskjemaet */} + + + {/* Bekreftelsesdialog */} + + + + Vil du forlate oppskriften? + + + + Du har endringer som ikke er lagret. + Hva ønsker du å gjøre? + + +
+ + + + + + + +
+
+
+
+
+ ) +} diff --git a/client/src/components/new-recipe/RecipeStep1.tsx b/client/src/components/new-recipe/RecipeStep1.tsx index 9303f23..0f35224 100644 --- a/client/src/components/new-recipe/RecipeStep1.tsx +++ b/client/src/components/new-recipe/RecipeStep1.tsx @@ -11,73 +11,65 @@ import TagDialog from './TagDialog' import type { RecipeFormData } from '../../routes/NewRecipePage' type RecipeStep1Props = { + onSaveDraft: () => Promise + savingDraft: boolean data: RecipeFormData setData: Dispatch> onNext: () => void } +const MAX_DESCRIPTION_CHAR = 260 // Maks tegn i kort beskrivelse const MAX_TAGS = 6 -const categories = ['Frokost', 'Lunsj', 'Middag', 'Dessert', 'Snacks', 'Drikke'] +const MAX_IMAGE_SIZE = 5 * 1024 * 1024 +const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'] +const categories = ['Frokost', 'Lunsj', 'Middag', 'Dessert', 'Snacks', 'Drikke', 'Tilbehør'] -export default function RecipeStep1({ data, setData, onNext }: RecipeStep1Props) { +const pillButton = + 'cursor-pointer rounded-full bg-background px-3 py-1.5 text-xs font-semibold text-primary shadow-sm' + +export default function RecipeStep1({ data, setData, onNext, onSaveDraft, savingDraft }: RecipeStep1Props) { const [tagDialogOpen, setTagDialogOpen] = useState(false) const [sourceOpen, setSourceOpen] = useState(false) const [imagePreview, setImagePreview] = useState(null) useEffect(() => { - if (!data.imageFile) { - setImagePreview(null) - return - } - - const objectUrl = URL.createObjectURL(data.imageFile) - setImagePreview(objectUrl) - return () => URL.revokeObjectURL(objectUrl) + if (!data.imageFile) return setImagePreview(null) + const url = URL.createObjectURL(data.imageFile) + setImagePreview(url) + return () => URL.revokeObjectURL(url) }, [data.imageFile]) - const update = ( - key: K, - value: RecipeFormData[K], - ) => { + const update = (key: K, value: RecipeFormData[K]) => setData((prev) => ({ ...prev, [key]: value })) - } - const toggleTag = (tag: string) => { + const toggleTag = (tag: string) => setData((prev) => { - const tags = prev.tags.includes(tag) - ? prev.tags.filter((item) => item !== tag) - : prev.tags.length < MAX_TAGS - ? [...prev.tags, tag] - : prev.tags - return { ...prev, tags } + if (prev.tags.includes(tag)) return { ...prev, tags: prev.tags.filter((t) => t !== tag) } + return prev.tags.length < MAX_TAGS ? { ...prev, tags: [...prev.tags, tag] } : prev }) - } const handleImageChange = (event: ChangeEvent) => { const file = event.target.files?.[0] if (!file) return - if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) { - alert('Du kan bare laste opp JPG, PNG eller WebP.') - event.target.value = '' - return - } + const error = !IMAGE_TYPES.includes(file.type) + ? 'Du kan bare laste opp JPG, PNG eller WebP.' + : file.size > MAX_IMAGE_SIZE + ? 'Bildet kan ikke være større enn 5 MB.' + : null - if (file.size > 5 * 1024 * 1024) { - alert('Bildet kan ikke være større enn 5 MB.') - event.target.value = '' - return - } - - update('imageFile', file) + if (error) alert(error) + else update('imageFile', file) event.target.value = '' } + const fileInput = ( + + ) + return (
-

- Lag din egen oppskrift og del den med andre. -

+

Lag din egen oppskrift og del den med andre.

{/* VENSTRE: BILDE, TID, PORSJONER, VURDERING OG KILDE */} @@ -91,38 +83,20 @@ export default function RecipeStep1({ data, setData, onNext }: RecipeStep1Props) className="h-60 w-full object-cover" />
-
) : ( )} @@ -136,7 +110,7 @@ export default function RecipeStep1({ data, setData, onNext }: RecipeStep1Props) min={1} placeholder="F.eks. 30" value={data.prepTime} - onChange={(event) => update('prepTime', Number(event.target.value))} + onChange={(e) => update('prepTime', Number(e.target.value))} /> min
@@ -194,28 +168,30 @@ export default function RecipeStep1({ data, setData, onNext }: RecipeStep1Props) id="recipe-title" placeholder="F.eks. kremet pasta med sopp" value={data.title} - onChange={(event) => update('title', event.target.value)} + onChange={(e) => update('title', e.target.value)} /> -