Merge pull request #74 from AlexisNP/features/category-management

Features/category management
This commit is contained in:
AlexisNP
2025-04-16 10:59:11 +02:00
committed by GitHub
68 changed files with 1816 additions and 990 deletions

View File

@@ -22,7 +22,7 @@
--primary: 245 58% 51%; --primary: 245 58% 51%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--secondary: 210 40% 96.1%; --secondary: 210 50% 95%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%; --accent: 210 40% 96.1%;
@@ -338,6 +338,81 @@
--border-color: color-mix(in srgb, var(--base-color), var(--color-slate-800) 50%); --border-color: color-mix(in srgb, var(--base-color), var(--color-slate-800) 50%);
} }
.bgc {
display: flex;
align-items: center;
flex-wrap: nowrap;
}
.bgc::before {
content: "";
display: inline-block;
width: .75rem;
aspect-ratio: 1;
margin-right: 0.5em;
border-radius: .25rem;
background-color: red;
border: 1px solid transparent;
}
.bgc-red::before {
@apply bg-red-500;
}
.bgc-orange::before {
@apply bg-orange-500;
}
.bgc-amber::before {
@apply bg-amber-500;
}
.bgc-yellow::before {
@apply bg-yellow-500;
}
.bgc-lime::before {
@apply bg-lime-500;
}
.bgc-green::before {
@apply bg-green-500;
}
.bgc-emerald::before {
@apply bg-emerald-600;
}
.bgc-teal::before {
@apply bg-teal-600;
}
.bgc-cyan::before {
@apply bg-cyan-600;
}
.bgc-sky::before {
@apply bg-sky-600;
}
.bgc-blue::before {
@apply bg-blue-600;
}
.bgc-indigo::before {
@apply bg-indigo-600;
}
.bgc-violet::before {
@apply bg-violet-600;
}
.bgc-purple::before {
@apply bg-purple-600;
}
.bgc-fuchsia::before {
@apply bg-fuchsia-600;
}
.bgc-pink::before {
@apply bg-pink-600;
}
.bgc-rose::before {
@apply bg-rose-600;
}
.bgc-black::before {
@apply bg-black dark:border-[1px] dark:border-slate-300;
}
.bgc-white::before {
@apply bg-white border-[1px] border-slate-700;
}
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: all .5s ease; transition: all .5s ease;

View File

@@ -8,17 +8,6 @@ import CenturyLayout from "./state/centennially/Layout.vue"
import DecadeLayout from "./state/decennially/Layout.vue" import DecadeLayout from "./state/decennially/Layout.vue"
import YearLayout from "./state/yearly/Layout.vue" import YearLayout from "./state/yearly/Layout.vue"
import type { Calendar } from "~/models/CalendarConfig"
import type { Category } from "~/models/Category"
const props = defineProps<{
calendarData: Calendar,
categories: Category[]
}>()
const { setActiveCalendar } = useCalendar()
setActiveCalendar(props.calendarData, props.categories)
const { currentConfig, jumpToDate, selectedDate } = useCalendar() const { currentConfig, jumpToDate, selectedDate } = useCalendar()
const { isReadOnly } = storeToRefs(useCalendar()) const { isReadOnly } = storeToRefs(useCalendar())
@@ -52,6 +41,7 @@ onMounted(() => {
<component :is="currentViewComponent" /> <component :is="currentViewComponent" />
<LazyCalendarSearch /> <LazyCalendarSearch />
<LazyCalendarDialogCategories v-if="!isReadOnly" />
<LazyCalendarDialogUpdateEvent v-if="!isReadOnly" /> <LazyCalendarDialogUpdateEvent v-if="!isReadOnly" />
<LazyCalendarDialogDeleteEvent v-if="!isReadOnly" /> <LazyCalendarDialogDeleteEvent v-if="!isReadOnly" />
</div> </div>

View File

@@ -1,200 +0,0 @@
<script lang="ts" setup>
import { computed, type ComputedRef } from "vue"
import { useCalendar } from "@/stores/CalendarStore"
import {
PhCaretDoubleLeft,
PhCaretDoubleRight,
PhCaretLeft,
PhCaretRight
} from "@phosphor-icons/vue"
const { t } = useI18n()
interface DirectionLabels {
pastFar: string
pastNear: string
futureNear: string
futureFar: string
}
const { currentConfig, decrementViewMonth, incrementViewMonth, decrementViewYear, incrementViewYear } =
useCalendar()
const activeDirectionLabels: ComputedRef<DirectionLabels> = computed(() => {
switch (currentConfig.viewType) {
case "month":
return {
pastFar: t("entity.calendar.years.prevSingular"),
pastNear: t("entity.calendar.months.prevSingular"),
futureNear: t("entity.calendar.months.nextSingular"),
futureFar: t("entity.calendar.years.nextSingular")
}
case "year":
return {
pastFar: t("entity.calendar.decades.prevSingular"),
pastNear: t("entity.calendar.years.prevSingular"),
futureNear: t("entity.calendar.years.nextSingular"),
futureFar: t("entity.calendar.decades.nextSingular")
}
case "decade":
return {
pastFar: t("entity.calendar.centuries.prevSingular"),
pastNear: t("entity.calendar.decades.prevSingular"),
futureNear: t("entity.calendar.decades.nextSingular"),
futureFar: t("entity.calendar.centuries.nextSingular")
}
case "century":
default:
return {
pastFar: t("entity.calendar.millenias.prevSingular"),
pastNear: t("entity.calendar.centuries.prevSingular"),
futureNear: t("entity.calendar.centuries.nextSingular"),
futureFar: t("entity.calendar.millenias.nextSingular")
}
}
})
function toPastFar(): void {
switch (currentConfig.viewType) {
case "month":
decrementViewYear()
break
case "year":
decrementViewYear(10)
break
case "decade":
decrementViewYear(100)
break
case "century":
default:
decrementViewYear(1000)
break
}
}
function toPastNear(): void {
switch (currentConfig.viewType) {
case "month":
decrementViewMonth()
break
case "year":
decrementViewYear()
break
case "decade":
decrementViewYear(10)
break
case "century":
default:
decrementViewYear(100)
break
}
}
function toFutureNear(): void {
switch (currentConfig.viewType) {
case "month":
incrementViewMonth()
break
case "year":
incrementViewYear()
break
case "decade":
incrementViewYear(10)
break
case "century":
default:
incrementViewYear(100)
break
}
}
function toFutureFar(): void {
switch (currentConfig.viewType) {
case "month":
incrementViewYear()
break
case "year":
incrementViewYear(10)
break
case "decade":
incrementViewYear(100)
break
case "century":
default:
incrementViewYear(1000)
break
}
}
</script>
<template>
<div class="flex items-center gap-2">
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton variant="outline" size="icon" @click="toPastFar()">
<PhCaretDoubleLeft size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ activeDirectionLabels.pastFar }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton variant="outline" size="icon" @click="toPastNear()">
<PhCaretLeft size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ activeDirectionLabels.pastNear }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton variant="outline" size="icon" @click="toFutureNear()">
<PhCaretRight size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ activeDirectionLabels.futureNear }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton variant="outline" size="icon" @click="toFutureFar()">
<PhCaretDoubleRight size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ activeDirectionLabels.futureFar }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
</template>

View File

@@ -1,122 +0,0 @@
<script lang="ts" setup>
const { currentDate } = useCalendar()
// const { toast } = useToast()
// const { t } = useI18n()
// function handleGotoPreviousEventPage(position: "next" | "prev" = "next") {
// let fromDate: RPGDate
// // To modify, obviously
// const daysPerMonth = 32
// const toDay = position === "next" ? daysPerMonth : 1
// const toMonth = position === "next" ? monthsPerYear : 0
// switch (currentConfig.viewType) {
// case "month":
// fromDate = {
// day: toDay,
// month: currentDate.currentMonth,
// year: currentDate.currentYear
// }
// break
// case "year":
// fromDate = {
// day: toDay,
// month: toMonth,
// year: currentDate.currentYear
// }
// break
// case "decade":
// fromDate = {
// day: toDay,
// month: currentDate.currentMonth,
// year: currentDate.currentYear
// }
// break
// case "century":
// default:
// fromDate = {
// day: toDay,
// month: currentDate.currentMonth,
// year: currentDate.currentYear
// }
// break
// }
// try {
// const { targetDate } = getRelativeEventFromDate(fromDate, position)
// jumpToDate(targetDate)
// } catch (err) {
// toast({
// title: t("entity.calendar.event.outOfBoundsTitle"),
// variant: "default",
// description: t("entity.calendar.event.outOfBoundsMessage"),
// duration: 4000,
// })
// }
// }
</script>
<template>
<div class="flex gap-2">
<div class="grid items-end w-40 px-4 py-2 border-slate-200 bg-white dark:bg-black dark:border-slate-700 border-x-[1px] border-t-[1px] rounded-t-sm text-sm transition-colors">
<ClientOnly>
<span>{{ currentDate.currentDateTitle }}</span>
<template #fallback>
<span class="inline-block">
<UiSkeleton class="h-[19px] w-full rounded-sm" />
</span>
</template>
</ClientOnly>
</div>
<!-- <div>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
variant="outline"
size="icon"
class="rounded-t-sm rounded-b-none border-b-0"
@click="handleGotoPreviousEventPage('prev')"
>
<PhArrowLineLeft size="22" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('entity.calendar.event.prevPage') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
<div>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
variant="outline"
size="icon"
class="rounded-t-sm rounded-b-none border-b-0"
@click="handleGotoPreviousEventPage('next')"
>
<PhArrowLineRight size="22" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('entity.calendar.event.nextPage') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div> -->
</div>
</template>

View File

@@ -1,34 +0,0 @@
<script setup lang="ts">
import { useCalendar } from "@/stores/CalendarStore"
import { PhCalendarBlank } from "@phosphor-icons/vue"
import { computed } from "vue"
const { currentConfig, viewTypeOptions, getViewTypeTitle, setViewType } = useCalendar()
const viewTypeTitle = computed(() => getViewTypeTitle(currentConfig.viewType))
</script>
<template>
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiButton size="sm" variant="secondary">
<PhCalendarBlank size="18" weight="fill" />
{{ viewTypeTitle }}
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent :side="'bottom'" :collision-padding="30">
<UiDropdownMenuLabel>
{{ $t('ui.displayMode') }}
</UiDropdownMenuLabel>
<UiDropdownMenuSeparator />
<UiDropdownMenuItem
v-for="option in viewTypeOptions"
:key="option"
@click="setViewType(option)"
>
{{ getViewTypeTitle(option) }}
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</template>

View File

@@ -0,0 +1,23 @@
<script lang="ts" setup>
import { PhTag } from "@phosphor-icons/vue"
const { toggleCategoriesModal } = useCalendar()
const { isReadOnly } = storeToRefs(useCalendar())
</script>
<template>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton v-if="!isReadOnly" variant="secondary" size="icon" @click="toggleCategoriesModal(true)">
<PhTag size="20" weight="light" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('entity.calendar.seeCategories') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</template>

View File

@@ -4,26 +4,29 @@ import { computed } from "vue"
import { PhMapPin } from "@phosphor-icons/vue" import { PhMapPin } from "@phosphor-icons/vue"
const { defaultDate, getFormattedDateTitle, getRelativeString } = useCalendar() const { defaultDate, getFormattedDateTitle, getRelativeString, getDifferenceInDays } = useCalendar()
const { selectedDate } = storeToRefs(useCalendar()) const { selectedDate } = storeToRefs(useCalendar())
const mainDateTitle = computed(() => getFormattedDateTitle(selectedDate.value, true)) const mainDateTitle = computed(() => getFormattedDateTitle(selectedDate.value, true))
// const mainDateTitle = computed(() => convertDateToDays(selectedDate.value)) // const mainDateTitle = computed(() => convertDateToDays(selectedDate.value))
const dateDifference = computed(() => getRelativeString(defaultDate, selectedDate.value)) const dateDifference = computed(() => getRelativeString(defaultDate, selectedDate.value))
const isToday = computed(() => getDifferenceInDays(defaultDate, selectedDate.value) === 0)
</script> </script>
<template> <template>
<ClientOnly> <ClientOnly>
<h1 class="text-2xl font-bold flex items-center gap-1"> <div class="flex gap-2 items-center">
<PhMapPin size="26" weight="bold" /> {{ mainDateTitle }} <h1 class="text-2xl font-bold flex items-center gap-1">
</h1> <PhMapPin size="26" weight="bold" /> {{ mainDateTitle }}
<h2 class="text-lg italic opacity-75"> </h1>
{{ dateDifference }} <h2 v-if="!isToday" class="text-xl italic opacity-75">
</h2> {{ dateDifference }}
</h2>
</div>
<template #fallback> <template #fallback>
<div class="grid gap-1"> <div class="flex items-center gap-1">
<UiSkeleton class="h-8 w-64" /> <UiSkeleton class="h-8 w-64" />
<UiSkeleton class="h-6 w-28" /> <UiSkeleton class="h-6 w-28" />
</div> </div>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { cn } from "@/lib/utils"
import { useCalendar } from "@/stores/CalendarStore"
import { PhCalendarBlank, PhCheckCircle, PhGear } from "@phosphor-icons/vue"
import { computed } from "vue"
const { currentConfig, viewTypeOptions, getViewTypeTitle, setViewType } = useCalendar()
const viewTypeTitle = computed(() => getViewTypeTitle(currentConfig.viewType))
</script>
<template>
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiButton variant="secondary" size="icon">
<PhGear size="20" weight="fill" />
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent :side="'bottom'" :align="'start'" :side-offset="10" :align-offset="25" :collision-padding="40" class="text-right">
<UiDropdownMenuArrow />
<UiDropdownMenuLabel>
{{ $t('entity.calendar.seeOptions') }}
</UiDropdownMenuLabel>
<UiDropdownMenuSub>
<UiDropdownMenuSubTrigger arrow-direction="left" class="p-0 rounded-none">
<UiDropdownMenuItem class="flex gap-[1ch] justify-end items-center pointer-events-none">
{{ $t('ui.displayMode') }}
<PhCalendarBlank size="18" weight="fill" />
</UiDropdownMenuItem>
</UiDropdownMenuSubTrigger>
<UiDropdownMenuPortal>
<UiDropdownMenuSubContent>
<UiDropdownMenuItem
v-for="option in viewTypeOptions"
:key="option"
class="flex gap-[.5ch] items-center rounded-none transition-colors"
:class="cn({ 'text-emerald-600': viewTypeTitle === getViewTypeTitle(option) })"
@click="setViewType(option)"
>
<PhCheckCircle v-if="viewTypeTitle === getViewTypeTitle(option)" size="20" weight="fill" />
{{ getViewTypeTitle(option) }}
</UiDropdownMenuItem>
</UiDropdownMenuSubContent>
</UiDropdownMenuPortal>
</UiDropdownMenuSub>
</UiDropdownMenuContent>
</UiDropdownMenu>
</template>

View File

@@ -68,7 +68,8 @@ const calendarLink = computed(() => isOwner.value ? `/my/calendars/${props.calen
<UiCardContent class="grow"> <UiCardContent class="grow">
<p class="flex items-center gap-1"> <p class="flex items-center gap-1">
<PhCalendarDots size="24" weight="fill" /> <PhCalendarDots size="24" weight="fill" />
<span>{{ $t("entity.calendar.hasXEvents", { count: calendar.eventNb?.[0].count }) }}</span> <span v-if="calendar.eventNb?.[0].count! > 1">{{ $t("entity.calendar.hasXEvents", { count: calendar.eventNb?.[0].count }) }}</span>
<span v-else>{{ $t("entity.calendar.hasXEvent", { count: calendar.eventNb?.[0].count }) }}</span>
</p> </p>
<div <div

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { Category } from "~/models/Category";
defineProps<{
categories: Category[]
}>()
</script>
<template>
<ul>
<li v-for="category in categories" :key="category.id">
<CalendarCategoryListItem :category="category" />
</li>
</ul>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { Category } from "~/models/Category";
defineProps<{
category: Category
}>()
</script>
<template>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ category.name }}
</span>
</div>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
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>
<div class="flex flex-col h-full">
<CalendarCategoryTableHeader />
<ScrollAreaRoot class="h-36 grow overflow-hidden ">
<ScrollAreaViewport class="w-full h-full pr-4" as-child>
<div class="[&:last-child]:border-0">
<CalendarCategoryTableRow
v-for="item in sortedCategories"
:key="item.id"
:category="item"
@on-delete-category="openDeleteDialog"
/>
</div>
</ScrollAreaViewport>
<ScrollAreaScrollbar
class="flex select-none touch-none p-0.5 bg-background transition-colors duration-100 ease-out hover:bg-foreground/5 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:h-2.5"
orientation="vertical"
>
<ScrollAreaThumb
class="flex-1 bg-foreground/20 rounded-[10px] relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full"
/>
</ScrollAreaScrollbar>
</ScrollAreaRoot>
<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

@@ -0,0 +1,130 @@
<script lang="ts" setup>
import { PhPlus } from "@phosphor-icons/vue"
import { useToast } from "~/components/ui/toast"
import { ToastLifetime } from "~/components/ui/toast/use-toast"
import { cn } from "~/lib/utils"
const { toast } = useToast()
const { t } = useI18n()
type FooterMode = "add" | "view"
const currentMode = ref<FooterMode>("view")
const rowRef = ref<HTMLDivElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const { focused: inputFocused } = useFocus(inputRef)
/**
* Toggle view mode options
*/
type ToggleViewOptions = {
execution?: "now" | "nextTick"
}
/**
* Toggle view mode
* @param options.execution - When to execute the toggle. "now" or "nextTick"
*/
function toggleView(options: ToggleViewOptions = { execution: "now" }) {
currentMode.value = "view"
if (options.execution === "now") {
inputFocused.value = false
} else {
nextTick(() => {
inputFocused.value = false
})
}
}
/**
* Toggle add mode
*/
function toggleAdd() {
currentMode.value = "add"
categorySkeleton.value = { name: "", color: "black" }
nextTick(() => {
inputFocused.value = true
})
}
onClickOutside(rowRef, () => toggleView({ execution: "nextTick" }))
onKeyStroke("Escape", () => toggleView({ execution: "now" }))
const { addCategoryFromSkeleton } = useCategoryStore()
const { categorySkeleton } = storeToRefs(useCategoryStore())
/**
* Submit the update
*/
async function submitNew() {
if (!categorySkeleton.value) return
const newCategoryName = toRaw(categorySkeleton.value).name
const { error } = await tryCatch(addCategoryFromSkeleton())
if (error) {
toast({
title: t("entity.category.addedToast.titleError", { category: newCategoryName }),
variant: "destructive",
duration: ToastLifetime.LONG
})
return
}
toggleView({ execution: "now" })
toast({
title: t("entity.category.addedToast.title", { category: newCategoryName }),
variant: "success",
duration: ToastLifetime.SHORT
})
}
</script>
<template>
<div class="border-y-[1px] border-b-foreground/10 mr-4">
<form
ref="rowRef"
class="grid grid-cols-12 items-center gap-4 p-1 bg-transparent hover:bg-slate-50 dark:bg-transparent dark:hover:bg-slate-900"
@submit.prevent="submitNew"
>
<div v-if="currentMode === 'add'" class="col-span-1" />
<div
:class="cn({
'col-span-6': currentMode === 'view',
'col-span-5': currentMode === 'add'
})"
>
<template v-if="currentMode === 'view'">
<button
class="p-2 h-full w-full text-left underline-offset-4 hover:underline cursor-pointer"
@click="toggleAdd"
>
<PhPlus size="18" />
</button>
</template>
<template v-if="currentMode === 'add' && categorySkeleton">
<input
ref="inputRef"
v-model="categorySkeleton.name"
type="text"
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 === 'add' && categorySkeleton">
<div class="-mx-2">
<InputColor
id="category-color"
v-model="categorySkeleton.color"
position="item-aligned"
theme="subtle"
/>
</div>
</template>
</div>
</form>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import { PhListBullets } from "@phosphor-icons/vue";
</script>
<template>
<header class="grid grid-cols-12 gap-4 px-2 py-4 border-b-[1px] border-b-foreground/10 font-bold mr-4">
<div class="col-span-1 flex items-end">
<PhListBullets size="20" />
</div>
<div class="col-span-5">
Nom
</div>
<div class="col-span-4">
Couleur
</div>
<div class="col-span-2" />
</header>
</template>

View File

@@ -0,0 +1,187 @@
<script setup lang="ts">
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"
import type { Category } from "~/models/Category"
const { toast } = useToast()
const { t } = useI18n()
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
*/
type ToggleViewOptions = {
execution?: "now" | "nextTick"
}
/**
* Toggle view mode
* @param options.execution - When to execute the toggle. "now" or "nextTick"
*/
function toggleView(options: ToggleViewOptions = { execution: "now" }) {
currentMode.value = "view"
if (options.execution === "now") {
inputFocused.value = false
} else {
nextTick(() => {
inputFocused.value = false
})
}
}
/**
* Toggle edit mode
*/
function toggleEdit() {
currentMode.value = "edit"
categorySkeleton.value = structuredClone(toRaw(category))
nextTick(() => {
inputFocused.value = true
})
}
onClickOutside(rowRef, () => toggleView({ execution: "nextTick" }))
onKeyStroke("Escape", () => toggleView({ execution: "now" }))
const { resetSkeleton, updateCategoryFromSkeleton } = useCategoryStore()
const { categorySkeleton } = storeToRefs(useCategoryStore())
onUnmounted(() => {
resetSkeleton()
})
/**
* Submit the update
*/
async function submitUpdate() {
const { error } = await tryCatch(updateCategoryFromSkeleton())
if (error) {
toast({
title: t("entity.category.updatedToast.titleError", { category: category.name }),
variant: "destructive",
duration: ToastLifetime.LONG
})
return
}
toggleView({ execution: "now" })
toast({
title: t("entity.category.updatedToast.title", { category: category.name }),
variant: "success",
duration: ToastLifetime.SHORT
})
}
function handleQueryDelete() {
categorySkeleton.value = structuredClone(toRaw(category))
emit("on-delete-category", category)
}
</script>
<template>
<div ref="rowRef" class="relative">
<form
class="grid grid-cols-12 items-center gap-4 p-1 border-b-[1px] border-b-foreground/10"
: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"
>
<div class="col-span-1 pointer-events-none">
<span class="opacity-50 p-2">{{ category.id }}</span>
</div>
<div
class="col-span-5"
>
<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' && categorySkeleton">
<input
ref="inputRef"
v-model="categorySkeleton.name"
type="text"
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}`) }}
</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
v-if="currentMode === 'edit' && categorySkeleton"
>
<UiButton
variant="secondary"
size="icon"
class="w-6 h-6 rounded-full bg-emerald-500 hover:bg-emerald-600 text-white"
: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

@@ -0,0 +1,41 @@
<script lang="ts" setup>
import { PhX } from "@phosphor-icons/vue";
const { toggleCategoriesModal } = useCalendar()
const { categories, isCategoriesModalOpen } = storeToRefs(useCalendar())
function handleClosing() {
toggleCategoriesModal(false)
}
</script>
<template>
<UiAlertDialog :open="isCategoriesModalOpen">
<UiAlertDialogContent
class="grid grid-rows-[auto_1fr_auto] items-start min-h-[66vh] max-w-4xl"
:disable-outside-pointer-events="true"
:trap-focus="true"
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@close-auto-focus="(e) => e.preventDefault()"
>
<header>
<UiAlertDialogTitle>
<span class="text-2xl">
{{ $t('entity.category.manageDialog.title') }}
</span>
</UiAlertDialogTitle>
<UiAlertDialogDescription>
{{ $t('entity.category.manageDialog.subtitle') }}
</UiAlertDialogDescription>
</header>
<UiButton size="icon" variant="ghost" class="absolute top-4 right-4" title="Fermer la fenêtre" @click="handleClosing">
<PhX size="20" />
</UiButton>
<CalendarCategoryTable :categories />
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -35,8 +35,7 @@ function openEventCreatePopover() {
* *
* @param e The closing event (can be keydown or click) * @param e The closing event (can be keydown or click)
*/ */
function handleClosing(e: Event) { function handleClosing() {
e.preventDefault()
popoverOpen.value = false popoverOpen.value = false
resetSkeleton() resetSkeleton()
} }
@@ -54,8 +53,8 @@ function handleClosing(e: Event) {
:disable-outside-pointer-events="true" :disable-outside-pointer-events="true"
:trap-focus="true" :trap-focus="true"
class="pl-3 w-[30rem] max-w-full border-indigo-200 dark:bg-slate-950 dark:border-indigo-950" class="pl-3 w-[30rem] max-w-full border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
@escape-key-down="handleClosing" @escape-key-down.prevent="handleClosing"
@pointer-down-outside="handleClosing" @pointer-down-outside.prevent="handleClosing"
> >
<CalendarFormCreateEvent @event-created="handleClosing" /> <CalendarFormCreateEvent @event-created="handleClosing" />
</UiPopoverContent> </UiPopoverContent>

View File

@@ -11,11 +11,8 @@ function toggleDialog() {
/** /**
* Prevents the modal from closing if's still loading * Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/ */
function handleClosing(e: Event) { function handleClosing() {
e.preventDefault()
setTimeout(() => resetSkeleton(), 100) setTimeout(() => resetSkeleton(), 100)
} }
</script> </script>
@@ -32,8 +29,8 @@ function handleClosing(e: Event) {
<UiDialog v-model:open="isDialogOpen"> <UiDialog v-model:open="isDialogOpen">
<UiDialogContent <UiDialogContent
class="border-indigo-200 dark:bg-slate-950 dark:border-indigo-950" class="border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
@escape-key-down="handleClosing" @escape-key-down.prevent="handleClosing"
@pointer-down-outside="handleClosing" @pointer-down-outside.prevent="handleClosing"
> >
<UiDialogTitle> <UiDialogTitle>
{{ $t("entity.calendar.event.addSingle") }} {{ $t("entity.calendar.event.addSingle") }}

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.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

@@ -8,18 +8,15 @@ const { isReadOnly } = storeToRefs(useCalendar())
</script> </script>
<template> <template>
<header class="border-slate-200 contrast-more:border-slate-500 dark:border-slate-700 border-b-[1px]"> <header class="mt-2 grid gap-4 border-slate-200 contrast-more:border-slate-500 dark:border-slate-700 border-b-[1px]">
<div class="px-8 flex justify-between"> <div class="px-8 flex items-center justify-between gap-2">
<menu class="flex items-center gap-2"> <menu class="flex items-center gap-2">
<li v-if="!isReadOnly"> <li v-if="!isReadOnly">
<CalendarDialogQuickCreateEvent /> <LazyCalendarDialogQuickCreateEvent />
</li> </li>
<li> <li>
<CalendarMenuToday /> <CalendarMenuToday />
</li> </li>
<li>
<CalendarMenuNav />
</li>
<li class="ml-4"> <li class="ml-4">
<CalendarCurrentDate /> <CalendarCurrentDate />
</li> </li>
@@ -29,13 +26,16 @@ const { isReadOnly } = storeToRefs(useCalendar())
<li> <li>
<UiButton search-slash @click="revealAdvancedSearch()"> <UiButton search-slash @click="revealAdvancedSearch()">
<PhMagnifyingGlass size="20" weight="light" /> <PhMagnifyingGlass size="20" weight="light" />
{{ $t('entity.advancedSearch.title') }} <span>
{{ $t('entity.advancedSearch.title') }}
</span>
</UiButton> </UiButton>
</li> </li>
<li> <li>
<ClientOnly> <CalendarCategoriesCTA />
<CalendarSwitch /> </li>
</ClientOnly> <li>
<CalendarOptionsCTA />
</li> </li>
</menu> </menu>
</div> </div>

View File

@@ -0,0 +1,230 @@
<script lang="ts" setup>
import { PhCaretDoubleLeft, PhCaretDoubleRight, PhCaretLeft, PhCaretRight } from "@phosphor-icons/vue"
const { currentDate } = useCalendar()
const { t } = useI18n()
interface DirectionLabels {
pastFar: string
pastNear: string
futureNear: string
futureFar: string
}
const { currentConfig, decrementViewMonth, incrementViewMonth, decrementViewYear, incrementViewYear } =
useCalendar()
const activeDirectionLabels: ComputedRef<DirectionLabels> = computed(() => {
switch (currentConfig.viewType) {
case "month":
return {
pastFar: t("entity.calendar.years.prevSingular"),
pastNear: t("entity.calendar.months.prevSingular"),
futureNear: t("entity.calendar.months.nextSingular"),
futureFar: t("entity.calendar.years.nextSingular")
}
case "year":
return {
pastFar: t("entity.calendar.decades.prevSingular"),
pastNear: t("entity.calendar.years.prevSingular"),
futureNear: t("entity.calendar.years.nextSingular"),
futureFar: t("entity.calendar.decades.nextSingular")
}
case "decade":
return {
pastFar: t("entity.calendar.centuries.prevSingular"),
pastNear: t("entity.calendar.decades.prevSingular"),
futureNear: t("entity.calendar.decades.nextSingular"),
futureFar: t("entity.calendar.centuries.nextSingular")
}
case "century":
default:
return {
pastFar: t("entity.calendar.millenias.prevSingular"),
pastNear: t("entity.calendar.centuries.prevSingular"),
futureNear: t("entity.calendar.centuries.nextSingular"),
futureFar: t("entity.calendar.millenias.nextSingular")
}
}
})
function toPastFar(): void {
switch (currentConfig.viewType) {
case "month":
decrementViewYear()
break
case "year":
decrementViewYear(10)
break
case "decade":
decrementViewYear(100)
break
case "century":
default:
decrementViewYear(1000)
break
}
}
function toPastNear(): void {
switch (currentConfig.viewType) {
case "month":
decrementViewMonth()
break
case "year":
decrementViewYear()
break
case "decade":
decrementViewYear(10)
break
case "century":
default:
decrementViewYear(100)
break
}
}
function toFutureNear(): void {
switch (currentConfig.viewType) {
case "month":
incrementViewMonth()
break
case "year":
incrementViewYear()
break
case "decade":
incrementViewYear(10)
break
case "century":
default:
incrementViewYear(100)
break
}
}
function toFutureFar(): void {
switch (currentConfig.viewType) {
case "month":
incrementViewYear()
break
case "year":
incrementViewYear(10)
break
case "decade":
incrementViewYear(100)
break
case "century":
default:
incrementViewYear(1000)
break
}
}
</script>
<template>
<div class="flex gap-2">
<div class="grid items-end w-40 px-4 py-2 border-slate-200 bg-white dark:bg-black dark:border-slate-700 border-x-[1px] border-t-[1px] rounded-t-sm text-sm transition-colors">
<ClientOnly>
<span>{{ currentDate.currentDateTitle }}</span>
<template #fallback>
<span class="inline-block">
<UiSkeleton class="h-[19px] w-full rounded-sm" />
</span>
</template>
</ClientOnly>
</div>
<div>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
variant="outline"
size="icon"
class="rounded-t-sm rounded-b-none border-b-0"
@click="toPastFar()"
>
<PhCaretDoubleLeft size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ activeDirectionLabels.pastFar }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
<div>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
variant="outline"
size="icon"
class="rounded-t-sm rounded-b-none border-b-0"
@click="toPastNear()"
>
<PhCaretLeft size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ activeDirectionLabels.pastNear }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
<div>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
variant="outline"
size="icon"
class="rounded-t-sm rounded-b-none border-b-0"
@click="toFutureNear()"
>
<PhCaretRight size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ activeDirectionLabels.futureNear }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
<div>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
variant="outline"
size="icon"
class="rounded-t-sm rounded-b-none border-b-0"
@click="toFutureFar()"
>
<PhCaretDoubleRight size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ activeDirectionLabels.futureFar }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
</div>
</template>

View File

@@ -26,7 +26,7 @@ import {
import SearchList from "./lists/SearchList.vue" import SearchList from "./lists/SearchList.vue"
import type { Category } from "~/models/Category" import type { Category } from "~/models/Category"
const { isAdvancedSearchOpen, allEvents } = storeToRefs(useCalendar()) const { isAdvancedSearchOpen, allEvents, categories } = storeToRefs(useCalendar())
const { characters } = storeToRefs(useCharacters()) const { characters } = storeToRefs(useCharacters())
const searchInput = shallowRef<HTMLInputElement>() const searchInput = shallowRef<HTMLInputElement>()
@@ -207,8 +207,7 @@ watch([currentPage, selectedEntity], () => {
}) })
// Compute categories based on current selectedEntity // Compute categories based on current selectedEntity
const { data: resCategories } = await useFetch("/api/calendars/categories/query") const currentCategories = computed(() => categories.value)
const currentCategories = ref<Category[]>(resCategories.value?.data as Category[])
const selectedCategories = ref<(Category)[]>([]) const selectedCategories = ref<(Category)[]>([])
const categoryFilterOpened = ref<boolean>(false) const categoryFilterOpened = ref<boolean>(false)

View File

@@ -5,7 +5,7 @@ import { useElementBounding } from "@vueuse/core"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { computed, ref, type ComputedRef } from "vue" import { computed, ref, type ComputedRef } from "vue"
import CalendarEventButton from "../../CalendarEvent.vue" import CalendarEventButton from "../../event/Event.vue"
const props = defineProps<{ const props = defineProps<{
date: RPGDate date: RPGDate

View File

@@ -2,8 +2,10 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { type RPGColor, rpgColors } from "~/models/Color"; import { type RPGColor, rpgColors } from "~/models/Color";
defineProps<{ const { id, theme = "normal", position = "popper" } = defineProps<{
id: string id: string
theme?: "normal" | "subtle"
position?: "item-aligned" | "popper" | undefined
}>(); }>();
const model = defineModel<RPGColor>({ default: "white" }); const model = defineModel<RPGColor>({ default: "white" });
@@ -11,27 +13,23 @@ const model = defineModel<RPGColor>({ default: "white" });
<template> <template>
<UiSelect v-model="model"> <UiSelect v-model="model">
<UiSelectTrigger :id> <UiSelectTrigger :id :class="cn({ 'h-9': theme === 'subtle' })">
<UiSelectValue <UiSelectValue
:placeholder="$t('ui.colors.selectOne')" :placeholder="$t('ui.colors.selectOne')"
class="input-color" class="bgc"
:class=" :class="cn(`bgc-${model}`)"
cn(
`input-color-${model}`,
)
"
/> />
</UiSelectTrigger> </UiSelectTrigger>
<UiSelectContent position="popper"> <UiSelectContent :position>
<UiSelectGroup> <UiSelectGroup>
<UiSelectItem <UiSelectItem
v-for="color in rpgColors" v-for="color in rpgColors"
:key="color" :key="color"
:value="color" :value="color"
class="input-color" class="bgc"
:class=" :class="
cn( cn(
`input-color-${color}`, `bgc-${color}`,
) )
" "
> >
@@ -41,76 +39,3 @@ const model = defineModel<RPGColor>({ default: "white" });
</UiSelectContent> </UiSelectContent>
</UiSelect> </UiSelect>
</template> </template>
<style lang="scss" scoped>
.input-color {
&::before {
content: "";
display: inline-block;
width: .75rem;
aspect-ratio: 1;
margin-right: 0.5em;
border-radius: .25rem;
background-color: red;
border: 1px solid transparent;
}
&.input-color-red::before {
@apply bg-red-500;
}
&.input-color-orange::before {
@apply bg-orange-500;
}
&.input-color-amber::before {
@apply bg-amber-500;
}
&.input-color-yellow::before {
@apply bg-yellow-500;
}
&.input-color-lime::before {
@apply bg-lime-500;
}
&.input-color-green::before {
@apply bg-green-500;
}
&.input-color-emerald::before {
@apply bg-emerald-600;
}
&.input-color-teal::before {
@apply bg-teal-600;
}
&.input-color-cyan::before {
@apply bg-cyan-600;
}
&.input-color-sky::before {
@apply bg-sky-600;
}
&.input-color-blue::before {
@apply bg-blue-600;
}
&.input-color-indigo::before {
@apply bg-indigo-600;
}
&.input-color-violet::before {
@apply bg-violet-600;
}
&.input-color-purple::before {
@apply bg-purple-600;
}
&.input-color-fuchsia::before {
@apply bg-fuchsia-600;
}
&.input-color-pink::before {
@apply bg-pink-600;
}
&.input-color-rose::before {
@apply bg-rose-600;
}
&.input-color-black::before {
@apply bg-black dark:border-[1px] dark:border-slate-300;
}
&.input-color-white::before {
@apply bg-white border-[1px] border-slate-700;
}
}
</style>

View File

@@ -25,7 +25,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template> <template>
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay <AlertDialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" class="fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/> />
<AlertDialogContent <AlertDialogContent
v-bind="forwarded" v-bind="forwarded"

View File

@@ -28,7 +28,7 @@ const props = withDefaults(defineProps<Props>(), {
v-if="props.searchSlash" v-if="props.searchSlash"
class="h-4 p-1 ml-1 grid place-items-center text-[0.7em] leading-none font-semibold text-slate-500 bg-slate-100 border-slate-300 border-[1px] rounded-[3px] shadow-sm group-hover:text-slate-600 group-hover:bg-slate-200 group-hover:border-slate-400 transition-colors" class="h-4 p-1 ml-1 grid place-items-center text-[0.7em] leading-none font-semibold text-slate-500 bg-slate-100 border-slate-300 border-[1px] rounded-[3px] shadow-sm group-hover:text-slate-600 group-hover:bg-slate-200 group-hover:border-slate-400 transition-colors"
> >
<span class="-mt-[2px]">CTRL + :</span> <kbd class="-mt-[2px]">CTRL + :</kbd>
</span> </span>
</Primitive> </Primitive>
</template> </template>

View File

@@ -10,7 +10,7 @@ export const buttonVariants = cva(
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input hover:bg-accent hover:text-accent-foreground", outline: "border border-input hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", secondary: "bg-secondary text-secondary-foreground hover:bg-primary/20",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline" link: "text-primary underline-offset-4 hover:underline"
}, },

View File

@@ -27,7 +27,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template> <template>
<DialogPortal> <DialogPortal>
<DialogOverlay <DialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" class="fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/> />
<DialogContent <DialogContent
v-bind="forwarded" v-bind="forwarded"

View File

@@ -27,7 +27,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template> <template>
<DialogPortal> <DialogPortal>
<DialogOverlay <DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
> >
<DialogContent <DialogContent
:class=" :class="

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import { DropdownMenuArrow, type DropdownMenuArrowProps, useForwardProps } from "radix-vue"
const props = defineProps<DropdownMenuArrowProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<DropdownMenuArrow class="fill-border" v-bind="forwardedProps">
<slot />
</DropdownMenuArrow>
</template>

View File

@@ -5,10 +5,10 @@ import {
type DropdownMenuSubTriggerProps, type DropdownMenuSubTriggerProps,
useForwardProps useForwardProps
} from "radix-vue" } from "radix-vue"
import { ChevronRight } from "lucide-vue-next"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PhCaretLeft, PhCaretRight } from "@phosphor-icons/vue";
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], arrowDirection?: "left" | "right" }>()
const delegatedProps = computed(() => { const delegatedProps = computed(() => {
const { class: _, ...delegated } = props const { class: _, ...delegated } = props
@@ -29,7 +29,17 @@ const forwardedProps = useForwardProps(delegatedProps)
) )
" "
> >
<slot /> <template v-if="props.arrowDirection === 'left'">
<ChevronRight class="ml-auto h-4 w-4" /> <PhCaretLeft class="mr-auto h-4 w-4" />
<span class="inline-block">
<slot />
</span>
</template>
<template v-else>
<slot />
<PhCaretRight class="ml-auto h-4 w-4" />
</template>
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
</template> </template>

View File

@@ -1,6 +1,7 @@
export { DropdownMenuPortal } from "radix-vue" export { DropdownMenuPortal } from "radix-vue"
export { default as DropdownMenu } from "./DropdownMenu.vue" export { default as DropdownMenu } from "./DropdownMenu.vue"
export { default as DropdownMenuArrow } from "./DropdownMenuArrow.vue"
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue" export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue"
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue" export { default as DropdownMenuContent } from "./DropdownMenuContent.vue"
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue" export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue"

View File

@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
v-bind="forwardedProps" v-bind="forwardedProps"
:class=" :class="
cn( cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background dark:bg-slate-950 contrast-more:dark:bg-slate-900 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1', 'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background dark:bg-slate-950 contrast-more:dark:bg-slate-900 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
props.class props.class
) )
" "

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div class="relative w-full overflow-auto">
<table :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tbody :class="cn('[&_tr:last-child]:border-0', props.class)">
<slot />
</tbody>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<caption :class="cn('mt-4 text-sm text-muted-foreground', props.class)">
<slot />
</caption>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<td
:class="
cn(
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
props.class,
)
"
>
<slot />
</td>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { computed } from "vue"
import TableCell from "./TableCell.vue"
import TableRow from "./TableRow.vue"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
colspan?: number
}>(), {
colspan: 1,
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<TableRow>
<TableCell
:class="
cn(
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
props.class,
)
"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tfoot :class="cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', props.class)">
<slot />
</tfoot>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<th :class="cn('h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', props.class)">
<slot />
</th>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<thead :class="cn('[&_tr]:border-b', props.class)">
<slot />
</thead>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tr :class="cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', props.class)">
<slot />
</tr>
</template>

View File

@@ -0,0 +1,9 @@
export { default as Table } from './Table.vue'
export { default as TableBody } from './TableBody.vue'
export { default as TableCaption } from './TableCaption.vue'
export { default as TableCell } from './TableCell.vue'
export { default as TableEmpty } from './TableEmpty.vue'
export { default as TableFooter } from './TableFooter.vue'
export { default as TableHead } from './TableHead.vue'
export { default as TableHeader } from './TableHeader.vue'
export { default as TableRow } from './TableRow.vue'

View File

@@ -79,7 +79,7 @@ export default defineI18nConfig(() => ({
dark: "Dark", dark: "Dark",
light: "Light", light: "Light",
system: "System", system: "System",
displayMode: "Display mode" displayMode: "Display"
}, },
common: { common: {
title: "Title", title: "Title",
@@ -93,13 +93,32 @@ export default defineI18nConfig(() => ({
search: "Search categories", search: "Search categories",
notFoundAny: "No categories found.", notFoundAny: "No categories found.",
addPrimary: "Add a primary category", addPrimary: "Add a primary category",
addSecondaries: "Add secondary categories" addSecondaries: "Add secondary categories",
manageDialog: {
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.",
},
updatedToast: {
title: "The category \"{category}\" has been successfuly updated.",
titleError: "An error has occured and the category \"{category}\" wasn't updated.",
},
deletedToast: {
title: "The category \"{category}\" has been successfuly deleted.",
},
}, },
isLoading: "Loading in progress…", isLoading: "Loading in progress…",
addDescription: "Add a description", addDescription: "Add a description",
deleteOne: "Delete \"{entity}\"", deleteOne: "Delete \"{entity}\"",
advancedSearch: { advancedSearch: {
title: "Advanced search", title: "Search",
subtitle: "Search through calendar and world data", subtitle: "Search through calendar and world data",
older: "Older", older: "Older",
newer: "Newer", newer: "Newer",
@@ -147,7 +166,10 @@ export default defineI18nConfig(() => ({
notFoundForWorld: "No calendar for this world… yet !", notFoundForWorld: "No calendar for this world… yet !",
backToList: "Go back to calendars", backToList: "Go back to calendars",
isLoading: "Calendar is loading…", isLoading: "Calendar is loading…",
hasXEvent: "{count} available event",
hasXEvents: "{count} available events", hasXEvents: "{count} available events",
seeCategories: "Modify categories",
seeOptions: "Calendar options",
date: { date: {
start: "Start date", start: "Start date",
end: "End date", end: "End date",
@@ -183,8 +205,8 @@ export default defineI18nConfig(() => ({
title: "Event title", title: "Event title",
isStart: "Start", isStart: "Start",
isEnd: "End", isEnd: "End",
isHidden: "Hidden event", isHidden: "Hidden",
isPublic: "Public event", isPublic: "Public",
hiddenTooltip: "This event is visible only to game masters.", hiddenTooltip: "This event is visible only to game masters.",
addLocation: "Add a place", addLocation: "Add a place",
prevPage: "Previous page with events", prevPage: "Previous page with events",
@@ -242,26 +264,31 @@ export default defineI18nConfig(() => ({
}, },
millennia: { millennia: {
nameSingular: "Millennia", nameSingular: "Millennia",
displayMode: "Millennial",
nextSingular: "Next millennia", nextSingular: "Next millennia",
prevSingular: "Last millennia", prevSingular: "Last millennia",
}, },
centuries: { centuries: {
nameSingular: "Century", nameSingular: "Century",
displayMode: "Centuries",
nextSingular: "Next century", nextSingular: "Next century",
prevSingular: "Last century", prevSingular: "Last century",
}, },
decades: { decades: {
nameSingular: "Decade", nameSingular: "Decade",
displayMode: "Decadal",
nextSingular: "Next decade", nextSingular: "Next decade",
prevSingular: "Last decade", prevSingular: "Last decade",
}, },
years: { years: {
nameSingular: "Year", nameSingular: "Year",
displayMode: "Yearly",
nextSingular: "Next year", nextSingular: "Next year",
prevSingular: "Last year", prevSingular: "Last year",
}, },
months: { months: {
nameSingular: "Month", nameSingular: "Month",
displayMode: "Monthly",
nextSingular: "Next month", nextSingular: "Next month",
prevSingular: "Last month", prevSingular: "Last month",
inputName: "Month's name", inputName: "Month's name",
@@ -375,7 +402,7 @@ export default defineI18nConfig(() => ({
dark: "Sombre", dark: "Sombre",
light: "Clair", light: "Clair",
system: "Système", system: "Système",
displayMode: "Mode d'affichage", displayMode: "Affichage",
}, },
common: { common: {
title: "Titre", title: "Titre",
@@ -389,13 +416,32 @@ export default defineI18nConfig(() => ({
search: "Rechercher les catégories", search: "Rechercher les catégories",
notFoundAny: "Aucune catégorie trouvée.", notFoundAny: "Aucune catégorie trouvée.",
addPrimary: "Ajouter une catégorie principale", addPrimary: "Ajouter une catégorie principale",
addSecondaries: "Ajouter des catégories secondaires" addSecondaries: "Ajouter des catégories secondaires",
manageDialog: {
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.",
},
updatedToast: {
title: "La catégorie \"{category}\" a été modifiée avec succès.",
titleError: "Une erreur s'est produite et la catégorie \"{category}\" n'a pas pu être modifiée.",
},
deletedToast: {
title: "La catégorie \"{category}\" a été supprimée avec succès.",
},
}, },
isLoading: "Chargement en cours…", isLoading: "Chargement en cours…",
addDescription: "Ajouter une description", addDescription: "Ajouter une description",
deleteOne: "Supprimer \"{entity}\"", deleteOne: "Supprimer \"{entity}\"",
advancedSearch: { advancedSearch: {
title: "Recherche avancée", title: "Rechercher",
subtitle: "Rechercher les données disponibles sur le calendrier", subtitle: "Rechercher les données disponibles sur le calendrier",
older: "Plus ancien", older: "Plus ancien",
newer: "Plus récent", newer: "Plus récent",
@@ -443,7 +489,10 @@ export default defineI18nConfig(() => ({
notFoundForWorld: "Aucun calendrier pour ce monde… pour l'instant !", notFoundForWorld: "Aucun calendrier pour ce monde… pour l'instant !",
backToList: "Retourner aux calendriers", backToList: "Retourner aux calendriers",
isLoading: "Chargement du calendrier…", isLoading: "Chargement du calendrier…",
hasXEvent: "{count} évènement disponible",
hasXEvents: "{count} évènements disponibles", hasXEvents: "{count} évènements disponibles",
seeCategories: "Gestion des catégories",
seeOptions: "Options du calendrier",
date: { date: {
start: "Date de début", start: "Date de début",
end: "Date de fin", end: "Date de fin",
@@ -479,8 +528,8 @@ export default defineI18nConfig(() => ({
title: "Titre de l'évènement", title: "Titre de l'évènement",
isStart: "Début", isStart: "Début",
isEnd: "Fin", isEnd: "Fin",
isHidden: "Évènement privé", isHidden: "Privé",
isPublic: "Évènement public", isPublic: "Public",
hiddenTooltip: "Cet évènement est uniquement visible pour les maîtres du jeu.", hiddenTooltip: "Cet évènement est uniquement visible pour les maîtres du jeu.",
addLocation: "Ajouter un endroit", addLocation: "Ajouter un endroit",
prevPage: "Précédente page à évènements", prevPage: "Précédente page à évènements",
@@ -541,26 +590,31 @@ export default defineI18nConfig(() => ({
}, },
millennia: { millennia: {
nameSingular: "Millénaire", nameSingular: "Millénaire",
displayMode: "Par millénaire",
nextSingular: "Millénaire suivant", nextSingular: "Millénaire suivant",
prevSingular: "Millénaire précédent", prevSingular: "Millénaire précédent",
}, },
centuries: { centuries: {
nameSingular: "Siècle", nameSingular: "Siècle",
displayMode: "Par siècle",
nextSingular: "Siècle suivant", nextSingular: "Siècle suivant",
prevSingular: "Siècle précédent", prevSingular: "Siècle précédent",
}, },
decades: { decades: {
nameSingular: "Décennie", nameSingular: "Décennie",
displayMode: "Par décennie",
nextSingular: "Décennie suivante", nextSingular: "Décennie suivante",
prevSingular: "Décennie précédente", prevSingular: "Décennie précédente",
}, },
years: { years: {
nameSingular: "Année", nameSingular: "Année",
displayMode: "Annuel",
nextSingular: "Année suivante", nextSingular: "Année suivante",
prevSingular: "Année précédente", prevSingular: "Année précédente",
}, },
months: { months: {
nameSingular: "Mois", nameSingular: "Mois",
displayMode: "Mensuel",
nextSingular: "Mois suivant", nextSingular: "Mois suivant",
prevSingular: "Mois précédent", prevSingular: "Mois précédent",
inputName: "Nom du mois", inputName: "Nom du mois",

View File

@@ -5,7 +5,7 @@ import { dateSchema, type RPGDate } from "./Date"
import type { World } from "./World" import type { World } from "./World"
import type { ContentState } from "./Entity" import type { ContentState } from "./Entity"
import type { RPGColor } from "./Color" import type { RPGColor } from "./Color"
import type { Category } from "./Category"
export interface CalendarConfig { export interface CalendarConfig {
months: CalendarMonth[] months: CalendarMonth[]
@@ -16,7 +16,8 @@ export interface Calendar extends CalendarConfig {
id?: number id?: number
shortId?: string shortId?: string
name: string name: string
events: CalendarEvent[] events: CalendarEvent[],
categories: Category[]
eventNb?: Array<{ count: number }> eventNb?: Array<{ count: number }>
state: ContentState state: ContentState
color?: RPGColor color?: RPGColor

View File

@@ -1,5 +1,5 @@
import { z } from "zod" import { z } from "zod"
import { categorySchema, type Category } from "./Category" import type { Category } from "./Category"
import { dateSchema, type RPGDate } from "./Date" import { dateSchema, type RPGDate } from "./Date"
export interface CalendarEvent { export interface CalendarEvent {
@@ -26,7 +26,11 @@ export const postEventBodySchema = z.object({
startDate: dateSchema.required(), startDate: dateSchema.required(),
endDate: dateSchema.optional().nullable(), endDate: dateSchema.optional().nullable(),
hidden: z.boolean().optional().nullable(), hidden: z.boolean().optional().nullable(),
category: categorySchema.optional().nullable(), category: z.object({
id: z.number().int(),
name: z.string(),
color: z.string().default("black")
}).optional().nullable(),
}), }),
calendarId: z.number({ coerce: true }).int().positive() calendarId: z.number({ coerce: true }).int().positive()
}) })

View File

@@ -2,13 +2,15 @@ import { z } from "zod"
import type { RPGColor } from "./Color" import type { RPGColor } from "./Color"
export interface Category { export interface Category {
id: number id?: number
name: string name: string
color?: RPGColor color?: RPGColor
} }
export const categorySchema = z.object({ export const categorySchema = z.object({
id: z.number().int(), category: z.object({
name: z.string(), name: z.string(),
color: z.string().default("black") color: z.string().default("black"),
}),
calendarId: z.number({ coerce: true }).int().positive()
}) })

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { PhArrowBendDoubleUpLeft, PhCalendarX, PhCircleNotch } from "@phosphor-icons/vue"; import { PhArrowBendDoubleUpLeft, PhCalendarX, PhCircleNotch } from "@phosphor-icons/vue";
import type { Calendar } from "~/models/CalendarConfig"; import type { Calendar } from "~/models/CalendarConfig";
import type { Category } from "~/models/Category";
const route = useRoute() const route = useRoute()
const shortId = route.params.id const shortId = route.params.id
@@ -14,7 +13,7 @@ const {
refresh: calRefresh refresh: calRefresh
} = await useLazyFetch<{ data: Calendar }>("/api/calendars/query", } = await useLazyFetch<{ data: Calendar }>("/api/calendars/query",
{ {
key: `calendar-${shortId}`, key: "active-calendar",
query: { query: {
shortId, shortId,
full: true full: true
@@ -22,20 +21,17 @@ const {
} }
) )
const { const isLoading = computed(() => calendarStatus.value === "pending")
data: categories,
status: categoriesStatus, watch(user, () => calRefresh())
refresh: catRefresh
} = await useLazyFetch<{ data: Category[] }>("/api/calendars/categories/query", const { setActiveCalendar } = useCalendar()
{ key: `categories-${shortId}` } watch(isLoading, (n) => {
if (!n && calendar.value?.data) {
setActiveCalendar(calendar.value?.data)
}
}, { immediate: true }
) )
const isLoading = computed(() => calendarStatus.value === "pending" || categoriesStatus.value === "pending")
watch(user, () => {
calRefresh()
catRefresh()
})
</script> </script>
<template> <template>
@@ -52,12 +48,12 @@ watch(user, () => {
</div> </div>
</div> </div>
<div v-else-if="calendar?.data && categories?.data" class="h-full w-full pt-8"> <div v-else-if="calendar?.data" class="h-full w-full pt-8">
<Head> <Head>
<Title>{{ calendar.data.name }}</Title> <Title>{{ calendar.data.name }}</Title>
</Head> </Head>
<Calendar :calendar-data="calendar.data" :categories="categories.data" /> <Calendar />
</div> </div>
<div v-else class="h-full w-full grid place-items-center"> <div v-else class="h-full w-full grid place-items-center">

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { PhArrowBendDoubleUpLeft, PhCalendarX, PhCircleNotch } from "@phosphor-icons/vue"; import { PhArrowBendDoubleUpLeft, PhCalendarX, PhCircleNotch } from "@phosphor-icons/vue";
import type { Calendar } from "~/models/CalendarConfig"; import type { Calendar } from "~/models/CalendarConfig";
import type { Category } from "~/models/Category";
definePageMeta({ definePageMeta({
middleware: ["auth-guard"] middleware: ["auth-guard"]
@@ -24,7 +23,7 @@ const {
status: calendarStatus status: calendarStatus
} = await useLazyFetch<{ data: Calendar }>("/api/calendars/query", } = await useLazyFetch<{ data: Calendar }>("/api/calendars/query",
{ {
key: `calendar-${id}`, key: "active-calendar",
query: { query: {
id, id,
full: true full: true
@@ -32,39 +31,14 @@ const {
} }
) )
const { const isLoading = computed(() => calendarStatus.value === "pending")
data: categories,
status: categoriesStatus
} = await useLazyFetch<{ data: Category[] }>("/api/calendars/categories/query",
{ key: `categories-${id}` }
)
const isLoading = computed(() => calendarStatus.value === "pending" || categoriesStatus.value === "pending") const { setActiveCalendar } = useCalendar()
watch([calendar], () => {
// Set custom menu if (calendar.value?.data) {
// This should be reserved for actions, not for breadcrumbs setActiveCalendar(calendar.value?.data)
// }
// const { t } = useI18n() }, { immediate: true })
// const { setCurrentMenu } = useUiStore()
// // Set menu once we have the calendar data
// watch(calendar, (n) => {
// if (n?.data) {
// setCurrentMenu([
// {
// tooltip: t("entity.world.backToMy"),
// to: "/my",
// phIcon: "universe",
// phIconWeight: "regular"
// },
// {
// tooltip: t("entity.world.backToSingle", { world: calendar.value?.data.world?.name }),
// to: `/my/worlds/${calendar.value?.data.world?.id}`,
// phIcon: "world"
// },
// ])
// }
// }, { immediate: true })
</script> </script>
<template> <template>
@@ -81,7 +55,7 @@ const isLoading = computed(() => calendarStatus.value === "pending" || categorie
</div> </div>
</div> </div>
<div v-else-if="calendar?.data && categories?.data" class="h-full w-full"> <div v-else-if="calendar?.data" class="h-full w-full">
<Head> <Head>
<Title>{{ calendar.data.name }}</Title> <Title>{{ calendar.data.name }}</Title>
</Head> </Head>
@@ -97,7 +71,7 @@ const isLoading = computed(() => calendarStatus.value === "pending" || categorie
/> />
</div> </div>
<Calendar :calendar-data="calendar.data" :categories="categories.data" /> <Calendar />
</div> </div>
</div> </div>

View File

@@ -23,8 +23,6 @@ export default defineEventHandler(async (event) => {
} }
if (bodyError) { if (bodyError) {
console.log(bodyData)
console.log(bodyError)
const error = createError({ const error = createError({
cause: "Utilisateur", cause: "Utilisateur",
fatal: false, fatal: false,

View File

@@ -0,0 +1,41 @@
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>()
if (error) throw error
return data
} catch (err) {
throw createError({
cause: "Serveur",
status: 500,
fatal: false,
message: "Une erreur inconnue est survenue."
})
}
})

View File

@@ -0,0 +1,73 @@
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.category.name,
color: bodyData.category.color,
calendar_id: bodyData.calendarId
} 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

@@ -0,0 +1,56 @@
import { serverSupabaseClient } from "#supabase/server";
import { type Category, categorySchema } from "@/models/Category";
export default defineEventHandler(async (event) => {
const client = await serverSupabaseClient(event)
const { data: bodyData, error: bodyError } = await readValidatedBody(event, body => categorySchema.safeParse(body))
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")
.insert(
{
name: bodyData.category.name,
color: bodyData.category.color,
calendar_id: bodyData.calendarId
} as never
)
.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

@@ -4,6 +4,7 @@ import type { Category } from "~/models/Category";
const querySchema = z.object({ const querySchema = z.object({
id: z.number({ coerce: true }).positive().int().optional(), id: z.number({ coerce: true }).positive().int().optional(),
calendarId: z.number({ coerce: true }).positive().int()
}) })
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -16,12 +17,17 @@ export default defineEventHandler(async (event) => {
.from("calendar_event_categories") .from("calendar_event_categories")
.select(` .select(`
id, id,
name name,
color
`) `)
if (query.id) { if (query.id) {
return output.eq("id", query.id).limit(1).single<Category>() return output.eq("id", query.id).limit(1).single<Category>()
} }
return output.returns<Category[]>() if (query.calendarId) {
output.eq("calendar_id", query.calendarId)
}
return output.overrideTypes<Category[]>()
}) })

View File

@@ -8,7 +8,6 @@ export default defineEventHandler(async (event) => {
const { data: bodyData, error: schemaError } = await readValidatedBody(event, body => postCalendarSchema.safeParse(body)) const { data: bodyData, error: schemaError } = await readValidatedBody(event, body => postCalendarSchema.safeParse(body))
if (schemaError) { if (schemaError) {
console.log(schemaError)
throw createError({ throw createError({
cause: "Utilisateur", cause: "Utilisateur",
fatal: false, fatal: false,

View File

@@ -13,7 +13,6 @@ export default defineEventHandler(async (event) => {
const { data: bodyData, error: bodyError } = await readValidatedBody(event, body => calendarMonthSchema.safeParse(body)) const { data: bodyData, error: bodyError } = await readValidatedBody(event, body => calendarMonthSchema.safeParse(body))
if (paramsError) { if (paramsError) {
console.log(paramsError)
throw createError({ throw createError({
cause: "Utilisateur", cause: "Utilisateur",
fatal: false, fatal: false,
@@ -23,7 +22,6 @@ export default defineEventHandler(async (event) => {
} }
if (bodyError) { if (bodyError) {
console.log(bodyData)
throw createError({ throw createError({
cause: "Utilisateur", cause: "Utilisateur",
fatal: false, fatal: false,

View File

@@ -6,7 +6,6 @@ export default defineEventHandler(async (event) => {
const { data: bodyData, error: schemaError } = await readValidatedBody(event, body => calendarMonthSchema.safeParse(body)) const { data: bodyData, error: schemaError } = await readValidatedBody(event, body => calendarMonthSchema.safeParse(body))
if (schemaError) { if (schemaError) {
console.log(schemaError)
throw createError({ throw createError({
cause: "Utilisateur", cause: "Utilisateur",
fatal: false, fatal: false,

View File

@@ -52,6 +52,11 @@ export default defineEventHandler(async (event) => {
category:calendar_event_categories!calendar_events_category_fkey (*), category:calendar_event_categories!calendar_events_category_fkey (*),
secondaryCategories:calendar_event_categories!calendar_event_categories_links (*) secondaryCategories:calendar_event_categories!calendar_event_categories_links (*)
), ),
categories:calendar_event_categories (
id,
name,
color
),
eventNb:calendar_events(count), eventNb:calendar_events(count),
world:worlds ( world:worlds (
id, id,

View File

@@ -8,7 +8,6 @@ export default defineEventHandler(async (event) => {
const { data: bodyData, error: schemaError } = await readValidatedBody(event, body => postWorldSchema.safeParse(body)) const { data: bodyData, error: schemaError } = await readValidatedBody(event, body => postWorldSchema.safeParse(body))
if (schemaError) { if (schemaError) {
console.log(schemaError)
throw createError({ throw createError({
cause: "Utilisateur", cause: "Utilisateur",
fatal: false, fatal: false,

View File

@@ -60,7 +60,7 @@ export const useCalendar = defineStore("calendar", () => {
*/ */
const months = ref<CalendarMonth[]>([]) const months = ref<CalendarMonth[]>([])
function setActiveCalendar(calendarData: Calendar, categoryData: Category[]) { function setActiveCalendar(calendarData: Calendar) {
try { try {
if (!calendarData.id) return if (!calendarData.id) return
@@ -88,7 +88,7 @@ export const useCalendar = defineStore("calendar", () => {
months.value = calendarData.months months.value = calendarData.months
baseEvents.value = calendarData.events baseEvents.value = calendarData.events
categories.value = categoryData categories.value = calendarData.categories
} catch (err) { } catch (err) {
console.log(err) console.log(err)
} }
@@ -345,17 +345,17 @@ export const useCalendar = defineStore("calendar", () => {
function getViewTypeTitle(viewType: CalendarViewType): string { function getViewTypeTitle(viewType: CalendarViewType): string {
switch (viewType) { switch (viewType) {
case "year": case "year":
return t("entity.calendar.years.nameSingular") return t("entity.calendar.years.displayMode")
case "decade": case "decade":
return "Décennie" return t("entity.calendar.decades.displayMode")
case "century": case "century":
return "Siècle" return t("entity.calendar.centuries.displayMode")
case "month": case "month":
default: default:
return t("entity.calendar.months.nameSingular") return t("entity.calendar.months.displayMode")
} }
} }
@@ -819,7 +819,7 @@ export const useCalendar = defineStore("calendar", () => {
} }
/** /**
* State for event modal edition * State for event modal deletion
*/ */
const isDeleteEventModalOpen = ref<boolean>(false) const isDeleteEventModalOpen = ref<boolean>(false)
@@ -910,6 +910,36 @@ export const useCalendar = defineStore("calendar", () => {
} }
} }
/**
* Updates all events categories
* This is used when the user updates a category and we want to update all events in the client
* @param categories The new categories
* @returns void
*/
function updateAllEventsCategories(categories: Category[]) {
baseEvents.value.forEach((event) => {
const category = categories.find((c) => c.id === event.category?.id)
if (category && event.category?.id) {
event.category = category
}
})
}
// Watch for categories changes
watch(categories, (n) => {
updateAllEventsCategories(n)
})
/**
* State for categories modal
*/
const isCategoriesModalOpen = ref<boolean>(false)
function toggleCategoriesModal(state: boolean) {
isCategoriesModalOpen.value = state
}
return { return {
isReadOnly, isReadOnly,
setReadStatus, setReadStatus,
@@ -969,6 +999,8 @@ export const useCalendar = defineStore("calendar", () => {
isEditEventModalOpen, isEditEventModalOpen,
revealEditEventModal, revealEditEventModal,
isDeleteEventModalOpen, isDeleteEventModalOpen,
revealDeleteEventModal revealDeleteEventModal,
isCategoriesModalOpen,
toggleCategoriesModal
} }
}) })

117
stores/CategoryStore.ts Normal file
View File

@@ -0,0 +1,117 @@
import type { Category } from "~/models/Category";
export const useCategoryStore = defineStore("calendar-category", () => {
const { activeCalendar, 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 addCategoryFromSkeleton() {
if (!categorySkeleton.value) return
abortController = new AbortController()
isCreatingCategory.value = true
const { data, error } = await tryCatch(
$fetch<Category>("/api/calendars/categories/create", { method: "POST", body: { category: categorySkeleton.value, calendarId: activeCalendar.value?.id }, signal: abortController.signal })
)
if (error) {
isCreatingCategory.value = false
throw error
}
// Update the category in the store
categories.value.push(data)
abortController = null
isCreatingCategory.value = false
resetSkeleton()
}
async function updateCategoryFromSkeleton() {
if (!categorySkeleton.value) return
abortController = new AbortController()
isUpdatingCategory.value = true
const { data, error } = await tryCatch(
$fetch<Category>(`/api/calendars/categories/${categorySkeleton.value.id}`, { method: "PATCH", body: { category: categorySkeleton.value, calendarId: activeCalendar.value?.id }, 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
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,
isDeletingCategory,
operationInProgress,
categorySkeleton,
resetSkeleton,
addCategoryFromSkeleton,
updateCategoryFromSkeleton,
deleteCategoryFromSkeleton,
cancelLatestRequest
}
})

View File

@@ -1,306 +0,0 @@
import type { CalendarEvent } from "@/models/CalendarEvent"
import type { RPGDate } from "@/models/Date"
import { defineStore } from "pinia"
import { ref, watch, type Ref } from "vue"
import type { Category } from "~/models/Category"
import { useCalendar } from "./CalendarStore"
export const useCalendarEvents = defineStore("calendar-events", () => {
const { activeCalendar, defaultDate, currentConfig, convertDateToDays, compareDates } = useCalendar()
const { currentRPGDate } = storeToRefs(useCalendar())
const baseEvents = ref<CalendarEvent[]>([])
async function fetchCalendarEvents(calendarId: number) {
try {
const res = await $fetch("/api/calendars/events/query", { query: { calendarId } })
const eventData = res.data as CalendarEvent[]
if (!eventData.length) return
baseEvents.value = eventData
} catch (err) {
console.log(err)
}
}
const categories = ref<Category[]>([])
function setCategories(data: Category[]) {
categories.value = data
}
// Order base events by dates
const allEvents = computed(() => baseEvents.value.sort((a, b) => {
return compareDates(a.startDate, b.startDate, "desc")
}))
// Gets all current event in its default state
const currentEvents: Ref<CalendarEvent[]> = ref([])
// Watch for currentDate or events' list changes
// This is deep because we're watching an array, and changes need to trigger and mutations like .push and .splice
watch([currentRPGDate, allEvents], () => {
currentEvents.value = computeCurrentEvents()
}, { deep: true, immediate: true })
/**
* Determines if the event can appear in the front end
*
* This function takes into consideration the viewType of the calendar config
*
* @param event The event to analyze
* @returns Whether the event should appear in the current view
*/
function shouldEventBeDisplayed(event: CalendarEvent): boolean {
const isEventOnCurrentScreen =
(event.startDate.year === currentRPGDate.value.day &&
event.startDate.month === currentRPGDate.value.month) ||
(event.endDate &&
event.endDate.year === currentRPGDate.value.year &&
event.endDate.month === currentRPGDate.value.month)
switch (currentConfig.viewType) {
case "month":
return isEventOnCurrentScreen!
case "year":
return event.startDate.year === currentRPGDate.value.year
case "decade":
return (
event.startDate.year >= currentRPGDate.value.year &&
event.startDate.year <= currentRPGDate.value.year + 10
)
case "century":
return (
event.startDate.year >= currentRPGDate.value.year &&
event.startDate.year <= currentRPGDate.value.year + 100
)
default:
return false
}
}
/**
* Fetches all the current events for the current view
*
* @returns A list of events that can appear in the current view
*/
function computeCurrentEvents(): CalendarEvent[] {
return allEvents.value.filter((event) => shouldEventBeDisplayed(event))
}
/**
* From a base event, gets the next or previous one in the timeline
*
* @param event The event at a given position in the data
* @param position Whether we should get the next or previous event
* @returns The next event in chronological order
*/
function getRelativeEventFromEvent(
event: CalendarEvent,
position: "next" | "prev" = "next",
initialIsEnd: boolean = false
): { event: CalendarEvent; targetDate: RPGDate } {
let dateToParse: RPGDate // Day value of the date that the user interacted with
if (initialIsEnd && event.endDate) {
dateToParse = event.endDate
} else {
dateToParse = event.startDate
}
return getRelativeEventFromDate(dateToParse, position)
}
/**
* From a date, gets the next or previous event in the timeline
*
* @param date The starting date from which to get the next event
* @param position Whether we should get the next or previous event
* @returns The next event in chronological order
*/
function getRelativeEventFromDate(
date: RPGDate,
position: "next" | "prev" = "next"
): { event: CalendarEvent; targetDate: RPGDate } {
const pivotValue = convertDateToDays(date)
let t: { eventData: CalendarEvent; distance: number; targetKey: "startDate" | "endDate" }[] = []
// Loop over all event once to convert the structure to a usable one
for (let i = 0; i < allEvents.value.length; i++) {
const e: CalendarEvent = allEvents.value[i]
// Estimate distance from pivot
const startDateDays: number = convertDateToDays(e.startDate)
const startDistance: number = startDateDays - pivotValue
// Push startDate to comparator array
t.push({
eventData: e,
distance: startDistance,
targetKey: "startDate"
})
// Check the same things for endDate
if (e.endDate) {
const endDateDays: number = convertDateToDays(e.endDate)
const endDistance: number = endDateDays - pivotValue
// Push optional endDate to comparator array
t.push({
eventData: e,
distance: endDistance,
targetKey: "endDate"
})
}
}
// Based on the direction, either ignore negative distance (past) or positive distance (future)
t = t.filter((i) => {
return position === "next" ? i.distance > 0 : i.distance < 0
})
if (!t.length) {
throw new Error(
"Aucun évènement suivant ou précédent trouvé ; Peut-être l'évènement se situe au début ou à la fin du calendrier ?"
)
}
// Get event with remaining minimum distance
const closestEvent = t.reduce((a, b) => {
return Math.abs(b.distance) < Math.abs(a.distance) ? b : a
})
return {
event: closestEvent.eventData,
targetDate: closestEvent.eventData[closestEvent.targetKey]!
}
}
/**
* State for event modal edition
*/
const isEditEventModalOpen: Ref<boolean> = ref<boolean>(false)
function revealEditEventModal() {
isEditEventModalOpen.value = true
}
/**
* State for event modal edition
*/
const isDeleteEventModalOpen: Ref<boolean> = ref<boolean>(false)
function revealDeleteEventModal() {
isDeleteEventModalOpen.value = true
}
/**
* EVENT CREATION FUNCTIONS
*/
const lastActiveEvent = ref<CalendarEvent | null>()
const isCreatingEvent = ref<boolean>(false)
const isUpdatingEvent = ref<boolean>(false)
const isDeletingEvent = ref<boolean>(false)
const operationInProgress = computed(() => isCreatingEvent.value || isUpdatingEvent.value || isDeletingEvent.value)
let abortController: AbortController | null = null
/**
* Dummy event to hold creation data
*/
const eventSkeleton: Ref<CalendarEvent> = ref<CalendarEvent>({ title: "", startDate: defaultDate })
/**
* Resets the dummy event data
*/
function resetSkeleton() {
eventSkeleton.value = { title: "", startDate: defaultDate }
}
/**
* Submits the skeleton event and creates a real event from its data
*
* We assume it's been sanitized by the caller
*/
async function submitSkeleton() {
abortController = new AbortController()
isCreatingEvent.value = true
try {
const res = await $fetch("/api/calendars/events/create", { method: "POST", body: { event : eventSkeleton.value, calendarId: activeCalendar?.id }, signal: abortController.signal })
baseEvents.value.push(res)
} catch (err) {
console.log(err)
} finally {
abortController = null
isCreatingEvent.value = false
}
}
async function updateEventFromSkeleton() {
abortController = new AbortController()
isUpdatingEvent.value = true
const res = await $fetch(`/api/calendars/events/${eventSkeleton.value.id}`, { method: "PATCH", body: { event : eventSkeleton.value, calendarId: activeCalendar?.id }, signal: abortController.signal })
const eventIndex = baseEvents.value.findIndex(e => e.id === eventSkeleton.value.id)
baseEvents.value[eventIndex] = res
abortController = null
isUpdatingEvent.value = false
}
async function deleteEventFromSkeleton() {
abortController = new AbortController()
isDeletingEvent.value = true
try {
await $fetch(`/api/calendars/events/${eventSkeleton.value.id}`, { method: "DELETE", signal: abortController.signal })
const eventIndex = baseEvents.value.findIndex(e => e.id === eventSkeleton.value.id)
baseEvents.value.splice(eventIndex, 1)
} catch (err) {
console.log(err)
} finally {
abortController = null
isDeletingEvent.value = false
}
}
function cancelLatestRequest() {
if (abortController) {
abortController.abort()
}
}
return {
allEvents,
fetchCalendarEvents,
categories,
setCategories,
currentEvents,
getRelativeEventFromDate,
getRelativeEventFromEvent,
cancelLatestRequest,
isCreatingEvent,
isUpdatingEvent,
isDeletingEvent,
operationInProgress,
eventSkeleton,
resetSkeleton,
submitSkeleton,
lastActiveEvent,
updateEventFromSkeleton,
deleteEventFromSkeleton,
isEditEventModalOpen,
revealEditEventModal,
isDeleteEventModalOpen,
revealDeleteEventModal
}
})

View File

@@ -14,40 +14,40 @@ create type public.calendar_state as enum ('published', 'draft', 'archived');
-- DATA STRUCTURES -- DATA STRUCTURES
-- Users -- Users
create table public.users ( create table public.users (
id uuid references auth.users not null primary key, -- UUID from auth.users id uuid references auth.users not null primary key, -- UUID from auth.users
username text username text
); );
comment on table public.users is 'Profile data for each user.'; comment on table public.users is 'Profile data for each user.';
comment on column public.users.id is 'References the internal Supabase Auth user.'; comment on column public.users.id is 'References the internal Supabase Auth user.';
-- Roles -- Roles
create table public.user_roles ( create table public.user_roles (
id bigint generated by default as identity primary key, id bigint generated by default as identity primary key,
role app_role not null, role app_role not null,
user_id uuid references public.users on delete cascade not null, user_id uuid references public.users on delete cascade not null,
unique (user_id, role) unique (user_id, role)
); );
comment on table public.user_roles is 'Application roles for each user.'; comment on table public.user_roles is 'Application roles for each user.';
-- Permissions -- Permissions
create table public.role_permissions ( create table public.role_permissions (
id bigint generated by default as identity primary key, id bigint generated by default as identity primary key,
role app_role not null, role app_role not null,
permission app_permission not null, permission app_permission not null,
unique (role, permission) unique (role, permission)
); );
comment on table public.role_permissions is 'Application permissions for each role.'; comment on table public.role_permissions is 'Application permissions for each role.';
-- Worlds -- Worlds
create table public.worlds ( create table public.worlds (
id bigint generated by default as identity primary key, id bigint generated by default as identity primary key,
name text not null, name text not null,
description text, description text,
color app_colors default 'black', color app_colors default 'black',
state world_state default 'draft', state world_state default 'draft',
created_at timestamptz default now(), created_at timestamptz default now(),
updated_at timestamptz, updated_at timestamptz,
gm_id uuid references public.users on delete cascade gm_id uuid references public.users on delete cascade
); );
comment on table public.worlds is 'Worlds belonging to a single user ; a game master.'; comment on table public.worlds is 'Worlds belonging to a single user ; a game master.';
create trigger handle_updated_at before update on public.worlds create trigger handle_updated_at before update on public.worlds
@@ -55,9 +55,9 @@ create trigger handle_updated_at before update on public.worlds
-- World Players (join table) -- World Players (join table)
create table public.world_players ( create table public.world_players (
world_id bigint references public.worlds on delete cascade, world_id bigint references public.worlds on delete cascade,
player_id uuid references public.users on delete cascade, player_id uuid references public.users on delete cascade,
joined_at timestamptz default now(), joined_at timestamptz default now(),
primary key (world_id, player_id) primary key (world_id, player_id)
); );
comment on table public.world_players is 'Players (users) that belong to specific worlds'; comment on table public.world_players is 'Players (users) that belong to specific worlds';
@@ -91,27 +91,27 @@ comment on table public.calendar_months is 'A calendar month.';
-- Calendar Event categories -- Calendar Event categories
create table public.calendar_event_categories ( create table public.calendar_event_categories (
id bigint generated by default as identity primary key, id bigint generated by default as identity primary key,
name text not null, name text not null,
color app_colors default 'black', color app_colors default 'white',
unique (name) calendar_id bigint references public.calendars on delete cascade not null
); );
comment on table public.calendar_event_categories is 'Categories describing events.'; comment on table public.calendar_event_categories is 'Categories describing events.';
-- Calendar Events -- Calendar Events
create table public.calendar_events ( create table public.calendar_events (
id bigint generated by default as identity primary key, id bigint generated by default as identity primary key,
title text not null, title text not null,
description text, description text,
location text, location text,
start_date json not null, start_date json not null,
end_date json, 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, hidden boolean default false,
wiki text, wiki text,
created_at timestamptz default now(), created_at timestamptz default now(),
updated_at timestamptz, updated_at timestamptz,
calendar_id bigint references public.calendars on delete cascade not null calendar_id bigint references public.calendars on delete cascade not null
); );
comment on table public.calendar_events is 'Events linked to a world'; comment on table public.calendar_events is 'Events linked to a world';
@@ -134,12 +134,12 @@ comment on table public.calendar_event_categories_links is 'Link tables for mult
-- Character categories -- Character categories
create table public.character_categories ( create table public.character_categories (
id bigint generated by default as identity primary key, id bigint generated by default as identity primary key,
name text not null, name text not null,
color app_colors default 'black', color app_colors default 'white',
created_at timestamptz default now(), created_at timestamptz default now(),
updated_at timestamptz, updated_at timestamptz,
unique (name) world_id bigint references public.worlds on delete cascade not null
); );
comment on table public.character_categories is 'Categories describing characters'; comment on table public.character_categories is 'Categories describing characters';
@@ -150,7 +150,7 @@ create table public.characters (
description text, description text,
birth json not null, birth json not null,
death json, 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_birth boolean,
hidden_death boolean, hidden_death boolean,
wiki text, wiki text,
@@ -335,7 +335,7 @@ create policy "Allow GMs to delete their calendar's months"
); );
-- Event policies -- Event policies
create policy "Allow anonymous access to non-hidden events in published calendars" on public.calendar_events create policy "Allow anon access to non-hidden events in published calendars" on public.calendar_events
for select for select
to authenticated, anon to authenticated, anon
using ( using (
@@ -420,12 +420,67 @@ create policy "Allow individual update access for GMs" on public.characters for
) )
); );
-- Categories are public to view but not to insert -- Categories policies
-- Needs to be refactored maybe, if in the future we want a default set AND user defined ones create policy "Allow anonymous access to published event categories" on public.calendar_event_categories
create policy "Allow all read access" on public.calendar_event_categories for select to authenticated, anon using ( true ); for select
create policy "Allow all read access" on public.calendar_event_categories_links for select to authenticated, anon using ( true ); to authenticated, anon
create policy "Allow logged-in read access" on public.character_categories for select using ( auth.role() = 'authenticated' ); using (
create policy "Allow logged-in read access" on public.character_categories_links for select using ( auth.role() = 'authenticated' ); exists (
select 1
from public.calendars c
join public.calendar_event_categories_links l on l.calendar_event_category_id = calendar_event_categories.id
where c.id = calendar_event_categories.calendar_id
and c.state = 'published'
)
or exists (
select 1
from public.calendars c
where c.id = calendar_event_categories.calendar_id
and c.state = 'published'
)
);
create policy "Allow GMs to add new events categories"
on public.calendar_event_categories
for insert
with check (
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()
)
);
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()
)
);
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 -- Send "previous data" on change
alter table public.users replica identity full; alter table public.users replica identity full;

View File

@@ -1,57 +1,57 @@
insert into public.role_permissions (role, permission) values ('sa', 'events.see.hidden'); insert into public.role_permissions (role, permission) values ('sa', 'events.see.hidden');
insert into public.role_permissions (role, permission) values ('sa', 'users.ban'); insert into public.role_permissions (role, permission) values ('sa', 'users.ban');
-- Event categories -- Worlds
insert into public.calendar_event_categories (name, color) values ('Naissance', 'white'); insert into public.worlds (name, description, color, state) values ('Léïm', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis aliquet congue aliquet. Curabitur eu iaculis diam. Nunc iaculis nibh orci, eu semper nunc congue congue. Praesent euismod tortor eget metus tristique lobortis vel in risus. In volutpat ligula orci, id pharetra lectus egestas at.', 'white', 'published');
insert into public.calendar_event_categories (name, color) values ('Mort', 'black');
insert into public.calendar_event_categories (name, color) values ('Catastrophe', 'orange');
insert into public.calendar_event_categories (name, color) values ('Catastrophe naturelle', 'red');
insert into public.calendar_event_categories (name, color) values ('Inauguration', 'green');
insert into public.calendar_event_categories (name, color) values ('Religion', 'violet');
insert into public.calendar_event_categories (name, color) values ('Invention', 'teal');
insert into public.calendar_event_categories (name, color) values ('Science', 'indigo');
insert into public.calendar_event_categories (name, color) values ('Bénédiction', 'white');
insert into public.calendar_event_categories (name, color) values ('Joueurs', 'white');
insert into public.calendar_event_categories (name, color) values ('Découverte', 'purple');
insert into public.calendar_event_categories (name, color) values ('Exploration', 'lime');
insert into public.calendar_event_categories (name, color) values ('Construction', 'blue');
insert into public.calendar_event_categories (name, color) values ('Arcanologie', 'cyan');
insert into public.calendar_event_categories (name, color) values ('Criminalité', 'rose');
insert into public.calendar_event_categories (name, color) values ('Scandale', 'pink');
insert into public.calendar_event_categories (name, color) values ('Commerce', 'amber');
insert into public.calendar_event_categories (name, color) values ('Législation', 'blue');
-- Character categories -- Character categories
insert into public.character_categories (name, color) values ('Joueur', 'white'); insert into public.character_categories (name, color, world_id) values ('Joueur', 'white', 1);
insert into public.character_categories (name, color) values ('Comte', 'emerald'); insert into public.character_categories (name, color, world_id) values ('Comte', 'emerald', 1);
insert into public.character_categories (name, color) values ('Scientifique', 'indigo'); insert into public.character_categories (name, color, world_id) values ('Scientifique', 'indigo', 1);
insert into public.character_categories (name, color) values ('Mage', 'cyan'); insert into public.character_categories (name, color, world_id) values ('Mage', 'cyan', 1);
insert into public.character_categories (name, color) values ('Professeur', 'teal'); insert into public.character_categories (name, color, world_id) values ('Professeur', 'teal', 1);
insert into public.character_categories (name, color) values ('Criminel', 'rose'); insert into public.character_categories (name, color, world_id) values ('Criminel', 'rose', 1);
insert into public.character_categories (name, color) values ('Étincelle', 'lime'); insert into public.character_categories (name, color, world_id) values ('Étincelle', 'lime', 1);
insert into public.character_categories (name, color) values ('Buse blanche', 'yellow'); insert into public.character_categories (name, color, world_id) values ('Buse blanche', 'yellow', 1);
insert into public.character_categories (name, color) values ('Ecclésiastique', 'violet'); insert into public.character_categories (name, color, world_id) values ('Ecclésiastique', 'violet', 1);
insert into public.character_categories (name, color) values ('Militaire', 'orange'); insert into public.character_categories (name, color, world_id) values ('Militaire', 'orange', 1);
insert into public.character_categories (name, color) values ('Activiste', 'sky'); insert into public.character_categories (name, color, world_id) values ('Activiste', 'sky', 1);
insert into public.character_categories (name, color) values ('Commerçant', 'amber'); insert into public.character_categories (name, color, world_id) values ('Commerçant', 'amber', 1);
-- Worlds
insert into public.worlds (name, description, color, state) values ('Léïm', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis aliquet congue aliquet. Curabitur eu iaculis diam. Nunc iaculis nibh orci, eu semper nunc congue congue. Praesent euismod tortor eget metus tristique lobortis vel in risus. In volutpat ligula orci, id pharetra lectus egestas at.', 'black', 'published');
-- Worlds' calendars -- Worlds' calendars
insert into public.calendars (world_id, name, today, state) values (1, 'Calendrier solaire', '{ "day": 23, "month": 8, "year": 3209 }', 'published'); insert into public.calendars (world_id, name, today, color, state) values (1, 'Calendrier solaire', '{ "day": 23, "month": 8, "year": 3209 }', 'white', 'published');
-- Calendar's months -- Calendar's months
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Jalen', 32, 1); insert into public.calendar_months (name, days, position, calendar_id) values ('Jalen', 32, 1, 1);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Malsen', 32, 2); insert into public.calendar_months (name, days, position, calendar_id) values ('Malsen', 32, 2, 1);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Verlys', 32, 3); insert into public.calendar_months (name, days, position, calendar_id) values ('Verlys', 32, 3, 1);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Nalys', 32, 4); insert into public.calendar_months (name, days, position, calendar_id) values ('Nalys', 32, 4, 1);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Verdore', 32, 5); insert into public.calendar_months (name, days, position, calendar_id) values ('Verdore', 32, 5, 1);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Sidore', 32, 6); insert into public.calendar_months (name, days, position, calendar_id) values ('Sidore', 32, 6, 1);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Lyllion', 32, 7); insert into public.calendar_months (name, days, position, calendar_id) values ('Lyllion', 32, 7, 1);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Rion', 32, 8); insert into public.calendar_months (name, days, position, calendar_id) values ('Rion', 32, 8, 1);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Farene', 32, 9); insert into public.calendar_months (name, days, position, calendar_id) values ('Farene', 32, 9, 1);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Dalvene', 32, 10); insert into public.calendar_months (name, days, position, calendar_id) values ('Dalvene', 32, 10, 1);
-- Event categories
insert into public.calendar_event_categories (name, color, calendar_id) values ('Naissance', 'white', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Mort', 'black', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Catastrophe', 'orange', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Catastrophe naturelle', 'red', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Inauguration', 'green', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Religion', 'violet', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Invention', 'teal', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Science', 'indigo', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Bénédiction', 'white', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Joueurs', 'white', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Découverte', 'purple', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Exploration', 'lime', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Construction', 'blue', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Magie', 'cyan', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Criminalité', 'rose', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Scandale', 'pink', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Commerce', 'amber', 1);
insert into public.calendar_event_categories (name, color, calendar_id) values ('Législation', 'blue', 1);
-- Events -- Events
insert into public.calendar_events (title, description, start_date, category, hidden, wiki, calendar_id) values ( insert into public.calendar_events (title, description, start_date, category, hidden, wiki, calendar_id) values (
@@ -203,7 +203,7 @@ insert into public.calendar_events (title, description, start_date, end_date, ca
'Celui qu''on surnomme la Bête d''Ambrose arrive à Handany, où il purgera sa peine.', 'Celui qu''on surnomme la Bête d''Ambrose arrive à Handany, où il purgera sa peine.',
'{ "day": 14, "month": 7, "year": 3209 }', '{ "day": 14, "month": 7, "year": 3209 }',
null, null,
18, 15,
false, false,
'https://alexcreates.fr/leim/index.php/Tivian_Rodhus', 'https://alexcreates.fr/leim/index.php/Tivian_Rodhus',
1 1
@@ -221,7 +221,7 @@ insert into public.calendar_events (title, description, location, start_date, en
'Tourgrise', 'Tourgrise',
'{ "day": 4, "month": 8, "year": 3209 }', '{ "day": 4, "month": 8, "year": 3209 }',
null, null,
18, 15,
false, false,
null, null,
1 1
@@ -245,7 +245,7 @@ insert into public.calendar_events (title, description, location, start_date, en
'{ "day": 28, "month": 7, "year": 3209 }', '{ "day": 28, "month": 7, "year": 3209 }',
null, null,
2, 2,
true, false,
null, null,
1 1
); );
@@ -256,7 +256,7 @@ insert into public.calendar_events (title, description, location, start_date, en
'{ "day": 32, "month": 7, "year": 3209 }', '{ "day": 32, "month": 7, "year": 3209 }',
null, null,
2, 2,
true, false,
null, null,
1 1
); );
@@ -267,7 +267,7 @@ insert into public.calendar_events (title, description, location, start_date, en
'{ "day": 10, "month": 8, "year": 3209 }', '{ "day": 10, "month": 8, "year": 3209 }',
null, null,
2, 2,
true, false,
null, null,
1 1
); );
@@ -278,7 +278,7 @@ insert into public.calendar_events (title, description, location, start_date, en
'{ "day": 19, "month": 8, "year": 3209 }', '{ "day": 19, "month": 8, "year": 3209 }',
null, null,
2, 2,
true, false,
null, null,
1 1
); );
@@ -289,7 +289,7 @@ insert into public.calendar_events (title, description, location, start_date, en
'{ "day": 22, "month": 8, "year": 3209 }', '{ "day": 22, "month": 8, "year": 3209 }',
null, null,
2, 2,
true, false,
null, null,
1 1
); );

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 };
}
}