diff --git a/client/.env.example b/client/.env.example index e7c8e53..b421087 100644 --- a/client/.env.example +++ b/client/.env.example @@ -1,5 +1,5 @@ # Adressen til Graphql backend. Kopier dette til .env.local VITE_GRAPHQL_URL=/project2/graphql -# Client ID fra Google Cloud -# jeg trenger en google bruker fra dere hvis dere ønsker å teste google innlogging -VITE_GOOGLE_CLIENT_ID=1070343515871-rvhhskd48nla3u2p3bsp7b1sl2st385m.apps.googleusercontent.com \ No newline at end of file + +# Google innlogging er satt på vent til vi ser om vi får HTTPS +#VITE_GOOGLE_CLIENT_ID= diff --git a/client/src/App.tsx b/client/src/App.tsx index 4723f7e..6fc1945 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -11,8 +11,9 @@ import DesignTestPage from './routes/DesignTestPage'; import LoginPage from './routes/LoginPage'; import TestUserLoginPage from './routes/TestUserLoginPage'; import RequireAuth from './components/layout/RequireAuth'; +import RegisterPage from './routes/RegisterPage'; -// Flyttet testsiden til DesignTestPage under routes +// flyttet testsiden til DesignTestPage under routes export default function App() { return ( @@ -28,6 +29,7 @@ export default function App() { } /> } /> + } /> {/* alle sidene som krever innlogging */} }> diff --git a/client/src/components/layout/Header.tsx b/client/src/components/layout/Header.tsx index 032d8cc..579c141 100644 --- a/client/src/components/layout/Header.tsx +++ b/client/src/components/layout/Header.tsx @@ -6,6 +6,7 @@ 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'; // Fokusmarkering ved tastaturnavigasjon const focusRing = @@ -33,15 +34,32 @@ function userClass({ isActive }: { isActive: boolean }) { } export default function Header() { - //hvem som er logget inn + // hvem som er logget inn const { user } = useCurrentUser(); // Søkeordet fra URL-en const [searchParams] = useSearchParams(); const query = searchParams.get('q') ?? ''; - // Holder styr på hvilket steg i Ny oppskrift brukeren er på + // holder styr på hvilket steg i Ny oppskrift brukeren er på const [currentStep, setCurrentStep] = useState(1); + // knapp som åpner ny oppskrift eller innloggingspopup hvis man ikke er logget inn + const newRecipeButton = ( + + ); + + // åpner innloggings popupen + const favoritesButton = ( + + ); + return (
@@ -65,10 +83,18 @@ export default function Header() { + {/* favoritter går gjennom når innlogget popup hvis ikke */}
  • - - Favoritter - + {user ? ( + + Favoritter + + ) : ( + + )}
  • @@ -77,25 +103,29 @@ export default function Header() {
  • - {/* Ny oppskrift */} + {/* ny oppskrift */}
  • - - Ny Oppskrift - - } - title="Ny oppskrift" - headerContent={ - - } - > - + } + > + + + ) : ( + - + )}
  • +
  • {user ? 'Bruker' : 'Logg inn'} diff --git a/client/src/components/layout/LoginRequiredDialog.tsx b/client/src/components/layout/LoginRequiredDialog.tsx new file mode 100644 index 0000000..4069dba --- /dev/null +++ b/client/src/components/layout/LoginRequiredDialog.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from 'react'; +import * as RadixDialog from '@radix-ui/react-dialog'; +import { useNavigate } from 'react-router'; +import { Button } from '../ui/Button'; + +type LoginRequiredDialogProps = { + trigger: ReactNode; + message: string; +}; + +// popup som ber om å logge inn før man kan bruke favoritter eller ny oppskrift +export function LoginRequiredDialog({ + trigger, + message, +}: LoginRequiredDialogProps) { + const navigate = useNavigate(); + + return ( + + {/* knappene som åpner popupen */} + {trigger} + + + {/* popup */} + + + Du må logge inn for å bruke dette + + + {message} + +
    + + + + {/* lukker popup og går til innloggingsside */} + + + +
    +
    +
    +
    + ); +} diff --git a/client/src/components/layout/SearchBar.tsx b/client/src/components/layout/SearchBar.tsx index c76ef3e..1a19a8f 100644 --- a/client/src/components/layout/SearchBar.tsx +++ b/client/src/components/layout/SearchBar.tsx @@ -1,4 +1,4 @@ -import { useState, type FormEvent } from 'react'; +import { useState, type SubmitEvent } from 'react'; import { useNavigate } from 'react-router'; type SearchBarProps = { @@ -10,7 +10,7 @@ export default function SearchBar({ initialQuery }: SearchBarProps) { const [input, setInput] = useState(initialQuery); const navigate = useNavigate(); - function handleSubmit(event: FormEvent) { + function handleSubmit(event: SubmitEvent) { // Hindrer at skjemaet laster siden på nytt event.preventDefault(); diff --git a/client/src/components/profile/PreferencesForm.tsx b/client/src/components/profile/PreferencesForm.tsx new file mode 100644 index 0000000..03e8d32 --- /dev/null +++ b/client/src/components/profile/PreferencesForm.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react'; + +// allergiene brukeren kan velge +const ALLERGIES = ['Gluten', 'Laktose', 'Nøtter', 'Egg', 'Skalldyr', 'Soya']; + +type Option = { value: string; label: string }; + +type RadioGroupProps = { + legend: string; + name: string; + value: string; + options: Option[]; + onChange: (value: string) => void; +}; + +// valgknapper der bare ett valg kan være aktivt +function RadioGroup({ + legend, + name, + value, + options, + onChange, +}: RadioGroupProps) { + return ( +
    + {legend} +
    + {options.map((option) => ( + + ))} +
    +
    + ); +} + +// språk, måleenheter og allergier +export function PreferencesForm() { + const [language, setLanguage] = useState('nb'); + const [units, setUnits] = useState('metric'); + const [allergies, setAllergies] = useState([]); + + // legger til allergien hvis den ikke er valgt + function toggleAllergy(name: string) { + if (allergies.includes(name)) { + setAllergies(allergies.filter((allergy) => allergy !== name)); + } else { + setAllergies([...allergies, name]); + } + } + + return ( +
    +
    +

    + Preferanser +

    +

    + Brukes til å tilpasse oppskriftene til deg. +

    +
    + + + + + +
    + Allergier +

    + Oppskrifter med disse blir markert. +

    +
    + {ALLERGIES.map((name) => { + const selected = allergies.includes(name); + return ( + + ); + })} +
    +
    +
    + ); +} diff --git a/client/src/lib/AuthProvider.tsx b/client/src/lib/AuthProvider.tsx new file mode 100644 index 0000000..a9db75f --- /dev/null +++ b/client/src/lib/AuthProvider.tsx @@ -0,0 +1,78 @@ +import { useState, type ReactNode } from 'react'; +import type { User } from '../types/user'; +import { AuthContext } from './authContext'; + +// midlertidige brukere til jeg får koblet meg til databasen +const MOCK_GUEST_USER: User = { + id: 'guest', + name: 'Gjest', + authProvider: 'GUEST', +}; +const MOCK_TEST_USER: User = { + id: 'test', + name: 'Testbruker', + authProvider: 'TEST', +}; + +// en konto er brukernavn, passord og brukeren som logges inn +type Account = { username: string; password: string; user: User }; + +// midlertidig liste over kontoer til backend er koblet på +const MOCK_ACCOUNTS: Account[] = [ + { username: 'test', password: 'test123', user: MOCK_TEST_USER }, +]; + +type AuthProviderProps = { children: ReactNode }; + +export function AuthProvider({ children }: AuthProviderProps) { + const [user, setUser] = useState(null); + // kontoene som finnes, nye legges til når noen lager bruker + const [accounts, setAccounts] = useState(MOCK_ACCOUNTS); + + const continueAsGuest = () => { + setUser(MOCK_GUEST_USER); + }; + + // logger inn hvis brukernavn og passord stemmer + const login = (username: string, password: string) => { + const account = accounts.find( + (account) => + account.username === username && account.password === password, + ); + + if (account) { + setUser(account.user); + return true; + } + return false; + }; + + // lager ny bruker og logger inn. false hvis brukernavnet er tatt + const register = (username: string, password: string) => { + if (accounts.some((account) => account.username === username)) { + return false; + } + + const newUser: User = { + id: username, + name: username, + authProvider: 'PASSWORD', + }; + + setAccounts([...accounts, { username, password, user: newUser }]); + setUser(newUser); + return true; + }; + + const logout = () => { + setUser(null); + }; + + return ( + + {children} + + ); +} diff --git a/client/src/lib/authContext.ts b/client/src/lib/authContext.ts new file mode 100644 index 0000000..8e11abc --- /dev/null +++ b/client/src/lib/authContext.ts @@ -0,0 +1,14 @@ +import { createContext } from 'react'; +import type { User } from '../types/user'; + +// hentes med useCurrentUser +export type AuthContextValue = { + user: User | null; + loading: boolean; + continueAsGuest: () => void; + login: (username: string, password: string) => boolean; + register: (username: string, password: string) => boolean; + logout: () => void; +}; +// felles boks for innlogget bruker +export const AuthContext = createContext(null); diff --git a/client/src/lib/useCurrentUser.ts b/client/src/lib/useCurrentUser.ts index b10dd7e..aa8a1c8 100644 --- a/client/src/lib/useCurrentUser.ts +++ b/client/src/lib/useCurrentUser.ts @@ -1,19 +1,9 @@ -import type { User } from '../types/user'; +import { useContext } from 'react'; +import { AuthContext } from './authContext'; -// Midlertidig bruker til backend er klar. Sett til null for å teste som utlogget. - -const MOCK_USER: User | null = null; - -/* const MOCK_USER: User | null = { - id: '1', - name: 'testesen', - authProvider: 'TEST', -}; */ - -// Hvem som er innlogget +// Hvem som er logget inn og funksjonen for å logge inn og ut export function useCurrentUser() { - return { - user: MOCK_USER, - loading: false, - }; + const auth = useContext(AuthContext); + if (auth === null) throw new Error('Fant ikke AuthProvider'); + return auth; } diff --git a/client/src/main.tsx b/client/src/main.tsx index 2eb4526..d23e3d1 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router'; import { ApolloProvider } from '@apollo/client/react'; import { GoogleOAuthProvider } from '@react-oauth/google'; +import { AuthProvider } from './lib/AuthProvider.tsx'; import './index.css'; import client from './lib/apolloClient.ts'; import App from './App.tsx'; @@ -16,10 +17,13 @@ createRoot(document.getElementById('root')!).render( > {/* Gir tilgang til Apollo klienten */} - {/* gjør at basename matcher base i vite config */} - - - + {/* holder styr på hvem som er innlogget */} + + {/* gjør at basename matcher base i vite config */} + + + + , diff --git a/client/src/routes/LoginPage.tsx b/client/src/routes/LoginPage.tsx index 30299f9..749f529 100644 --- a/client/src/routes/LoginPage.tsx +++ b/client/src/routes/LoginPage.tsx @@ -1,24 +1,36 @@ -import { useNavigate } from 'react-router'; +import { Link, useNavigate } from 'react-router'; +import { useCurrentUser } from '../lib/useCurrentUser'; import { Button } from '../components/ui/Button'; import { GoogleLogin, type CredentialResponse } from '@react-oauth/google'; import { useEffect, useRef, useState } from 'react'; -// Innloggingsside +// Google knappen vises bare hvis client ID er satt i .env +const hasGoogle = Boolean(import.meta.env.VITE_GOOGLE_CLIENT_ID); + +// innloggingsside export default function LoginPage() { const navigate = useNavigate(); + const { continueAsGuest } = useCurrentUser(); - //Midlertidig. token skal sendes til backend senere + // Midlertidig. token skal sendes til backend senere function handleGoogleSuccess(response: CredentialResponse) { console.log('Google-token:', response.credential); } - // for å måle bredden rundt google kanpp + // logger inn som gjest og går til forside + function handleGuest() { + continueAsGuest(); + navigate('/'); + } + + // for å måle bredden rundt google-knappen const googleBoxRef = useRef(null); const [googleButtonWidth, setGoogleButtonWidth] = useState(400); // måler hver gang den endrer størrelse useEffect(() => { const box = googleBoxRef.current; + // stopper hvis Google-knappen er skjult if (!box) return; const observer = new ResizeObserver(() => { @@ -34,28 +46,51 @@ export default function LoginPage() { return (

    Velkommen tilbake

    -

    - Logg inn med Google, utforsk oppskriftene som gjest eller testbruker. -

    +

    Logg inn med brukeren din, eller utforsk oppskriftene som gjest.

    - {/* Midlertidig: Google og gjest kobles til backend senere */} + {hasGoogle && ( + <> + {/* Midlertidig: Google kobles til backend senere */} +
    +
    + console.log('Google innlogging feilet')} + text="signin_with" + size="large" + width={googleButtonWidth} + shape="rectangular" + /> +
    +

    + Lagre favorittene dine og del egne oppskrifter på tvers av + enheter. +

    +
    + + )} + + {/* logg inn med brukernavn og passord */}
    -
    - console.log('Google innlogging feilet')} - text="signin_with" - size="large" - width={googleButtonWidth} - shape="rectangular" - /> -
    +

    - Lagre favorittene dine og del egne oppskrifter. + Har du ikke bruker?{' '} + + Lag bruker +

    - {/* Skiller Google innlogging fra de andre valgene */} + {/* skiller innlogging fra de andre valgene */}
    eller @@ -63,26 +98,25 @@ export default function LoginPage() {
    + {/* går til innloggingssiden, testbrukeren er brukernavn: test , passord: test123 */} -

    Se oppskrifter uten å logge inn.

    +

    + Prøv med en ferdig testkonto: test /{' '} + test123 +

    - {/* Går til logg inn siden for testbrukeren */} - -

    Prøv med en ferdig testkonto.

    +

    Kom i gang uten en konto.

    ); diff --git a/client/src/routes/ProfilePage.tsx b/client/src/routes/ProfilePage.tsx index 6caadce..611654d 100644 --- a/client/src/routes/ProfilePage.tsx +++ b/client/src/routes/ProfilePage.tsx @@ -1,4 +1,216 @@ -// TODO: preferanser og brukerens egne oppskrifter koblet til spsifikke brukere +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router'; +import { Button } from '../components/ui/Button'; +import { PreferencesForm } from '../components/profile/PreferencesForm'; +import { useCurrentUser } from '../lib/useCurrentUser'; + +type Tab = 'preferences' | 'favorites' | 'shared' | 'sharedWithMe'; +type RecipeTab = Exclude; + +// tekstene for hver oppskriftsfane (tomme til backend er koblet på) +const RECIPE_TABS: Record = { + favorites: { label: 'Favoritter', empty: 'Du har ingen favoritter ennå.' }, + shared: { + label: 'Mine oppskrifter', + empty: 'Du har ikke laget noen oppskrifter ennå.', + }, + sharedWithMe: { + label: 'Delt med meg', + empty: 'Ingen har delt oppskrifter med deg ennå.', + }, +}; + +// pillene i mobilmenyen +const MOBILE_TABS: { value: Tab; label: string }[] = [ + { value: 'preferences', label: 'Preferanser' }, + { value: 'favorites', label: 'Favoritter' }, + { value: 'shared', label: 'Mine oppskrifter' }, + { value: 'sharedWithMe', label: 'Delt med meg' }, +]; + +// rosa fokusring +const FOCUS_RING = + 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pink-light'; + export default function ProfilePage() { - return

    Min side

    -} \ No newline at end of file + const { user, logout } = useCurrentUser(); + const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState('preferences'); + + // RequireAuth sørger for at user finnes + if (!user) return null; + + const isGuest = user.authProvider === 'GUEST'; + + // på desktop vises preferanser alltid til venstre, så høyre side viser favoritter i stedet + const desktopTab: RecipeTab = + activeTab === 'preferences' ? 'favorites' : activeTab; + + function handleLogout() { + logout(); + navigate('/'); + } + + return ( +
    +
    + {isGuest ? ( + // gjest enkel topp med bokstav og navn +
    +
    + {user.name.charAt(0)} +
    +
    +

    {user.name}

    +

    Logget inn som gjest

    +
    +
    + ) : ( + // innlogget olivengrønt panel +
    +

    + Din oppskriftsbok +

    +

    Hei {user.name}

    +

    + Favorittene dine, oppskriftene du har delt og det andre har delt + med deg. +

    + + {/* TODO: hent ekte tall fra backend */} +
      + {[ + { count: 0, label: 'mine oppskrifter' }, + { count: 0, label: 'favoritter' }, + { count: 0, label: 'delte' }, + { count: 0, label: 'delt med deg' }, + ].map((stat) => ( +
    • + {stat.count} + {stat.label} +
    • + ))} +
    + +
    + + +
    +
    + )} + + {/* gjest: rosa boks som oppfordrer til å lage bruker */} + {isGuest && ( +
    +

    + Du er logget inn som gjest +

    +

    + Ta vare på det du lagrer +

    +

    + Det du lagrer nå finnes bare i denne nettleseren. Lag en bruker + for å beholde det på alle enhetene dine. +

    + + + +
    + )} + + {/* mobilmeny som kan sveipes */} + + + {/* preferanser på mobil bare når fanen er valgt */} +
    + +
    +
    + + {/* oppskriftsfanene */} +
    +
    + {(Object.keys(RECIPE_TABS) as RecipeTab[]).map((tab) => ( + + ))} +
    + +
    +

    {RECIPE_TABS[desktopTab].empty}

    + {desktopTab === 'favorites' && ( + + Utforsk oppskrifter + + )} +
    +
    +
    + ); +} diff --git a/client/src/routes/RegisterPage.tsx b/client/src/routes/RegisterPage.tsx new file mode 100644 index 0000000..5e6366f --- /dev/null +++ b/client/src/routes/RegisterPage.tsx @@ -0,0 +1,143 @@ +import { useState, type SubmitEvent } from 'react'; +import { Link, useNavigate } from 'react-router'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import { Button } from '../components/ui/Button'; +import { Field } from '../components/ui/Field'; +import { Input } from '../components/ui/Input'; +import { useCurrentUser } from '../lib/useCurrentUser'; + +// passordet må være minst så mange tegn +const MIN_PASSWORD_LENGTH = 6; + +// lag ny bruker med brukernavn og passord +export default function RegisterPage() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [repeatPassword, setRepeatPassword] = useState(''); + // Om passordene vises som tekst eller prikker + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(''); + const navigate = useNavigate(); + const { register } = useCurrentUser(); + + function handleSubmit(event: SubmitEvent) { + // hindrer at siden lastes på nytt + event.preventDefault(); + + // sjekker at alle feltene er fylt ut + if (!username.trim() || !password || !repeatPassword) { + setError('Fyll inn alle feltene.'); + return; + } + + // sjekker at passordet er langt nok + if (password.length < MIN_PASSWORD_LENGTH) { + setError(`Passordet må være minst ${MIN_PASSWORD_LENGTH} tegn.`); + return; + } + + // sjekker at passordene er like + if (password !== repeatPassword) { + setError('Passordene er ikke like.'); + return; + } + + // lager brukeren, false betyr at brukernavnet er tatt + if (register(username.trim(), password)) { + navigate('/'); + } else { + setError('Brukernavnet er allerede tatt.'); + } + } + + return ( +
    +

    Lag bruker

    +

    Lagre favorittene dine og del egne oppskrifter.

    + +
    + + setUsername(event.target.value)} + placeholder="Brukernavn" + autoComplete="username" + /> + + + +
    + setPassword(event.target.value)} + placeholder={`Minst ${MIN_PASSWORD_LENGTH} tegn`} + autoComplete="new-password" + className="pr-12" + /> + + {/* knapp som viser eller skjuler begge passordene */} + +
    +
    + + + setRepeatPassword(event.target.value)} + placeholder="Skriv passordet en gang til" + autoComplete="new-password" + /> + + + {error && ( +

    + {error} +

    + )} + + +
    + +

    + Har du allerede bruker?{' '} + + Logg inn + +

    + + + Tilbake til innloggingsvalg + +
    + ); +} diff --git a/client/src/routes/TestUserLoginPage.tsx b/client/src/routes/TestUserLoginPage.tsx index ff341e1..aa70d92 100644 --- a/client/src/routes/TestUserLoginPage.tsx +++ b/client/src/routes/TestUserLoginPage.tsx @@ -1,32 +1,45 @@ import { useState, type SubmitEvent } from 'react'; -import { Link } from 'react-router'; +import { Link, useNavigate } from 'react-router'; import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import { Button } from '../components/ui/Button'; import { Field } from '../components/ui/Field'; import { Input } from '../components/ui/Input'; +import { useCurrentUser } from '../lib/useCurrentUser'; -// Innlogging for testbrukeren +// innlogging med brukernavn og passord export default function TestUserLoginPage() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); // Om passordet vises som tekst eller prikker const [showPassword, setShowPassword] = useState(false); + const navigate = useNavigate(); + const { login } = useCurrentUser(); + // feilmelding hvis noe er feil + const [error, setError] = useState(''); function handleSubmit(event: SubmitEvent) { - // Hindrer at siden lastes på nytt + // hindrer at siden lastes på nytt event.preventDefault(); - // Midlertidig: kobles til backend senere - console.log('testbruker', username); + // sjekker at begge feltene er fylt ut + if (!username.trim() || !password) { + setError('Fyll inn brukernavn og passord.'); + return; + } + + // sjekker brukernavn og passord + if (login(username, password)) { + navigate('/'); + } else { + setError('Feil brukernavn eller passord.'); + } } return (

    Innlogging

    -

    - Logg inn med testbrukeren for en ferdiglaget bruker med oppskrifter. -

    +

    Logg inn med brukernavnet og passordet ditt.

    @@ -36,7 +49,6 @@ export default function TestUserLoginPage() { onChange={(event) => setUsername(event.target.value)} placeholder="Brukernavn" autoComplete="username" - required /> @@ -50,9 +62,9 @@ export default function TestUserLoginPage() { placeholder="Passord" autoComplete="current-password" className="pr-12" - required /> - {/* Øyeknapp som viser eller skjuler passordet */} + + {/* knapp som viser eller skjuler passordet */}
    + {error && ( +

    + {error} +

    + )} + +

    + Har du ikke bruker?{' '} + + Lag bruker + +

    +