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-foreground: 0 0% 100%;
--secondary: 210 40% 96.1%;
--secondary: 210 50% 95%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
@@ -338,6 +338,81 @@
--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-leave-active {
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 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 { isReadOnly } = storeToRefs(useCalendar())
@@ -52,6 +41,7 @@ onMounted(() => {
<component :is="currentViewComponent" />
<LazyCalendarSearch />
<LazyCalendarDialogCategories v-if="!isReadOnly" />
<LazyCalendarDialogUpdateEvent v-if="!isReadOnly" />
<LazyCalendarDialogDeleteEvent v-if="!isReadOnly" />
</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"
const { defaultDate, getFormattedDateTitle, getRelativeString } = useCalendar()
const { defaultDate, getFormattedDateTitle, getRelativeString, getDifferenceInDays } = useCalendar()
const { selectedDate } = storeToRefs(useCalendar())
const mainDateTitle = computed(() => getFormattedDateTitle(selectedDate.value, true))
// const mainDateTitle = computed(() => convertDateToDays(selectedDate.value))
const dateDifference = computed(() => getRelativeString(defaultDate, selectedDate.value))
const isToday = computed(() => getDifferenceInDays(defaultDate, selectedDate.value) === 0)
</script>
<template>
<ClientOnly>
<h1 class="text-2xl font-bold flex items-center gap-1">
<PhMapPin size="26" weight="bold" /> {{ mainDateTitle }}
</h1>
<h2 class="text-lg italic opacity-75">
{{ dateDifference }}
</h2>
<div class="flex gap-2 items-center">
<h1 class="text-2xl font-bold flex items-center gap-1">
<PhMapPin size="26" weight="bold" /> {{ mainDateTitle }}
</h1>
<h2 v-if="!isToday" class="text-xl italic opacity-75">
{{ dateDifference }}
</h2>
</div>
<template #fallback>
<div class="grid gap-1">
<div class="flex items-center gap-1">
<UiSkeleton class="h-8 w-64" />
<UiSkeleton class="h-6 w-28" />
</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">
<p class="flex items-center gap-1">
<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>
<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)
*/
function handleClosing(e: Event) {
e.preventDefault()
function handleClosing() {
popoverOpen.value = false
resetSkeleton()
}
@@ -54,8 +53,8 @@ function handleClosing(e: Event) {
:disable-outside-pointer-events="true"
:trap-focus="true"
class="pl-3 w-[30rem] max-w-full border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
@escape-key-down="handleClosing"
@pointer-down-outside="handleClosing"
@escape-key-down.prevent="handleClosing"
@pointer-down-outside.prevent="handleClosing"
>
<CalendarFormCreateEvent @event-created="handleClosing" />
</UiPopoverContent>

View File

@@ -11,11 +11,8 @@ function toggleDialog() {
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing(e: Event) {
e.preventDefault()
function handleClosing() {
setTimeout(() => resetSkeleton(), 100)
}
</script>
@@ -32,8 +29,8 @@ function handleClosing(e: Event) {
<UiDialog v-model:open="isDialogOpen">
<UiDialogContent
class="border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
@escape-key-down="handleClosing"
@pointer-down-outside="handleClosing"
@escape-key-down.prevent="handleClosing"
@pointer-down-outside.prevent="handleClosing"
>
<UiDialogTitle>
{{ $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>
<template>
<header class="border-slate-200 contrast-more:border-slate-500 dark:border-slate-700 border-b-[1px]">
<div class="px-8 flex justify-between">
<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 items-center justify-between gap-2">
<menu class="flex items-center gap-2">
<li v-if="!isReadOnly">
<CalendarDialogQuickCreateEvent />
<LazyCalendarDialogQuickCreateEvent />
</li>
<li>
<CalendarMenuToday />
</li>
<li>
<CalendarMenuNav />
</li>
<li class="ml-4">
<CalendarCurrentDate />
</li>
@@ -29,13 +26,16 @@ const { isReadOnly } = storeToRefs(useCalendar())
<li>
<UiButton search-slash @click="revealAdvancedSearch()">
<PhMagnifyingGlass size="20" weight="light" />
{{ $t('entity.advancedSearch.title') }}
<span>
{{ $t('entity.advancedSearch.title') }}
</span>
</UiButton>
</li>
<li>
<ClientOnly>
<CalendarSwitch />
</ClientOnly>
<CalendarCategoriesCTA />
</li>
<li>
<CalendarOptionsCTA />
</li>
</menu>
</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 type { Category } from "~/models/Category"
const { isAdvancedSearchOpen, allEvents } = storeToRefs(useCalendar())
const { isAdvancedSearchOpen, allEvents, categories } = storeToRefs(useCalendar())
const { characters } = storeToRefs(useCharacters())
const searchInput = shallowRef<HTMLInputElement>()
@@ -207,8 +207,7 @@ watch([currentPage, selectedEntity], () => {
})
// Compute categories based on current selectedEntity
const { data: resCategories } = await useFetch("/api/calendars/categories/query")
const currentCategories = ref<Category[]>(resCategories.value?.data as Category[])
const currentCategories = computed(() => categories.value)
const selectedCategories = ref<(Category)[]>([])
const categoryFilterOpened = ref<boolean>(false)

View File

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

View File

@@ -2,8 +2,10 @@
import { cn } from "@/lib/utils";
import { type RPGColor, rpgColors } from "~/models/Color";
defineProps<{
const { id, theme = "normal", position = "popper" } = defineProps<{
id: string
theme?: "normal" | "subtle"
position?: "item-aligned" | "popper" | undefined
}>();
const model = defineModel<RPGColor>({ default: "white" });
@@ -11,27 +13,23 @@ const model = defineModel<RPGColor>({ default: "white" });
<template>
<UiSelect v-model="model">
<UiSelectTrigger :id>
<UiSelectTrigger :id :class="cn({ 'h-9': theme === 'subtle' })">
<UiSelectValue
:placeholder="$t('ui.colors.selectOne')"
class="input-color"
:class="
cn(
`input-color-${model}`,
)
"
class="bgc"
:class="cn(`bgc-${model}`)"
/>
</UiSelectTrigger>
<UiSelectContent position="popper">
<UiSelectContent :position>
<UiSelectGroup>
<UiSelectItem
v-for="color in rpgColors"
:key="color"
:value="color"
class="input-color"
class="bgc"
:class="
cn(
`input-color-${color}`,
`bgc-${color}`,
)
"
>
@@ -41,76 +39,3 @@ const model = defineModel<RPGColor>({ default: "white" });
</UiSelectContent>
</UiSelect>
</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>
<AlertDialogPortal>
<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
v-bind="forwarded"

View File

@@ -28,7 +28,7 @@ const props = withDefaults(defineProps<Props>(), {
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"
>
<span class="-mt-[2px]">CTRL + :</span>
<kbd class="-mt-[2px]">CTRL + :</kbd>
</span>
</Primitive>
</template>

View File

@@ -10,7 +10,7 @@ export const buttonVariants = cva(
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
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",
link: "text-primary underline-offset-4 hover:underline"
},

View File

@@ -27,7 +27,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template>
<DialogPortal>
<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
v-bind="forwarded"

View File

@@ -27,7 +27,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template>
<DialogPortal>
<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
: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,
useForwardProps
} from "radix-vue"
import { ChevronRight } from "lucide-vue-next"
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 { class: _, ...delegated } = props
@@ -29,7 +29,17 @@ const forwardedProps = useForwardProps(delegatedProps)
)
"
>
<slot />
<ChevronRight class="ml-auto h-4 w-4" />
<template v-if="props.arrowDirection === 'left'">
<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>
</template>

View File

@@ -1,6 +1,7 @@
export { DropdownMenuPortal } from "radix-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 DropdownMenuContent } from "./DropdownMenuContent.vue"
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue"

View File

@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
v-bind="forwardedProps"
:class="
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
)
"

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

View File

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

View File

@@ -1,5 +1,5 @@
import { z } from "zod"
import { categorySchema, type Category } from "./Category"
import type { Category } from "./Category"
import { dateSchema, type RPGDate } from "./Date"
export interface CalendarEvent {
@@ -26,7 +26,11 @@ export const postEventBodySchema = z.object({
startDate: dateSchema.required(),
endDate: dateSchema.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()
})

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup>
import { PhArrowBendDoubleUpLeft, PhCalendarX, PhCircleNotch } from "@phosphor-icons/vue";
import type { Calendar } from "~/models/CalendarConfig";
import type { Category } from "~/models/Category";
definePageMeta({
middleware: ["auth-guard"]
@@ -24,7 +23,7 @@ const {
status: calendarStatus
} = await useLazyFetch<{ data: Calendar }>("/api/calendars/query",
{
key: `calendar-${id}`,
key: "active-calendar",
query: {
id,
full: true
@@ -32,39 +31,14 @@ const {
}
)
const {
data: categories,
status: categoriesStatus
} = await useLazyFetch<{ data: Category[] }>("/api/calendars/categories/query",
{ key: `categories-${id}` }
)
const isLoading = computed(() => calendarStatus.value === "pending")
const isLoading = computed(() => calendarStatus.value === "pending" || categoriesStatus.value === "pending")
// Set custom menu
// This should be reserved for actions, not for breadcrumbs
//
// const { t } = useI18n()
// 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 })
const { setActiveCalendar } = useCalendar()
watch([calendar], () => {
if (calendar.value?.data) {
setActiveCalendar(calendar.value?.data)
}
}, { immediate: true })
</script>
<template>
@@ -81,7 +55,7 @@ const isLoading = computed(() => calendarStatus.value === "pending" || categorie
</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>
<Title>{{ calendar.data.name }}</Title>
</Head>
@@ -97,7 +71,7 @@ const isLoading = computed(() => calendarStatus.value === "pending" || categorie
/>
</div>
<Calendar :calendar-data="calendar.data" :categories="categories.data" />
<Calendar />
</div>
</div>

View File

@@ -23,8 +23,6 @@ export default defineEventHandler(async (event) => {
}
if (bodyError) {
console.log(bodyData)
console.log(bodyError)
const error = createError({
cause: "Utilisateur",
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({
id: z.number({ coerce: true }).positive().int().optional(),
calendarId: z.number({ coerce: true }).positive().int()
})
export default defineEventHandler(async (event) => {
@@ -16,12 +17,17 @@ export default defineEventHandler(async (event) => {
.from("calendar_event_categories")
.select(`
id,
name
name,
color
`)
if (query.id) {
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))
if (schemaError) {
console.log(schemaError)
throw createError({
cause: "Utilisateur",
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))
if (paramsError) {
console.log(paramsError)
throw createError({
cause: "Utilisateur",
fatal: false,
@@ -23,7 +22,6 @@ export default defineEventHandler(async (event) => {
}
if (bodyError) {
console.log(bodyData)
throw createError({
cause: "Utilisateur",
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))
if (schemaError) {
console.log(schemaError)
throw createError({
cause: "Utilisateur",
fatal: false,

View File

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

View File

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

View File

@@ -60,7 +60,7 @@ export const useCalendar = defineStore("calendar", () => {
*/
const months = ref<CalendarMonth[]>([])
function setActiveCalendar(calendarData: Calendar, categoryData: Category[]) {
function setActiveCalendar(calendarData: Calendar) {
try {
if (!calendarData.id) return
@@ -88,7 +88,7 @@ export const useCalendar = defineStore("calendar", () => {
months.value = calendarData.months
baseEvents.value = calendarData.events
categories.value = categoryData
categories.value = calendarData.categories
} catch (err) {
console.log(err)
}
@@ -345,17 +345,17 @@ export const useCalendar = defineStore("calendar", () => {
function getViewTypeTitle(viewType: CalendarViewType): string {
switch (viewType) {
case "year":
return t("entity.calendar.years.nameSingular")
return t("entity.calendar.years.displayMode")
case "decade":
return "Décennie"
return t("entity.calendar.decades.displayMode")
case "century":
return "Siècle"
return t("entity.calendar.centuries.displayMode")
case "month":
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)
@@ -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 {
isReadOnly,
setReadStatus,
@@ -969,6 +999,8 @@ export const useCalendar = defineStore("calendar", () => {
isEditEventModalOpen,
revealEditEventModal,
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
-- Users
create table public.users (
id uuid references auth.users not null primary key, -- UUID from auth.users
username text
id uuid references auth.users not null primary key, -- UUID from auth.users
username text
);
comment on table public.users is 'Profile data for each user.';
comment on column public.users.id is 'References the internal Supabase Auth user.';
-- Roles
create table public.user_roles (
id bigint generated by default as identity primary key,
role app_role not null,
user_id uuid references public.users on delete cascade not null,
id bigint generated by default as identity primary key,
role app_role not null,
user_id uuid references public.users on delete cascade not null,
unique (user_id, role)
);
comment on table public.user_roles is 'Application roles for each user.';
-- Permissions
create table public.role_permissions (
id bigint generated by default as identity primary key,
role app_role not null,
permission app_permission not null,
id bigint generated by default as identity primary key,
role app_role not null,
permission app_permission not null,
unique (role, permission)
);
comment on table public.role_permissions is 'Application permissions for each role.';
-- Worlds
create table public.worlds (
id bigint generated by default as identity primary key,
name text not null,
description text,
color app_colors default 'black',
state world_state default 'draft',
created_at timestamptz default now(),
updated_at timestamptz,
gm_id uuid references public.users on delete cascade
id bigint generated by default as identity primary key,
name text not null,
description text,
color app_colors default 'black',
state world_state default 'draft',
created_at timestamptz default now(),
updated_at timestamptz,
gm_id uuid references public.users on delete cascade
);
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
@@ -55,9 +55,9 @@ create trigger handle_updated_at before update on public.worlds
-- World Players (join table)
create table public.world_players (
world_id bigint references public.worlds on delete cascade,
player_id uuid references public.users on delete cascade,
joined_at timestamptz default now(),
world_id bigint references public.worlds on delete cascade,
player_id uuid references public.users on delete cascade,
joined_at timestamptz default now(),
primary key (world_id, player_id)
);
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
create table public.calendar_event_categories (
id bigint generated by default as identity primary key,
name text not null,
color app_colors default 'black',
unique (name)
id bigint generated by default as identity primary key,
name text not null,
color app_colors default 'white',
calendar_id bigint references public.calendars on delete cascade not null
);
comment on table public.calendar_event_categories is 'Categories describing events.';
-- Calendar Events
create table public.calendar_events (
id bigint generated by default as identity primary key,
title text not null,
description text,
location text,
start_date json not null,
end_date json,
category bigint references public.calendar_event_categories on delete cascade,
hidden boolean default false,
wiki text,
created_at timestamptz default now(),
updated_at timestamptz,
calendar_id bigint references public.calendars on delete cascade not null
id bigint generated by default as identity primary key,
title text not null,
description text,
location text,
start_date json not null,
end_date json,
category bigint references public.calendar_event_categories on delete set null,
hidden boolean default false,
wiki text,
created_at timestamptz default now(),
updated_at timestamptz,
calendar_id bigint references public.calendars on delete cascade not null
);
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
create table public.character_categories (
id bigint generated by default as identity primary key,
name text not null,
color app_colors default 'black',
created_at timestamptz default now(),
updated_at timestamptz,
unique (name)
id bigint generated by default as identity primary key,
name text not null,
color app_colors default 'white',
created_at timestamptz default now(),
updated_at timestamptz,
world_id bigint references public.worlds on delete cascade not null
);
comment on table public.character_categories is 'Categories describing characters';
@@ -150,7 +150,7 @@ create table public.characters (
description text,
birth json not null,
death json,
category bigint references public.character_categories on delete cascade,
category bigint references public.character_categories on delete set null,
hidden_birth boolean,
hidden_death boolean,
wiki text,
@@ -335,7 +335,7 @@ create policy "Allow GMs to delete their calendar's months"
);
-- 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
to authenticated, anon
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
-- Needs to be refactored maybe, if in the future we want a default set AND user defined ones
create policy "Allow all read access" on public.calendar_event_categories for select to authenticated, anon using ( true );
create policy "Allow all read access" on public.calendar_event_categories_links for select to authenticated, anon using ( true );
create policy "Allow logged-in read access" on public.character_categories for select using ( auth.role() = 'authenticated' );
create policy "Allow logged-in read access" on public.character_categories_links for select using ( auth.role() = 'authenticated' );
-- Categories policies
create policy "Allow anonymous access to published event categories" on public.calendar_event_categories
for select
to authenticated, anon
using (
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
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', 'users.ban');
-- Event categories
insert into public.calendar_event_categories (name, color) values ('Naissance', 'white');
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');
-- 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.', 'white', 'published');
-- Character categories
insert into public.character_categories (name, color) values ('Joueur', 'white');
insert into public.character_categories (name, color) values ('Comte', 'emerald');
insert into public.character_categories (name, color) values ('Scientifique', 'indigo');
insert into public.character_categories (name, color) values ('Mage', 'cyan');
insert into public.character_categories (name, color) values ('Professeur', 'teal');
insert into public.character_categories (name, color) values ('Criminel', 'rose');
insert into public.character_categories (name, color) values ('Étincelle', 'lime');
insert into public.character_categories (name, color) values ('Buse blanche', 'yellow');
insert into public.character_categories (name, color) values ('Ecclésiastique', 'violet');
insert into public.character_categories (name, color) values ('Militaire', 'orange');
insert into public.character_categories (name, color) values ('Activiste', 'sky');
insert into public.character_categories (name, color) values ('Commerçant', 'amber');
-- 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');
insert into public.character_categories (name, color, world_id) values ('Joueur', 'white', 1);
insert into public.character_categories (name, color, world_id) values ('Comte', 'emerald', 1);
insert into public.character_categories (name, color, world_id) values ('Scientifique', 'indigo', 1);
insert into public.character_categories (name, color, world_id) values ('Mage', 'cyan', 1);
insert into public.character_categories (name, color, world_id) values ('Professeur', 'teal', 1);
insert into public.character_categories (name, color, world_id) values ('Criminel', 'rose', 1);
insert into public.character_categories (name, color, world_id) values ('Étincelle', 'lime', 1);
insert into public.character_categories (name, color, world_id) values ('Buse blanche', 'yellow', 1);
insert into public.character_categories (name, color, world_id) values ('Ecclésiastique', 'violet', 1);
insert into public.character_categories (name, color, world_id) values ('Militaire', 'orange', 1);
insert into public.character_categories (name, color, world_id) values ('Activiste', 'sky', 1);
insert into public.character_categories (name, color, world_id) values ('Commerçant', 'amber', 1);
-- 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
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Jalen', 32, 1);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Malsen', 32, 2);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Verlys', 32, 3);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Nalys', 32, 4);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Verdore', 32, 5);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Sidore', 32, 6);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Lyllion', 32, 7);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Rion', 32, 8);
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Farene', 32, 9);
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 ('Jalen', 32, 1, 1);
insert into public.calendar_months (name, days, position, calendar_id) values ('Malsen', 32, 2, 1);
insert into public.calendar_months (name, days, position, calendar_id) values ('Verlys', 32, 3, 1);
insert into public.calendar_months (name, days, position, calendar_id) values ('Nalys', 32, 4, 1);
insert into public.calendar_months (name, days, position, calendar_id) values ('Verdore', 32, 5, 1);
insert into public.calendar_months (name, days, position, calendar_id) values ('Sidore', 32, 6, 1);
insert into public.calendar_months (name, days, position, calendar_id) values ('Lyllion', 32, 7, 1);
insert into public.calendar_months (name, days, position, calendar_id) values ('Rion', 32, 8, 1);
insert into public.calendar_months (name, days, position, calendar_id) values ('Farene', 32, 9, 1);
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
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.',
'{ "day": 14, "month": 7, "year": 3209 }',
null,
18,
15,
false,
'https://alexcreates.fr/leim/index.php/Tivian_Rodhus',
1
@@ -221,7 +221,7 @@ insert into public.calendar_events (title, description, location, start_date, en
'Tourgrise',
'{ "day": 4, "month": 8, "year": 3209 }',
null,
18,
15,
false,
null,
1
@@ -245,7 +245,7 @@ insert into public.calendar_events (title, description, location, start_date, en
'{ "day": 28, "month": 7, "year": 3209 }',
null,
2,
true,
false,
null,
1
);
@@ -256,7 +256,7 @@ insert into public.calendar_events (title, description, location, start_date, en
'{ "day": 32, "month": 7, "year": 3209 }',
null,
2,
true,
false,
null,
1
);
@@ -267,7 +267,7 @@ insert into public.calendar_events (title, description, location, start_date, en
'{ "day": 10, "month": 8, "year": 3209 }',
null,
2,
true,
false,
null,
1
);
@@ -278,7 +278,7 @@ insert into public.calendar_events (title, description, location, start_date, en
'{ "day": 19, "month": 8, "year": 3209 }',
null,
2,
true,
false,
null,
1
);
@@ -289,7 +289,7 @@ insert into public.calendar_events (title, description, location, start_date, en
'{ "day": 22, "month": 8, "year": 3209 }',
null,
2,
true,
false,
null,
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 };
}
}