Added category update
This commit is contained in:
@@ -2,9 +2,13 @@
|
|||||||
import type { Category } from "~/models/Category";
|
import type { Category } from "~/models/Category";
|
||||||
import { ScrollAreaRoot, ScrollAreaViewport, ScrollAreaScrollbar, ScrollAreaThumb } from "radix-vue"
|
import { ScrollAreaRoot, ScrollAreaViewport, ScrollAreaScrollbar, ScrollAreaThumb } from "radix-vue"
|
||||||
|
|
||||||
defineProps<{
|
const { categories } = defineProps<{
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const sortedCategories = computed(() => {
|
||||||
|
return categories.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -23,7 +27,7 @@ defineProps<{
|
|||||||
<ScrollAreaViewport class="w-full h-full pr-4" as-child>
|
<ScrollAreaViewport class="w-full h-full pr-4" as-child>
|
||||||
<div class="[&:last-child]:border-0">
|
<div class="[&:last-child]:border-0">
|
||||||
<CalendarCategoryTableRow
|
<CalendarCategoryTableRow
|
||||||
v-for="item in categories"
|
v-for="item in sortedCategories"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:category="item"
|
:category="item"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { PhCheck } from "@phosphor-icons/vue";
|
import { PhCheck } from "@phosphor-icons/vue"
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils"
|
||||||
import type { Category } from "~/models/Category";
|
import type { Category } from "~/models/Category"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
category: Category
|
category: Category
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
type RowMode = "edit" | "view";
|
type RowMode = "edit" | "view"
|
||||||
const currentMode = ref<RowMode>("view");
|
const currentMode = ref<RowMode>("view")
|
||||||
|
|
||||||
const rowRef = ref<HTMLDivElement | null>(null);
|
const rowRef = ref<HTMLDivElement | null>(null)
|
||||||
const inputRef = ref<HTMLInputElement | null>(null);
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
const { focused: inputFocused } = useFocus(inputRef);
|
const { focused: inputFocused } = useFocus(inputRef)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle view mode options
|
* Toggle view mode options
|
||||||
@@ -26,15 +26,14 @@ type ToggleViewOptions = {
|
|||||||
* @param options.execution - When to execute the toggle. "now" or "nextTick"
|
* @param options.execution - When to execute the toggle. "now" or "nextTick"
|
||||||
*/
|
*/
|
||||||
function toggleView(options: ToggleViewOptions = { execution: "now" }) {
|
function toggleView(options: ToggleViewOptions = { execution: "now" }) {
|
||||||
currentMode.value = "view";
|
currentMode.value = "view"
|
||||||
categorySkeleton.value = structuredClone(toRaw(props.category));
|
|
||||||
|
|
||||||
if (options.execution === "now") {
|
if (options.execution === "now") {
|
||||||
inputFocused.value = false;
|
inputFocused.value = false;
|
||||||
} else {
|
} else {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
inputFocused.value = false;
|
inputFocused.value = false;
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,95 +42,107 @@ function toggleView(options: ToggleViewOptions = { execution: "now" }) {
|
|||||||
*/
|
*/
|
||||||
function toggleEdit() {
|
function toggleEdit() {
|
||||||
currentMode.value = "edit";
|
currentMode.value = "edit";
|
||||||
|
categorySkeleton.value = structuredClone(toRaw(props.category))
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
inputFocused.value = true;
|
inputFocused.value = true;
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onClickOutside(rowRef, () => toggleView({ execution: "nextTick" }));
|
onClickOutside(rowRef, () => toggleView({ execution: "nextTick" }))
|
||||||
onKeyStroke("Escape", () => toggleView({ execution: "now" }));
|
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
|
* Submit the update
|
||||||
* TODO: Implement the update logic
|
* TODO: Implement the update logic
|
||||||
*/
|
*/
|
||||||
function submitUpdate() {
|
async function submitUpdate() {
|
||||||
console.log("oui")
|
const { error } = await tryCatch(updateCategoryFromSkeleton())
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
toggleView({ execution: "now" })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="relative">
|
||||||
ref="rowRef"
|
<form
|
||||||
class="grid grid-cols-12 items-center gap-4 p-1"
|
ref="rowRef"
|
||||||
:class="cn(
|
class="grid grid-cols-12 items-center gap-4 p-1"
|
||||||
{ 'bg-slate-100 hover:bg-slate-200 dark:bg-slate-900 dark:hover:bg-slate-800': currentMode === 'edit' },
|
:class="cn(
|
||||||
{ 'bg-transparent hover:bg-slate-50 dark:bg-transparent dark:hover:bg-slate-900': currentMode !== 'edit' }
|
{ '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
|
@submit.prevent="submitUpdate"
|
||||||
class="col-span-6"
|
|
||||||
>
|
>
|
||||||
<template v-if="currentMode === 'view'">
|
<div
|
||||||
<button
|
class="col-span-6"
|
||||||
class="py-2 px-1 h-full w-full text-left underline-offset-4 hover:underline cursor-pointer"
|
>
|
||||||
@click="toggleEdit"
|
<template v-if="currentMode === 'view'">
|
||||||
>
|
<button
|
||||||
{{ category.name }}
|
class="py-2 px-1 h-full w-full text-left underline-offset-4 hover:underline cursor-pointer"
|
||||||
</button>
|
@click="toggleEdit"
|
||||||
</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}`)"
|
|
||||||
>
|
>
|
||||||
{{ $t(`ui.colors.${category.color}`) }}
|
{{ category.name }}
|
||||||
</span>
|
</button>
|
||||||
</button>
|
</template>
|
||||||
</template>
|
<template v-else-if="currentMode === 'edit' && categorySkeleton">
|
||||||
<template v-else-if="currentMode === 'edit'">
|
<input
|
||||||
<div class="-mx-2">
|
ref="inputRef"
|
||||||
<InputColor
|
v-model="categorySkeleton.name"
|
||||||
id="category-color"
|
type="text"
|
||||||
v-model="categorySkeleton.color"
|
class="p-1 h-full w-full bg-transparent focus-visible:outline-none italic"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<PhCheck size="14" weight="bold" />
|
</template>
|
||||||
</UiButton>
|
</div>
|
||||||
</li>
|
<div class="col-span-4">
|
||||||
</menu>
|
<template v-if="currentMode === 'view'">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ export interface Category {
|
|||||||
export const categorySchema = z.object({
|
export const categorySchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
color: z.string().default("black")
|
color: z.string().default("black"),
|
||||||
})
|
})
|
||||||
|
|||||||
72
server/api/calendars/categories/[id].patch.ts
Normal file
72
server/api/calendars/categories/[id].patch.ts
Normal 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."
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -61,8 +61,6 @@ export const useCalendar = defineStore("calendar", () => {
|
|||||||
const months = ref<CalendarMonth[]>([])
|
const months = ref<CalendarMonth[]>([])
|
||||||
|
|
||||||
function setActiveCalendar(calendarData: Calendar, categoryData: Category[]) {
|
function setActiveCalendar(calendarData: Calendar, categoryData: Category[]) {
|
||||||
console.log("proced")
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!calendarData.id) return
|
if (!calendarData.id) return
|
||||||
|
|
||||||
|
|||||||
62
stores/CategoryStore.ts
Normal file
62
stores/CategoryStore.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
|
-- Send "previous data" on change
|
||||||
alter table public.users replica identity full;
|
alter table public.users replica identity full;
|
||||||
|
|
||||||
|
|||||||
31
utils/TryCatch.ts
Normal file
31
utils/TryCatch.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user