diff --git a/client/src/components/icons/TrashIcon.tsx b/client/src/components/icons/TrashIcon.tsx new file mode 100644 index 0000000..7c96b1f --- /dev/null +++ b/client/src/components/icons/TrashIcon.tsx @@ -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 ( + + ); +} diff --git a/client/src/components/profile/DeleteRecipesDialog.tsx b/client/src/components/profile/DeleteRecipesDialog.tsx new file mode 100644 index 0000000..7a9da18 --- /dev/null +++ b/client/src/components/profile/DeleteRecipesDialog.tsx @@ -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 ( + + + + + + + + + {count === 1 + ? 'Vil du slette 1 oppskrift?' + : `Vil du slette ${count} oppskrifter?`} + + + Oppskriftene blir slettet permanent: + + +
    + {recipes.map((recipe) => ( +
  • {recipe.title}
  • + ))} +
+ + {error && ( +

+ {error.message} +

+ )} + +
+ + + + +
+
+
+
+ ); +} diff --git a/client/src/components/ui/RecipeImage.tsx b/client/src/components/ui/RecipeImage.tsx index a052cff..929a020 100644 --- a/client/src/components/ui/RecipeImage.tsx +++ b/client/src/components/ui/RecipeImage.tsx @@ -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, @@ -19,7 +31,7 @@ export function RecipeImage({ if (imageUrl && !failed) { return ( {alt} setFailed(true)} className={`object-cover ${className}`} diff --git a/client/src/graphql/profile.ts b/client/src/graphql/profile.ts index e885df6..f7bbfa6 100644 --- a/client/src/graphql/profile.ts +++ b/client/src/graphql/profile.ts @@ -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) + } +`; diff --git a/client/src/routes/ProfilePage.tsx b/client/src/routes/ProfilePage.tsx index 4ad12cf..889979b 100644 --- a/client/src/routes/ProfilePage.tsx +++ b/client/src/routes/ProfilePage.tsx @@ -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'; @@ -42,6 +46,9 @@ export default function ProfilePage() { const { user, logout } = useCurrentUser(); const navigate = useNavigate(); const [activeTab, setActiveTab] = useState('preferences'); + // om man holder på å velge oppskrifter som skal slettes + const [selecting, setSelecting] = useState(false); + const [selectedIds, setSelectedIds] = useState([]); // oppskriftene brukeren har laget og den henter på nytt hver gang siden åpnes på nytt const { data: myRecipesData } = useQuery(MY_RECIPES, { @@ -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 (
@@ -102,7 +127,7 @@ export default function ProfilePage() {

{/* TODO: hent ekte tall fra backend */} -
    +
      {[ { count: myRecipes.length, label: 'mine oppskrifter' }, { count: favorites.length, label: 'favoritter' }, @@ -110,7 +135,7 @@ export default function ProfilePage() { { count: 0, label: 'delt med deg' }, ].map((stat) => (
    • - + {stat.count} {stat.label} @@ -123,7 +148,7 @@ export default function ProfilePage() { trigger={ @@ -132,7 +157,7 @@ export default function ProfilePage() { @@ -228,14 +253,71 @@ export default function ProfilePage() { ))}
+ {/* søppelbøtte som slår på valg av egne oppskrifter */} + {desktopTab === 'shared' && myRecipes.length > 0 && ( +
+ {isSelecting && ( + <> + + {selectedIds.length} valgt + + + selectedIds.includes(recipe.id), + )} + onDeleted={toggleSelecting} + /> + + )} + +
+ )} + {tabRecipes.length > 0 ? ( -
    +
      {tabRecipes.map((recipe) => (
    • -
      - -
      + {isSelecting ? ( + // dekker kortet så trykk velger i stedet for å åpne oppskriften + + ) : ( +
      + +
      + )}
    • ))}
    diff --git a/server/src/schema/resolvers/user.ts b/server/src/schema/resolvers/user.ts index 14117aa..0fb6e5b 100644 --- a/server/src/schema/resolvers/user.ts +++ b/server/src/schema/resolvers/user.ts @@ -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: { diff --git a/server/src/schema/typeDefs/user.graphql b/server/src/schema/typeDefs/user.graphql index 3cf43af..96cde7e 100644 --- a/server/src/schema/typeDefs/user.graphql +++ b/server/src/schema/typeDefs/user.graphql @@ -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! }