Migration to nuxt 4

Used codemods CLI and reworked most alias'd path that stopped working
This commit is contained in:
Alexis
2025-07-27 14:30:30 +02:00
parent 68b9a38f83
commit 7fdab8601f
280 changed files with 847 additions and 496 deletions

View File

@@ -0,0 +1,68 @@
<script lang="ts" setup>
import { useCalendar } from "@/stores/CalendarStore"
import { computed, type Component, type ComputedRef } from "vue"
// import { PhMagnifyingGlass } from '@phosphor-icons/vue'
import MonthlyLayout from "./state/monthly/Layout.vue"
import CenturyLayout from "./state/centennially/Layout.vue"
import DecadeLayout from "./state/decennially/Layout.vue"
import YearLayout from "./state/yearly/Layout.vue"
const { currentConfig, jumpToDate, selectedDate } = useCalendar()
const { isReadOnly } = storeToRefs(useCalendar())
const currentViewComponent: ComputedRef<Component> = computed<Component>(() => {
switch (currentConfig.viewType) {
case "month":
return MonthlyLayout
case "year":
return YearLayout
case "decade":
return DecadeLayout
case "century":
default:
return CenturyLayout
}
})
onMounted(() => {
jumpToDate(selectedDate)
})
</script>
<template>
<div class="h-full w-full relative">
<div class="h-full grid grid-rows-[auto_1fr]">
<CalendarMenu />
<component :is="currentViewComponent" />
<CalendarSearch />
<LazyCalendarDialogCategories v-if="!isReadOnly" />
<LazyCalendarDialogUpdateEvent v-if="!isReadOnly" />
<LazyCalendarDialogDeleteEvent v-if="!isReadOnly" />
</div>
</div>
</template>
<style lang="scss" scoped>
.screen-move, /* apply transition to moving elements */
.screen-enter-active,
.screen-leave-active {
transition: all .4s ease-out;
}
.screen-enter-from,
.screen-leave-to {
opacity: 0;
}
/* ensure leaving items are taken out of layout flow so that moving
animations can be calculated correctly. */
.screen-leave-active {
position: absolute;
}
</style>

View File

@@ -0,0 +1,25 @@
<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>
<ClientOnly>
<UiButton v-if="!isReadOnly" variant="secondary" size="icon" @click="toggleCategoriesModal(true)">
<PhTag size="20" weight="light" />
</UiButton>
</ClientOnly>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('entity.calendar.seeCategories') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</template>

View File

@@ -0,0 +1,45 @@
<script lang="ts" setup>
import { storeToRefs } from "pinia"
import { computed } from "vue"
import { PhMapPin } from "@phosphor-icons/vue"
import { breakpointsTailwind } from "@vueuse/core"
const { defaultDate, getFormattedDateTitle, getRelativeString, getDifferenceInDays } = useCalendar()
const { selectedDate } = storeToRefs(useCalendar())
const breakpoints = useBreakpoints(
breakpointsTailwind
)
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>
<div class="grid md:flex md:gap-2 items-center">
<h1 class="text-lg md:text-2xl max-md:leading-tight font-bold flex items-center gap-1">
<PhMapPin
:size="breakpoints.md.value ? 26 : 18"
class="max-md:hidden"
weight="light"
/>
{{ mainDateTitle }}
</h1>
<h2 v-if="!isToday" class="text-sm max-md:leading-tight md:text-xl italic opacity-75">
<span class="max-md:hidden"></span> {{ dateDifference }}
</h2>
</div>
<template #fallback>
<div class="flex items-center gap-1">
<UiSkeleton class="h-8 w-64 max-md:max-w-full" />
<UiSkeleton class="max-md:hidden h-6 w-28" />
</div>
</template>
</ClientOnly>
</template>

View File

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

View File

@@ -0,0 +1,103 @@
<script lang="ts" setup>
import { cn } from "@/lib/utils"
import { PhArchive, PhCalendarDots, PhFile, PhFileDashed, PhFilePlus, PhPencil, PhPencilSimpleLine, PhTrash } from "@phosphor-icons/vue"
import { DateTime } from "luxon"
import type { Calendar } from "@@/models/CalendarConfig"
const props = defineProps<{
calendar: Calendar
gmId?: string
showActions?: boolean
}>()
const emit = defineEmits(["on-edit", "on-delete"])
const cardRef = ref(null)
const isCardHovered = useElementHover(cardRef)
const { focused: isCardFocused } = useFocusWithin(cardRef)
const { locale } = useI18n()
const createdAt = DateTime.fromISO(props.calendar.createdAt!).toLocaleString(DateTime.DATETIME_MED, { locale: locale.value })
const updatedAt = computed<string>(() => props.calendar.updatedAt ? DateTime.fromISO(props.calendar.updatedAt).toLocaleString(DateTime.DATETIME_MED, { locale: locale.value }) : "")
const user = useSupabaseUser()
const isOwner = computed(() => user.value && props.gmId && user.value.id === props.gmId)
const calendarLink = computed(() => isOwner.value ? `/my/calendars/${props.calendar.id}` : `/calendars/${props.calendar.shortId}`)
const hasActions = computed(() => isOwner.value && props.showActions)
</script>
<template>
<UiCard
ref="cardRef"
class="w-full h-full flex flex-col"
:link="calendarLink"
:class="cn(
calendar.color ? `group card-color element-${calendar.color}` : '',
)"
>
<UiCardHeader class="gap-4">
<UiCardTitle
class="text-xl"
:class="cn({
'pr-18': hasActions
})"
>
{{ calendar.name }}
</UiCardTitle>
<div v-if="calendar.state === 'published'" class="flex items-center gap-1 text-sm">
<PhFile size="20" weight="fill" />
<span>{{ $t('ui.contentState.published') }}</span>
</div>
<div v-if="calendar.state === 'draft'" class="flex items-center gap-1 text-sm">
<PhFileDashed size="20" weight="fill" />
<span>{{ $t('ui.contentState.draft') }}</span>
</div>
<div v-if="calendar.state === 'archived'" class="flex items-center gap-1 text-sm">
<PhArchive size="20" weight="fill" />
<span>{{ $t('ui.contentState.archived') }}</span>
</div>
</UiCardHeader>
<UiCardContent class="grow">
<p class="flex items-center gap-1">
<PhCalendarDots size="24" weight="fill" />
<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
v-if="hasActions && (isCardHovered || isCardFocused)"
class="flex gap-2 absolute top-4 right-4 z-20"
>
<Transition name="fade" appear>
<UiButton size="icon" variant="outline" class="size-8 border-foreground/20 group-hover:border-foreground/30 hover:text-background hover:border-foreground hover:bg-foreground" @click="emit('on-edit')">
<PhPencil size="15" weight="fill" />
</UiButton>
</Transition>
<Transition name="fade" appear>
<UiButton size="icon" variant="outline" class="size-8 border-foreground/25 group-hover:border-foreground/40 hover:text-background hover:border-rose-500 hover:bg-rose-500" @click="emit('on-delete')">
<PhTrash size="15" weight="fill" />
</UiButton>
</Transition>
</div>
</UiCardContent>
<hr>
<UiCardFooter class="border-t-0">
<ul class="grid gap-1 text-sm">
<li class="flex gap-1 items-center">
<PhFilePlus size="18" />
<span>{{ $t('common.createdAt', { createdAt }) }}</span>
</li>
<li v-if="updatedAt" class="flex gap-1 items-center">
<PhPencilSimpleLine size="18" />
<span>{{ $t('common.updatedAt', { updatedAt }) }}</span>
</li>
</ul>
</UiCardFooter>
</UiCard>
</template>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { PhMagnifyingGlass } from "@phosphor-icons/vue"
const { revealAdvancedSearch } = useCalendar()
const breakpoints = useBreakpoints(
breakpointsTailwind
)
</script>
<template>
<ClientOnly>
<Transition name="fade" appear>
<UiButton
:search-slash="breakpoints.lg.value"
:size="breakpoints.lg.value ? 'default' : 'icon'"
@click="revealAdvancedSearch()"
>
<PhMagnifyingGlass size="20" weight="light" />
<span v-if="breakpoints.lg.value">
{{ $t('entity.advancedSearch.title') }}
</span>
</UiButton>
</Transition>
</ClientOnly>
</template>

View File

@@ -0,0 +1,3 @@
export const searchUnifier = new RegExp(/[^a-zA-Z0-9\-']/g)
export type SearchMode = "characters" | "events" | undefined

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import type { Category } from "@@/models/Category";
import { ScrollAreaRoot, ScrollAreaViewport, ScrollAreaScrollbar, ScrollAreaThumb } from "radix-vue"
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:border-0">
<CalendarCategoryTableRow
v-for="item in sortedCategories"
:key="item.id"
:category="item"
@on-delete-category="openDeleteDialog"
/>
</div>
</ScrollAreaViewport>
<ScrollAreaScrollbar
class="flex select-none touch-none p-0.5 bg-background transition-colors duration-100 ease-out hover:bg-foreground/5 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:h-2.5"
orientation="vertical"
>
<ScrollAreaThumb
class="flex-1 bg-foreground/20 rounded-[10px] relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full"
/>
</ScrollAreaScrollbar>
</ScrollAreaRoot>
<CalendarCategoryTableFooter />
</div>
<UiDialog v-model:open="deleteDialogOpened">
<UiDialogContent
:disable-outside-pointer-events="true"
:trap-focus="true"
class="min-w-96 border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
@escape-key-down="closeDeleteDialog"
@focus-outside="closeDeleteDialog"
@interact-outside="closeDeleteDialog"
@pointer-down-outside="closeDeleteDialog"
>
<UiDialogTitle>
{{ $t('entity.category.deleteDialog.title') }}
</UiDialogTitle>
<UiDialogDescription>
{{ $t('entity.category.deleteDialog.subtitle') }}
</UiDialogDescription>
<CalendarFormDeleteCategory />
</UiDialogContent>
</UiDialog>
</template>

View File

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

View File

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

View File

@@ -0,0 +1,187 @@
<script setup lang="ts">
import { PhCheck, PhTrash } from "@phosphor-icons/vue"
import { useToast } from "~/components/ui/toast"
import { ToastLifetime } from "~/components/ui/toast/use-toast"
import { cn } from "@/lib/utils"
import type { Category } from "@@/models/Category"
const { toast } = useToast()
const { t } = useI18n()
const { category } = defineProps<{
category: Category
}>()
const emit = defineEmits<{
"on-delete-category": [payload: Category]
}>()
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-hidden 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(`element-${category.color}`)"
>
{{ $t(`ui.colors.${category.color}`) }}
</span>
</button>
</template>
<template v-else-if="currentMode === 'edit' && categorySkeleton">
<div class="-mx-2">
<InputColor
id="category-color"
v-model="categorySkeleton.color"
position="item-aligned"
theme="subtle"
/>
</div>
</template>
</div>
</form>
<menu class="w-fit absolute top-1/2 -translate-y-1/2 right-2 flex items-center gap-2">
<li
v-if="currentMode === 'edit' && categorySkeleton"
>
<UiButton
variant="secondary"
size="icon"
class="w-6 h-6 rounded-full bg-emerald-500 hover:bg-emerald-600 text-white"
:title="$t('ui.actions.edit')"
@click="submitUpdate"
>
<PhCheck size="14" weight="bold" />
</UiButton>
</li>
<li v-else-if="rowHovered">
<UiButton
variant="secondary"
size="icon"
class="w-6 h-6 rounded-full hover:bg-red-600 hover:text-white"
:title="$t('ui.actions.delete')"
@click="handleQueryDelete"
>
<PhTrash size="14" weight="bold" />
</UiButton>
</li>
</menu>
</div>
</template>

View File

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

View File

@@ -0,0 +1,47 @@
<script lang="ts" setup>
import { PhX } from "@phosphor-icons/vue";
import type { World } from "@@/models/World";
defineProps<{
world: World,
modalState?: boolean
}>()
const calendarSkeletonName = ref<string>("")
function onChangedName(newName: string) {
calendarSkeletonName.value = newName
}
const emit = defineEmits(["on-close"])
function handleClose() {
emit("on-close")
setTimeout(() => calendarSkeletonName.value = "", 100)
}
</script>
<template>
<UiAlertDialog :open="modalState">
<UiAlertDialogContent class="grid grid-rows-[auto_1fr_auto] items-start min-h-[66vh] max-w-4xl gap-6" @close-auto-focus="(e) => e.preventDefault()">
<UiAlertDialogTitle>
<span class="text-2xl">
<strong class="font-bold">{{ world.name }}</strong>
<span class="opacity-30"> — </span>
<span v-if="calendarSkeletonName">
{{ calendarSkeletonName }}
</span>
<span v-else>
{{ $t('entity.calendar.createDialog.title') }}
</span>
</span>
</UiAlertDialogTitle>
<UiButton size="icon" variant="ghost" class="absolute top-4 right-4" title="Fermer la fenêtre" @click="handleClose">
<PhX size="20" />
</UiButton>
<CalendarFormCreate v-if="world.id" :world-id="world.id" @on-changed-name="onChangedName" @on-close="handleClose" />
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import type { RPGDate } from "@@/models/Date";
const { eventSkeleton, operationInProgress, currentRPGDate } = storeToRefs(useCalendar())
const { resetSkeleton } = useCalendar()
const popoverOpen = ref(false)
const props = defineProps<{
date?: RPGDate
btnClass?: string
}>()
/**
* Opens event creation's popover
*/
function openEventCreatePopover() {
// If another operation is in progress, whether it's another create popup or a modal, don't bother opening it
if (operationInProgress.value) {
popoverOpen.value = false
return
}
resetSkeleton()
popoverOpen.value = true
// Set skeleton initial startDate if it's known
if (props.date) {
eventSkeleton.value.startDate = { ...props.date } // We need to clone it otherwise the props ends up mutating (?)
}
}
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing() {
popoverOpen.value = false
resetSkeleton()
}
// If the date changes, the popover should close
// Only for months for now (because it's the only view)
watch(currentRPGDate, () => {
if (!popoverOpen.value) return
handleClosing()
})
</script>
<template>
<UiPopover v-model:open="popoverOpen">
<UiPopoverTrigger as-child>
<button :class="btnClass" @click="openEventCreatePopover()" />
</UiPopoverTrigger>
<UiPopoverContent
:align="'center'"
:side="'right'"
:collision-padding="60"
: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.prevent="handleClosing"
@pointer-down-outside.prevent="handleClosing"
>
<CalendarFormCreateEvent @event-created="handleClosing" />
</UiPopoverContent>
</UiPopover>
</template>

View File

@@ -0,0 +1,103 @@
<script lang="ts" setup>
import { PhCircleNotch } from "@phosphor-icons/vue";
import { useToast } from "~/components/ui/toast";
import { ToastLifetime } from "~/components/ui/toast/use-toast";
import type { Calendar } from "@@/models/CalendarConfig";
const { toast } = useToast()
const { t } = useI18n()
const props = defineProps<{
modalState: boolean,
calendar: Calendar | null
}>()
const isLoading = ref<boolean>(false)
const emit = defineEmits(["on-close"])
async function handleAction(): Promise<void> {
if (isLoading.value) return
if (!props.calendar) return
isLoading.value = true
const { error } = await tryCatch($fetch(`/api/calendars/${props.calendar.id}`, { method: "DELETE" }))
if (error) {
toast({
title: error.message,
variant: "destructive"
})
isLoading.value = false
return
}
toast({
title: t("entity.calendar.deletedToast.title", { calendar: props.calendar.name }),
variant: "success",
duration: ToastLifetime.SHORT
})
emit("on-close")
isLoading.value = false
}
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing() {
if (!isLoading.value) {
emit("on-close")
}
}
</script>
<template>
<UiAlertDialog :open="modalState">
<UiAlertDialogContent
:disable-outside-pointer-events="true"
:trap-focus="true"
class="min-w-96"
@escape-key-down="handleClosing"
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@pointer-down-outside="handleClosing"
@close-auto-focus="(e) => e.preventDefault()"
>
<UiAlertDialogTitle>
{{ $t('entity.calendar.deleteDialog.title') }}
</UiAlertDialogTitle>
<UiAlertDialogDescription>
{{ $t('entity.calendar.deleteDialog.subtitle') }}
</UiAlertDialogDescription>
<form @submit.prevent="handleAction">
<footer class="flex gap-2 justify-between">
<UiButton type="button" size="sm" variant="outline" @click="handleClosing">
{{ $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">
{{ $t('ui.action.cancel') }}
</UiButton>
</Transition>
<UiButton v-if="calendar" size="sm" variant="destructive" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
</Transition>
{{ $t('entity.deleteOne', { entity: calendar?.name }) }}
</UiButton>
</div>
</footer>
</form>
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -0,0 +1,45 @@
<script lang="ts" setup>
const { isDeleteEventModalOpen, eventSkeleton, lastActiveEvent } = storeToRefs(useCalendar())
const isLoading = ref<boolean>(false)
// Watch the popover state
watch(isDeleteEventModalOpen, (hasOpened, _o) => {
if (hasOpened && lastActiveEvent.value) {
eventSkeleton.value = structuredClone(toRaw(lastActiveEvent.value))
}
})
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing(e: Event): void {
if (isLoading.value) {
e.preventDefault()
}
}
</script>
<template>
<UiAlertDialog v-model:open="isDeleteEventModalOpen">
<UiAlertDialogContent
: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="handleClosing"
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@pointer-down-outside="handleClosing"
>
<UiAlertDialogTitle>{{ $t('entity.calendar.event.deleteDialog.title') }}</UiAlertDialogTitle>
<UiAlertDialogDescription>
{{ $t('entity.calendar.event.deleteDialog.subtitle') }}
</UiAlertDialogDescription>
<CalendarFormDeleteEvent />
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { PhPlus } from "@phosphor-icons/vue";
import { VisuallyHidden } from "radix-vue";
const isDialogOpen = ref<boolean>(false);
const { resetSkeleton } = useCalendar();
// Toggles the dialog
function toggleDialog() {
isDialogOpen.value = !isDialogOpen.value;
};
/**
* Prevents the modal from closing if's still loading
*/
function handleClosing() {
setTimeout(() => resetSkeleton(), 100)
}
const breakpoints = useBreakpoints(
breakpointsTailwind
)
</script>
<template>
<ClientOnly>
<Transition name="fade" appear>
<UiButton
class="max-md:fixed max-md:bottom-8 max-md:right-8 max-md:z-50 max-md:size-14 max-md:rounded-xl"
:size="breakpoints.md.value ? 'default' : 'icon'"
@click="toggleDialog"
>
<PhPlus :size="breakpoints.md.value ? 18 : 24" weight="bold" />
<strong v-if="breakpoints.md.value" class="font-semibold">
{{ $t("entity.calendar.event.newEvent") }}
</strong>
</UiButton>
</Transition>
</ClientOnly>
<UiDialog v-model:open="isDialogOpen">
<UiDialogContent
class="max-md:translate-0 max-md:inset-0 max-md:w-full max-md:block"
:trap-focus="true"
@escape-key-down="handleClosing"
@pointer-down-outside.prevent="handleClosing"
>
<UiDialogTitle class="max-md:mb-8">
{{ $t("entity.calendar.event.addSingle") }}
</UiDialogTitle>
<VisuallyHidden>
<UiDialogDescription>
{{ $t("entity.calendar.event.addSingleDescription") }}
</UiDialogDescription>
</VisuallyHidden>
<CalendarFormCreateEvent @event-created="toggleDialog" />
</UiDialogContent>
</UiDialog>
</template>

View File

@@ -0,0 +1,49 @@
<script lang="ts" setup>
import { PhX } from "@phosphor-icons/vue";
import type { Calendar } from "@@/models/CalendarConfig";
import type { World } from "@@/models/World";
const props = defineProps<{
calendar: Calendar | null,
world: World | null,
modalState?: boolean
}>()
const calendarSkeletonName = ref<string>(props.calendar?.name ?? "")
function onChangedName(newName: string) {
calendarSkeletonName.value = newName
}
const emit = defineEmits(["on-close"])
function handleClose() {
emit("on-close")
setTimeout(() => calendarSkeletonName.value = "", 100)
}
</script>
<template>
<UiAlertDialog :open="modalState">
<UiAlertDialogContent class="grid grid-rows-[auto_1fr_auto] items-start min-h-[66vh] max-w-4xl gap-6" @close-auto-focus="(e) => e.preventDefault()">
<UiAlertDialogTitle>
<span class="text-2xl">
<strong class="font-bold">{{ world?.name }}</strong>
<span class="opacity-30"> — </span>
<span v-if="calendarSkeletonName">
{{ calendarSkeletonName }}
</span>
<span v-else>
{{ $t('entity.calendar.updateDialog.title') }}
</span>
</span>
</UiAlertDialogTitle>
<UiButton size="icon" variant="ghost" class="absolute top-4 right-4" title="Fermer la fenêtre" @click="handleClose">
<PhX size="20" />
</UiButton>
<CalendarFormUpdate v-if="calendar" :world="world" :calendar="calendar" @on-changed-name="onChangedName" @on-close="handleClose" />
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -0,0 +1,47 @@
<script lang="ts" setup>
const { eventSkeleton, lastActiveEvent, isEditEventModalOpen } = storeToRefs(useCalendar())
const { resetSkeleton } = useCalendar();
// Watch the popover state
watch(isEditEventModalOpen, (hasOpened, _o) => {
if (hasOpened && lastActiveEvent.value) {
eventSkeleton.value = structuredClone(toRaw(lastActiveEvent.value))
}
})
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing() {
isEditEventModalOpen.value = false
setTimeout(() => resetSkeleton(), 100)
}
</script>
<template>
<UiDialog v-model:open="isEditEventModalOpen">
<UiDialogContent
:disable-outside-pointer-events="true"
:trap-focus="true"
class="pl-3 md:min-w-96 max-md:translate-0 max-md:inset-0 max-md:w-full max-md:block"
@escape-key-down="handleClosing"
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@pointer-down-outside="(e) => e.preventDefault()"
>
<header class="pl-8 grid gap-y-2 max-md:mb-8">
<UiDialogTitle>
{{ $t('entity.calendar.event.editDialog.title') }}
</UiDialogTitle>
<UiDialogDescription>
{{ $t('entity.calendar.event.editDialog.subtitle') }}
</UiDialogDescription>
</header>
<CalendarFormUpdateEvent @event-updated="handleClosing" />
</UiDialogContent>
</UiDialog>
</template>

View File

@@ -0,0 +1,225 @@
<script lang="ts" setup>
import { cn } from "@/lib/utils"
import type { RPGDate } from "@@/models/Date"
import type { CalendarEvent } from "@@/models/CalendarEvent"
import { useCalendar } from "@/stores/CalendarStore"
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import {
PhHourglassMedium,
PhAlarm,
PhHourglassHigh,
PhHourglassLow,
PhMapPinArea,
PhDotsThreeOutlineVertical,
PhEye
} from "@phosphor-icons/vue"
import type { CollisionPadding } from "@@/models/Popover"
const { defaultDate, getFormattedDateTitle, jumpToDate, getRelativeString, revealEditEventModal, revealDeleteEventModal } = useCalendar()
const { lastActiveEvent, isReadOnly } = storeToRefs(useCalendar())
const props = defineProps<{
event: CalendarEvent
spansMultipleDays: boolean
isStartEvent?: boolean
isEndEvent?: boolean
}>()
const breakpoints = useBreakpoints(
breakpointsTailwind
)
const collisionPadLg: CollisionPadding = {
top: 50,
bottom: 50,
left: 50,
right: 50,
}
const collisionPad: CollisionPadding = {
top: 80,
bottom: 80,
left: 12.5,
right: 12.5,
}
// Ref for the popover
const eventDetails = ref<HTMLElement>()
const emit = defineEmits(["query:close-popover"])
const dateDifference = computed<string>(() => getRelativeString(defaultDate, props.event.startDate))
const dateDuration = computed<string | null>(() => props.event.endDate ? getRelativeString(props.event.startDate, props.event.endDate, "compact") : null)
function handleJumpToDate(date: RPGDate) {
jumpToDate(date)
emit("query:close-popover")
}
/**
* Edit event
*/
const commandMenuOpened = ref(false)
function deployEditModal() {
lastActiveEvent.value = structuredClone(toRaw(props.event))
revealEditEventModal()
commandMenuOpened.value = false
emit("query:close-popover")
}
/**
* Confirm event deletion
*/
function deployDeleteModal() {
lastActiveEvent.value = structuredClone(toRaw(props.event))
revealDeleteEventModal()
commandMenuOpened.value = false
emit("query:close-popover")
}
</script>
<template>
<UiPopoverContent
class="max-w-full w-96 event-popover"
:align="'center'"
:align-offset="breakpoints.lg.value ? 50 : 25"
:side-offset="8"
:side="breakpoints.lg.value ? 'left' : 'top'"
:collision-padding="breakpoints.lg.value ? collisionPadLg : collisionPad"
:class="cn(
event.category ? `element-${event.category.color}` : '',
)"
@focus-outside.prevent
>
<div ref="eventDetails" class="grid gap-1">
<header class="pr-12">
<div class="mb-2 max-h-24 overflow-y-auto text-lg font-semibold">
{{ event.title }}
</div>
<div class="mb-1">
<template v-if="!event.endDate">
<p class="font-semibold">{{ getFormattedDateTitle(event.startDate, true) }}</p>
</template>
<template v-else>
<p class="font-semibold">
{{
$t('entity.calendar.date.fromTo',
{
startDate: getFormattedDateTitle(event.startDate, true),
endDate: getFormattedDateTitle(event.endDate, true)
}
)
}}
</p>
</template>
</div>
</header>
<div class="mb-1 space-y-1">
<template v-if="event.location">
<div class="text-sm italic dark:opacity-75 flex items-center gap-1">
<div>
<PhMapPinArea size="16" weight="fill" />
</div>
<p class="max-h-12 overflow-y-auto">{{ event.location }}</p>
</div>
</template>
<div class="text-sm italic dark:opacity-75 flex items-center gap-1">
<div>
<PhAlarm size="16" weight="fill" />
</div>
<p>{{ dateDifference }}</p>
</div>
<template v-if="dateDuration">
<div class="text-sm italic dark:opacity-75 flex items-center gap-1">
<div>
<PhHourglassMedium size="16" weight="fill" />
</div>
<p>{{ $t('entity.calendar.date.while', { duration: dateDuration } )}}</p>
</div>
</template>
</div>
<template v-if="event.category || event.secondaryCategories">
<ul class="flex gap-1">
<li v-if="event.category">
<UiBadge class="mix-blend-luminosity font-bold bg-gray-400 hover:bg-gray-300 dark:bg-gray-800 dark:hover:bg-gray-600 lowercase" variant="secondary">
{{ event.category?.name }}
</UiBadge>
</li>
<li v-for="cat in event.secondaryCategories" :key="cat.id">
<UiBadge class="mix-blend-luminosity bg-gray-300 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600 lowercase" variant="secondary">
{{ cat.name }}
</UiBadge>
</li>
</ul>
</template>
<template v-if="event.description">
<hr class="border-border mt-2" >
<div class="max-h-48 overflow-y-auto mt-2 text-sm text-slate-600 dark:text-slate-300">
{{ event.description }}
</div>
</template>
</div>
<menu v-if="!isReadOnly" class="absolute top-4 right-4" :class="cn({ 'top-6': event.hidden })">
<UiPopover v-model:open="commandMenuOpened">
<UiPopoverTrigger as-child>
<UiButton size="icon" variant="outline" class="mix-blend-luminosity bg-gray-300 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600">
<PhDotsThreeOutlineVertical size="20" weight="fill" />
</UiButton>
</UiPopoverTrigger>
<UiPopoverContent class="w-fit p-0" align="start" side="right" :collision-padding="20" >
<UiCommand>
<UiCommandList>
<UiCommandGroup>
<UiCommandItem value="edit-event" @select="deployEditModal"> {{ $t('ui.action.edit') }} </UiCommandItem>
<UiCommandItem value="delete-event" @select="deployDeleteModal"> {{ $t('ui.action.delete') }} </UiCommandItem>
</UiCommandGroup>
</UiCommandList>
</UiCommand>
</UiPopoverContent>
</UiPopover>
</menu>
<nav v-if="event.startDate && event.endDate" class="mt-2 flex gap-2">
<UiBadge class="hover:bg-indigo-400 dark:hover:bg-slate-300" as-child>
<button
class="flex gap-1"
title="Naviguer au début"
@click="handleJumpToDate(event.startDate!)"
>
<PhHourglassHigh size="16" weight="fill" /> {{ $t('entity.calendar.event.isStart') }}
</button>
</UiBadge>
<UiBadge class="hover:bg-indigo-400 dark:hover:bg-slate-300" as-child title="Naviguer à la fin">
<button
class="flex gap-1"
title="Naviguer à la fin"
@click="handleJumpToDate(event.endDate!)"
>
<PhHourglassLow size="16" weight="fill" /> {{ $t('entity.calendar.event.isEnd') }}
</button>
</UiBadge>
</nav>
<UiTooltipProvider v-if="event.hidden" :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiBadge class="absolute -top-2 right-2 flex gap-1">
<PhEye size="16" weight="fill" /> {{ $t('entity.calendar.event.isHidden') }}
</UiBadge>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('entity.calendar.event.hiddenTooltip') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</UiPopoverContent>
</template>

View File

@@ -0,0 +1,86 @@
<script lang="ts" setup>
import { cn } from "@/lib/utils"
import type { RPGDate } from "@@/models/Date"
import type { CalendarEvent } from "@@/models/CalendarEvent"
const props = defineProps<{
event: CalendarEvent
tileDate: RPGDate
}>()
const { areDatesIdentical, revealEditEventModal, revealDeleteEventModal } = useCalendar()
const { lastActiveEvent } = storeToRefs(useCalendar())
const spansMultipleDays = computed(() => Boolean(props.event.startDate && props.event.endDate))
const isStartEvent = computed(() => spansMultipleDays.value && areDatesIdentical(props.tileDate, props.event.startDate))
const isEndEvent = computed(() => spansMultipleDays.value && props.event.endDate && areDatesIdentical(props.tileDate, props.event.endDate))
const titleCharLimit = 50;
const eventTitle = computed<string>(() => props.event.title.length <= titleCharLimit ? props.event.title : `${props.event.title.slice(0, titleCharLimit)}`)
// Popover code
const isPopoverDetailsOpen = ref<boolean>(false)
function handleDoubleClick() {
isPopoverDetailsOpen.value = false
lastActiveEvent.value = structuredClone(toRaw(props.event))
revealEditEventModal()
}
function handleDelete() {
isPopoverDetailsOpen.value = false
lastActiveEvent.value = structuredClone(toRaw(props.event))
revealDeleteEventModal()
}
function handleClosePopover() {
isPopoverDetailsOpen.value = false
}
onMounted(() => {
// Listen for keydown events
window.addEventListener("keydown", (e: KeyboardEvent) => {
// If the popover isn't opened, this is not the event we're trying to delete, so return
if (!isPopoverDetailsOpen.value) return
// If the key isn't the delete one, return
if (e.key !== "Delete") return
handleDelete()
})
})
</script>
<template>
<UiPopover v-model:open="isPopoverDetailsOpen">
<UiPopoverTrigger as-child>
<button
class="event-button text-2xs md:text-xs px-[5px] py-[5px] md:px-2 md:py-1 block w-full text-left rounded-sm transition-colors outline-offset-1 cursor-pointer"
:class="
cn(
`element-${event.category?.color}`,
{
'is-hidden': event.hidden,
'rounded-r-none': isStartEvent,
'rounded-l-none': isEndEvent,
}
)
"
@dblclick="handleDoubleClick"
@keydown.delete="handleDelete"
>
<div class="line-clamp-2 [overflow-wrap:anywhere] hyphens-auto">
{{ eventTitle }}
</div>
</button>
</UiPopoverTrigger>
<CalendarEventDetails
:event
:spans-multiple-days
:is-start-event
:is-end-event
@query:close-popover="handleClosePopover"
/>
</UiPopover>
</template>

View File

@@ -0,0 +1,155 @@
<script lang="ts" setup>
import type { Calendar } from "@@/models/CalendarConfig";
import { PhAlarm, PhCalendarDots, PhCircleNotch, PhWrench } from "@phosphor-icons/vue";
const props = defineProps<{
worldId: number
}>()
const defaultSkeleton: Calendar = { name: "", today: { day: 1, month: 0, year: 0 }, months: [], events: [], categories: [], state: "draft", color: "white" }
const calendarSkeleton = ref<Calendar>({ ...defaultSkeleton })
onMounted(() => {
calendarSkeleton.value = { ...defaultSkeleton }
})
type FormTabs = "global" | "months" | "today"
const activeTab = ref<FormTabs>("global")
/**
* === Current date ===
*/
// If the months data change, just reset today's month
// This is a failsafe mainly because of 1) month positions and 2) month names
watch(calendarSkeleton.value.months, () => {
calendarSkeleton.value.today.month = 0
}, { deep: true })
/**
* === Form Validation ===
*/
/** Whether the skeleton has valid month data */
const validSkeletonMonths = computed(() => calendarSkeleton.value.months.length > 0)
/** Whether the skeleton has a valid name */
const validSkeletonGeneral = computed(() => calendarSkeleton.value.name)
/** Whether all the data checks above are a-ok */
const validSkeleton = computed(() => validSkeletonGeneral.value && validSkeletonMonths.value)
/** Send the data to the store for validation */
const isCreatingCalendar = ref<boolean>(false)
async function handleSubmit() {
isCreatingCalendar.value = true
const { error } = await tryCatch($fetch("/api/calendars/create", { method: "POST", body: { ...calendarSkeleton.value, worldId: props.worldId } }))
if (error) {
console.log(error.message)
isCreatingCalendar.value = false
return
}
emit("on-close")
isCreatingCalendar.value = false
}
/**
* Watch for name changes to display above
*/
const emit = defineEmits<{
"on-changed-name": [calendarName: string]
"on-close": []
}>()
/** Hook to emit a debounced event for the changed skeleton name */
const handleNameChange = useDebounceFn(() => {
emit("on-changed-name", calendarSkeleton.value.name)
}, 400)
function handleFormCancel() {
emit("on-close")
}
</script>
<template>
<template v-if="calendarSkeleton">
<form class="h-full grid grid-rows-[1fr_auto]" @submit.prevent="handleSubmit">
<UiTabs v-model:model-value="activeTab">
<UiTabsList class="grid w-full grid-cols-3 mb-4">
<UiTabsTrigger value="global" class="font-bold">
<div class="flex items-center gap-1">
<PhWrench size="18" weight="fill" />
{{ $t('entity.calendar.actionDialog.tabs.general.title') }}
</div>
</UiTabsTrigger>
<UiTabsTrigger value="months" class="font-bold">
<div class="flex items-center gap-1">
<PhCalendarDots size="18" weight="fill" />
{{ $t('entity.calendar.actionDialog.tabs.months.title') }}
</div>
</UiTabsTrigger>
<UiTabsTrigger value="today" class="font-bold">
<div class="flex items-center gap-1">
<PhAlarm size="18" weight="fill" />
{{ $t('entity.calendar.actionDialog.tabs.today.title') }}
</div>
</UiTabsTrigger>
</UiTabsList>
<UiTabsContent value="global" class="grid gap-4">
<input
id="new-calendar-name"
v-model="calendarSkeleton.name"
type="text"
name="new-calendar-name"
required
:placeholder="$t('common.title')"
class="w-full py-2 -mx-1 px-1 text-xl border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-blue-600"
@input="handleNameChange"
>
<div class="-mx-1 px-1 grid gap-3">
<UiLabel for="new-calendar-state">
{{ $t('ui.contentState.label') }}
</UiLabel>
<InputContentState id="new-calendar-state" v-model="calendarSkeleton.state" />
</div>
<div class="-mx-1 grid gap-3">
<UiLabel for="new-calendar-color">
{{ $t('ui.colors.label') }}
</UiLabel>
<InputColor id="new-calendar-color" v-model="calendarSkeleton.color" />
</div>
</UiTabsContent>
<UiTabsContent value="months">
<CalendarInputMonthList v-model:model-value="calendarSkeleton.months" />
</UiTabsContent>
<UiTabsContent value="today">
<CalendarInputTodaySelect v-model:model-value="calendarSkeleton.today" :available-months="calendarSkeleton.months"/>
</UiTabsContent>
</UiTabs>
<footer class="flex justify-end gap-2 mt-6">
<UiButton type="button" variant="destructive" @click="handleFormCancel">
{{ $t('ui.action.cancel') }}
</UiButton>
<UiButton type="submit" :disabled="!validSkeleton || isCreatingCalendar">
<Transition name="fade">
<PhCircleNotch v-if="isCreatingCalendar" size="20" class="opacity-50 animate-spin"/>
</Transition>
{{ $t('ui.action.save') }}
</UiButton>
</footer>
</form>
</template>
</template>

View File

@@ -0,0 +1,181 @@
<script lang="ts" setup>
import type { RPGDate } from "@@/models/Date";
import { PhAlarm, PhCircleNotch, PhEye, PhEyeClosed, PhMapPinArea, PhTag } from "@phosphor-icons/vue"
const { t } = useI18n()
const emit = defineEmits(["event-created"])
const props = defineProps<{
date?: RPGDate
btnClass?: string
}>()
const { eventSkeleton } = storeToRefs(useCalendar())
const { submitSkeleton, cancelLatestRequest } = useCalendar()
const newEventTitle = shallowRef<HTMLInputElement>()
useFocus(newEventTitle, { initialValue: true })
const isLoading = ref(false)
const formErrors = reactive<{ message: string | null }>({
message: null
})
async function handleSubmit() {
// Prevent form submission if already loading
if (isLoading.value) return
isLoading.value = true
const { error } = await tryCatch(submitSkeleton())
if (error) {
formErrors.message = error.message
isLoading.value = false
return
}
emit("event-created")
isLoading.value = false
}
/**
* Click on the cancel button
*
* Must cancel the abortController in the store, and stop the loading
*/
function handleCancel() {
cancelLatestRequest()
isLoading.value = false
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div class="grid grid-cols-2 gap-y-3">
<div class="col-span-2 pl-8">
<input
id="new-event-title"
ref="newEventTitle"
v-model="eventSkeleton.title"
type="text"
name="new-event-title"
required
:placeholder="$t('entity.calendar.event.title')"
:maxlength="120"
pattern="([A-Za-zÀ-ÖØ-öø-ÿ0-9\s\&\-\~]+){3,120}"
class="w-full -my-1 py-1 -mx-1 px-1 text-lg border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-blue-600 invalid:border-red-500"
>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.title') }}
</div>
</div>
<div class="col-span-2 my-2 pl-8">
<textarea
id="new-event-description"
v-model="eventSkeleton.description"
name="new-event-description"
:placeholder="$t('entity.addDescription')"
:maxlength="1200"
class="w-full -my-1 py-1 -mx-1 px-1 min-h-24 max-h-36 text-sm border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-blue-600 invalid:border-red-500"
/>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.description') }}
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhAlarm size="18" weight="fill" />
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.startDate"
:placeholder="$t('entity.calendar.date.start')"
:initial-date="props.date"
:required="true"
/>
<span></span>
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.endDate"
:placeholder="$t('entity.calendar.date.end')"
:initial-date="props.date"
/>
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhTag size="18" weight="fill" />
<CalendarInputEventCategory
v-model="eventSkeleton.category"
:placeholder="$t('entity.category.addPrimary')"
/>
</div>
</div>
<div class="col-span-2 mb-2">
<div class="flex items-center gap-4">
<PhMapPinArea size="18" weight="fill" />
<div class="grow">
<input
id="new-event-location"
v-model="eventSkeleton.location"
type="text"
name="new-event-location"
:placeholder="$t('entity.calendar.event.addLocation')"
:maxlength="160"
pattern="([A-Za-zÀ-ÖØ-öø-ÿ0-9\s\&\-\~]+){3,160}"
class="w-full -my-1 py-2 px-2 text-sm border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-blue-600"
>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.location') }}
</div>
</div>
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhEye v-if="!eventSkeleton.hidden" size="18" weight="fill" />
<PhEyeClosed v-else size="18" />
<div class="flex items-center gap-x-2">
<UiCheckbox id="new-event-visibility" v-model:checked="eventSkeleton.hidden" />
<UiLabel for="new-event-visibility">
{{ $t('entity.calendar.event.hiddenLabel') }}
</UiLabel>
</div>
</div>
</div>
<div class="text-red-500 pl-8">
<span class="text-sm">
{{ formErrors.message }}
</span>
</div>
<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" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="opacity-50 animate-spin"/>
</Transition>
{{ $t('ui.action.save') }}
</UiButton>
</div>
</div>
</form>
</template>

View File

@@ -0,0 +1,84 @@
<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
const { error } = await tryCatch(deleteCategoryFromSkeleton())
if (error) {
formErrors.message = error.message
isLoading.value = false
return
}
toast({
title: t("entity.category.deletedToast.title", { category: categoryName }),
variant: "success",
duration: ToastLifetime.MEDIUM
})
isLoading.value = false
}
/**
* Click on the cancel button
*
* Must cancel the abortController in the store, and stop the loading
*/
function handleCancel(): void {
cancelLatestRequest()
isLoading.value = false
}
</script>
<template>
<form @submit.prevent="handleAction">
<div class="grid grid-cols-2 gap-y-4">
<div class="text-red-500 ml-8">
<span class="text-sm">
{{ formErrors.message }}
</span>
</div>
</div>
<footer class="flex gap-2 justify-between">
<UiButton type="button" size="sm" variant="outline" @click="() => isDeletingCategory = false">
{{ $t('ui.action.back') }}
</UiButton>
<div class="flex gap-2 justify-end">
<Transition name="fade-delay">
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
{{ $t('ui.action.cancel') }}
</UiButton>
</Transition>
<UiButton size="sm" variant="destructive" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
</Transition>
{{ $t('ui.action.delete') }}
</UiButton>
</div>
</footer>
</form>
</template>

View File

@@ -0,0 +1,87 @@
<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 { resetSkeleton, deleteEventFromSkeleton, cancelLatestRequest } = useCalendar()
const { isDeleteEventModalOpen, eventSkeleton } = storeToRefs(useCalendar())
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) return
isLoading.value = true
const eventTitle = eventSkeleton.value.title
const { error } = await tryCatch(deleteEventFromSkeleton())
if (error) {
formErrors.message = error.message
isLoading.value = false
return
}
isDeleteEventModalOpen.value = false
resetSkeleton()
toast({
title: t("entity.calendar.event.deletedToast.title", { event: eventTitle }),
variant: "success",
duration: ToastLifetime.MEDIUM
})
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="() => isDeleteEventModalOpen = false">
{{ $t('ui.action.back') }}
</UiButton>
<div class="flex gap-2 justify-end">
<Transition name="fade-delay">
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
{{ $t('ui.action.cancel') }}
</UiButton>
</Transition>
<UiButton size="sm" variant="destructive" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
</Transition>
{{ $t('ui.action.delete') }}
</UiButton>
</div>
</footer>
</form>
</template>

View File

@@ -0,0 +1,131 @@
<script lang="ts" setup>
import type { Calendar } from "@@/models/CalendarConfig";
import { PhCircleNotch, PhWrench } from "@phosphor-icons/vue";
import type { World } from "@@/models/World";
const props = defineProps<{
calendar: Calendar | null,
world: World | null,
}>()
const calendarSkeleton = ref<Calendar>({ ...props.calendar } as Calendar)
onMounted(() => {
calendarSkeleton.value = { ...props.calendar } as Calendar
})
type FormTabs = "global" | "months" | "today"
const activeTab = ref<FormTabs>("global")
/**
* === Form Validation ===
*/
/** Whether the skeleton has valid month data */
const validSkeletonMonths = computed(() => calendarSkeleton.value.months.length > 0)
/** Whether the skeleton has a valid name */
const validSkeletonGeneral = computed(() => calendarSkeleton.value.name)
/** Whether all the data checks above are a-ok */
const validSkeleton = computed(() => validSkeletonGeneral.value && validSkeletonMonths.value)
/** Send the data to the store for validation */
const isUpdatingCalendar = ref<boolean>(false)
async function handleSubmit() {
isUpdatingCalendar.value = true
const fetchBody = { ...calendarSkeleton.value, worldId: props.world?.id }
const { error } = await tryCatch(
$fetch(`/api/calendars/${calendarSkeleton.value.id}`, { method: "PATCH", body: fetchBody })
)
if (error) {
console.log(error.message)
isUpdatingCalendar.value = false
return
}
emit("on-close")
isUpdatingCalendar.value = false
}
/**
* Watch for name changes to display above
*/
const emit = defineEmits<{
"on-changed-name": [calendarName: string]
"on-close": []
}>()
/** Hook to emit a debounced event for the changed skeleton name */
const handleNameChange = useDebounceFn(() => {
emit("on-changed-name", calendarSkeleton.value.name)
}, 400)
function handleFormCancel() {
emit("on-close")
}
</script>
<template>
<template v-if="calendarSkeleton">
<form class="h-full grid grid-rows-[1fr_auto]" @submit.prevent="handleSubmit">
<UiTabs v-model:model-value="activeTab">
<UiTabsList class="grid w-full grid-cols-1 mb-4">
<UiTabsTrigger value="global" class="font-bold">
<div class="flex items-center gap-1">
<PhWrench size="18" weight="fill" />
{{ $t('entity.calendar.actionDialog.tabs.general.title') }}
</div>
</UiTabsTrigger>
</UiTabsList>
<UiTabsContent value="global" class="grid gap-4">
<input
id="new-calendar-name"
v-model="calendarSkeleton.name"
type="text"
name="new-calendar-name"
required
:placeholder="$t('common.title')"
class="w-full py-2 -mx-1 px-1 text-xl border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-blue-600"
@input="handleNameChange"
>
<div class="-mx-1 px-1 grid gap-3">
<UiLabel for="new-calendar-state">
{{ $t('ui.contentState.label') }}
</UiLabel>
<InputContentState id="new-calendar-state" v-model="calendarSkeleton.state" />
</div>
<div class="-mx-1 grid gap-3">
<UiLabel for="new-calendar-color">
{{ $t('ui.colors.label') }}
</UiLabel>
<InputColor id="new-calendar-color" v-model="calendarSkeleton.color" />
</div>
</UiTabsContent>
</UiTabs>
<footer class="flex justify-end gap-2 mt-6">
<UiButton type="button" variant="destructive" @click="handleFormCancel">
{{ $t('ui.action.cancel') }}
</UiButton>
<UiButton type="submit" :disabled="!validSkeleton || isUpdatingCalendar">
<Transition name="fade">
<PhCircleNotch v-if="isUpdatingCalendar" size="20" class="opacity-50 animate-spin"/>
</Transition>
{{ $t('ui.action.save') }}
</UiButton>
</footer>
</form>
</template>
</template>

View File

@@ -0,0 +1,202 @@
<script lang="ts" setup>
import { PhAlarm, PhCircleNotch, PhEye, PhEyeClosed, PhMapPinArea, PhPencilSimpleLine, PhTag } from "@phosphor-icons/vue"
import { useToast } from "~/components/ui/toast";
import { ToastLifetime } from "~/components/ui/toast/use-toast";
import type { APIError } from "@@/models/Errors";
const emit = defineEmits(["event-updated"])
const { resetSkeleton, updateEventFromSkeleton, cancelLatestRequest } = useCalendar()
const { eventSkeleton, lastActiveEvent } = storeToRefs(useCalendar())
const { toast } = useToast()
const { t } = useI18n()
const isLoading = ref(false)
const formErrors = reactive<{ message: string | null }>({
message: null
})
async function handleAction() {
if (isLoading.value) return
isLoading.value = true
const { error } = await tryCatch(updateEventFromSkeleton())
if (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const apiError = (error as any).data as APIError
apiError.data.errors.forEach((error) => {
toast({
title: t("entity.calendar.event.editErrors.toastTitle"),
variant: "destructive",
description: t(`entity.calendar.event.editErrors.${error.path[1]}_${error.code}`),
duration: ToastLifetime.MEDIUM,
})
})
isLoading.value = false
return
}
emit("event-updated")
toast({
title: t("entity.calendar.event.updatedToast.title", { event: eventSkeleton.value.title }),
variant: "success",
duration: ToastLifetime.SHORT
})
isLoading.value = false
resetSkeleton()
}
/**
* Click on the cancel button
*
* Must cancel the abortController in the store, and stop the loading
*/
function handleCancel() {
cancelLatestRequest()
isLoading.value = false
}
</script>
<template>
<form @submit.prevent="handleAction">
<div class="grid grid-cols-2 gap-y-3">
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhPencilSimpleLine size="20" weight="fill" />
<div class="grow">
<input
id="new-event-title"
v-model="eventSkeleton.title"
type="text"
name="new-event-title"
required
:placeholder="$t('entity.calendar.event.title')"
:minlength="3"
:maxlength="120"
class="w-full -my-1 py-1 -mx-1 px-1 text-lg border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-blue-600 invalid:border-red-500"
>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.title') }}
</div>
</div>
</div>
</div>
<div class="col-span-2 my-2 ml-8">
<textarea
id="new-event-description"
v-model="eventSkeleton.description"
name="new-event-description"
:placeholder="$t('entity.addDescription')"
:maxlength="1200"
class="w-full -my-1 py-1 -mx-1 px-1 min-h-24 max-h-36 text-sm border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-blue-600 invalid:border-red-500"
/>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.description') }}
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhAlarm size="18" weight="fill" />
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.startDate"
:placeholder="$t('entity.calendar.date.start')"
:initial-date="lastActiveEvent?.startDate"
:required="true"
/>
<span></span>
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.endDate"
:placeholder="$t('entity.calendar.date.end')"
:initial-date="lastActiveEvent?.endDate"
/>
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhTag size="18" weight="fill" />
<div class="w-1/2">
<CalendarInputEventCategory
v-model="eventSkeleton.category"
:placeholder="$t('entity.category.addPrimary')"
/>
</div>
</div>
</div>
<div class="col-span-2 mb-2">
<div class="flex items-center gap-4">
<PhMapPinArea size="18" weight="fill" />
<div class="grow">
<input
id="new-event-location"
v-model="eventSkeleton.location"
type="text"
name="new-event-location"
:placeholder="$t('entity.calendar.event.addLocation')"
:minlength="3"
:maxlength="160"
class="w-full -my-1 py-2 px-2 text-sm border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-blue-600 invalid:border-red-500"
>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.location') }}
</div>
</div>
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhEye v-if="!eventSkeleton.hidden" size="18" weight="fill" />
<PhEyeClosed v-else size="18" />
<div class="flex items-center gap-x-2">
<UiCheckbox id="new-event-visibility" v-model:checked="eventSkeleton.hidden" />
<UiLabel for="new-event-visibility">
{{ $t('entity.calendar.event.hiddenLabel') }}
</UiLabel>
</div>
</div>
</div>
<div class="text-red-500 ml-8">
<span class="text-sm">
{{ formErrors.message }}
</span>
</div>
</div>
<footer 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" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
</Transition>
{{ $t('ui.action.save') }}
</UiButton>
</footer>
</form>
</template>

View File

@@ -0,0 +1,87 @@
<script lang="ts" setup>
import type { Category } from "@@/models/Category";
import { PhCaretDown, PhCheck } from "@phosphor-icons/vue";
const isPopoverOpen = ref<boolean>(false)
const props = defineProps<{
placeholder?: string
}>()
const model = defineModel<Category[]>({ default: [] })
const modelBuffer = ref<Category[]>([])
watch(modelBuffer.value, () => {
model.value = [ ...modelBuffer.value ]
})
const { categories: availableCategories } = useCalendar()
const searchTerm = ref<string>("")
const filteredCategories = computed(() =>
searchTerm.value === ""
? availableCategories
: availableCategories.filter((category) => {
return category.name.toLowerCase().includes(searchTerm.value.toLowerCase())
})
)
</script>
<template>
<UiPopover v-model:open="isPopoverOpen">
<UiPopoverTrigger as-child>
<UiButton
variant="outline"
role="combobox"
:aria-expanded="isPopoverOpen"
aria-controls="event-categories"
class="relative w-full max-w-full h-fit justify-between"
>
<template v-if="!model.length">
{{ props.placeholder }}
</template>
<template v-else>
<ul class="flex flex-wrap gap-1">
<li v-for="category in model" :key="`selected-cat-${category.id}`">
<UiBadge class="lowercase" variant="secondary">
{{ category.name }}
</UiBadge>
</li>
</ul>
</template>
<PhCaretDown class="ml-2 size-4 shrink-0 opacity-50" />
</UiButton>
</UiPopoverTrigger>
<UiPopoverContent
id="event-categories"
align="start"
side="bottom"
:collision-padding="50"
class="w-fit h-[33vh] p-0"
>
<UiCommand v-model="modelBuffer" v-model:search-term="searchTerm" :multiple="true">
<UiCommandInput :placeholder="$t('entity.category.search')" />
<UiCommandEmpty>{{ $t('entity.category.notFoundAny') }}</UiCommandEmpty>
<UiCommandList>
<UiCommandGroup>
<UiCommandItem
v-for="category in filteredCategories"
:key="category.id"
:value="category"
class="cursor-pointer flex justify-between items-center"
>
<span>
{{ category.name }}
</span>
<PhCheck v-if="model.find(cat => cat.id === category.id)" />
</UiCommandItem>
</UiCommandGroup>
</UiCommandList>
</UiCommand>
</UiPopoverContent>
</UiPopover>
</template>

View File

@@ -0,0 +1,87 @@
<script lang="ts" setup>
import { PhCaretDown } from "@phosphor-icons/vue";
import { cn } from "@/lib/utils";
import type { Category } from "@@/models/Category";
const isPopoverOpen = ref<boolean>(false)
const props = defineProps<{
placeholder?: string
}>()
const model = defineModel<Category>()
const { categories: availableCategories } = useCalendar()
const searchTerm = ref<string>("")
function handleCatSelect() {
isPopoverOpen.value = false
}
const filteredCategories = computed(() =>
searchTerm.value === ""
? availableCategories
: availableCategories.filter((category) => {
return category.name.toLowerCase().includes(searchTerm.value.toLowerCase())
})
)
</script>
<template>
<UiPopover v-model:open="isPopoverOpen">
<UiPopoverTrigger as-child>
<UiButton
variant="outline"
role="combobox"
:aria-expanded="isPopoverOpen"
aria-controls="event-category"
class="w-full max-w-full justify-between"
>
<template v-if="!model">
{{ props.placeholder }}
</template>
<template v-else>
<span
class="bgc"
:class="cn(`element-${model.color}`)"
>
{{ capitalize(model.name) }}
</span>
</template>
<PhCaretDown class="ml-2 size-4 shrink-0 opacity-50" />
</UiButton>
</UiPopoverTrigger>
<UiPopoverContent
id="event-category"
align="start"
side="bottom"
:collision-padding="50"
class="w-fit h-[33vh] p-0"
>
<UiCommand v-model="model" v-model:search-term="searchTerm">
<UiCommandInput :placeholder="$t('entity.category.search')" />
<UiCommandEmpty>{{ $t('entity.category.notFoundAny') }}</UiCommandEmpty>
<UiCommandList>
<UiCommandGroup>
<UiCommandItem
v-for="category in filteredCategories"
:key="category.id"
:value="category"
class="cursor-pointer"
@select="handleCatSelect"
>
<span
class="bgc"
:class="cn(`element-${category.color}`)"
>
{{ capitalize(category.name) }}
</span>
</UiCommandItem>
</UiCommandGroup>
</UiCommandList>
</UiCommand>
</UiPopoverContent>
</UiPopover>
</template>

View File

@@ -0,0 +1,172 @@
<script lang="ts" setup>
import { cn } from "@/lib/utils";
import { useSortable } from "@vueuse/integrations/useSortable";
import type { CalendarMonth } from "@@/models/CalendarMonth";
import { PhList, PhPlus, PhTrash } from "@phosphor-icons/vue";
const model = defineModel<CalendarMonth[]>({ required: true })
/**
* Input value for new month's name
*/
const monthName: Ref<string | undefined> = ref<string>()
const monthNameRef = ref<HTMLInputElement>()
const { focused: monthNameFocused } = useFocus(monthNameRef)
const validMonthNameDatatypes = ["string"]
const monthNameIsTaken = computed(() => model.value.find(m => m.name === monthName.value))
const validMonthName = computed(() => validMonthNameDatatypes.includes(typeof monthName.value) && !monthNameIsTaken.value)
/**
* Input value for new month's number of days
*/
const monthDays: Ref<number | undefined> = ref<number>()
const monthDaysRef = ref<HTMLInputElement>()
const validMonthDaysDatatypes = ["number"]
const validMonthDays = computed(() => validMonthDaysDatatypes.includes(typeof monthDays.value) && monthDays.value && monthDays.value >= 12)
const validNewMonth = computed(() => validMonthDays.value && validMonthName.value)
/**
* Add current month input data to the model list
*/
function addMonthToModel(): void {
if (!monthDays.value || !monthName.value) return
// Create month object to add
const monthToInsert: CalendarMonth = {
name: monthName.value,
days: monthDays.value,
position: model.value.length
}
model.value.push(monthToInsert)
// Reset form state
monthName.value = ""
monthNameFocused.value = true
}
/**
* Remove a specific month from the model
*
* @param index Index position of the month in the model
*/
function removeMonthFromModel(index: number) {
if (isNaN(index)) return
model.value.splice(index, 1)
}
// Sortable setup
const monthSortableList = ref<HTMLElement | null>(null)
useSortable(monthSortableList, model.value, { animation: 150, handle: ".handle" })
</script>
<template>
<div class="grid md:grid-cols-12 gap-4 items-center">
<div class="md:col-start-2 md:col-span-5">
<UiInput
id="new-month-name"
ref="monthNameRef"
v-model="monthName"
type="text"
name="newMonthName"
:placeholder="$t('entity.calendar.months.inputName')"
:class="cn({ 'border-red-600': monthNameIsTaken })"
/>
</div>
<div class="md:col-span-5">
<UiInput
id="new-month-days"
ref="monthDaysRef"
v-model="monthDays"
type="number"
name="newMonthName"
:placeholder="$t('entity.calendar.months.daysNb')"
min="0"
step="1"
class="invalid:border-red-600"
/>
</div>
<div class="md:col-span-1">
<UiButton size="icon" class="rounded-full size-8" :disabled="!validNewMonth" @click.prevent="addMonthToModel">
<PhPlus size="17"/>
</UiButton>
</div>
<div class="md:col-span-full">
<div
class="border-[1px] border-border p-4 rounded-sm max-h-80 overflow-y-auto"
:class="model.length ? 'md:grid md:grid-cols-12 md:gap-4 md:items-center' : ''"
>
<div v-if="model.length" class="hidden md:block col-span-1">
<ul class="grid gap-y-4 justify-center">
<li v-for="(m, i) in model" :key="`num-${m.name}`">
<UiButton size="icon" variant="secondary" class="size-8 rounded-full">
<span class="font-bold text-sm">{{ i + 1 }}</span>
</UiButton>
</li>
</ul>
</div>
<div class="md:col-span-11">
<ul ref="monthSortableList" class="grid gap-y-2" :class="model.length ? 'visible' : 'absolute invisible'">
<template v-if="model.length">
<li v-for="(m, i) in model" :key="m.name" class="grid md:grid-cols-12 gap-4 md:items-center text-slate-900 bg-slate-200 rounded-md">
<div class="md:col-span-1 text-right duration-200 ease-out transition transform origin-top-right">
<UiButton type="button" variant="ghost" size="icon" class="handle rounded-full size-8">
<PhList size="17" />
</UiButton>
</div>
<div class="md:col-span-3">
<div class="font-bold md:pl-2">
{{ m.name }}
</div>
</div>
<div class="md:col-span-2">
<UiInput
:id="`month-days-n${i}`"
v-model="m.days"
class="bg-transparent border-none"
type="number"
:name="`monthDays-n${i}`"
:placeholder="$t('entity.calendar.months.daysNb')"
min="0"
/>
</div>
<div class="md:col-span-1">
{{ $t('entity.calendar.months.daysMaybePlural') }}
</div>
<div class="md:col-start-12">
<UiTooltipProvider>
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton type="button" variant="ghost" size="icon" class="rounded-full size-8" @click="removeMonthFromModel(i)">
<PhTrash size="17" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('entity.calendar.months.deleteOne', { month: m.name }) }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
</li>
</template>
</ul>
</div>
<template v-if="!model.length">
<p class="col-span-12 text-lg text-center italic opacity-50">
{{ $t('entity.calendar.months.none') }}
</p>
</template>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,160 @@
<script lang="ts" setup>
import type { RPGDate } from "@@/models/Date";
import {
PhXCircle
} from "@phosphor-icons/vue"
const model = defineModel<RPGDate | null>()
const id = useId()
const props = defineProps<{
initialDate?: RPGDate
placeholder?: string
required?: boolean
}>()
const { getFormattedDateTitle, getMonthName, defaultDate } = useCalendar()
const { sortedMonths } = storeToRefs(useCalendar())
const popoverOpened = ref<boolean>(false)
const inputPlaceholder = computed<string>(() => {
if (model.value) {
return getFormattedDateTitle(model.value, true)
} else if (props.placeholder) {
return props.placeholder
}
return ""
})
const initialDateValue = props.initialDate ? { ...props.initialDate } : null
const monthTitle = computed<string>(() => {
if (model.value) {
return getMonthName(model.value.month)
}
return ""
})
const hasValue = computed<boolean>(() => !!model.value)
const monthData = computed(() => {
if (model.value) {
return sortedMonths.value[model.value.month]
}
return null
})
function setModelDay(day: number) {
if (model.value) {
model.value.day = day
}
}
function handleMonthChange(e: string) {
const monthId = Number(e)
if (model.value) {
model.value.month = monthId
}
}
/**
* Handle click if the date is null
*/
function handleNullClick() {
// Early return
if (model.value) return
if (!model.value) {
if (props.initialDate) {
model.value = initialDateValue
} else {
model.value = { ...defaultDate }
}
}
}
function handleValueReset() {
// Early return
if (!model.value) return
model.value = null
}
</script>
<template>
<UiPopover v-model:open="popoverOpened">
<UiPopoverTrigger>
<div class="flex">
<UiButton
size='sm'
variant="outline"
:class="{
'text-slate-500': !hasValue,
'rounded-tr-none rounded-br-none': model
}"
@click.prevent="handleNullClick"
>
{{ inputPlaceholder }}
</UiButton>
<UiButton
v-if="model && !required"
size='sm'
variant="outline"
class="rounded-tl-none rounded-bl-none px-2"
@click.prevent="handleValueReset"
>
<PhXCircle size="14" />
</UiButton>
</div>
</UiPopoverTrigger>
<UiPopoverContent
v-if="model"
align="start"
side="bottom"
class="border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
>
<div class="grid grid-cols-2 items-center gap-x-2 gap-y-3">
<UiSelect @update:model-value="handleMonthChange">
<UiSelectTrigger>
<UiSelectValue :placeholder="monthTitle" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectGroup class="max-h-[50vh]">
<UiSelectItem v-for="(month, i) in sortedMonths" :key="`popover-select-item-${id}-${i}`" :value="i.toString()">
{{ month.name }}
</UiSelectItem>
</UiSelectGroup>
</UiSelectContent>
</UiSelect>
<UiInput v-model="model.year" type="number" />
<hr class="col-span-2" >
<div class="col-span-2">
<div v-if="monthData" class="grid grid-cols-7 gap-1">
<button
v-for="day in monthData.days"
:key="`popover-day-grid-${id}-${day}`"
class="aspect-square rounded-full text-[.8em] transition-colors"
:class="{
'hover:bg-indigo-200 dark:hover:bg-indigo-700': day !== model.day,
'bg-indigo-500 hover:bg-indigo-700 text-white': day === model.day
}"
@click="setModelDay(day)"
>
{{ day }}
</button>
</div>
</div>
</div>
</UiPopoverContent>
</UiPopover>
</template>

View File

@@ -0,0 +1,71 @@
<script lang="ts" setup>
import type { CalendarMonth } from "@@/models/CalendarMonth";
import type { RPGDate } from "@@/models/Date";
import { PhCalendarBlank } from "@phosphor-icons/vue";
const model = defineModel<RPGDate>({ required: true })
const props = defineProps<{
availableMonths: CalendarMonth[]
}>()
/**
*
* @param e The name of today's month
*/
function setTodayMonth(e: string) {
model.value.month = e
}
// When the model changes, get the month index from the month name
watch(model.value, (n, _o) => {
// If the month value is already an index, return early
if (!isNaN(+model.value.month)) return
const monthId = props.availableMonths.findIndex((m) => m.name === n.month)
if (monthId !== -1) {
model.value.month = monthId + 1
}
})
</script>
<template>
<div class="flex gap-2 items-stretch">
<UiInput id="new-month-current-day" v-model="model.day" type="number" name="newMonthCurrentDay" :placeholder="$t('entity.calendar.months.daysNameSingular')" min="1" step="1" class="invalid:border-red-600" />
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child :disabled="props.availableMonths.length < 1">
<UiButton size="sm" variant="secondary">
<PhCalendarBlank size="18" weight="fill" />
<template v-if="props.availableMonths.length < 1">
{{ $t('entity.calendar.months.noneAvailable') }}
</template>
<template v-else-if="model.month && typeof model.month === 'number'">
{{ props.availableMonths[model.month - 1].name }}
</template>
<template v-else>
{{ $t('entity.calendar.months.chooseOne') }}
</template>
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent :side="'bottom'" :collision-padding="30">
<UiDropdownMenuLabel>
{{ $t('entity.calendar.months.available') }}
</UiDropdownMenuLabel>
<UiDropdownMenuSeparator />
<UiDropdownMenuItem
v-for="m in props.availableMonths"
:key="m.name"
@click="setTodayMonth(m.name)"
>
{{ m.name }}
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
<UiInput id="new-month-current-day" v-model="model.year" type="number" name="newMonthCurrentYear" :placeholder="$t('entity.calendar.years.nameSingular')" step="1" class="invalid:border-red-600" />
</div>
</template>

View File

@@ -0,0 +1,46 @@
<script lang="ts" setup>
import { useCalendar } from "@/stores/CalendarStore"
import { breakpointsTailwind } from "@vueuse/core"
const breakpoints = useBreakpoints(
breakpointsTailwind
)
const { isReadOnly, defaultDate } = storeToRefs(useCalendar())
</script>
<template>
<header class="md:mt-2 grid gap-3 md:gap-4 border-border border-b-[1px] transition-colors">
<div class="px-5 md:px-8 flex items-center justify-between gap-2">
<menu class="flex items-center md:gap-2">
<li>
<LazyCalendarDialogQuickCreateEvent v-if="!isReadOnly" />
</li>
<li class="max-md:hidden">
<LazyCalendarMenuToday v-if="defaultDate" />
</li>
<li class="ml-2 md:ml-4">
<CalendarCurrentDate />
</li>
</menu>
<menu class="flex items-center gap-2">
<li>
<CalendarSearchCTA />
</li>
<ClientOnly>
<li v-if="breakpoints.md.value">
<CalendarCategoriesCTA />
</li>
</ClientOnly>
<li>
<CalendarOptionsCTA />
</li>
</menu>
</div>
<div class="ml-4 md:ml-8">
<CalendarMenuSubnav />
</div>
</header>
</template>

View File

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

View File

@@ -0,0 +1,41 @@
<script lang="ts" setup>
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { PhCalendar } from "@phosphor-icons/vue"
const { defaultDate, jumpToDefaultDate, getFormattedDateTitle, currentDate } = useCalendar()
const defaultDateFormatted: string = getFormattedDateTitle(defaultDate, true)
const buttonDisabledState: ComputedRef<boolean> = computed<boolean>(() => {
return currentDate.currentMonth === defaultDate.month && currentDate.currentYear === defaultDate.year
})
const breakpoints = useBreakpoints(
breakpointsTailwind
)
</script>
<template>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<ClientOnly>
<UiButton
:size="breakpoints.md.value ? 'default' : 'icon'"
variant="secondary"
:disabled="buttonDisabledState" @click="jumpToDefaultDate"
>
<PhCalendar v-if="!breakpoints.md.value" size="20" weight="fill" />
<span v-if="breakpoints.md.value">
{{ $t('entity.calendar.date.today') }}
</span>
</UiButton>
</ClientOnly>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ defaultDateFormatted }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</template>

View File

@@ -0,0 +1,461 @@
<script lang="ts" setup>
import {
isCharacter,
type Character,
} from "@@/models/Characters"
import type { RPGDateOrder } from "@@/models/Date"
import {
isCalendarEvent,
type CalendarEvent,
} from "@@/models/CalendarEvent"
import { capitalize } from "~/utils/Strings"
import { useMagicKeys, useScroll, useStorage, whenever } from "@vueuse/core"
import { computed, ref, watch } from "vue"
import { searchUnifier, type SearchMode } from "../SearchMode"
import { PhCaretDown, PhClockClockwise, PhClockCounterClockwise, PhMagnifyingGlass } from "@phosphor-icons/vue"
import {
ComboboxAnchor,
ComboboxInput,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
VisuallyHidden
} from "radix-vue"
import SearchList from "./lists/SearchList.vue"
import type { Category } from "@@/models/Category"
import { cn } from "@/lib/utils"
const { isAdvancedSearchOpen, allEvents, categories } = storeToRefs(useCalendar())
const { characters } = storeToRefs(useCharacters())
const searchInput = shallowRef<HTMLInputElement>()
useFocus(searchInput, { initialValue: true })
const searchQuery = ref<string>("")
// const searchEnough = computed<boolean>(() => searchQuery.value.length >= 2)
// If the query changes, resets the pagination
// This prevents active page to be greater than the search results
watch(searchQuery, resetPage)
const selectedEntity = useStorage("se", "events" as SearchMode)
// Order
const selectedOrder = ref<RPGDateOrder>("asc")
function setOrderAsc() {
selectedOrder.value = "asc"
resetPage()
}
function setOrderDesc() {
selectedOrder.value = "desc"
resetPage()
}
// Limit
const currentPage = ref<number>(1)
const itemsPerPage: number = 20
const startOfList = computed<number>(() => (currentPage.value - 1) * itemsPerPage)
const endOfList = computed<number>(() => startOfList.value + itemsPerPage)
/**
* Resets the pagination
*/
function resetPage() {
currentPage.value = 1
}
const searchResults = computed<(Character | CalendarEvent)[]>(() => {
let results: (Character | CalendarEvent)[] = []
let dataToFilter: Character[] | CalendarEvent[] | (Character | CalendarEvent)[]
const shouldFilterCategories = selectedCategories.value.length > 0
// Assign data to loop over and filter
// They are assigned this way for readability
if (selectedEntity.value === "events") {
dataToFilter = allEvents.value
} else if (selectedEntity.value === "characters") {
dataToFilter = characters.value
} else {
dataToFilter = [...allEvents.value, ...characters.value]
}
/**
* TODO: Refactor the categories logic, basically extract the return out of the ifs, like above
*/
results = dataToFilter.filter((item) => {
// Filter calendar events
if (isCalendarEvent(item)) {
const queryString = (searchQuery.value as string)
.replace(searchUnifier, "")
.toLocaleLowerCase()
const hitTitle = item.title
.replace(searchUnifier, "")
.toLocaleLowerCase()
.includes(queryString)
const hitDesc = item.description
?.replace(searchUnifier, "")
.toLocaleLowerCase()
.includes(queryString)
if (!shouldFilterCategories) {
return hitTitle || hitDesc
}
// Handle categories logic
let hitCategories: boolean = false
const allCategories: Category[] = []
if (item.category) {
allCategories.push(item.category)
}
if (item.secondaryCategories && item.secondaryCategories?.length > 0) {
allCategories.push(...item.secondaryCategories)
}
hitCategories = selectedCategories.value.every((selectedCat) => {
return allCategories.findIndex((c) => c.name === selectedCat.name) !== -1
})
return (hitTitle || hitDesc) && hitCategories
}
// Filter characters
if (isCharacter(item)) {
const queryString = (searchQuery.value as string)
.replace(searchUnifier, "")
.toLocaleLowerCase()
const hitTitle = item.name
.replace(searchUnifier, "")
.toLocaleLowerCase()
.includes(queryString)
if (!shouldFilterCategories) {
return hitTitle
}
// Handle categories logic
let hitCategories: boolean = false
const allCategories: Category[] = []
if (item.category) {
allCategories.push(item.category)
}
if (item.secondaryCategories && item.secondaryCategories?.length > 0) {
allCategories.push(...item.secondaryCategories)
}
hitCategories = selectedCategories.value.every((selectedCat) => {
return allCategories.includes(selectedCat as Category)
})
return hitTitle && hitCategories
}
})
return results
})
/**
* Removes the search query, resets the pagination and removes all selected categories
*/
function resetSearch() {
searchQuery.value = ""
resetPage()
selectedCategories.value = []
}
/**
* Opens the search Uidialog
*/
function openUiDialog() {
isAdvancedSearchOpen.value = true
}
/**
* Closes the search Uidialog
*/
function closeUiDialog() {
isAdvancedSearchOpen.value = false
}
/**
* Switches the selectedEntity
*/
function handleEntitySwitch() {
resetPage()
selectedCategories.value = []
}
// Key combos to deploy modal
const keys = useMagicKeys()
whenever(keys.control_period, () => {
openUiDialog()
})
const searchResultsRef = ref<HTMLElement | null>(null)
const { y: searchResultsY } = useScroll(searchResultsRef)
watch([currentPage, selectedEntity], () => {
searchResultsY.value = 0
})
// Compute categories based on current selectedEntity
const currentCategories = computed(() => categories.value)
const selectedCategories = ref<(Category)[]>([])
const categoryFilterOpened = ref<boolean>(false)
const searchCategory = ref<string>("")
const filteredCategories = computed(() => {
// Display original data
if (!currentCategories.value) return []
// If current categories are selected, ignore them
if (!searchCategory.value) return currentCategories.value.filter((i) => !selectedCategories.value.includes(i))
// If we also have a query to filter them by, do that
return currentCategories.value.filter((i) => {
return !selectedCategories.value.includes(i) && i.name.toLocaleLowerCase().includes(searchCategory.value.toLocaleLowerCase())
})
})
// Reactivity rules for category searches
const categoryInput = ref(null)
const categoryInputValue = ref<string>("")
const { focused: categoryInputFocused } = useFocus(categoryInput)
watch(categoryInputFocused, (isFocused) => {
categoryFilterOpened.value = isFocused
})
/**
* Handles the category selections from the TagInput component
*
* @param e Radix Change Event
*/
function handleCategorySelect(e: Category) {
searchCategory.value = ""
selectedCategories.value.push(e)
if (filteredCategories.value.length === 0) {
categoryFilterOpened.value = false
}
}
/**
* Removes a category from selection from the TagInput component
*/
function handleCategoryUnselect(e: Category) {
selectedCategories.value.splice(selectedCategories.value.findIndex(sc => sc.name === e.name), 1)
if (filteredCategories.value.length === 0) {
categoryFilterOpened.value = false
}
}
</script>
<template>
<UiDialog v-model:open="isAdvancedSearchOpen" @update:open="resetSearch()">
<UiDialogContent
class="flex flex-col flex-nowrap top-10 -translate-y-0 data-[state=closed]:slide-out-to-top-[5%] data-[state=open]:slide-in-from-top-[5%] max-md:rounded-none"
:class="cn({
'max-md:w-full bottom-0 md:bottom-10 top-0 md:top-10': searchResults.length > 0
})"
>
<VisuallyHidden>
<UiDialogTitle> {{ $t('entity.advancedSearch.title') }} </UiDialogTitle>
</VisuallyHidden>
<VisuallyHidden>
<UiDialogDescription>
{{ $t('entity.advancedSearch.title') }}
</UiDialogDescription>
</VisuallyHidden>
<div id="searchForm" class="grid gap-3">
<div class="relative w-full h-fit">
<UiInput
id="search"
ref="searchInput"
v-model:model-value="searchQuery"
type="text"
:placeholder="$t('entity.advancedSearch.ctaPlaceholder')"
class="pl-10 py-6 text-lg"
autocomplete="off"
/>
<span class="absolute start-1 inset-y-0 flex items-center justify-center px-2 opacity-50">
<PhMagnifyingGlass size="20" />
</span>
</div>
<div class="flex items-center justify-between gap-8">
<div class="hidden">
<UiToggleGroup
v-model="selectedEntity"
type="single"
class="justify-start"
@update:model-value="handleEntitySwitch()"
>
<UiToggleGroupItem value="events" aria-label="Uniquement les évènements">
{{ $t('entity.calendar.event.namePlural') }}
</UiToggleGroupItem>
</UiToggleGroup>
</div>
<div class="grow flex justify-end items-center gap-1">
<UiTagsInput class="grow px-0 gap-y-1 md:w-80">
<div v-if="selectedCategories.length > 0" class="flex gap-2 flex-wrap items-center px-3">
<UiTagsInputItem v-for="cat in selectedCategories" :key="cat.id" :value="cat.name">
<button
class="bgc px-2 capitalize cursor-pointer"
:class="cn(`element-${cat.color}`)"
@click="handleCategoryUnselect(cat)"
>
{{ capitalize(cat.name) }}
</button>
</UiTagsInputItem>
</div>
<ComboboxRoot
v-model="selectedCategories"
v-model:open="categoryFilterOpened"
v-model:search-term="searchCategory"
class="grow flex items-center gap-y-1 pr-2"
>
<ComboboxAnchor as-child>
<ComboboxInput :placeholder="$t('entity.category.namePlural')" as-child>
<UiTagsInputInput
ref="categoryInput"
v-model="categoryInputValue"
class="min-w-16 px-3"
@keydown.enter.prevent
/>
</ComboboxInput>
</ComboboxAnchor>
<ComboboxTrigger>
<PhCaretDown size="16" />
</ComboboxTrigger>
<ComboboxPortal :to="'#searchForm'">
<UiCommandList
position="popper"
class="w-(--radix-popper-anchor-width) rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50"
:dismissable="true"
>
<UiCommandEmpty />
<UiCommandGroup>
<UiCommandItem
v-for="cat in filteredCategories"
:key="cat.name"
:value="cat"
@select.prevent="handleCategorySelect(cat)"
>
<span
class="bgc"
:class="cn(`element-${cat.color}`)"
>
{{ capitalize(cat.name) }}
</span>
</UiCommandItem>
</UiCommandGroup>
</UiCommandList>
</ComboboxPortal>
</ComboboxRoot>
</UiTagsInput>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
:variant="selectedOrder === 'desc' ? 'secondary' : 'outline'"
size="icon"
@click="setOrderDesc()"
>
<PhClockCounterClockwise size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ $t('entity.advancedSearch.older') }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
:variant="selectedOrder === 'asc' ? 'secondary' : 'outline'"
size="icon"
@click="setOrderAsc()"
>
<PhClockClockwise size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ $t('entity.advancedSearch.newer') }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
</div>
</div>
<hr>
<div v-if="searchResults.length > 0" ref="searchResultsRef" class="grow overflow-y-auto">
<SearchList
:results="searchResults"
:current-entity="selectedEntity"
:order="selectedOrder"
:start-at="startOfList"
:end-at="endOfList"
@jumped-to-date="closeUiDialog()"
/>
</div>
<div class="flex justify-end">
<UiPagination
v-model:page="currentPage"
:total="searchResults.length"
:items-per-page="itemsPerPage"
:sibling-count="2"
show-edges
:default-page="1"
>
<UiPaginationList v-slot="{ items }" class="flex items-center gap-1">
<UiPaginationFirst />
<UiPaginationPrev />
<template v-for="(item, index) in items">
<UiPaginationListItem
v-if="item.type === 'page'"
:key="index"
:value="item.value"
as-child
>
<UiButton
class="size-8 md:size-10 p-0"
:variant="item.value === currentPage ? 'default' : 'outline'"
>
{{ item.value }}
</UiButton>
</UiPaginationListItem>
<UiPaginationEllipsis v-else :key="item.type" :index="index" />
</template>
<UiPaginationNext />
<UiPaginationLast />
</UiPaginationList>
</UiPagination>
</div>
</UiDialogContent>
</UiDialog>
</template>

View File

@@ -0,0 +1,91 @@
<script lang="ts" setup>
import type { Character } from "@@/models/Characters"
import type { RPGDate } from "@@/models/Date"
import { useCalendar } from "@/stores/CalendarStore"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip"
import { Button } from "~/components/ui/button"
import { PhArrowSquareOut, PhPlant, PhSkull } from "@phosphor-icons/vue"
const props = defineProps<{
character: Character
}>()
defineEmits<{
"query:date-jump": [payload: RPGDate]
}>()
const { getFormattedDateTitle } = useCalendar()
console.log(props.character.birth)
</script>
<template>
<div class="block w-full text-left py-3 px-4 border-[1px] border-border rounded-sm">
<div class="grid gap-2">
<div class="flex gap-2">
<h2 class="font-bold">
{{ character.name }}
</h2>
<div v-if="character.wiki">
<Button variant="link" size="xs" as-child>
<a :href="character.wiki" target="_blank">
Wiki
<PhArrowSquareOut size="16" weight="fill" />
</a>
</Button>
</div>
</div>
<menu class="flex gap-2 border-[1px] border-border rounded-sm w-fit">
<li v-if="character.birth">
<TooltipProvider :delay-duration="100">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="xs"
@click="$emit('query:date-jump', character.birth!)"
>
<PhPlant size="16" weight="fill" />
{{ getFormattedDateTitle(character.birth, true) }}
</Button>
</TooltipTrigger>
<TooltipContent :side="'bottom'" :align="'end'" :align-offset="20">
<p>Date de naissance</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</li>
<span v-if="character.birth && character.death">-</span>
<li v-if="character.death">
<TooltipProvider :delay-duration="100">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="xs"
@click="$emit('query:date-jump', character.death!)"
>
<PhSkull size="16" weight="fill" />
{{ getFormattedDateTitle(character.death, true) }}
</Button>
</TooltipTrigger>
<TooltipContent :side="'bottom'" :align="'start'" :align-offset="20">
<p>Date de décès</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</li>
</menu>
<hr v-if="character.description" class="border-white opacity-25" >
<div v-if="character.description" class="text-sm">
<span class="opacity-75">
{{ character.description }}
</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,121 @@
<script lang="ts" setup>
import { cn } from "@/lib/utils"
import type { RPGDate } from "@@/models/Date"
import type { CalendarEvent } from "@@/models/CalendarEvent"
import { PhArrowSquareOut, PhHourglassMedium, PhAlarm, PhMapPinArea, PhEye } from "@phosphor-icons/vue"
const props = defineProps<{
event: CalendarEvent
}>()
defineEmits<{
"query:date-jump": [payload: RPGDate]
}>()
const { getRelativeString, defaultDate, getFormattedDateTitle } = useCalendar()
const dateDifference: string = getRelativeString(defaultDate, props.event.startDate)
const dateDuration = computed<string | null>(() => props.event.endDate ? getRelativeString(props.event.startDate, props.event.endDate, "compact") : null)
</script>
<template>
<button
class="event-callout group relative block w-full text-left py-2 px-3 md:py-3 md:px-4 cursor-pointer transition-colors"
:class="cn(
event.category ? `element-${event.category.color}` : '',
{
'pt-4 is-hidden': event.hidden,
})"
@click="$emit('query:date-jump', event.startDate)"
>
<div class="flex gap-2 items-center mb-1">
<h2 class="font-semibold md:font-bold md:text-lg underline-offset-4 group-hover:underline">
{{ event.title }}
</h2>
<div v-if="event.wiki">
<UiButton variant="link" size="xs" as-child class="text-inherit">
<a :href="event.wiki" target="_blank">
Wiki
<PhArrowSquareOut size="16" weight="fill" />
</a>
</UiButton>
</div>
</div>
<div class="flex gap-2 items-center justify-between mb-2 md:mb-1">
<template v-if="!event.endDate">
<p class="font-semibold text-sm opacity-75">
{{ getFormattedDateTitle(event.startDate, true) }}
</p>
</template>
<template v-else>
<p class="font-semibold text-sm opacity-75">
{{
$t('entity.calendar.date.fromTo',
{
startDate: getFormattedDateTitle(event.startDate, true),
endDate: getFormattedDateTitle(event.endDate, true)
}
)
}}
</p>
</template>
<div v-if="event.hidden" class="flex justify-end">
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiBadge class="flex gap-1">
<PhEye size="16" weight="fill" /> {{ $t('entity.calendar.event.isHidden') }}
</UiBadge>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('entity.calendar.event.hiddenTooltip') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
</div>
<div class="mb-1 flex gap-x-2 items-center">
<template v-if="event.location">
<p class="w-fit text-xs md:text-sm italic opacity-75 flex items-center gap-1">
<PhMapPinArea size="16" weight="fill" /> {{ event.location }}
</p>
</template>
<p class="w-fit text-xs md:text-sm italic opacity-75 flex items-center gap-1">
<PhAlarm size="16" weight="fill" /> {{ dateDifference }}
</p>
<template v-if="dateDuration">
<p class="w-fit text-xs md:text-sm italic opacity-75 flex items-center gap-1">
<PhHourglassMedium size="16" weight="fill" /> {{ $t('entity.calendar.date.while', { duration: dateDuration } )}}
</p>
</template>
</div>
<div v-if="event.category || event.secondaryCategories" class="absolute top-3 right-4">
<ul class="flex gap-1">
<li v-if="event.category">
<UiBadge class="mix-blend-luminosity font-bold bg-gray-300 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600 lowercase" variant="secondary">
{{ event.category?.name }}
</UiBadge>
</li>
<li v-for="cat in event.secondaryCategories" :key="cat.id">
<UiBadge class="mix-blend-luminosity bg-gray-300 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600 lowercase" variant="secondary">
{{ cat.name }}
</UiBadge>
</li>
</ul>
</div>
<div v-if="event.description" class="text-xs md:text-sm">
<hr class="my-3 md:my-2 border-white opacity-50" >
<span class="opacity-75">
{{ event.description }}
</span>
</div>
</button>
</template>

View File

@@ -0,0 +1,74 @@
<script lang="ts" setup>
import { isCharacter, type Character } from "@@/models/Characters"
import type { RPGDate, RPGDateOrder } from "@@/models/Date"
import { useCalendar } from "@/stores/CalendarStore"
import { computed } from "vue"
import { isCalendarEvent, type CalendarEvent } from "@@/models/CalendarEvent"
import type { SearchMode } from "../../SearchMode"
import CharacterCallout from "./CharacterCallout.vue"
import EventCallout from "./EventCallout.vue"
const props = defineProps<{
results: (Character | CalendarEvent)[]
currentEntity: SearchMode
order: RPGDateOrder
startAt: number
endAt: number
limit?: number
}>()
const emit = defineEmits(["jumpedToDate"])
const { jumpToDate, compareDates } = useCalendar()
function handleJumpToDate(date?: RPGDate) {
if (!date) return
jumpToDate(date)
emit("jumpedToDate")
}
// Initial sorting of the results
const sortedResults = computed(() => {
return [...props.results].sort((a, b) => {
let firstDate: RPGDate
let secondDate: RPGDate
if (isCalendarEvent(a)) {
firstDate = a.startDate
} else if (isCharacter(a) && a.birth) {
firstDate = a.birth
} else {
return 1
}
if (isCalendarEvent(b)) {
secondDate = b.startDate
} else if (isCharacter(b) && b.birth) {
secondDate = b.birth
} else {
return -1
}
return compareDates(firstDate, secondDate, props.order)
})
})
// Page the sorted results
const pagedResults = computed(() => sortedResults.value.slice(props.startAt, props.endAt))
</script>
<template>
<ul class="grid gap-3 md:gap-4 pl-4 pr-6 md:pl-8 md:pr-12">
<li v-for="r in pagedResults" :key="isCalendarEvent(r) ? r.title : r.name">
<EventCallout v-if="isCalendarEvent(r)" :event="r" @query:date-jump="handleJumpToDate" />
<CharacterCallout
v-else-if="isCharacter(r)"
:character="r"
@query:date-jump="handleJumpToDate"
/>
</li>
</ul>
</template>

View File

@@ -0,0 +1,5 @@
<script lang="ts" setup></script>
<template>
<div>Century</div>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<div>Decade</div>
</template>

View File

@@ -0,0 +1,194 @@
<script lang="ts" setup>
import type { RPGDate } from "@@/models/Date"
import type { CalendarEvent } from "@@/models/CalendarEvent"
import { breakpointsTailwind, useElementBounding } from "@vueuse/core"
import { storeToRefs } from "pinia"
import { computed, ref, type ComputedRef } from "vue"
import CalendarEventButton from "../../event/Event.vue"
const props = defineProps<{
date: RPGDate
faded?: boolean
}>()
const emit = defineEmits<{
"on-open-create-dialog": [date: RPGDate]
}>()
const calendarTile = ref()
const calendarEventsList = ref()
const { defaultDate, selectDate, areDatesIdentical, getFormattedDateTitle } = useCalendar()
const { selectedDate, currentEvents, isReadOnly } = storeToRefs(useCalendar())
const breakpoints = useBreakpoints(
breakpointsTailwind
)
/**
* All events with a startDate / endDate that starts or ends on the tile
*/
const eventsForTheDay = computed(() => {
return currentEvents.value.filter((currentEvent) => {
return (
areDatesIdentical(currentEvent.startDate, props.date) ||
areDatesIdentical(currentEvent.endDate!, props.date)
)
})
})
/**
* Is the tile on today's date ?
*/
const isDefaultDate = computed(() => {
return areDatesIdentical(props.date, defaultDate)
})
/**
* Is the tile on the hightlighted date ?
*/
const isSelectedDate = computed(() => {
return areDatesIdentical(props.date, selectedDate.value)
})
// Get bounding elements for both tile and events list
const { height: tileHeight, top: tileTop } = useElementBounding(calendarTile)
const { top: tileListTop } = useElementBounding(calendarEventsList)
// Compute the available number of events that can be displayed from refs heights
const numberOfEventsToFit: ComputedRef<number> = computed(() => {
if (!eventsForTheDay.value.length) return 0
return Math.trunc((tileHeight.value - (tileListTop.value - tileTop.value)) / 40)
})
/**
* Events that can fit in the tile's space
*/
const eventsToDisplay: ComputedRef<CalendarEvent[]> = computed<CalendarEvent[]>(() => {
return [...eventsForTheDay.value].splice(0, numberOfEventsToFit.value)
})
/**
* Remaining events that are not displayed
*
* Used if not all events can be seen in the tile's space
*/
const eventsNotDisplayed: ComputedRef<number> = computed<number>(() => eventsForTheDay.value.length - eventsToDisplay.value.length)
</script>
<template>
<div
ref="calendarTile"
class="tile relative p-1 md:p-2 transition-colors"
:class="{
'text-slate-300 dark:text-slate-500': props.faded,
'text-slate-500 dark:text-slate-300': !props.faded
}"
>
<button
class="relative z-10 group block w-full text-2xs md:text-xs text-center cursor-pointer"
@click="selectDate(date)"
>
<ClientOnly>
<span
class="inline-flex size-7 md:size-8 aspect-square items-center justify-center rounded-full border-1 md:border-2 border-transparent font-bold transition-colors group-hover:border-indigo-300 dark:group-hover:border-indigo-700"
:class="{
'text-white bg-slate-600 dark:bg-slate-800': isDefaultDate && !isSelectedDate,
'text-white bg-indigo-500': isSelectedDate
}"
>
{{ date.day }}
</span>
</ClientOnly>
</button>
<ClientOnly>
<ul
ref="calendarEventsList"
class="absolute top-9 md:top-12 bottom-1 md:bottom-2 inset-x-2 grid auto-rows-min gap-1 z-10 pointer-events-none transition-opacity"
:class="{
'opacity-40': props.faded && !isSelectedDate
}"
>
<TransitionGroup name="event">
<li v-for="event in eventsToDisplay" :key="event.id" class="grid w-full pointer-events-auto">
<CalendarEventButton :event :tile-date="date" />
</li>
</TransitionGroup>
<li v-if="eventsNotDisplayed > 0" class="pointer-events-auto">
<UiPopover>
<UiPopoverTrigger as-child>
<button
class="text-2xs md:text-xs px-[5px] py-[5px] md:px-2 md:py-1 block w-full text-left font-bold rounded-sm whitespace-nowrap overflow-hidden text-ellipsis cursor-pointer transition-colors hover:text-foreground hover:bg-foreground/10"
>
{{ eventsNotDisplayed }} autre{{ eventsNotDisplayed > 1 ? 's' : '' }}
</button>
</UiPopoverTrigger>
<UiPopoverContent
class="w-80"
:align="'center'"
:align-offset="breakpoints.lg.value ? 50 : 25"
:collision-padding="breakpoints.lg.value ? 50 : 25"
:side="breakpoints.lg.value ? 'left' : 'bottom'"
>
<div class="text-center mb-2">
<span
class="text-lg font-semibold rounded-full"
>
{{ getFormattedDateTitle(date, true) }}
</span>
</div>
<ul class="grid auto-rows-min gap-1 z-10 pointer-events-none transition-opacity">
<li
v-for="event in eventsForTheDay"
:key="event.title"
class="grid pointer-events-auto"
>
<CalendarEventButton :event :tile-date="date" />
</li>
</ul>
</UiPopoverContent>
</UiPopover>
</li>
</ul>
</ClientOnly>
<ClientOnly>
<LazyCalendarDialogCreateEvent v-if="!isReadOnly && breakpoints.lg.value" :date btn-class="absolute inset-0 w-full h-full cursor-default z-0" />
<button
v-else-if="!isReadOnly && !breakpoints.lg.value"
class="absolute inset-0 w-full h-full cursor-default z-0"
@click="emit('on-open-create-dialog', props.date)"
/>
</ClientOnly>
</div>
</template>
<style lang="scss" scoped>
.tile {
border-right-width: 1px;
border-bottom-width: 1px;
}
.event-enter-active {
transition: all 120ms ease-in-out;
}
.event-move,
.event-leave-active {
position: absolute;
transition: all 200ms ease-in-out;
}
.event-enter-from,
.event-leave-to {
opacity: 0;
}
.event-enter-from {
transform: translateX(.15rem);
}
.event-leave-to {
transform: translateX(-.5rem);
}
</style>

View File

@@ -0,0 +1,100 @@
<script lang="ts" setup>
import { useCalendar } from "@/stores/CalendarStore"
import { useThrottleFn } from "@vueuse/core"
import type { RPGDate } from "@@/models/Date"
import { VisuallyHidden } from "radix-vue"
const { currentDate, decrementViewMonth, incrementViewMonth, resetSkeleton } = useCalendar()
const { currentMonthData, operationInProgress, eventSkeleton } = storeToRefs(useCalendar())
function handleWheel(e: WheelEvent) {
// Prevent scrolling
e.preventDefault()
const isMovingUp = e.deltaY < 0
if (isMovingUp) {
moveCalendarLeft()
} else {
moveCalendarRight()
}
}
const moveCalendarLeft = useThrottleFn(() => {
decrementViewMonth()
}, 100)
const moveCalendarRight = useThrottleFn(() => {
incrementViewMonth()
}, 100)
const isDialogOpen = ref<boolean>(false);
/**
* Opens event creation's popover
*/
function handleDialogOpen(date: RPGDate) {
// If another operation is in progress, whether it's another create popup or a modal, don't bother opening it
if (operationInProgress.value) {
isDialogOpen.value = false
return
}
resetSkeleton()
isDialogOpen.value = true
// Set skeleton initial startDate if it's known
if (date) {
eventSkeleton.value.startDate = date
}
}
function toggleDialog() {
isDialogOpen.value = !isDialogOpen.value;
};
/**
* Prevents the modal from closing if's still loading
*/
function handleClosing() {
setTimeout(() => resetSkeleton(), 100)
}
</script>
<template>
<div class="grid grid-cols-5 md:grid-cols-7 lg:grid-cols-10" @wheel="handleWheel">
<template v-if="currentMonthData">
<CalendarStateMonthlyDayTile
v-for="day in currentMonthData?.days"
:key="`layout-month-grid-${day}`"
:date="{
day: day,
month: currentDate.currentMonth,
year: currentDate.currentYear
}"
@on-open-create-dialog="handleDialogOpen"
/>
<UiDialog v-model:open="isDialogOpen">
<UiDialogContent
class="max-md:translate-0 max-md:inset-0 max-md:w-full max-md:block"
:trap-focus="true"
@escape-key-down="handleClosing"
@pointer-down-outside.prevent="null"
>
<UiDialogTitle class="max-md:mb-8">
{{ $t("entity.calendar.event.addSingle") }}
</UiDialogTitle>
<VisuallyHidden>
<UiDialogDescription>
{{ $t("entity.calendar.event.addSingleDescription") }}
</UiDialogDescription>
</VisuallyHidden>
<CalendarFormCreateEvent @event-created="toggleDialog" />
</UiDialogContent>
</UiDialog>
</template>
</div>
</template>

View File

@@ -0,0 +1,53 @@
<script lang="ts" setup>
import type { RPGDate } from "@@/models/Date"
import { storeToRefs } from "pinia"
import { computed, type ComputedRef } from "vue"
const { currentDate, defaultDate, selectDate, areDatesIdentical } = useCalendar()
const { selectedDate, currentEvents } = storeToRefs(useCalendar())
const props = defineProps<{
monthNumber: number
dayNumber: number
}>()
const tileDate: ComputedRef<RPGDate> = computed(() => {
return {
day: props.dayNumber,
month: props.monthNumber,
year: currentDate.currentYear
}
})
const isDefaultDate = computed<boolean>(() => {
return areDatesIdentical(tileDate.value, defaultDate)
})
const isSelectedDate = computed<boolean>(() => {
return areDatesIdentical(tileDate.value, selectedDate.value)
})
const hasAtLeastOneEvent = computed<boolean>(() => {
return Boolean(
currentEvents.value.find((currentEvent) => {
return areDatesIdentical(currentEvent.startDate, tileDate.value)
})
)
})
</script>
<template>
<button
class="relative grid place-items-center aspect-square rounded-full border-2 border-transparent transition-colors after:content-[''] after:absolute after:top-1 after:right-1 after:w-[.3rem] after:h-[.3rem] after:rounded-full after:transition-colors"
:class="{
'text-slate-500 hover:border-indigo-300 hover:text-slate-900': !isDefaultDate && !isSelectedDate,
'font-semibold text-white bg-slate-600 dark:bg-slate-800 hover:border-indigo-400': isDefaultDate && !isSelectedDate,
'font-semibold text-white bg-indigo-500 hover:bg-indigo-600 hover:border-indigo-500': isSelectedDate,
'after:bg-green-600': hasAtLeastOneEvent,
'after:bg-slate-950': hasAtLeastOneEvent && isSelectedDate
}"
@click="selectDate(tileDate)"
>
<span ref="tileRef" class="text-[.85em]">{{ dayNumber }}</span>
</button>
</template>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import { useCalendar } from "@/stores/CalendarStore"
import { useThrottleFn } from "@vueuse/core"
const { decrementViewYear, incrementViewYear } = useCalendar()
const { sortedMonths: months } = storeToRefs(useCalendar())
function handleWheel(e: WheelEvent) {
const isMovingUp = e.deltaY < 0
if (isMovingUp) {
moveCalendarLeft()
} else {
moveCalendarRight()
}
}
const moveCalendarLeft = useThrottleFn(() => {
decrementViewYear()
}, 100)
const moveCalendarRight = useThrottleFn(() => {
incrementViewYear()
}, 100)
</script>
<template>
<div class="container mx-auto mt-[10vh] mb-auto" @wheel="handleWheel">
<div class="grid grid-cols-5 gap-x-8 gap-y-16">
<CalendarStateYearlyMonthTile
v-for="month in months"
:key="month.id"
:month
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { CalendarMonth } from "@@/models/CalendarMonth";
defineProps<{
month: CalendarMonth
}>()
</script>
<template>
<div>
<div class="font-medium">
{{ month.name }}
</div>
<div class="grid grid-cols-7 gap-1">
<CalendarStateYearlyDayTile
v-for="day in month.days"
:key="day"
:month-number="month.position"
:day-number="day"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script lang="ts" setup>
import { PhPlusCircle } from "@phosphor-icons/vue";
const emit = defineEmits(["on-click"])
</script>
<template>
<UiCard
class="h-full lg:w-fit transition-all bg-transparent hover:text-slate-900 text-slate-500 dark:hover:text-slate-100 dark:text-slate-500 dark:focus-within:outline-gray-900 hover:-translate-y-0"
has-click
@on-click="emit('on-click')"
>
<UiCardHeader class="h-full justify-center items-center text-center gap-0">
<PhPlusCircle size="38" weight="fill" />
<UiCardTitle class="text-xl">
<slot />
</UiCardTitle>
</UiCardHeader>
</UiCard>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { PhCaretRight } from "@phosphor-icons/vue";
const route = useRoute()
export type BreadcrumbItem = {
label?: string;
translateKey?: string;
to?: string;
}
defineProps<{
items?: BreadcrumbItem[];
}>()
</script>
<template>
<div class="flex gap-3 items-center">
<div class="md:hidden">
<SidebarToggle />
</div>
<UiBreadcrumb>
<UiBreadcrumbList>
<UiBreadcrumbItem v-if="route.path !== '/my'">
<UiBreadcrumbLink as-child>
<NuxtLink to="/my">
{{ $t("breadcrumbs.profile") }}
</NuxtLink>
</UiBreadcrumbLink>
</UiBreadcrumbItem>
<UiBreadcrumbItem v-else>
<UiBreadcrumbPage>
{{ $t("breadcrumbs.profile") }}
</UiBreadcrumbPage>
</UiBreadcrumbItem>
<template v-for="(item, index) in items" :key="index">
<UiBreadcrumbSeparator>
<PhCaretRight />
</UiBreadcrumbSeparator>
<UiBreadcrumbItem>
<UiBreadcrumbLink v-if="item.to" as-child>
<NuxtLink :to="item.to">
<template v-if="item.label">
{{ item.label }}
</template>
<template v-else>
{{ $t(`breadcrumbs.${item.translateKey}`) }}
</template>
</NuxtLink>
</UiBreadcrumbLink>
<UiBreadcrumbPage v-else>
<template v-if="item.label">
{{ item.label }}
</template>
<template v-else>
{{ $t(`breadcrumbs.${item.translateKey}`) }}
</template>
</UiBreadcrumbPage>
</UiBreadcrumbItem>
</template>
</UiBreadcrumbList>
</UiBreadcrumb>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script lang="ts" setup>
type HeadingLevel = "h0" | "h1" | "h2" | "h3"
interface HeadingProps {
level?: HeadingLevel
}
withDefaults(defineProps<HeadingProps>(), {
level: "h2"
})
</script>
<template>
<h1 v-if="level === 'h0'" class="text-4xl md:text-6xl font-bold flex">
<slot />
</h1>
<h1 v-else-if="level === 'h1'" class="text-2xl md:text-4xl font-bold flex">
<slot />
</h1>
<h2 v-else-if="level === 'h2'" class="text-xl md:text-2xl font-bold flex">
<slot />
</h2>
<h3 v-if="level === 'h3'" class="text-lg md:text-xl font-bold flex">
<slot />
</h3>
</template>

View File

@@ -0,0 +1,21 @@
<script lang="ts" setup>
const { lineNb = 3 } = defineProps<{
lineNb?: number
}>()
</script>
<template>
<UiCard class="w-full h-full flex flex-col">
<UiCardHeader>
<UiCardTitle>
<UiSkeleton class="w-2/3 max-w-full h-8" />
</UiCardTitle>
</UiCardHeader>
<UiCardContent class="grow grid gap-2">
<UiSkeleton class="w-full max-w-full h-6" />
<template v-for="(l, i) in lineNb" :key="i">
<UiSkeleton class="max-w-full h-6" :style="`width: ${Math.floor(Math.random() * 100) + 1}%`" />
</template>
</UiCardContent>
</UiCard>
</template>

View File

@@ -0,0 +1,30 @@
<script lang="ts" setup>
import { cn } from "@/lib/utils";
interface SpacingProps {
size?: "xs" | "sm" | "md" | "lg" | "xlg" | "2xl" | "3xl"
}
const props = defineProps<SpacingProps>()
let spacingClass: string
switch (props.size) {
case "lg":
spacingClass = "space-y-4"
break;
case "md":
default:
spacingClass = "space-y-2"
break;
}
</script>
<template>
<div
:class="cn(spacingClass)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,41 @@
<script lang="ts" setup>
import { cn } from "@/lib/utils";
import { type RPGColor, rpgColors } from "@@/models/Color";
const { id, theme = "normal", position = "popper" } = defineProps<{
id: string
theme?: "normal" | "subtle"
position?: "item-aligned" | "popper" | undefined
}>();
const model = defineModel<RPGColor>({ default: "white" });
</script>
<template>
<UiSelect v-model="model">
<UiSelectTrigger :id :class="cn({ 'h-9': theme === 'subtle' })">
<UiSelectValue
:placeholder="$t('ui.colors.selectOne')"
class="bgc"
:class="cn(`element-${model}`)"
/>
</UiSelectTrigger>
<UiSelectContent :position>
<UiSelectGroup>
<UiSelectItem
v-for="color in rpgColors"
:key="color"
:value="color"
class="bgc"
:class="
cn(
`element-${color}`,
)
"
>
{{ $t(`ui.colors.${color}`) }}
</UiSelectItem>
</UiSelectGroup>
</UiSelectContent>
</UiSelect>
</template>

View File

@@ -0,0 +1,30 @@
<script lang="ts" setup>
import { contentStates, type ContentState } from "@@/models/Entity";
defineProps<{
id: string
}>();
const model = defineModel<ContentState>({ default: "draft" });
</script>
<template>
<UiSelect v-model="model">
<UiSelectTrigger :id>
<UiSelectValue
:placeholder="$t('ui.contentState.selectOne')"
/>
</UiSelectTrigger>
<UiSelectContent position="popper">
<UiSelectGroup>
<UiSelectItem
v-for="state in contentStates"
:key="state"
:value="state"
>
{{ $t(`ui.contentState.${state}`) }}
</UiSelectItem>
</UiSelectGroup>
</UiSelectContent>
</UiSelect>
</template>

View File

@@ -0,0 +1,190 @@
<script lang="ts" setup>
import { PhCompass, PhGlobeHemisphereEast, PhHurricane, PhInfo, PhX } from "@phosphor-icons/vue"
import type { SidebarMenuActionType, SidebarMenuIcon } from "./SidebarProps";
import { cn } from "@/lib/utils";
import { breakpointsTailwind } from "@vueuse/core"
const { revealAdvancedSearch } = useCalendar()
const { toggleSidebar } = useUiStore()
const { currentMenu, isSidebarOpened } = storeToRefs(useUiStore())
function handleMenuItemAction(actionType: SidebarMenuActionType) {
if (actionType === "event-search") {
revealAdvancedSearch()
}
}
function computeMenuItemIcon(iconString: SidebarMenuIcon) {
switch (iconString) {
case "universe":
return PhHurricane
case "world":
return PhGlobeHemisphereEast
default:
return PhCompass
}
}
const breakpoints = useBreakpoints(
breakpointsTailwind
)
// const sidebarRef = ref(null)
// onClickOutside(sidebarRef, () => {
// isSidebarOpened.value = false
// })
</script>
<template>
<nav
ref="sidebarRef"
:class="cn(
['md:relative md:isolate w-16 py-6 grid gap-4 grid-rows-[1fr_auto] justify-center md:transition-none'], // Base appearance
['after:opacity-50 after:contrast-125 dark:after:opacity-75 dark:after:contrast-175 after:-hue-rotate-60'], // After styling
['border-r-[1px] bg-indigo-700 dark:bg-black text-white border-r-indigo-700 dark:border-r-indigo-950 shadow-navbar-light dark:shadow-navbar-dark'], // Colours
['max-md:justify-stretch max-md:px-4 max-md:py-4 max-md:absolute max-md:left-0 max-md:inset-0 max-md:z-50 max-md:w-40 max-md:max-w-full max-md:transition-all'], // Responsive behaviours
{
'max-md:-translate-x-40': !isSidebarOpened,
'max-md:-translate-x-0 shadow-navbar-dark dark:bg-slate-950': isSidebarOpened
}
)"
>
<menu class="flex flex-col gap-4 max-md:items-center">
<li class="mb-12 mt-4 max-md:self-start">
<UiButton
variant="outline"
size="icon"
class="md:hidden size-9 border-background/30"
@click="toggleSidebar"
>
<PhX size="19" />
</UiButton>
</li>
<li class="max-md:self-start">
<UiTooltipProvider :delay-duration="50" :disabled="!breakpoints.md.value">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
variant="ghost"
size="icon"
class="rounded-full max-md:hidden"
as-child
>
<RouterLink to="/explore">
<PhCompass size="24" weight="fill" />
</RouterLink>
</UiButton>
<RouterLink
to="/explore"
class="md:hidden flex items-center gap-[.6ch] underline-offset-4 hover:underline"
>
<PhCompass size="22" weight="fill" />
<span class="text-[.9em]">
{{ $t('pages.explore.menuLabel') }}
</span>
</RouterLink>
</UiTooltipTrigger>
<UiTooltipContent :side="'right'" :side-offset="6">
<p>
{{ $t('pages.explore.menuLabel') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</li>
<li class="max-md:self-start">
<UiTooltipProvider :delay-duration="50" :disabled="!breakpoints.md.value">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
variant="ghost"
size="icon"
class="rounded-full max-md:hidden"
as-child
>
<RouterLink to="/about">
<PhInfo size="24" weight="fill" />
</RouterLink>
</UiButton>
<RouterLink
to="/about"
class="md:hidden flex items-center gap-[.6ch] underline-offset-4 hover:underline"
>
<PhInfo size="22" weight="fill" />
<span class="text-[.9em]">
{{ $t('pages.about.menuLabel') }}
</span>
</RouterLink>
</UiTooltipTrigger>
<UiTooltipContent :side="'right'" :side-offset="6">
<p>
{{ $t('pages.about.menuLabel') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</li>
<ClientOnly>
<li v-for="(item, i) in currentMenu" :key="i">
<UiTooltipProvider :delay-duration="50">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
v-if="item.to"
variant="ghost"
size="icon"
class="rounded-full"
as-child
>
<RouterLink :to="item.to">
<component :is="computeMenuItemIcon(item.phIcon)" size="24" :weight="item.phIconWeight || 'fill'" />
</RouterLink>
</UiButton>
<UiButton
v-if="item.action"
variant="ghost"
size="icon"
class="rounded-full"
@click="handleMenuItemAction(item.action!)"
>
<component :is="computeMenuItemIcon(item.phIcon)" size="24" :weight="item.phIconWeight || 'fill'" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent :side="'right'" :side-offset="6">
<p>{{ item.tooltip }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</li>
</ClientOnly>
</menu>
<UserCTA />
</nav>
</template>
<style lang="scss" scoped>
nav {
&::after {
display: block;
content: '';
position: absolute;
bottom: 0;
inset-inline: 0;
height: 25rem;
max-height: 100%;
background-image: url("/images/sidebar.png");
background-position: bottom;
background-size: cover;
background-repeat: no-repeat;
mask-image: linear-gradient(to top, black 25%, transparent 50%, transparent);
z-index: -1;
}
}
</style>

View File

@@ -0,0 +1,17 @@
export type SidebarMenuActionType = "event-search"
export type SidebarMenuIcon = "universe" | "world"
export interface SidebarMenuItem {
phIcon: SidebarMenuIcon,
phIconWeight?: "regular" | "light" | "fill" | "duotone" | "bold" | "thin"
highlight?: boolean
tooltip: string
action?: SidebarMenuActionType
to?: string
}
export interface SidebarProps {
menuItems: SidebarMenuItem[],
isHome?: boolean
}

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
import { PhList } from "@phosphor-icons/vue"
const { toggleSidebar } = useUiStore()
</script>
<template>
<UiButton
size="icon"
variant="outline"
@click="toggleSidebar"
>
<PhList size="19" weight="light" />
</UiButton>
</template>

View File

@@ -0,0 +1,204 @@
<script lang="ts" setup>
import { computed } from "vue"
import { PhCheckCircle, PhUser, PhLaptop, PhMoon, PhPalette, PhSignIn, PhSignOut, PhSun, PhTranslate, PhUserCircle } from "@phosphor-icons/vue"
import { cn } from "@/lib/utils";
const router = useRouter()
const { auth } = useSupabaseClient()
const user = useSupabaseUser()
const userMeta = computed(() => user.value?.user_metadata)
const profileUrl: string = `${useRequestURL().origin}/my/`
const { locale, setLocale } = useI18n()
const menuOpened = ref<boolean>(false)
function closeMenu() {
menuOpened.value = false
}
watch(user, closeMenu)
async function handleGoogleLogin() {
const { error } = await auth.signInWithOAuth({
provider: "google",
options: {
queryParams: {
access_type: "offline",
prompt: "consent"
},
redirectTo: profileUrl
}
})
if (error) {
console.log(error.message)
}
}
async function handleLogout() {
const { error } = await auth.signOut()
if (error) {
console.log(error.message)
}
}
type AvailableRoutes = "/my" | "/my/settings"
function pushRoute(to: AvailableRoutes) {
router.push({ path: to })
closeMenu()
}
</script>
<template>
<ClientOnly>
<UiDropdownMenu v-model:open="menuOpened">
<UiDropdownMenuTrigger>
<UiAvatar v-if="user" id="user-avatar" class="ring-[.2rem] ring-indigo-700 dark:ring-neutral-900 cursor-pointer">
<UiAvatarImage
:src="userMeta?.avatar_url"
:alt="userMeta?.full_name"
referrerpolicy="no-referrer"
/>
<UiAvatarFallback>
{{ $t('ui.sidebarMenu.avatarFallback') }}
</UiAvatarFallback>
</UiAvatar>
<UiButton v-else variant="outline" size="icon" class="rounded-full border-indigo-200 bg-indigo-700 dark:border-slate-300 dark:bg-neutral-950 dark:hover:bg-slate-50 dark:hover:text-slate-950 cursor-pointer">
<PhUserCircle size="24" />
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent class="w-72 p-0 pb-1" :align="'start'" :side="'top'" :side-offset="10" :align-offset="25" :collision-padding="40">
<template v-if="user">
<p class="p-2 text-[.7em] opacity-75">
{{ $t('ui.greeting', { user: user?.email }) }}
</p>
<UiDropdownMenuItem class="flex gap-[.5ch] items-center rounded-none" @click="pushRoute('/my')">
<PhUser size="20" weight="fill" />
<span>
{{ $t('ui.sidebarMenu.profile') }}
</span>
</UiDropdownMenuItem>
<UiDropdownMenuSeparator />
</template>
<template v-else>
<p class="p-2 text-[.7em] opacity-75">
{{ $t('ui.anonymousGreeting') }}
</p>
</template>
<UiDropdownMenuSub>
<UiDropdownMenuSubTrigger class="p-0 rounded-none">
<UiDropdownMenuItem class="flex gap-[.5ch] items-center pointer-events-none">
<PhPalette size="20" weight="fill" />
<span>
{{ $t('ui.sidebarMenu.appearance') }}
</span>
</UiDropdownMenuItem>
</UiDropdownMenuSubTrigger>
<UiDropdownMenuPortal>
<UiDropdownMenuSubContent>
<UiDropdownMenuItem
class="flex gap-[.5ch] items-center rounded-none transition-colors"
:class="cn({ 'text-emerald-600': $colorMode.preference === 'dark' })"
@select.prevent="$colorMode.preference = 'dark'"
>
<PhCheckCircle v-if="$colorMode.preference === 'dark'" size="20" weight="fill" />
<PhMoon v-else size="20" />
<span>
{{ $t('ui.dark') }}
</span>
</UiDropdownMenuItem>
<UiDropdownMenuItem
class="flex gap-[.5ch] items-center rounded-none transition-colors"
:class="cn({ 'text-emerald-600': $colorMode.preference === 'light' })"
@select.prevent="$colorMode.preference = 'light'"
>
<PhCheckCircle v-if="$colorMode.preference === 'light'" size="20" weight="fill" />
<PhSun v-else size="20" />
<span>
{{ $t('ui.light') }}
</span>
</UiDropdownMenuItem>
<UiDropdownMenuItem
class="flex gap-[.5ch] items-center rounded-none transition-colors"
:class="cn({ 'text-emerald-600': $colorMode.preference === 'system' })"
@select.prevent="$colorMode.preference = 'system'"
>
<PhCheckCircle v-if="$colorMode.preference === 'system'" size="20" weight="fill" />
<PhLaptop v-else size="20" />
<span>
{{ $t('ui.system') }}
</span>
</UiDropdownMenuItem>
</UiDropdownMenuSubContent>
</UiDropdownMenuPortal>
</UiDropdownMenuSub>
<UiDropdownMenuSub>
<UiDropdownMenuSubTrigger class="p-0 rounded-none">
<UiDropdownMenuItem class="flex gap-[.5ch] items-center pointer-events-none">
<PhTranslate size="20" />
<span>
{{ $t('ui.sidebarMenu.language') }}
</span>
</UiDropdownMenuItem>
</UiDropdownMenuSubTrigger>
<UiDropdownMenuPortal>
<UiDropdownMenuSubContent>
<UiDropdownMenuSubContent>
<UiDropdownMenuItem
class="flex gap-[.5ch] items-center rounded-none transition-colors"
:class="cn({ 'text-emerald-600': locale === 'fr' })"
@select.prevent="setLocale('fr')"
>
<PhCheckCircle v-if="locale === 'fr'" size="20" weight="fill" />
<img v-else src="/images/flag-fr.png" width="20" alt="" loading="eager">
<span>Français</span>
</UiDropdownMenuItem>
<UiDropdownMenuItem
class="flex gap-[.5ch] items-center rounded-none transition-colors"
:class="cn({ 'text-emerald-600': locale === 'en' })"
@select.prevent="setLocale('en')"
>
<PhCheckCircle v-if="locale === 'en'" size="20" weight="fill" />
<img v-else src="/images/flag-uk.png" width="20" alt="" loading="eager">
<span>English</span>
</UiDropdownMenuItem>
</UiDropdownMenuSubContent>
</UiDropdownMenuSubContent>
</UiDropdownMenuPortal>
</UiDropdownMenuSub>
<UiDropdownMenuSeparator />
<template v-if="user">
<UiDropdownMenuItem class="flex gap-[.5ch] items-center rounded-none" @click="handleLogout">
<PhSignOut size="20" weight="fill" />
<span>
{{ $t('ui.sidebarMenu.logout') }}
</span>
</UiDropdownMenuItem>
</template>
<UiDropdownMenuItem v-if="!user" class="flex gap-[.5ch] items-center rounded-none" @click="handleGoogleLogin">
<PhSignIn size="18" weight="fill" />
<span>
{{ $t('ui.sidebarMenu.login') }}
</span>
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</ClientOnly>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import { type AlertDialogEmits, type AlertDialogProps, AlertDialogRoot, useForwardPropsEmits } from "radix-vue"
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue"
import { AlertDialogAction, type AlertDialogActionProps } from "radix-vue"
import { cn } from "@/lib/utils"
import { buttonVariants } from "~/components/ui/button/index"
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue"
import { AlertDialogCancel, type AlertDialogCancelProps } from "radix-vue"
import { cn } from "@/lib/utils"
import { buttonVariants } from "~/components/ui/button/index"
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AlertDialogCancel v-bind="delegatedProps" :class="cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)">
<slot />
</AlertDialogCancel>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue"
import {
AlertDialogContent,
type AlertDialogContentEmits,
type AlertDialogContentProps,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from "radix-vue"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
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"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-[calc(100%-2rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 gap-4 border border-indigo-200 bg-background dark:bg-slate-950 dark:border-indigo-950 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-50% data-[state=closed]:slide-out-to-top-48% data-[state=open]:slide-in-from-left-50% data-[state=open]:slide-in-from-top-48% rounded-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue"
import {
AlertDialogDescription,
type AlertDialogDescriptionProps,
} from "radix-vue"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AlertDialogDescription
v-bind="delegatedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</AlertDialogDescription>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue"
import { AlertDialogTitle, type AlertDialogTitleProps } from "radix-vue"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AlertDialogTitle
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { AlertDialogTrigger, type AlertDialogTriggerProps } from "radix-vue"
const props = defineProps<AlertDialogTriggerProps>()
</script>
<template>
<AlertDialogTrigger v-bind="props">
<slot />
</AlertDialogTrigger>
</template>

View File

@@ -0,0 +1,9 @@
export { default as AlertDialog } from "@/components/ui/alert-dialog/AlertDialog.vue"
export { default as AlertDialogTrigger } from "@/components/ui/alert-dialog/AlertDialogTrigger.vue"
export { default as AlertDialogContent } from "@/components/ui/alert-dialog/AlertDialogContent.vue"
export { default as AlertDialogHeader } from "@/components/ui/alert-dialog/AlertDialogHeader.vue"
export { default as AlertDialogTitle } from "@/components/ui/alert-dialog/AlertDialogTitle.vue"
export { default as AlertDialogDescription } from "@/components/ui/alert-dialog/AlertDialogDescription.vue"
export { default as AlertDialogFooter } from "@/components/ui/alert-dialog/AlertDialogFooter.vue"
export { default as AlertDialogAction } from "@/components/ui/alert-dialog/AlertDialogAction.vue"
export { default as AlertDialogCancel } from "@/components/ui/alert-dialog/AlertDialogCancel.vue"

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { AvatarRoot } from "radix-vue"
import { type AvatarVariants, avatarVariant } from "."
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
size?: AvatarVariants["size"]
shape?: AvatarVariants["shape"]
}>(), {
size: "sm",
shape: "circle",
})
</script>
<template>
<AvatarRoot :class="cn(avatarVariant({ size, shape }), props.class)">
<slot />
</AvatarRoot>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { AvatarFallback, type AvatarFallbackProps } from "radix-vue"
const props = defineProps<AvatarFallbackProps>()
</script>
<template>
<AvatarFallback v-bind="props">
<slot />
</AvatarFallback>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import { AvatarImage, type AvatarImageProps } from "radix-vue"
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage v-bind="props" class="h-full w-full object-cover" />
</template>

View File

@@ -0,0 +1,24 @@
import { type VariantProps, cva } from "class-variance-authority"
export { default as Avatar } from "@/components/ui/avatar/Avatar.vue"
export { default as AvatarImage } from "@/components/ui/avatar/AvatarImage.vue"
export { default as AvatarFallback } from "@/components/ui/avatar/AvatarFallback.vue"
export const avatarVariant = cva(
"inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden",
{
variants: {
size: {
sm: "h-10 w-10 text-xs",
base: "h-16 w-16 text-2xl",
lg: "h-32 w-32 text-5xl",
},
shape: {
circle: "rounded-full",
square: "rounded-md",
},
},
},
)
export type AvatarVariants = VariantProps<typeof avatarVariant>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { type BadgeVariants, badgeVariants } from "."
import { cn } from "@/lib/utils"
const props = defineProps<{
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn(badgeVariants({ variant }), props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,25 @@
import { type VariantProps, cva } from "class-variance-authority"
export { default as Badge } from "@/components/ui/badge/Badge.vue"
export const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<nav aria-label="breadcrumb" :class="props.class">
<slot />
</nav>
</template>

View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { PhArrowsHorizontal } from "@phosphor-icons/vue";
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
role="presentation"
aria-hidden="true"
:class="cn('flex h-9 w-9 items-center justify-center', props.class)"
>
<slot>
<PhArrowsHorizontal class="size-4" />
</slot>
<span class="sr-only">More</span>
</span>
</template>

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<li
:class="cn('inline-flex items-center gap-1.5', props.class)"
>
<slot />
</li>
</template>

View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { PrimitiveProps } from "radix-vue"
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { Primitive } from "radix-vue"
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>(), {
as: "a",
})
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn('transition-colors rounded-[1px] hover:text-primary focus-visible:text-primary underline-offset-4 hover:underline focus-visible:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-4', props.class)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<ol
:class="cn('flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5', props.class)"
>
<slot />
</ol>
</template>

View File

@@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
role="link"
aria-disabled="true"
aria-current="page"
:class="cn('font-normal text-foreground', props.class)"
>
<slot />
</span>
</template>

View File

@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { PhCaretRight } from "@phosphor-icons/vue";
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<li
role="presentation"
aria-hidden="true"
:class="cn('[&>svg]:w-3 [&>svg]:h-3', props.class)"
>
<slot>
<PhCaretRight />
</slot>
</li>
</template>

View File

@@ -0,0 +1,7 @@
export { default as Breadcrumb } from "@/components/ui/breadcrumb/Breadcrumb.vue"
export { default as BreadcrumbEllipsis } from "@/components/ui/breadcrumb/BreadcrumbEllipsis.vue"
export { default as BreadcrumbItem } from "@/components/ui/breadcrumb/BreadcrumbItem.vue"
export { default as BreadcrumbLink } from "@/components/ui/breadcrumb/BreadcrumbLink.vue"
export { default as BreadcrumbList } from "@/components/ui/breadcrumb/BreadcrumbList.vue"
export { default as BreadcrumbPage } from "@/components/ui/breadcrumb/BreadcrumbPage.vue"
export { default as BreadcrumbSeparator } from "@/components/ui/breadcrumb/BreadcrumbSeparator.vue"

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { Primitive, type PrimitiveProps } from "radix-vue"
import { type ButtonVariants, buttonVariants } from "."
import { cn } from "@/lib/utils"
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
searchSlash?: boolean
}
const props = withDefaults(defineProps<Props>(), {
as: "button"
})
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class, 'group cursor-pointer')"
>
<slot />
<span
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-xs group-hover:text-slate-600 group-hover:bg-slate-200 group-hover:border-slate-400 transition-colors"
>
<kbd class="-mt-[2px]">CTRL + :</kbd>
</span>
</Primitive>
</template>

View File

@@ -0,0 +1,32 @@
import { type VariantProps, cva } from "class-variance-authority"
export { default as Button } from "@/components/ui/button/Button.vue"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-1 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-30",
{
variants: {
variant: {
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-primary/20",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline"
},
size: {
default: "h-10 px-4 py-2",
xs: "h-7 rounded-sm px-2 text-xs",
sm: "h-9 rounded-md px-3 text-sm",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
link?: string
hasClick?: boolean
}>()
const emit = defineEmits(["on-click"])
</script>
<template>
<div
:class="
cn('rounded-lg border bg-card text-card-foreground shadow-xs transition-all isolate', props.class, {
'relative hover:-translate-y-[.2rem]':
props.link || props.hasClick
})
"
>
<NuxtLink
v-if="props.link"
:to="props.link"
class="rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 absolute inset-0 z-10 cursor-pointer"
/>
<button
v-if="props.hasClick"
class="rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 absolute inset-0 z-10 cursor-pointer"
@click="emit('on-click')"
/>
<slot />
</div>
</template>

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('flex items-center p-6 border-t-2 border-t-border', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h3
:class="
cn('text-2xl font-semibold leading-none tracking-tight', props.class)
"
>
<slot />
</h3>
</template>

View File

@@ -0,0 +1,6 @@
export { default as Card } from "@/components/ui/card/Card.vue"
export { default as CardHeader } from "@/components/ui/card/CardHeader.vue"
export { default as CardTitle } from "@/components/ui/card/CardTitle.vue"
export { default as CardDescription } from "@/components/ui/card/CardDescription.vue"
export { default as CardContent } from "@/components/ui/card/CardContent.vue"
export { default as CardFooter } from "@/components/ui/card/CardFooter.vue"

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue"
import type { CheckboxRootEmits, CheckboxRootProps } from "radix-vue"
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "radix-vue"
import { cn } from "@/lib/utils"
import { PhCheck } from "@phosphor-icons/vue";
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CheckboxRoot
v-bind="forwarded"
:class="
cn('peer size-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
props.class)"
>
<CheckboxIndicator class="flex h-full w-full items-center justify-center text-current">
<slot>
<PhCheck size="10" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as Checkbox } from "@/components/ui/checkbox/Checkbox.vue"

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue"
import type { ComboboxRootEmits, ComboboxRootProps } from "radix-vue"
import { ComboboxRoot, useForwardPropsEmits } from "radix-vue"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<ComboboxRootProps & { class?: HTMLAttributes["class"] }>(), {
open: true,
modelValue: ""
})
const emits = defineEmits<ComboboxRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ComboboxRoot
v-bind="forwarded"
:class="
cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
props.class
)
"
>
<slot />
</ComboboxRoot>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { useForwardPropsEmits } from "radix-vue"
import type { DialogRootEmits, DialogRootProps } from "radix-vue"
import Command from "./Command.vue"
import { Dialog, DialogContent } from "~/components/ui/dialog"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<Dialog v-bind="forwarded">
<DialogContent class="overflow-hidden p-0 shadow-lg">
<Command
class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
>
<slot />
</Command>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue"
import type { ComboboxEmptyProps } from "radix-vue"
import { ComboboxEmpty } from "radix-vue"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxEmptyProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ComboboxEmpty v-bind="delegatedProps" :class="cn('py-6 text-center text-sm', props.class)">
<slot />
</ComboboxEmpty>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue"
import type { ComboboxGroupProps } from "radix-vue"
import { ComboboxGroup, ComboboxLabel } from "radix-vue"
import { cn } from "@/lib/utils"
const props = defineProps<
ComboboxGroupProps & {
class?: HTMLAttributes["class"]
heading?: string
}
>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ComboboxGroup
v-bind="delegatedProps"
:class="
cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
props.class
)
"
>
<ComboboxLabel v-if="heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ heading }}
</ComboboxLabel>
<slot />
</ComboboxGroup>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue"
import { ComboboxInput, type ComboboxInputProps, useForwardProps } from "radix-vue"
import { cn } from "@/lib/utils"
import { PhMagnifyingGlass } from "@phosphor-icons/vue"
defineOptions({
inheritAttrs: false
})
const props = defineProps<
ComboboxInputProps & {
class?: HTMLAttributes["class"]
}
>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<div class="flex items-center border-b px-3" cmdk-input-wrapper>
<PhMagnifyingGlass class="mr-2 size-4 shrink-0 opacity-50" />
<ComboboxInput
v-bind="{ ...forwardedProps, ...$attrs }"
auto-focus
:class="
cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
props.class
)
"
/>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More