Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions client/src/components/favorites/FavoriteButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useMutation, useQuery } from '@apollo/client/react';
import FavoriteIcon from '@mui/icons-material/Favorite';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import { useCurrentUser } from '../../lib/useCurrentUser';
import { LoginRequiredDialog } from '../layout/LoginRequiredDialog';
import { MY_FAVORITES, TOGGLE_FAVORITE } from '../../graphql/favorites';

type FavoriteButtonProps = {
recipeId: string;
title: string;
};

// rødt hjerte både tomt og fylt
const BUTTON =
'flex cursor-pointer rounded-full bg-background p-2 text-primary shadow-sm transition-colors hover:bg-primary/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pink-light disabled:opacity-50';

// hjerteknapp som legger til eller fjerner en favoritt
export function FavoriteButton({ recipeId, title }: FavoriteButtonProps) {
const { user } = useCurrentUser();
// henter favorittene en gang og deler svaret med alle hjertene på siden
const { data } = useQuery(MY_FAVORITES, { skip: !user });
const [toggleFavorite, { loading }] = useMutation(TOGGLE_FAVORITE, {
variables: { recipeId },
// henter favorittene på nytt så hjertene og favorittsiden blir riktige
refetchQueries: [MY_FAVORITES],
});

// ikke innlogget så hjertet åpner innloggingspopupen
if (!user) {
return (
<LoginRequiredDialog
trigger={
<button
type="button"
aria-label={`Lagre ${title} som favoritt`}
className={BUTTON}
>
<FavoriteBorderIcon aria-hidden="true" />
</button>
}
message="Logg inn eller fortsett som gjest for å lagre favoritter."
/>
);
}

const isFavorite =
data?.myFavorites.some((recipe) => recipe.id === recipeId) ?? false;

return (
<button
type="button"
onClick={() => toggleFavorite()}
disabled={loading}
aria-pressed={isFavorite}
aria-label={`Lagre ${title} som favoritt`}
className={BUTTON}
>
{isFavorite ? (
<FavoriteIcon aria-hidden="true" />
) : (
<FavoriteBorderIcon aria-hidden="true" />
)}
</button>
);
}
6 changes: 3 additions & 3 deletions client/src/components/profile/PreferencesForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function PreferencesForm() {
const [updatePreferences, { error }] = useMutation(UPDATE_PREFERENCES);
// om siste endring ble lagret
const [saved, setSaved] = useState(false);

// lagrer alle valgene hver gang noe endres
async function save(
nextLanguage: string,
Expand Down Expand Up @@ -91,7 +91,7 @@ export function PreferencesForm() {
save(language, value, allergies);
}

// legger til allergien hvis den ikke er valgt
// legger til allergien hvis den ikke er valgt
function toggleAllergy(name: string) {
const next = allergies.includes(name)
? allergies.filter((allergy) => allergy !== name)
Expand All @@ -106,7 +106,7 @@ export function PreferencesForm() {
className="flex h-full flex-col gap-6 rounded-large border border-secondary/60 bg-background p-6 md:p-8"
>
<div>
<h2 id="preferanser-tittel" className="font-heading text-3xl">
<h2 id="preferanser-tittel" className="font-display text-3xl">
Preferanser
</h2>
<p className="text-sm text-text/70">
Expand Down
25 changes: 25 additions & 0 deletions client/src/graphql/favorites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { gql, type TypedDocumentNode } from '@apollo/client';
import type { Recipe } from '../types/recipe';

// oppskriftene innlogget bruker har som favoritter
export const MY_FAVORITES: TypedDocumentNode<{ myFavorites: Recipe[] }> = gql`
query MyFavorites {
myFavorites {
id
imageUrl
title
prepTimeMinutes
servings
}
}
`;

// legger til eller fjerner en favoritt
export const TOGGLE_FAVORITE: TypedDocumentNode<
{ toggleFavorite: boolean },
{ recipeId: string }
> = gql`
mutation ToggleFavorite($recipeId: ID!) {
toggleFavorite(recipeId: $recipeId)
}
`;
6 changes: 3 additions & 3 deletions client/src/index.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Tangerine:wght@400;700&display=swap');

@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Tangerine:wght@400;700&family=Cormorant+Garamond:wght@500;600&display=swap');
@import "tailwindcss";


Expand All @@ -24,7 +23,8 @@
/* Typografi */
--font-heading: "Tangerine", cursive;
--font-body: "DM Sans", Arial, sans-serif;

--font-display: "Cormorant Garamond", Georgia, serif;

/* Border radius */
--radius-small: 6px;
--radius-medium: 10px;
Expand Down
51 changes: 49 additions & 2 deletions client/src/routes/FavoritesPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,51 @@
// TODO: liste over brukerens favoritter
import { Link } from 'react-router';
import { useQuery } from '@apollo/client/react';
import { RecipeCard } from '../components/recipe/RecipeCard';
import { FavoriteButton } from '../components/favorites/FavoriteButton';
import { MY_FAVORITES } from '../graphql/favorites';

// oppskriftene brukeren har lagret som favoritter
export default function FavoritesPage() {
return <h1 className="font-heading text-4xl md:text-5xl">Favoritter</h1>;
// henter på nytt hver gang siden åpnes så nye favoritter kommer med
const { data, loading, error } = useQuery(MY_FAVORITES, {
fetchPolicy: 'cache-and-network',
});
const favorites = data?.myFavorites ?? [];

return (
<>
<h1 className="font-heading text-4xl md:text-5xl">Favoritter</h1>

{loading && !data && (
<p role="status" className="mt-4">
Laster favoritter...
</p>
)}
{error && (
<p role="alert" className="mt-4">
Kunne ikke hente favorittene. Prøv igjen senere.
</p>
)}
{!loading && !error && favorites.length === 0 && (
<p className="mt-4">
Du har ingen favoritter ennå.{' '}
<Link to="/recipes" className="text-primary underline">
Utforsk oppskrifter
</Link>
</p>
)}

<ul className="mt-8 grid grid-cols-2 gap-6 lg:grid-cols-4">
{favorites.map((recipe) => (
<li key={recipe.id} className="relative h-full">
<RecipeCard recipe={recipe} />
{/* hjertet åpner ikke oppskriften */}
<div className="absolute top-3 right-3">
<FavoriteButton recipeId={recipe.id} title={recipe.title} />
</div>
</li>
))}
</ul>
</>
);
}
8 changes: 6 additions & 2 deletions client/src/routes/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export default function LoginPage() {
{/* går til innloggingssiden, testbrukeren er brukernavn: test , passord: test123 */}
<Button
variant="secondary"
className="font-bold"
className="font-bold hover:bg-primary/10!"
onClick={() => navigate('/login/test')}
>
Logg inn som testbruker
Expand All @@ -115,7 +115,11 @@ export default function LoginPage() {
</div>

<div className="flex flex-col gap-2">
<Button variant="secondary" className="font-bold" onClick={handleGuest}>
<Button
variant="secondary"
className="font-bold hover:bg-primary/10!"
onClick={handleGuest}
>
Fortsett som gjest
</Button>
<p className="text-center text-sm">Kom i gang uten en konto.</p>
Expand Down
39 changes: 32 additions & 7 deletions client/src/routes/ProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { Button } from '../components/ui/Button';
import { PreferencesForm } from '../components/profile/PreferencesForm';
import { useCurrentUser } from '../lib/useCurrentUser';
import { useQuery } from '@apollo/client/react';
import { RecipeList } from '../components/recipe/RecipeList';
import { MY_RECIPES } from '../graphql/profile';
import { RecipeCard } from '../components/recipe/RecipeCard';
import { FavoriteButton } from '../components/favorites/FavoriteButton';
import { MY_FAVORITES } from '../graphql/favorites';

type Tab = 'preferences' | 'favorites' | 'shared' | 'sharedWithMe';
type RecipeTab = Exclude<Tab, 'preferences'>;
Expand Down Expand Up @@ -44,6 +46,10 @@ export default function ProfilePage() {
const { data: myRecipesData } = useQuery(MY_RECIPES, {
fetchPolicy: 'cache-and-network',
});
// favorittene til brukeren
const { data: favoritesData } = useQuery(MY_FAVORITES, {
fetchPolicy: 'cache-and-network',
});

// RequireAuth sørger for at user finnes
if (!user) return null;
Expand All @@ -55,6 +61,14 @@ export default function ProfilePage() {
const desktopTab: RecipeTab =
activeTab === 'preferences' ? 'favorites' : activeTab;

const favorites = favoritesData?.myFavorites ?? [];
const tabRecipes =
desktopTab === 'shared'
? myRecipes
: desktopTab === 'favorites'
? favorites
: [];

async function handleLogout() {
await logout();
navigate('/login');
Expand All @@ -77,7 +91,7 @@ export default function ProfilePage() {
) : (
// innlogget olivengrønt panel
<section className="rounded-large bg-[#52552f] p-6 text-background md:p-8">
<p className="text-xs tracking-widest uppercase">
<p className="font-display font-semibold text-sm tracking-widest uppercase">
Din oppskriftsbok
</p>
<h1 className="mt-2 font-heading text-5xl">Hei {user.name}</h1>
Expand All @@ -90,12 +104,14 @@ export default function ProfilePage() {
<ul className="mt-5 flex flex-wrap gap-x-6 gap-y-3">
{[
{ count: myRecipes.length, label: 'mine oppskrifter' },
{ count: 0, label: 'favoritter' },
{ count: favorites.length, label: 'favoritter' },
{ count: 0, label: 'delte' },
{ count: 0, label: 'delt med deg' },
].map((stat) => (
<li key={stat.label} className="flex flex-col">
<span className="font-heading text-3xl">{stat.count}</span>
<span className="font-display font-semibold text-3xl">
{stat.count}
</span>
<span className="text-xs">{stat.label}</span>
</li>
))}
Expand Down Expand Up @@ -197,7 +213,7 @@ export default function ProfilePage() {
role="tab"
aria-selected={desktopTab === tab}
onClick={() => setActiveTab(tab)}
className={`-mb-px cursor-pointer border-b-2 pb-2 font-heading text-2xl transition-colors ${FOCUS_RING} ${
className={`-mb-px cursor-pointer border-b-2 pb-2 font-display text-2xl transition-colors ${FOCUS_RING} ${
desktopTab === tab
? 'border-primary text-primary'
: 'border-transparent hover:text-primary'
Expand All @@ -208,8 +224,17 @@ export default function ProfilePage() {
))}
</div>

{desktopTab === 'shared' && myRecipes.length > 0 ? (
<RecipeList recipes={myRecipes} />
{tabRecipes.length > 0 ? (
<ul className="grid grid-cols-2 gap-4 xl:grid-cols-3">
{tabRecipes.map((recipe) => (
<li key={recipe.id} className="relative">
<RecipeCard recipe={recipe} />
<div className="absolute top-3 right-3">
<FavoriteButton recipeId={recipe.id} title={recipe.title} />
</div>
</li>
))}
</ul>
) : (
<section className="rounded-large border border-dashed border-peach p-8 text-center">
<p>{RECIPE_TABS[desktopTab].empty}</p>
Expand Down
38 changes: 37 additions & 1 deletion server/src/schema/resolvers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const MIN_PASSWORD_LENGTH = 6;

type Credentials = { username: string; password: string };


type Preferences = {
languagePref: string;
unitSystem: string;
Expand All @@ -40,6 +39,18 @@ export const userResolvers = {
include: { ingredients: true, steps: true, tags: true },
});
},
// oppskifter som bruker har som favoritt
myFavorites: async (_: unknown, __: unknown, context: Context) => {
if (!context.userId) return [];
const favorites = await prisma.favorite.findMany({
where: { userId: context.userId },
orderBy: { createdAt: 'desc' },
include: {
recipe: { include: { ingredients: true, steps: true, tags: true } },
},
});
return favorites.map((favorite) => favorite.recipe);
},
},

Mutation: {
Expand Down Expand Up @@ -162,6 +173,31 @@ export const userResolvers = {
},
});
},
// legger til favoritter eller fjerner de hvis den allere finnes
toggleFavorite: async (
_: unknown,
args: { recipeId: string },
context: Context,
) => {
if (!context.userId) {
throw new GraphQLError('Du må være logget inn.', {
extensions: { code: 'UNAUTHENTICATED' },
});
}

const key = { userId: context.userId, recipeId: args.recipeId };
const existing = await prisma.favorite.findUnique({
where: { userId_recipeId: key },
});

if (existing) {
await prisma.favorite.delete({ where: { id: existing.id } });
return false;
}

await prisma.favorite.create({ data: key });
return true;
},
},

User: {
Expand Down
4 changes: 4 additions & 0 deletions server/src/schema/typeDefs/user.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ extend type Query {
me: User
# oppskrifter innlogget bruker har laget
myRecipes: [Recipe!]!
# oppskriftene innloggedet bruker har som favoritter
myFavorites: [Recipe!]!
}

extend type Mutation {
Expand All @@ -26,4 +28,6 @@ extend type Mutation {
unitSystem: String!
allergens: [String!]!
): User!
# legger til eller fjerner en favoritt
toggleFavorite(recipeId: ID!): Boolean!
}