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
48 changes: 48 additions & 0 deletions client/src/components/icons/TrashIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// ikonet jeg ville ha fantes ikke i mui så må lage det selv med hjelp av ai

type TrashIconProps = {
open: boolean;
};

// søppelbøtte som åpner lokket og blir fylt når valg er på
export function TrashIcon({ open }: TrashIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className="h-7 w-7 overflow-visible"
aria-hidden="true"
>
{/* lokket av søppelkassen som vipper opp */}
<g
className={`origin-[21px_9px] transition-transform duration-200 motion-reduce:transition-none ${
open ? 'rotate-15' : ''
}`}
>
<rect
x="3"
y="8"
width="18"
height="2.2"
rx="1"
fill={open ? 'currentColor' : 'none'}
/>
<path d="M9 8V6.8c0-.7.5-1.3 1.2-1.3h3.6c.7 0 1.2.6 1.2 1.3V8" />
</g>
{/* bøtta smalner av nedover */}
<path
d="M5 11h14l-1.2 10.4c-.1.9-.8 1.6-1.7 1.6H7.9c-.9 0-1.6-.7-1.7-1.6L5 11z"
fill={open ? 'currentColor' : 'none'}
/>
{/* stripene blir bakgrunnsfarge når bøtta er fylt så de ser ut som hull */}
<path
d="M9.5 13.5v6.5M12 13.5v6.5M14.5 13.5v6.5"
stroke={open ? 'var(--color-background)' : 'currentColor'}
/>
</svg>
);
}
103 changes: 103 additions & 0 deletions client/src/components/profile/DeleteRecipesDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useState } from 'react';
import * as RadixDialog from '@radix-ui/react-dialog';
import { useMutation } from '@apollo/client/react';
import { Button } from '../ui/Button';
import { DELETE_MY_RECIPES, MY_RECIPES } from '../../graphql/profile';

type DeleteRecipesDialogProps = {
recipes: { id: string; title: string }[];
onDeleted: () => void;
};

// spør om man er sikker og sletter de valgte oppskriftene
export function DeleteRecipesDialog({
recipes,
onDeleted,
}: DeleteRecipesDialogProps) {
const [open, setOpen] = useState(false);
const [deleteRecipes, { loading, error }] = useMutation(DELETE_MY_RECIPES, {
refetchQueries: [MY_RECIPES],
});

const count = recipes.length;

async function handleDelete() {
try {
await deleteRecipes({
variables: { ids: recipes.map((recipe) => recipe.id) },
// fjerner oppskriftene fra apollo
update(cache) {
recipes.forEach((recipe) => {
cache.evict({
id: cache.identify({ __typename: 'Recipe', id: recipe.id }),
});
});
cache.gc();
},
});
setOpen(false);
onDeleted();
} catch {
// feilmeldingen vises i popupen
}
}

return (
<RadixDialog.Root open={open} onOpenChange={setOpen}>
<RadixDialog.Trigger asChild>
<Button
variant="primary"
size="small"
className="font-bold"
disabled={count === 0}
>
Slett valgte
</Button>
</RadixDialog.Trigger>
<RadixDialog.Portal>
<RadixDialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
<RadixDialog.Content className="fixed top-1/2 left-1/2 z-50 max-h-[92vh] w-[94%] max-w-md -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-large bg-background p-6 text-text shadow-lg focus:outline-none">
<RadixDialog.Title className="font-display text-3xl lining-nums">
{count === 1
? 'Vil du slette 1 oppskrift?'
: `Vil du slette ${count} oppskrifter?`}
</RadixDialog.Title>
<RadixDialog.Description className="mt-2">
Oppskriftene blir slettet permanent:
</RadixDialog.Description>

<ul className="mt-3 list-disc pl-5">
{recipes.map((recipe) => (
<li key={recipe.id}>{recipe.title}</li>
))}
</ul>

{error && (
<p role="alert" className="mt-4 font-bold text-primary">
{error.message}
</p>
)}

<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<RadixDialog.Close asChild>
<Button
variant="secondary"
className="font-bold hover:bg-primary/10!"
>
Avbryt
</Button>
</RadixDialog.Close>
<Button
variant="primary"
className="font-bold"
disabled={loading}
onClick={handleDelete}
>
Slett
</Button>
</div>
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
}
14 changes: 13 additions & 1 deletion client/src/components/ui/RecipeImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ type RecipeImageProps = {
className?: string;
};

// legger til /project2/ foran opplastede bilder
function toImageSrc(imageUrl: string) {
if (
imageUrl.startsWith('http') ||
imageUrl.startsWith('data:') ||
imageUrl.startsWith('/project2/')
) {
return imageUrl;
}
return `/project2/${imageUrl.replace(/^\/+/, '')}`;
}

// viser bildet til oppskriften eller en tegning hvis bildet mangler
export function RecipeImage({
imageUrl,
Expand All @@ -19,7 +31,7 @@ export function RecipeImage({
if (imageUrl && !failed) {
return (
<img
src={imageUrl}
src={toImageSrc(imageUrl)}
alt={alt}
onError={() => setFailed(true)}
className={`object-cover ${className}`}
Expand Down
10 changes: 10 additions & 0 deletions client/src/graphql/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,13 @@ export const MY_RECIPES: TypedDocumentNode<{ myRecipes: Recipe[] }> = gql`
}
}
`;

// sletter oppskrifter brukeren selv har laget
export const DELETE_MY_RECIPES: TypedDocumentNode<
{ deleteMyRecipes: number },
{ ids: string[] }
> = gql`
mutation DeleteMyRecipes($ids: [ID!]!) {
deleteMyRecipes(ids: $ids)
}
`;
98 changes: 90 additions & 8 deletions client/src/routes/ProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { Link, useNavigate } from 'react-router';
import { Button } from '../components/ui/Button';
import { PreferencesForm } from '../components/profile/PreferencesForm';
import { EditProfileDialog } from '../components/profile/EditProfileDialog';
import { DeleteRecipesDialog } from '../components/profile/DeleteRecipesDialog';
import { TrashIcon } from '../components/icons/TrashIcon';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
import { useCurrentUser } from '../lib/useCurrentUser';
import { useQuery } from '@apollo/client/react';
import { MY_RECIPES } from '../graphql/profile';
Expand Down Expand Up @@ -42,6 +46,9 @@ export default function ProfilePage() {
const { user, logout } = useCurrentUser();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<Tab>('preferences');
// om man holder på å velge oppskrifter som skal slettes
const [selecting, setSelecting] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);

// oppskriftene brukeren har laget og den henter på nytt hver gang siden åpnes på nytt
const { data: myRecipesData } = useQuery(MY_RECIPES, {
Expand Down Expand Up @@ -75,6 +82,24 @@ export default function ProfilePage() {
navigate('/login');
}

// valg vises bare under mine oppskrifter
const isSelecting = selecting && desktopTab === 'shared';

// slår valg av eller på og tømmer det som var valgt
function toggleSelecting() {
setSelecting(!selecting);
setSelectedIds([]);
}

// legger til eller fjerner en oppskrift fra valget
function toggleSelected(id: string) {
if (selectedIds.includes(id)) {
setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id));
} else {
setSelectedIds([...selectedIds, id]);
}
}

return (
<div className="mx-auto flex max-w-6xl flex-col gap-6 px-5 pt-6 pb-16 md:mt-6 md:grid md:grid-cols-[minmax(0,560px)_1fr] md:items-start md:gap-8 md:px-4">
<div className="flex flex-col gap-6">
Expand Down Expand Up @@ -102,15 +127,15 @@ export default function ProfilePage() {
</p>

{/* TODO: hent ekte tall fra backend */}
<ul className="mt-5 flex flex-wrap gap-x-6 gap-y-3">
<ul className="mt-5 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
{[
{ count: myRecipes.length, label: 'mine oppskrifter' },
{ 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-display font-semibold text-3xl">
<span className="font-display font-semibold text-3xl lining-nums">
{stat.count}
</span>
<span className="text-xs">{stat.label}</span>
Expand All @@ -123,7 +148,7 @@ export default function ProfilePage() {
trigger={
<button
type="button"
className={`flex-1 cursor-pointer rounded-full bg-background px-5 py-3 text-sm font-medium text-text transition-colors hover:bg-peach md:flex-none ${FOCUS_RING}`}
className={`flex-1 cursor-pointer rounded-full bg-background px-4 whitespace-nowrap py-3 text-sm font-medium text-text transition-colors hover:bg-peach md:flex-none ${FOCUS_RING}`}
>
Rediger profil
</button>
Expand All @@ -132,7 +157,7 @@ export default function ProfilePage() {
<button
type="button"
onClick={handleLogout}
className={`flex-1 cursor-pointer rounded-full border border-background px-5 py-3 text-sm transition-colors hover:bg-background/10 md:flex-none ${FOCUS_RING}`}
className={`flex-1 cursor-pointer rounded-full border border-background px-4 whitespace-nowrap py-3 text-sm transition-colors hover:bg-background/10 md:flex-none ${FOCUS_RING}`}
>
Logg ut
</button>
Expand Down Expand Up @@ -228,14 +253,71 @@ export default function ProfilePage() {
))}
</div>

{/* søppelbøtte som slår på valg av egne oppskrifter */}
{desktopTab === 'shared' && myRecipes.length > 0 && (
<div className="mb-4 flex items-center justify-end gap-3">
{isSelecting && (
<>
<span className="text-sm" aria-live="polite">
{selectedIds.length} valgt
</span>
<DeleteRecipesDialog
recipes={myRecipes.filter((recipe) =>
selectedIds.includes(recipe.id),
)}
onDeleted={toggleSelecting}
/>
</>
)}
<button
type="button"
onClick={toggleSelecting}
aria-pressed={isSelecting}
aria-label={
isSelecting ? 'Avslutt valg' : 'Velg oppskrifter å slette'
}
className={`flex cursor-pointer rounded-lg p-2 transition-colors ${FOCUS_RING} ${
isSelecting
? 'text-primary'
: 'text-[#52552f] hover:text-primary'
}`}
>
<TrashIcon open={isSelecting} />
</button>
</div>
)}

{tabRecipes.length > 0 ? (
<ul className="grid grid-cols-2 gap-4 xl:grid-cols-3">
<ul className="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] gap-4">
{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>
{isSelecting ? (
// dekker kortet så trykk velger i stedet for å åpne oppskriften
<button
type="button"
onClick={() => toggleSelected(recipe.id)}
aria-pressed={selectedIds.includes(recipe.id)}
aria-label={`Velg ${recipe.title}`}
className={`absolute inset-0 flex cursor-pointer items-start rounded-lg p-3 ${FOCUS_RING} ${
selectedIds.includes(recipe.id)
? 'bg-primary/10 ring-2 ring-primary'
: ''
}`}
>
<span className="flex rounded-full bg-background text-primary shadow-sm">
{selectedIds.includes(recipe.id) ? (
<CheckCircleIcon />
) : (
<RadioButtonUncheckedIcon />
)}
</span>
</button>
) : (
<div className="absolute top-3 right-3">
<FavoriteButton recipeId={recipe.id} title={recipe.title} />
</div>
)}
</li>
))}
</ul>
Expand Down
29 changes: 28 additions & 1 deletion server/src/schema/resolvers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,9 +264,36 @@ export const userResolvers = {
where: { id: context.userId },
data: { passwordHash },
});

return true;
},
// sletter oppskriftene bare hvis brukeren er forfatteren
deleteMyRecipes: async (
_: unknown,
args: { ids: string[] },
context: Context,
) => {
if (!context.userId) {
throw new GraphQLError('Du må være logget inn.', {
extensions: { code: 'UNAUTHENTICATED' },
});
}

// sletter ingenting hvis forfatter er en annen
const result = await prisma.recipe.deleteMany({
where: { id: { in: args.ids }, authorId: context.userId },
});

if (result.count === 0) {
throw new GraphQLError(
'Fant ikke oppskriften eller så eier du den ikke.',
{
extensions: { code: 'FORBIDDEN' },
},
);
}

return result.count;
},
},

User: {
Expand Down
2 changes: 2 additions & 0 deletions server/src/schema/typeDefs/user.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,6 @@ extend type Mutation {
changePassword(currentPassword: String!, newPassword: String!): Boolean!
# legger til eller fjerner en favoritt
toggleFavorite(recipeId: ID!): Boolean!
# sletter en oppskrift brukeren selv har laget
deleteMyRecipes(ids: [ID!]!): Int!
}