Added sample behaviour for category modal

This commit is contained in:
Alexis
2025-03-28 20:54:12 +01:00
parent 2856185a3c
commit 637a5cd7e3
15 changed files with 104 additions and 340 deletions

View File

@@ -22,7 +22,7 @@
--primary: 245 58% 51%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96.1%;
--secondary: 210 50% 95%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;

View File

@@ -52,6 +52,7 @@ onMounted(() => {
<component :is="currentViewComponent" />
<LazyCalendarSearch />
<LazyCalendarDialogCategories v-if="!isReadOnly" />
<LazyCalendarDialogUpdateEvent v-if="!isReadOnly" />
<LazyCalendarDialogDeleteEvent v-if="!isReadOnly" />
</div>

View File

@@ -2,7 +2,6 @@
import { useCalendar } from "@/stores/CalendarStore"
import { PhMagnifyingGlass } from "@phosphor-icons/vue"
import CalendarOptions from "./CalendarOptions.vue"
const { revealAdvancedSearch } = useCalendar()
const { isReadOnly } = storeToRefs(useCalendar())
@@ -13,7 +12,7 @@ const { isReadOnly } = storeToRefs(useCalendar())
<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 />
@@ -33,7 +32,10 @@ const { isReadOnly } = storeToRefs(useCalendar())
</UiButton>
</li>
<li>
<CalendarOptions />
<CalendarCategoriesCTA />
</li>
<li>
<CalendarOptionsCTA />
</li>
</menu>
</div>

View File

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

View File

@@ -1,32 +1,23 @@
<script setup lang="ts">
import { cn } from "@/lib/utils"
import { useCalendar } from "@/stores/CalendarStore"
import { PhCalendarBlank, PhCheckCircle, PhGear, PhTag } from "@phosphor-icons/vue"
import { PhCalendarBlank, PhCheckCircle, PhGear } from "@phosphor-icons/vue"
import { computed } from "vue"
const optionsOpened = ref<boolean>(false)
const user = useSupabaseUser()
function closeMenu() {
optionsOpened.value = false
}
watch(user, closeMenu)
const { currentConfig, viewTypeOptions, getViewTypeTitle, setViewType } = useCalendar()
const { isReadOnly } = storeToRefs(useCalendar())
const viewTypeTitle = computed(() => getViewTypeTitle(currentConfig.viewType))
</script>
<template>
<UiDropdownMenu v-model:open="optionsOpened">
<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>
@@ -54,16 +45,6 @@ const viewTypeTitle = computed(() => getViewTypeTitle(currentConfig.viewType))
</UiDropdownMenuSubContent>
</UiDropdownMenuPortal>
</UiDropdownMenuSub>
<template v-if="!isReadOnly">
<UiDropdownMenuSeparator />
<UiDropdownMenuItem class="flex gap-[1ch] justify-end items-center">
{{ $t('entity.category.namePlural') }}
<PhTag size="18" weight="fill" />
</UiDropdownMenuItem>
</template>
</UiDropdownMenuContent>
</UiDropdownMenu>
</template>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import { PhX } from "@phosphor-icons/vue";
const { toggleCategoriesModal } = useCalendar()
const { 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 gap-6"
:disable-outside-pointer-events="true"
:trap-focus="true"
@escape-key-down="handleClosing"
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@close-auto-focus="(e) => e.preventDefault()"
>
<UiAlertDialogTitle>
<span class="text-2xl">
<strong class="font-bold">Gestion des catégories</strong>
</span>
</UiAlertDialogTitle>
<UiButton size="icon" variant="ghost" class="absolute top-4 right-4" title="Fermer la fenêtre" @click="handleClosing">
<PhX size="20" />
</UiButton>
catégories
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -148,6 +148,7 @@ export default defineI18nConfig(() => ({
backToList: "Go back to calendars",
isLoading: "Calendar is loading…",
hasXEvents: "{count} available events",
seeCategories: "Modify categories",
seeOptions: "Calendar options",
date: {
start: "Start date",
@@ -395,7 +396,7 @@ 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",
},
isLoading: "Chargement en cours…",
addDescription: "Ajouter une description",
@@ -450,6 +451,7 @@ export default defineI18nConfig(() => ({
backToList: "Retourner aux calendriers",
isLoading: "Chargement du calendrier…",
hasXEvents: "{count} évènements disponibles",
seeCategories: "Gestion des catégories",
seeOptions: "Options du calendrier",
date: {
start: "Date de début",

View File

@@ -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,15 @@ export const useCalendar = defineStore("calendar", () => {
}
}
/**
* State for categories modal
*/
const isCategoriesModalOpen = ref<boolean>(false)
function toggleCategoriesModal(state: boolean) {
isCategoriesModalOpen.value = state
}
return {
isReadOnly,
setReadStatus,
@@ -969,6 +978,8 @@ export const useCalendar = defineStore("calendar", () => {
isEditEventModalOpen,
revealEditEventModal,
isDeleteEventModalOpen,
revealDeleteEventModal
revealDeleteEventModal,
isCategoriesModalOpen,
toggleCategoriesModal
}
})

View File

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