Merge pull request #74 from AlexisNP/features/category-management
Features/category management
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
23
components/calendar/CategoriesCTA.vue
Normal file
23
components/calendar/CategoriesCTA.vue
Normal 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>
|
||||
@@ -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>
|
||||
50
components/calendar/OptionsCTA.vue
Normal file
50
components/calendar/OptionsCTA.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
15
components/calendar/category/List.vue
Normal file
15
components/calendar/category/List.vue
Normal 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>
|
||||
15
components/calendar/category/ListItem.vue
Normal file
15
components/calendar/category/ListItem.vue
Normal 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>
|
||||
72
components/calendar/category/Table.vue
Normal file
72
components/calendar/category/Table.vue
Normal 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>
|
||||
130
components/calendar/category/TableFooter.vue
Normal file
130
components/calendar/category/TableFooter.vue
Normal 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>
|
||||
19
components/calendar/category/TableHeader.vue
Normal file
19
components/calendar/category/TableHeader.vue
Normal 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>
|
||||
|
||||
187
components/calendar/category/TableRow.vue
Normal file
187
components/calendar/category/TableRow.vue
Normal 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>
|
||||
41
components/calendar/dialog/Categories.vue
Normal file
41
components/calendar/dialog/Categories.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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") }}
|
||||
|
||||
85
components/calendar/form/DeleteCategory.vue
Normal file
85
components/calendar/form/DeleteCategory.vue
Normal 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>
|
||||
@@ -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>
|
||||
230
components/calendar/menu/MenuSubnav.vue
Normal file
230
components/calendar/menu/MenuSubnav.vue
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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="
|
||||
|
||||
13
components/ui/dropdown-menu/DropdownMenuArrow.vue
Normal file
13
components/ui/dropdown-menu/DropdownMenuArrow.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
"
|
||||
|
||||
16
components/ui/table/Table.vue
Normal file
16
components/ui/table/Table.vue
Normal 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>
|
||||
14
components/ui/table/TableBody.vue
Normal file
14
components/ui/table/TableBody.vue
Normal 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>
|
||||
14
components/ui/table/TableCaption.vue
Normal file
14
components/ui/table/TableCaption.vue
Normal 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>
|
||||
21
components/ui/table/TableCell.vue
Normal file
21
components/ui/table/TableCell.vue
Normal 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>
|
||||
38
components/ui/table/TableEmpty.vue
Normal file
38
components/ui/table/TableEmpty.vue
Normal 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>
|
||||
14
components/ui/table/TableFooter.vue
Normal file
14
components/ui/table/TableFooter.vue
Normal 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>
|
||||
14
components/ui/table/TableHead.vue
Normal file
14
components/ui/table/TableHead.vue
Normal 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>
|
||||
14
components/ui/table/TableHeader.vue
Normal file
14
components/ui/table/TableHeader.vue
Normal 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>
|
||||
14
components/ui/table/TableRow.vue
Normal file
14
components/ui/table/TableRow.vue
Normal 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>
|
||||
9
components/ui/table/index.ts
Normal file
9
components/ui/table/index.ts
Normal 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'
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
if (bodyError) {
|
||||
console.log(bodyData)
|
||||
console.log(bodyError)
|
||||
const error = createError({
|
||||
cause: "Utilisateur",
|
||||
fatal: false,
|
||||
|
||||
41
server/api/calendars/categories/[id].delete.ts
Normal file
41
server/api/calendars/categories/[id].delete.ts
Normal 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."
|
||||
})
|
||||
}
|
||||
})
|
||||
73
server/api/calendars/categories/[id].patch.ts
Normal file
73
server/api/calendars/categories/[id].patch.ts
Normal 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."
|
||||
})
|
||||
}
|
||||
})
|
||||
56
server/api/calendars/categories/create.post.ts
Normal file
56
server/api/calendars/categories/create.post.ts
Normal 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."
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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[]>()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
117
stores/CategoryStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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
31
utils/TryCatch.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Types for the result object with discriminated union
|
||||
type Success<T> = {
|
||||
data: T;
|
||||
error: null;
|
||||
};
|
||||
|
||||
type Failure<E> = {
|
||||
data: null;
|
||||
error: E;
|
||||
};
|
||||
|
||||
type Result<T, E = Error> = Success<T> | Failure<E>;
|
||||
|
||||
/**
|
||||
* A utility function to handle promises with try-catch
|
||||
* and return a result object, courtesy of t3.
|
||||
*
|
||||
* @param promise The promise to be executed
|
||||
* @returns A promise that resolves to an object with either the data or the error
|
||||
* @link https://gist.github.com/t3dotgg/a486c4ae66d32bf17c09c73609dacc5b
|
||||
*/
|
||||
export async function tryCatch<T, E = Error>(
|
||||
promise: Promise<T>,
|
||||
): Promise<Result<T, E>> {
|
||||
try {
|
||||
const data = await promise;
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
return { data: null, error: error as E };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user