Added category update

This commit is contained in:
Alexis
2025-04-06 14:41:46 +02:00
parent 8005a8e9a2
commit b32fab81f6
8 changed files with 281 additions and 89 deletions

View File

@@ -2,9 +2,13 @@
import type { Category } from "~/models/Category";
import { ScrollAreaRoot, ScrollAreaViewport, ScrollAreaScrollbar, ScrollAreaThumb } from "radix-vue"
defineProps<{
const { categories } = defineProps<{
categories: Category[]
}>()
const sortedCategories = computed(() => {
return categories.toSorted((a, b) => a.name.localeCompare(b.name))
})
</script>
<template>
@@ -23,7 +27,7 @@ defineProps<{
<ScrollAreaViewport class="w-full h-full pr-4" as-child>
<div class="[&:last-child]:border-0">
<CalendarCategoryTableRow
v-for="item in categories"
v-for="item in sortedCategories"
:key="item.id"
:category="item"
/>

View File

@@ -1,18 +1,18 @@
<script setup lang="ts">
import { PhCheck } from "@phosphor-icons/vue";
import { cn } from "~/lib/utils";
import type { Category } from "~/models/Category";
import { PhCheck } from "@phosphor-icons/vue"
import { cn } from "~/lib/utils"
import type { Category } from "~/models/Category"
const props = defineProps<{
category: Category
}>()
type RowMode = "edit" | "view";
const currentMode = ref<RowMode>("view");
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 rowRef = ref<HTMLDivElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const { focused: inputFocused } = useFocus(inputRef)
/**
* Toggle view mode options
@@ -26,15 +26,14 @@ type ToggleViewOptions = {
* @param options.execution - When to execute the toggle. "now" or "nextTick"
*/
function toggleView(options: ToggleViewOptions = { execution: "now" }) {
currentMode.value = "view";
categorySkeleton.value = structuredClone(toRaw(props.category));
currentMode.value = "view"
if (options.execution === "now") {
inputFocused.value = false;
} else {
nextTick(() => {
inputFocused.value = false;
});
})
}
}
@@ -43,95 +42,107 @@ function toggleView(options: ToggleViewOptions = { execution: "now" }) {
*/
function toggleEdit() {
currentMode.value = "edit";
categorySkeleton.value = structuredClone(toRaw(props.category))
nextTick(() => {
inputFocused.value = true;
});
})
}
onClickOutside(rowRef, () => toggleView({ execution: "nextTick" }));
onKeyStroke("Escape", () => toggleView({ execution: "now" }));
onClickOutside(rowRef, () => toggleView({ execution: "nextTick" }))
onKeyStroke("Escape", () => toggleView({ execution: "now" }))
const categorySkeleton = ref<Category>(structuredClone(toRaw(props.category)))
const { resetSkeleton, updateCategoryFromSkeleton } = useCategoryStore()
const { categorySkeleton } = storeToRefs(useCategoryStore())
onUnmounted(() => {
resetSkeleton()
})
/**
* Submit the update
* TODO: Implement the update logic
*/
function submitUpdate() {
console.log("oui")
async function submitUpdate() {
const { error } = await tryCatch(updateCategoryFromSkeleton())
if (!error) {
toggleView({ execution: "now" })
}
}
</script>
<template>
<div
ref="rowRef"
class="grid grid-cols-12 items-center gap-4 p-1"
:class="cn(
{ 'bg-slate-100 hover:bg-slate-200 dark:bg-slate-900 dark:hover:bg-slate-800': currentMode === 'edit' },
{ 'bg-transparent hover:bg-slate-50 dark:bg-transparent dark:hover:bg-slate-900': currentMode !== 'edit' }
)"
>
<div
class="col-span-6"
<div class="relative">
<form
ref="rowRef"
class="grid grid-cols-12 items-center gap-4 p-1"
:class="cn(
{ 'bg-slate-100 hover:bg-slate-200 dark:bg-slate-900 dark:hover:bg-slate-800': currentMode === 'edit' },
{ 'bg-transparent hover:bg-slate-50 dark:bg-transparent dark:hover:bg-slate-900': currentMode !== 'edit' }
)"
@submit.prevent="submitUpdate"
>
<template v-if="currentMode === 'view'">
<button
class="py-2 px-1 h-full w-full text-left underline-offset-4 hover:underline cursor-pointer"
@click="toggleEdit"
>
{{ category.name }}
</button>
</template>
<template v-else-if="currentMode === 'edit'">
<input
ref="inputRef"
type="text"
:value="category.name"
class="p-1 h-full w-full bg-transparent focus-visible:outline-none italic"
>
</template>
</div>
<div class="col-span-4">
<template v-if="currentMode === 'view'">
<button
class="p-1 h-full w-full text-left text-sm cursor-pointer"
@click="toggleEdit"
>
<span
class="bgc"
:class="cn(`bgc-${category.color}`)"
<div
class="col-span-6"
>
<template v-if="currentMode === 'view'">
<button
class="py-2 px-1 h-full w-full text-left underline-offset-4 hover:underline cursor-pointer"
@click="toggleEdit"
>
{{ $t(`ui.colors.${category.color}`) }}
</span>
</button>
</template>
<template v-else-if="currentMode === 'edit'">
<div class="-mx-2">
<InputColor
id="category-color"
v-model="categorySkeleton.color"
position="item-aligned"
theme="subtle"
/>
</div>
</template>
</div>
<div class="col-span-2">
<menu class="w-fit ml-auto">
<li>
<UiButton
v-if="currentMode === 'edit'"
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"
{{ category.name }}
</button>
</template>
<template v-else-if="currentMode === 'edit' && categorySkeleton">
<input
ref="inputRef"
v-model="categorySkeleton.name"
type="text"
class="p-1 h-full w-full bg-transparent focus-visible:outline-none italic"
>
<PhCheck size="14" weight="bold" />
</UiButton>
</li>
</menu>
</div>
</template>
</div>
<div class="col-span-4">
<template v-if="currentMode === 'view'">
<button
class="p-1 h-full w-full text-left text-sm cursor-pointer"
@click="toggleEdit"
>
<span
class="bgc"
:class="cn(`bgc-${category.color}`)"
>
{{ $t(`ui.colors.${category.color}`) }}
</span>
</button>
</template>
<template v-else-if="currentMode === 'edit' && categorySkeleton">
<div class="-mx-2">
<InputColor
id="category-color"
v-model="categorySkeleton.color"
position="item-aligned"
theme="subtle"
/>
</div>
</template>
</div>
</form>
<menu class="w-fit absolute top-1/2 -translate-y-1/2 right-2 flex items-center gap-2">
<li>
<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>
</menu>
</div>
</template>

View File

@@ -10,5 +10,5 @@ export interface Category {
export const categorySchema = z.object({
id: z.number().int(),
name: z.string(),
color: z.string().default("black")
color: z.string().default("black"),
})

View File

@@ -0,0 +1,72 @@
import { z } from "zod"
import { serverSupabaseClient } from "#supabase/server"
import type { Category} from "~/models/Category";
import { categorySchema } 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)
const { data: bodyData, error: bodyError } = await readValidatedBody(event, body => categorySchema.safeParse(body))
if (paramsError) {
throw createError({
cause: "Utilisateur",
fatal: false,
message: "L'identifiant de la catégorie est manquant ou mal renseigné.",
status: 401,
})
}
if (bodyError) {
const error = createError({
cause: "Utilisateur",
fatal: false,
statusCode: 401,
statusMessage: "Validation Error",
message: "Erreur de validation du schéma, probablement dûe à une erreur utilisateur.",
data: {
errors: bodyError.issues.map(issue => ({
path: issue.path,
message: issue.message,
code: issue.code
}))
}
})
throw error
}
try {
const { data, error } = await client
.from("calendar_event_categories")
.update(
{
name: bodyData.name,
color: bodyData.color,
} as never
)
.eq("id", params.id)
.select(`
id,
name,
color
`)
.single<Category>()
if (error) throw error
return data
} catch (err) {
throw createError({
cause: "Serveur",
status: 500,
fatal: false,
message: "Une erreur inconnue est survenue."
})
}
})

View File

@@ -61,8 +61,6 @@ export const useCalendar = defineStore("calendar", () => {
const months = ref<CalendarMonth[]>([])
function setActiveCalendar(calendarData: Calendar, categoryData: Category[]) {
console.log("proced")
try {
if (!calendarData.id) return

62
stores/CategoryStore.ts Normal file
View File

@@ -0,0 +1,62 @@
import type { Category } from "~/models/Category";
export const useCategoryStore = defineStore("calendar-category", () => {
const { categories } = storeToRefs(useCalendar())
/**
* Dummy event to hold creation data
*/
const isCreatingCategory = ref<boolean>(false)
const isUpdatingCategory = ref<boolean>(false)
const isDeletingCategory = ref<boolean>(false)
const categorySkeleton = ref<Category | null>(null);
const operationInProgress = computed<boolean>(() => isCreatingCategory.value || isUpdatingCategory.value || isDeletingCategory.value)
let abortController: AbortController | null = null
/**
* Resets the dummy category data
*/
function resetSkeleton() {
categorySkeleton.value = null
}
async function updateCategoryFromSkeleton() {
if (!categorySkeleton.value) return
abortController = new AbortController()
isUpdatingCategory.value = true
const body = categorySkeleton.value
const { data, error } = await tryCatch(
$fetch<Category>(`/api/calendars/categories/${categorySkeleton.value.id}`, { method: "PATCH", body, signal: abortController.signal })
)
if (error) {
isUpdatingCategory.value = false
throw error
}
// Update the category in the store
categories.value = categories.value.map((category) => {
if (category.id === data.id) {
return { ...category, ...data }
}
return category
})
abortController = null
isUpdatingCategory.value = false
}
return {
isCreatingCategory,
isUpdatingCategory,
isDeletingCategory,
operationInProgress,
categorySkeleton,
resetSkeleton,
updateCategoryFromSkeleton
}
})

View File

@@ -440,6 +440,20 @@ create policy "Allow anonymous access to published event categories" on public.c
)
);
create policy "Allow GMs to update their events categories"
on public.calendar_event_categories
for update
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;

31
utils/TryCatch.ts Normal file
View File

@@ -0,0 +1,31 @@
// Types for the result object with discriminated union
type Success<T> = {
data: T;
error: null;
};
type Failure<E> = {
data: null;
error: E;
};
type Result<T, E = Error> = Success<T> | Failure<E>;
/**
* A utility function to handle promises with try-catch
* and return a result object, courtesy of t3.
*
* @param promise The promise to be executed
* @returns A promise that resolves to an object with either the data or the error
* @link https://gist.github.com/t3dotgg/a486c4ae66d32bf17c09c73609dacc5b
*/
export async function tryCatch<T, E = Error>(
promise: Promise<T>,
): Promise<Result<T, E>> {
try {
const data = await promise;
return { data, error: null };
} catch (error) {
return { data: null, error: error as E };
}
}