Added category deletion interface

This commit is contained in:
Alexis
2025-04-15 20:32:03 +02:00
parent 58f3783b13
commit 1e0d840b41
9 changed files with 245 additions and 17 deletions

View File

@@ -2,11 +2,23 @@
import type { Category } from "~/models/Category";
import { ScrollAreaRoot, ScrollAreaViewport, ScrollAreaScrollbar, ScrollAreaThumb } from "radix-vue"
const { t } = useI18n()
const { categories } = defineProps<{
categories: Category[]
}>()
const sortedCategories = computed(() => categories.toSorted((a, b) => a.name.localeCompare(b.name)))
const deleteDialogOpened = ref<boolean>(false)
function openDeleteDialog() {
deleteDialogOpened.value = true
}
function closeDeleteDialog() {
deleteDialogOpened.value = false
}
</script>
<template>
@@ -20,6 +32,7 @@ const sortedCategories = computed(() => categories.toSorted((a, b) => a.name.loc
v-for="item in sortedCategories"
:key="item.id"
:category="item"
@on-delete-category="openDeleteDialog"
/>
</div>
</ScrollAreaViewport>
@@ -35,4 +48,25 @@ const sortedCategories = computed(() => categories.toSorted((a, b) => a.name.loc
<CalendarCategoryTableFooter />
</div>
<UiDialog v-model:open="deleteDialogOpened">
<UiDialogContent
:disable-outside-pointer-events="true"
:trap-focus="true"
class="min-w-96 border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
@escape-key-down="closeDeleteDialog"
@focus-outside="closeDeleteDialog"
@interact-outside="closeDeleteDialog"
@pointer-down-outside="closeDeleteDialog"
>
<UiDialogTitle>
{{ $t('entity.category.deleteDialog.title') }}
</UiDialogTitle>
<UiDialogDescription>
{{ $t('entity.category.deleteDialog.subtitle') }}
</UiDialogDescription>
<CalendarFormDeleteCategory />
</UiDialogContent>
</UiDialog>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { PhCheck } from "@phosphor-icons/vue"
import { PhCheck, PhTrash } from "@phosphor-icons/vue"
import { useToast } from "~/components/ui/toast"
import { ToastLifetime } from "~/components/ui/toast/use-toast"
import { cn } from "~/lib/utils"
@@ -12,12 +12,17 @@ const { category } = defineProps<{
category: Category
}>()
const emit = defineEmits<{
(e: "on-delete-category", payload: Category): void
}>()
type RowMode = "edit" | "view"
const currentMode = ref<RowMode>("view")
const rowRef = ref<HTMLDivElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const { focused: inputFocused } = useFocus(inputRef)
const rowHovered = useElementHover(rowRef)
/**
* Toggle view mode options
@@ -86,6 +91,11 @@ async function submitUpdate() {
duration: ToastLifetime.SHORT
})
}
function handleQueryDelete() {
categorySkeleton.value = structuredClone(toRaw(category))
emit("on-delete-category", category)
}
</script>
<template>
@@ -148,19 +158,30 @@ async function submitUpdate() {
</div>
</form>
<menu class="w-fit absolute top-1/2 -translate-y-1/2 right-2 flex items-center gap-2">
<li>
<li
v-if="currentMode === 'edit' && categorySkeleton"
>
<UiButton
v-if="currentMode === 'edit' && categorySkeleton"
variant="secondary"
size="icon"
class="w-6 h-6 rounded-full bg-emerald-500 hover:bg-emerald-600 text-white"
:to="{ name: 'calendar.category.edit', params: { id: category.id } }"
:title="$t('ui.actions.edit')"
@click="submitUpdate"
>
<PhCheck size="14" weight="bold" />
</UiButton>
</li>
<li v-else-if="rowHovered">
<UiButton
variant="secondary"
size="icon"
class="w-6 h-6 rounded-full hover:bg-red-600 hover:text-white"
:title="$t('ui.actions.delete')"
@click="handleQueryDelete"
>
<PhTrash size="14" weight="bold" />
</UiButton>
</li>
</menu>
</div>
</template>

View File

@@ -1,9 +1,8 @@
<script lang="ts" setup>
import { CalendarFormCategories } from "#components";
import { PhX } from "@phosphor-icons/vue";
const { toggleCategoriesModal } = useCalendar()
const { isCategoriesModalOpen } = storeToRefs(useCalendar())
const { categories, isCategoriesModalOpen } = storeToRefs(useCalendar())
function handleClosing() {
toggleCategoriesModal(false)
@@ -36,7 +35,7 @@ function handleClosing() {
<PhX size="20" />
</UiButton>
<CalendarFormCategories />
<CalendarCategoryTable :categories />
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -1,7 +0,0 @@
<script lang="ts" setup>
const { categories } = storeToRefs(useCalendar())
</script>
<template>
<CalendarCategoryTable :categories />
</template>

View File

@@ -0,0 +1,85 @@
<script lang="ts" setup>
import { PhCircleNotch } from "@phosphor-icons/vue";
import { useToast } from "~/components/ui/toast";
import { ToastLifetime } from "~/components/ui/toast/use-toast";
const { deleteCategoryFromSkeleton, cancelLatestRequest } = useCategoryStore()
const { isDeletingCategory, categorySkeleton } = storeToRefs(useCategoryStore())
const { toast } = useToast()
const { t } = useI18n()
const isLoading = ref<boolean>(false)
const formErrors = reactive<{ message: string | null }>({
message: null
})
async function handleAction(): Promise<void> {
if (isLoading.value && !categorySkeleton.value) return
isLoading.value = true
const categoryName = categorySkeleton.value?.name
try {
await deleteCategoryFromSkeleton()
toast({
title: t("entity.calendar.category.deletedToast.title", { category: categoryName }),
variant: "success",
duration: ToastLifetime.MEDIUM
})
} catch (err) {
if (err instanceof Error) {
formErrors.message = err.message
}
} finally {
isLoading.value = false
}
}
/**
* Click on the cancel button
*
* Must cancel the abortController in the store, and stop the loading
*/
function handleCancel(): void {
cancelLatestRequest()
isLoading.value = false
}
</script>
<template>
<form @submit.prevent="handleAction">
<div class="grid grid-cols-2 gap-y-4">
<div class="text-red-500 ml-8">
<span class="text-sm">
{{ formErrors.message }}
</span>
</div>
</div>
<footer class="flex gap-2 justify-between">
<UiButton type="button" size="sm" variant="outline" @click="() => isDeletingCategory = false">
{{ $t('ui.action.back') }}
</UiButton>
<div class="flex gap-2 justify-end">
<Transition name="fade-delay">
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
{{ $t('ui.action.cancel') }}
</UiButton>
</Transition>
<UiButton size="sm" variant="destructive" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
</Transition>
{{ $t('ui.action.delete') }}
</UiButton>
</div>
</footer>
</form>
</template>

View File

@@ -98,6 +98,10 @@ export default defineI18nConfig(() => ({
title: "Manage categories",
subtitle: "Add and change the categories of your calendar",
},
deleteDialog: {
title: "Delete this category ?",
subtitle: "The events attached to this category won't be deleted, but you'll lose the category for this calendar.",
},
addedToast: {
title: "The category \"{category}\" has been added to the calendar.",
titleError: "An error has occured and the category \"{category}\" wasn't added to the calendar.",
@@ -413,6 +417,10 @@ export default defineI18nConfig(() => ({
title: "Gestion des catégories",
subtitle: "Créer et modifier les catégories de votre calendrier",
},
deleteDialog: {
title: "Supprimer cette catégorie ?",
subtitle: "Les évènements l'utilisant ne seront pas supprimés.",
},
addedToast: {
title: "La catégorie \"{category}\" a été ajoutée au calendrier.",
titleError: "Une erreur s'est produite et la catégorie \"{category}\" n'a pas pu être ajoutée.",

View File

@@ -0,0 +1,43 @@
import { z } from "zod"
import { serverSupabaseClient } from "#supabase/server"
import type { Category } from "~/models/Category"
const paramsSchema = z.object({
id: z.number({ coerce: true }).positive().int()
})
export default defineEventHandler(async (event) => {
const client = await serverSupabaseClient(event)
const { data: params, error: paramsError} = await getValidatedRouterParams(event, paramsSchema.safeParse)
if (paramsError) {
throw createError({
cause: "Utilisateur",
fatal: false,
message: "L'identifiant de l'évènement est manquant ou mal renseigné.",
status: 401,
})
}
try {
const { data, error } = await client
.from("calendar_event_categories")
.delete()
.eq("id", params.id)
.maybeSingle<Category>()
console.log(error)
if (error) throw error
return data
} catch (err) {
throw createError({
cause: "Serveur",
status: 500,
fatal: false,
message: "Une erreur inconnue est survenue."
})
}
})

View File

@@ -73,6 +73,35 @@ export const useCategoryStore = defineStore("calendar-category", () => {
resetSkeleton()
}
async function deleteCategoryFromSkeleton() {
if (!categorySkeleton.value) return
abortController = new AbortController()
isDeletingCategory.value = true
const { error } = await tryCatch(
$fetch<Category>(`/api/calendars/categories/${categorySkeleton.value.id}`, { method: "DELETE", signal: abortController.signal })
)
if (error) {
isDeletingCategory.value = false
throw error
}
const categoryIndex = categories.value.findIndex(c => c.id === categorySkeleton.value!.id)
categories.value.splice(categoryIndex, 1)
abortController = null
isDeletingCategory.value = false
resetSkeleton()
}
function cancelLatestRequest() {
if (abortController) {
abortController.abort()
}
}
return {
isCreatingCategory,
isUpdatingCategory,
@@ -81,6 +110,8 @@ export const useCategoryStore = defineStore("calendar-category", () => {
categorySkeleton,
resetSkeleton,
addCategoryFromSkeleton,
updateCategoryFromSkeleton
updateCategoryFromSkeleton,
deleteCategoryFromSkeleton,
cancelLatestRequest
}
})

View File

@@ -106,7 +106,7 @@ create table public.calendar_events (
location text,
start_date json not null,
end_date json,
category bigint references public.calendar_event_categories on delete cascade,
category bigint references public.calendar_event_categories on delete set null,
hidden boolean default false,
wiki text,
created_at timestamptz default now(),
@@ -150,7 +150,7 @@ create table public.characters (
description text,
birth json not null,
death json,
category bigint references public.character_categories on delete cascade,
category bigint references public.character_categories on delete set null,
hidden_birth boolean,
hidden_death boolean,
wiki text,
@@ -468,6 +468,20 @@ create policy "Allow GMs to update their events categories"
)
);
create policy "Allow GMs to delete their events categories"
on public.calendar_event_categories
for delete
using (
exists (
select 1
from public.calendars c
join public.worlds w on w.id = c.world_id
where
c.id = calendar_event_categories.calendar_id
and w.gm_id = auth.uid()
)
);
-- Send "previous data" on change
alter table public.users replica identity full;