diff --git a/client/src/components/new-recipe/RecipeStep1.tsx b/client/src/components/new-recipe/RecipeStep1.tsx index ed69a9e..8ce566c 100644 --- a/client/src/components/new-recipe/RecipeStep1.tsx +++ b/client/src/components/new-recipe/RecipeStep1.tsx @@ -2,12 +2,13 @@ import { useState } from 'react' import type { Dispatch, SetStateAction } from 'react' import Rating from '@mui/material/Rating' + import { Button } from '../ui/Button' import { Field } from '../ui/Field' import { Input, Textarea } from '../ui/Input' import { Tag } from '../ui/Tag' -import { ImageUpload } from '../ui/ImageUpload' import TagDialog from './TagDialog' + import type { RecipeFormData } from '../../routes/NewRecipePage' // Sånn at informasjonen blir lagret mellom stegene @@ -49,7 +50,10 @@ export default function RecipeStep1({ key: K, value: RecipeFormData[K], ) => { - setData((prev) => ({ ...prev, [key]: value })) + setData((prev) => ({ + ...prev, + [key]: value, + })) } // Legger til eller fjerner en tag (maks 6 totalt) @@ -69,6 +73,35 @@ export default function RecipeStep1({ (tag) => !dietaryTags.includes(tag), ) + // Når brukeren velger et bilde + const handleImageChange = ( + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0] + + if (!file) return + + const allowedTypes = [ + 'image/jpeg', + 'image/png', + 'image/webp', + ] + + if (!allowedTypes.includes(file.type)) { + alert('Du kan bare laste opp JPG, PNG eller WebP.') + event.target.value = '' + return + } + + if (file.size > 5 * 1024 * 1024) { + alert('Bildet kan ikke være større enn 5 MB.') + event.target.value = '' + return + } + + update('imageFile', file) + } + return (

@@ -78,13 +111,50 @@ export default function RecipeStep1({

{/* VENSTRE SIDE */}
+ {/* BILDEOPPLASTING */} - update('imageFile', file)} - /> +
+ + + {/* Viser valgt bilde */} + {data.imageFile && ( +
+ Forhåndsvisning av oppskriftsbilde + + +
+ )} +
+ {/* BESKRIVELSE */} + {/* VURDERING */} update('rating', value)} + onChange={(_, value) => + update('rating', value) + } /> - {/* Lenke til Instagram Reel eller TikTok */} + {/* VIDEO */} + {/* OPPSKRIFTSTITTEL */} - {/* Tid og porsjoner */} + {/* TID OG PORSJONER */}
- update('prepTime', Number(e.target.value)) + update( + 'prepTime', + Number(e.target.value), + ) } /> + min @@ -193,7 +271,10 @@ export default function RecipeStep1({ className="stepper-button" aria-label="Øk antall porsjoner" onClick={() => - update('servings', data.servings + 1) + update( + 'servings', + data.servings + 1, + ) } > + @@ -202,6 +283,7 @@ export default function RecipeStep1({
+ {/* KATEGORI */}
{categories.map((category) => ( @@ -235,7 +317,9 @@ export default function RecipeStep1({ {/* Åpner dialogen med flere tags */} setTagDialogOpen(true)} + onClick={() => + setTagDialogOpen(true) + } > Annet + @@ -256,7 +340,7 @@ export default function RecipeStep1({
)} - {/* Viser hvor mange tags som er valgt */} + {/* Antall valgte tags */}

{data.tags.length} av {MAX_TAGS} tags valgt

@@ -266,7 +350,10 @@ export default function RecipeStep1({ {/* KNAPPER */}
- @@ -283,8 +370,12 @@ export default function RecipeStep1({ update('tags', tags)} - onClose={() => setTagDialogOpen(false)} + onChange={(tags) => + update('tags', tags) + } + onClose={() => + setTagDialogOpen(false) + } />
) diff --git a/client/src/routes/NewRecipePage.tsx b/client/src/routes/NewRecipePage.tsx index 94305f6..a9ddb1b 100644 --- a/client/src/routes/NewRecipePage.tsx +++ b/client/src/routes/NewRecipePage.tsx @@ -1,9 +1,8 @@ -//For at frontend og backend kobler seg + import { useState } from 'react' import { useMutation } from '@apollo/client/react' import { CREATE_RECIPE } from '../graphql/mutations' -//Daten fra steps dukker opp her import RecipeStep1 from '../components/new-recipe/RecipeStep1' import RecipeStep2 from '../components/new-recipe/RecipeStep2' import RecipeStep3 from '../components/new-recipe/RecipeStep3' @@ -23,7 +22,7 @@ export type Instruction = { text: string } -// Definerer all informasjonen som lagres i oppskriftsskjemaet +// All informasjon som lagres i oppskriftsskjemaet export type RecipeFormData = { // Step 1 - Generell informasjon title: string @@ -47,6 +46,27 @@ export type RecipeFormData = { steps: { id: number; text: string; minutes: string }[] } +// Deler samlet tekst opp i separate steg +function parseInstructionText(text: string): string[] { + const trimmedText = text.trim() + + if (!trimmedText) { + return [] + } + + // Finner nummererte steg som 1., 2., 3. osv. + // Fungerer både med og uten mellomrom etter punktum. + // Nummereringen må starte på en ny linje eller etter + // et mellomrom. + const parts = trimmedText.split( + /(?:^|\s)(?=\d+\.\s*[A-Za-zÆØÅæøå])/, + ) + + return parts + .map((step) => step.replace(/^\d+\.\s*/, '').trim()) + .filter(Boolean) +} + // Props som styrer hvilket steg brukeren er på type NewRecipePageProps = { currentStep: number @@ -57,7 +77,7 @@ export default function NewRecipePage({ currentStep, setCurrentStep, }: NewRecipePageProps) { - // Lagrer all informasjonen brukeren fyller inn + // Lagrer informasjonen brukeren fyller inn const [recipeData, setRecipeData] = useState({ // Step 1 title: '', @@ -94,11 +114,23 @@ export default function NewRecipePage({ ], }) - // Kobler komponenten til GraphQL-mutasjonen CREATE_RECIPE - const [createRecipe, { loading, error }] = useMutation(CREATE_RECIPE) + // Kobler frontend til GraphQL-mutasjonen + const [createRecipe, { loading, error }] = + useMutation(CREATE_RECIPE) + + // Holder styr på om bildet lastes opp + const [uploading, setUploading] = useState(false) + const [publishError, setPublishError] = useState( + null, + ) - // Funksjonen som kjøres når brukeren publiserer oppskriften + // Funksjonen som kjøres når man publiserer oppskriften const handlePublish = async () => { + if (loading || uploading) return + + setPublishError(null) + setUploading(true) + // Gjør ingrediensene om til formatet backend forventer const ingredients = recipeData.ingredients.map( (ingredient, index) => ({ @@ -106,25 +138,71 @@ export default function NewRecipePage({ amount: Number(ingredient.amount), unit: ingredient.unit, order: index + 1, - }) + }), ) // Gjør fremgangsmåten om til formatet backend forventer - const steps = - recipeData.instructionMode === 'steps' - ? recipeData.steps.map((step, index) => ({ - order: index + 1, - instruction: step.text, - })) - : [ - { - order: 1, - instruction: recipeData.instructionText, - }, - ] + let steps: { order: number; instruction: string }[] = [] + + if (recipeData.instructionMode === 'steps') { + // Brukeren har skrevet ett steg i hvert felt + steps = recipeData.steps + .filter((step) => step.text.trim() !== '') + .map((step, index) => ({ + order: index + 1, + instruction: step.text.trim(), + })) + } else { + // Brukeren har skrevet hele fremgangsmåten i ett tekstfelt + const parsedSteps = parseInstructionText( + recipeData.instructionText, + ) + + steps = parsedSteps.map((instruction, index) => ({ + order: index + 1, + instruction, + })) + } + + // Sørger for at oppskriften har minst ett steg + if (steps.length === 0) { + setPublishError( + 'Du må legge til minst ett steg i fremgangsmåten.', + ) + setUploading(false) + return + } try { - // Sender oppskriftsinformasjonen til GraphQL + // 1. Laster opp bildet til backend hvis brukeren har valgt et + let imageUrl: string | null = null + + if (recipeData.imageFile) { + const formData = new FormData() + formData.append('image', recipeData.imageFile) + + const uploadResponse = await fetch( + 'http://localhost:3001/upload', + { + method: 'POST', + body: formData, + }, + ) + + if (!uploadResponse.ok) { + throw new Error('Kunne ikke laste opp bildet') + } + + // Backend returnerer adressen til bildet + const uploadResult: { imageUrl: string } = + await uploadResponse.json() + + imageUrl = uploadResult.imageUrl + + console.log('Bilde lastet opp:', imageUrl) + } + + // 2. Sender oppskriften og bilde-URL-en til GraphQL const result = await createRecipe({ variables: { input: { @@ -133,6 +211,7 @@ export default function NewRecipePage({ prepTimeMinutes: recipeData.prepTime, servings: recipeData.servings, category: recipeData.category, + imageUrl, tagNames: recipeData.tags, ingredients, steps, @@ -140,13 +219,20 @@ export default function NewRecipePage({ }, }) - // Viser resultatet hvis oppskriften ble lagret console.log('Oppskrift publisert:', result.data) + console.log('Antall steg lagret:', steps.length) alert('Oppskriften ble publisert!') } catch (err) { - // Skriver ut feilen hvis publiseringen mislykkes console.error('Kunne ikke publisere oppskrift:', err) + + setPublishError( + err instanceof Error + ? err.message + : 'Noe gikk galt under publiseringen.', + ) + } finally { + setUploading(false) } } @@ -190,17 +276,16 @@ export default function NewRecipePage({ onPublish={handlePublish} /> - {/* Viser en melding mens oppskriften lagres */} - {loading && ( + {(uploading || loading) && (

Publiserer oppskriften...

)} - {/* Viser en feilmelding hvis publiseringen mislykkes */} - {error && ( + {(publishError || error) && (

- Kunne ikke publisere oppskriften: {error.message} + Kunne ikke publisere oppskriften:{' '} + {publishError || error?.message}

)} diff --git a/client/src/routes/RecipeDetailPage.tsx b/client/src/routes/RecipeDetailPage.tsx index e676eaf..2a80b15 100644 --- a/client/src/routes/RecipeDetailPage.tsx +++ b/client/src/routes/RecipeDetailPage.tsx @@ -1,28 +1,59 @@ -import { useParams } from 'react-router'; -import { RecipeDetail } from '../components/recipe/RecipeDetail'; -import { useQuery } from '@apollo/client/react'; -import { GET_RECIPE } from '../graphql/recipes'; -// Placeholder for en oppskrift. Henter id fra URL-en. som /recipes/5 gir id = 5 +import { useParams } from 'react-router' +import { RecipeDetail } from '../components/recipe/RecipeDetail' +import { useQuery } from '@apollo/client/react' +import { GET_RECIPE } from '../graphql/recipes' + +// Gjør en relativ bilde-URL om til en fullstendig adresse +function getImageUrl(imageUrl: string | null | undefined) { + if (!imageUrl) { + return null + } + + // Hvis bildet allerede har en full URL, bruker vi den + if ( + imageUrl.startsWith('http://') || + imageUrl.startsWith('https://') + ) { + return imageUrl + } + + // Bilder som er lastet opp til backend + return `http://localhost:3001/${imageUrl.replace(/^\/+/, '')}` +} + +// Henter oppskriften basert på ID-en i URL-en export default function RecipeDetailPage() { - const { id } = useParams(); + const { id } = useParams() + const { data, loading, error } = useQuery(GET_RECIPE, { variables: { id: id ?? '' }, skip: !id, - }); + }) - if(loading) { + if (loading) { return

Laster oppskriften...

} if (error) { - return

Kunne ikke hente oppskriften. Prøv igjen senere.

+ return ( +

+ Kunne ikke hente oppskriften. Prøv igjen senere. +

+ ) } - const recipe = data?.recipe; + const recipe = data?.recipe + if (!recipe) { return

Fant ikke oppskriften.

} - return ; + // Sørger for at bildet hentes fra backend-serveren + const recipeWithImage = { + ...recipe, + imageUrl: getImageUrl(recipe.imageUrl), + } + + return } diff --git a/client/src/routes/RecipeListPage.tsx b/client/src/routes/RecipeListPage.tsx index 336d4b9..b82ea84 100644 --- a/client/src/routes/RecipeListPage.tsx +++ b/client/src/routes/RecipeListPage.tsx @@ -1,85 +1,148 @@ -import { useSearchParams } from 'react-router'; -import { RecipeList } from '../components/recipe/RecipeList'; -import { useQuery } from '@apollo/client/react'; -import { GET_RECIPES } from '../graphql/recipes'; -import { useState } from 'react'; -const PAGE_SIZE = 12; +import { useState } from 'react' +import { useSearchParams } from 'react-router' +import { useQuery } from '@apollo/client/react' -// Listen over alle oppskrifter eller søksresultatet -// Selve søkefeltet ligger i headeren +import { RecipeList } from '../components/recipe/RecipeList' +import { GET_RECIPES } from '../graphql/recipes' + +const PAGE_SIZE = 12 + +// Gjør relative bildeadresser om til fullstendige URL-er +function getImageUrl(imageUrl: string | null | undefined) { + if (!imageUrl) { + return null + } + + // Hvis bildet allerede har en fullstendig URL + if ( + imageUrl.startsWith('http://') || + imageUrl.startsWith('https://') + ) { + return imageUrl + } + + // Bilder som er lastet opp til backend + return `http://localhost:3001/${imageUrl.replace(/^\/+/, '')}` +} + +// Listen over alle oppskrifter eller søkeresultater export default function RecipeListPage() { - const [searchParams] = useSearchParams(); - const query = searchParams.get('q') ?? ''; + const [searchParams] = useSearchParams() + const query = searchParams.get('q') ?? '' const { data, loading, error, fetchMore } = useQuery(GET_RECIPES, { - variables: { search: query || undefined, page: 1, pageSize: PAGE_SIZE }, - }); + variables: { + search: query || undefined, + page: 1, + pageSize: PAGE_SIZE, + }, + }) + + const [hasMore, setHasMore] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) - const [hasMore, setHasMore] = useState(true); + // Nullstiller paginering når man søker på noe nytt + const [prevQuery, setPrevQuery] = useState(query) - // nullstiller når man søker på noe nytt - const [prevQuery, setPrevQuery] = useState(query); if (query !== prevQuery) { - setPrevQuery(query); - setHasMore(true); + setPrevQuery(query) + setHasMore(true) } if (loading && !data) { - return

Laster oppskrifter...

; + return

Laster oppskrifter...

} + if (error) { return ( -

Kunne ikke hente oppskriftene. Prøv igjen senere.

- ); +

+ Kunne ikke hente oppskriftene. Prøv igjen senere. +

+ ) } - const recipes = data?.recipes ?? []; + // Henter oppskriftene fra GraphQL + const recipes = data?.recipes ?? [] + + // Sørger for at bildene bruker riktig backend-adresse + const recipesWithImages = recipes.map((recipe) => ({ + ...recipe, + imageUrl: getImageUrl(recipe.imageUrl), + })) + // Henter flere oppskrifter når brukeren trykker "Last flere" async function handleLoadMore() { - // Regner ut hvilke side som er neste. - const nextPage = Math.floor(recipes.length / PAGE_SIZE) + 1; - - // Henter neste side - const result = await fetchMore({ - variables: { page: nextPage }, - // Legger de nye oppskriftene etter de gamle - updateQuery: (previous, { fetchMoreResult }) => ({ - recipes: [...previous.recipes, ...fetchMoreResult.recipes], - }), - }); - - // Dersom det er færre enn 12 er det ikke flere oppskrifter igjen. - if ((result.data?.recipes.length ?? 0) < PAGE_SIZE) { - setHasMore(false); + if (loadingMore || !hasMore) return + + setLoadingMore(true) + + try { + const nextPage = Math.floor(recipes.length / PAGE_SIZE) + 1 + + const result = await fetchMore({ + variables: { + search: query || undefined, + page: nextPage, + pageSize: PAGE_SIZE, + }, + + // Legger de nye oppskriftene etter de gamle + updateQuery: (previous, { fetchMoreResult }) => ({ + recipes: [ + ...previous.recipes, + ...fetchMoreResult.recipes, + ], + }), + }) + + // Hvis færre enn 12 oppskrifter ble hentet, + // finnes det ikke flere sider + if ((result.data?.recipes.length ?? 0) < PAGE_SIZE) { + setHasMore(false) + } + } catch (err) { + console.error('Kunne ikke hente flere oppskrifter:', err) + } finally { + setLoadingMore(false) } } return ( <> -

Oppskrifter

+

+ Oppskrifter +

- {query && recipes.length > 0 &&

Alt med '{query}'

} + {query && recipes.length > 0 && ( +

+ Alt med «{query}» +

+ )} - {/* melding hvis søket ikke finner noe */} + {/* Melding hvis søket ikke finner noe */} {query && recipes.length === 0 && ( -

Fant ingen oppskrifter med '{query}'.

+

+ Fant ingen oppskrifter med «{query}». +

)} - - {/*Knappen vises bare hvis det kan finnes flere oppskrifter og første side er full*/} + {/* Viser oppskriftene med riktige bildeadresser */} + + + {/* Last flere-knappen */} {hasMore && recipes.length >= PAGE_SIZE && (
)} - ); + ) } diff --git a/server/uploads/b81e7879-bf28-4b29-b0cd-480874d24c44.png b/server/uploads/b81e7879-bf28-4b29-b0cd-480874d24c44.png new file mode 100644 index 0000000..e6a1747 Binary files /dev/null and b/server/uploads/b81e7879-bf28-4b29-b0cd-480874d24c44.png differ diff --git a/server/uploads/eb01205e-c97f-428b-9f8c-1dc81e4c660e.png b/server/uploads/eb01205e-c97f-428b-9f8c-1dc81e4c660e.png new file mode 100644 index 0000000..4a9feda Binary files /dev/null and b/server/uploads/eb01205e-c97f-428b-9f8c-1dc81e4c660e.png differ