42 Commits

Author SHA1 Message Date
Alexis
674a38c30d Fixed search shortcut 2025-08-24 11:38:10 +02:00
Alexis
e7cef314a1 Fixed untranslated string 2025-08-24 11:26:11 +02:00
Alexis
806d3a7908 Updated package 2025-08-23 22:45:35 +02:00
Alexis
6251499940 Added sheet for sidebar mobile 2025-08-15 15:37:35 +02:00
Alexis
f3d5db76ac Removed sidebar toggle on public pages 2025-08-12 11:18:47 +02:00
Alexis
08620b5044 Made content body smaller 2025-08-12 11:16:52 +02:00
Alexis
f89cc22e87 Fixed quick create dialog not resetting skeleton 2025-08-12 11:09:28 +02:00
Alexis
51c678d27e Fixed dashboard link hitbox 2025-08-12 10:44:56 +02:00
Alexis
b9b891c61f Fixed nav colors in dark mode 2025-08-12 10:43:49 +02:00
Alexis
df06228e68 Fixed transition render issue 2025-08-12 10:37:37 +02:00
Alexis
44193aa018 Added about link to CTA 2025-08-12 10:34:23 +02:00
Alexis
73eceda96c Changed secondary button color 2025-08-12 10:18:07 +02:00
Alexis
e305b21f80 Changed projects page handle 2025-08-12 10:11:28 +02:00
Alexis
d2b66a42dd Fixed dark mode select hover 2025-08-12 10:07:12 +02:00
Alexis
be32a018c6 Added input focus on entity forms 2025-08-11 20:23:25 +02:00
Alexis
a5a232bac7 Added navbar on public pages 2025-08-11 20:00:04 +02:00
Alexis
20045b125e Changed explore page h2 2025-08-09 21:06:42 +02:00
Alexis
f1a3854d1d Merge branch 'feat/drag-events' into dev 2025-08-09 18:56:57 +02:00
Alexis
cebcb50903 Added tooltip on rich text 2025-08-09 18:55:55 +02:00
Alexis
396215bb91 Fixed some UI issues with drag state 2025-08-09 16:40:50 +02:00
Alexis
7468374bb5 Added update logic for single day events 2025-08-09 16:24:59 +02:00
Alexis
5a421cf07f Merge branch 'feat/editor' into dev 2025-08-08 10:49:14 +02:00
Alexis
25a9099a18 Added rich text to event description 2025-08-08 10:48:29 +02:00
Alexis
c208393f9f Bigger richtext window 2025-08-07 23:01:34 +02:00
Alexis
d75f3b5423 Added control options 2025-08-07 22:55:53 +02:00
Alexis
97ce5b3553 Added simple tiptap component 2025-08-07 22:37:49 +02:00
Alexis
0a35f441e0 Merge branch 'code/cleanup' into dev 2025-08-04 22:43:49 +02:00
Alexis
c8a345406c Updated packages and some other colours 2025-08-04 22:43:38 +02:00
Alexis
119968e7fc Fixed a bunch of color transition issues on theme switch 2025-08-04 21:48:57 +02:00
Alexis
9b8f48ad8e Fixed tooltip position on client side component 2025-08-04 16:45:46 +02:00
Alexis
2199359b96 Fixed badge color 2025-08-04 13:41:43 +02:00
Alexis
422673cc4c Updated color scheme 2025-08-04 12:31:28 +02:00
Alexis
6aad01c96f Updated color scheme and identity 2025-08-03 19:42:43 +02:00
Alexis
0c96466263 Updated data loading strategy on world page 2025-08-02 22:42:23 +02:00
Alexis
7697e818bf Fixed supabase channel conflicts 2025-08-01 17:18:06 +02:00
Alexis
fd766b9f42 Updated packages 2025-07-29 22:45:22 +02:00
Alexis
0e7b60235a Fixed log in animation 2025-07-29 22:30:14 +02:00
Alexis
300507bbed Made sidebar image smaller 2025-07-29 21:59:36 +02:00
Alexis
693f506610 Reworked data fetching for explore page 2025-07-27 18:57:31 +02:00
Alexis
f2076e413c Made about page more reactive 2025-07-27 18:17:13 +02:00
Alexis
6b02629b01 Fixed nuxt database warning 2025-07-27 17:31:21 +02:00
Alexis
1d8b56c58e Merge branch 'upgrade/nuxt-4' into dev 2025-07-27 15:14:33 +02:00
89 changed files with 4414 additions and 2464 deletions

View File

@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ConfigProvider } from "radix-vue" import { ConfigProvider } from "radix-vue"
import { cn } from "./lib/utils";
useHead({ useHead({
titleTemplate: (titleChunk) => { titleTemplate: (titleChunk) => {
@@ -27,33 +26,17 @@ useHead({
}) })
const useIdFunction = () => useId() const useIdFunction = () => useId()
const { isSidebarOpened } = storeToRefs(useUiStore())
</script> </script>
<template> <template>
<div class="h-screen"> <div class="h-screen">
<NuxtLoadingIndicator /> <NuxtLoadingIndicator />
<NuxtLayout> <ConfigProvider :use-id="useIdFunction">
<ConfigProvider :use-id="useIdFunction"> <NuxtLayout>
<div <NuxtPage />
class="h-full grid md:grid-cols-[auto_1fr] dark:bg-black shadow-body-light dark:shadow-body-dark transition-colors after:absolute after:transition-colors" </NuxtLayout>
:class="cn({ </ConfigProvider>
'max-md:after:bg-transparent': isSidebarOpened,
'has-sidebar max-md:after:bg-black/20 md:after:opacity-0 md:after:pointer-events-none': isSidebarOpened
})"
>
<Sidebar />
<div
class="wrapper max-h-screen transition-all overflow-y-auto"
>
<NuxtPage />
</div>
</div>
</ConfigProvider>
</NuxtLayout>
<ClientOnly> <ClientOnly>
<UiToaster /> <UiToaster />

View File

@@ -116,6 +116,11 @@
--color-event-white-background: var(--event-white-background); --color-event-white-background: var(--event-white-background);
} }
::selection {
color: var(--color-background);
background: color-mix(in srgb, var(--color-primary), var(--color-foreground) 20%)
}
.event-button, .event-button,
.event-popover, .event-popover,
.event-callout, .event-callout,

View File

@@ -40,23 +40,19 @@
} }
} }
p, ul, ol {
font-size: var(--text-sm);
}
/* Normal link */ /* Normal link */
p a:not([class]) { p a:not([class]) {
color: var(--color-sky-600); color: var(--color-primary);
text-underline-offset: .25rem; text-underline-offset: .25rem;
&:hover { &:hover {
color: var(--color-sky-500); color: var(--color-accent);
text-decoration: underline; text-decoration: underline;
} }
@variant dark {
color: var(--color-teal-500);
&:hover {
color: var(--color-teal-300);
}
}
} }
ul { ul {

View File

@@ -2,3 +2,9 @@
--text-2xs: 0.7rem; --text-2xs: 0.7rem;
--text-2xs--line-height: 0.75rem; --text-2xs--line-height: 0.75rem;
} }
@layer utilities {
.flow > * + * {
margin-top: 1em;
}
}

View File

@@ -4,6 +4,7 @@
@import "./theme.css" layer(theme); @import "./theme.css" layer(theme);
@import "./_typography.css" layer(theme); @import "./_typography.css" layer(theme);
@import "./_typography.css" layer(utilities);
@import "./_colors.css" layer(theme); @import "./_colors.css" layer(theme);
@import "./_colors.css" layer(utilities); @import "./_colors.css" layer(utilities);
@@ -33,18 +34,30 @@ body {
} }
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active,
.fade-group-enter-active,
.fade-group-leave-active {
transition: all .5s ease; transition: all .5s ease;
} }
.fade-delay-enter-active, .fade-delay-enter-active,
.fade-delay-leave-active { .fade-delay-leave-active,
.fade-group-delay-enter-active,
.fade-group-delay-leave-active {
transition: all .5s ease 1s; transition: all .5s ease 1s;
} }
.fade-enter-from, .fade-enter-from,
.fade-leave-to, .fade-leave-to,
.fade-delay-enter-from, .fade-delay-enter-from,
.fade-delay-leave-to { .fade-delay-leave-to,
.fade-group-enter-from,
.fade-group-leave-to,
.fade-group-delay-enter-from,
.fade-group-delay-leave-to {
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
} }
.fade-group-leave-active {
position: absolute;
}

View File

@@ -7,6 +7,7 @@
--popover: hsl(0 0% 100%); --popover: hsl(0 0% 100%);
--popover-foreground: hsl(222.2 84% 4.9%); --popover-foreground: hsl(222.2 84% 4.9%);
--popover-border: rgb(175, 224, 200);
--card: hsl(0 0% 100%); --card: hsl(0 0% 100%);
--card-foreground: hsl(222.2 84% 4.9%); --card-foreground: hsl(222.2 84% 4.9%);
@@ -14,16 +15,16 @@
--border: hsl(214.3 31.8% 91.4%); --border: hsl(214.3 31.8% 91.4%);
--input: hsl(214.3 31.8% 91.4%); --input: hsl(214.3 31.8% 91.4%);
--primary: hsl(245 58% 51%); --primary: rgb(101, 230, 166);
--primary-foreground: hsl(0 0% 100%); --primary-foreground: hsl(222.2 84% 4.9%);
--secondary: hsl(210 50% 95%); --secondary: hsl(210 50% 95%);
--secondary-foreground: hsl(222.2 47.4% 11.2%); --secondary-foreground: hsl(222.2 84% 4.9%);
--accent: hsl(210 40% 96.1%); --accent: hsl(11, 100%, 86%);
--accent-foreground: hsl(222.2 47.4% 11.2%); --accent-foreground: hsl(222.2 84% 4.9%);
--destructive: hsl(0 84.2% 60.2%); --destructive: hsl(0, 100%, 63%);
--destructive-foreground: hsl(210 40% 98%); --destructive-foreground: hsl(210 40% 98%);
--success: hsl(156 72% 67%); --success: hsl(156 72% 67%);
@@ -42,25 +43,26 @@
--muted: hsl(0, 0%, 12%); --muted: hsl(0, 0%, 12%);
--muted-foreground: hsl(215 20.2% 65.1%); --muted-foreground: hsl(215 20.2% 65.1%);
--popover: hsl(180 0 4.9%); --popover: hsl(165, 47%, 5%);
--popover-foreground: hsl(210 40% 98%); --popover-foreground: hsl(210 40% 98%);
--popover-border: hsl(165, 47%, 20%);
--card: hsl(180 0 4.9%); --card: hsl(180 0 4.9%);
--card-foreground: hsl(210 40% 98%); --card-foreground: hsl(210 40% 98%);
--border: hsl(0, 0%, 16%); --border: hsl(0, 0%, 22.5%);
--input: hsl(244 47% 20%); --input: hsl(165, 47%, 20%);
--primary: hsl(245 58% 51%); --primary: rgb(101, 230, 166);
--primary-foreground: hsl(0 0% 100%); --primary-foreground: hsl(180 0 4.9%);
--secondary: hsl(244 47% 20%); --secondary: hsl(0, 0%, 22.5%);
--secondary-foreground: hsl(210 40% 98%); --secondary-foreground: hsl(210 40% 98%);
--accent: hsl(244 47% 20%); --accent: hsl(11, 87%, 69%);
--accent-foreground: hsl(210 40% 98%); --accent-foreground: hsl(222.2 84% 4.9%);
--destructive: hsl(0 62.8% 30.6%); --destructive: hsl(0, 62%, 49%);
--destructive-foreground: hsl(210 40% 98%); --destructive-foreground: hsl(210 40% 98%);
--success: hsl(142 72% 29%); --success: hsl(142 72% 29%);
@@ -81,6 +83,7 @@
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-popover-border: var(--popover-border);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);

View File

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

View File

@@ -51,7 +51,7 @@ function closeDeleteDialog() {
<UiDialogContent <UiDialogContent
:disable-outside-pointer-events="true" :disable-outside-pointer-events="true"
:trap-focus="true" :trap-focus="true"
class="min-w-96 border-indigo-200 dark:bg-slate-950 dark:border-indigo-950" class="min-w-96"
@escape-key-down="closeDeleteDialog" @escape-key-down="closeDeleteDialog"
@focus-outside="closeDeleteDialog" @focus-outside="closeDeleteDialog"
@interact-outside="closeDeleteDialog" @interact-outside="closeDeleteDialog"

View File

@@ -86,7 +86,7 @@ async function submitNew() {
<div class="border-y-[1px] border-b-foreground/10 mr-4"> <div class="border-y-[1px] border-b-foreground/10 mr-4">
<form <form
ref="rowRef" 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" class="grid grid-cols-12 items-center gap-4 p-1 bg-primary/0 hover:bg-primary/15"
@submit.prevent="submitNew" @submit.prevent="submitNew"
> >
<div v-if="currentMode === 'add'" class="col-span-1" /> <div v-if="currentMode === 'add'" class="col-span-1" />

View File

@@ -103,8 +103,8 @@ function handleQueryDelete() {
<form <form
class="grid grid-cols-12 items-center gap-4 p-1 border-b-[1px] border-b-foreground/10" class="grid grid-cols-12 items-center gap-4 p-1 border-b-[1px] border-b-foreground/10"
:class="cn( :class="cn(
{ 'bg-slate-100 hover:bg-slate-200 dark:bg-slate-900 dark:hover:bg-slate-800': currentMode === 'edit' }, { 'bg-primary/10 hover:bg-primary/20': currentMode === 'edit' },
{ 'bg-transparent hover:bg-slate-50 dark:bg-transparent dark:hover:bg-slate-900': currentMode !== 'edit' } { 'bg-primary/0 hover:bg-primary/15': currentMode !== 'edit' }
)" )"
@submit.prevent="submitUpdate" @submit.prevent="submitUpdate"
> >
@@ -162,9 +162,9 @@ function handleQueryDelete() {
v-if="currentMode === 'edit' && categorySkeleton" v-if="currentMode === 'edit' && categorySkeleton"
> >
<UiButton <UiButton
variant="secondary" variant="default"
size="icon" size="icon"
class="w-6 h-6 rounded-full bg-emerald-500 hover:bg-emerald-600 text-white" class="w-6 h-6 rounded-full"
:title="$t('ui.actions.edit')" :title="$t('ui.actions.edit')"
@click="submitUpdate" @click="submitUpdate"
> >
@@ -173,9 +173,9 @@ function handleQueryDelete() {
</li> </li>
<li v-else-if="rowHovered"> <li v-else-if="rowHovered">
<UiButton <UiButton
variant="secondary" variant="destructive"
size="icon" size="icon"
class="w-6 h-6 rounded-full hover:bg-red-600 hover:text-white" class="w-6 h-6 rounded-full"
:title="$t('ui.actions.delete')" :title="$t('ui.actions.delete')"
@click="handleQueryDelete" @click="handleQueryDelete"
> >

View File

@@ -59,7 +59,7 @@ watch(currentRPGDate, () => {
:collision-padding="60" :collision-padding="60"
:disable-outside-pointer-events="true" :disable-outside-pointer-events="true"
:trap-focus="true" :trap-focus="true"
class="pl-3 w-[30rem] max-w-full border-indigo-200 dark:bg-slate-950 dark:border-indigo-950" class="pl-3 w-[30rem] max-w-full"
@escape-key-down.prevent="handleClosing" @escape-key-down.prevent="handleClosing"
@pointer-down-outside.prevent="handleClosing" @pointer-down-outside.prevent="handleClosing"
> >

View File

@@ -27,7 +27,7 @@ function handleClosing(e: Event): void {
<UiAlertDialogContent <UiAlertDialogContent
:disable-outside-pointer-events="true" :disable-outside-pointer-events="true"
:trap-focus="true" :trap-focus="true"
class="min-w-96 border-indigo-200 dark:bg-slate-950 dark:border-indigo-950" class="min-w-96"
@escape-key-down="handleClosing" @escape-key-down="handleClosing"
@focus-outside="handleClosing" @focus-outside="handleClosing"
@interact-outside="handleClosing" @interact-outside="handleClosing"

View File

@@ -6,6 +6,13 @@ import { VisuallyHidden } from "radix-vue";
const isDialogOpen = ref<boolean>(false); const isDialogOpen = ref<boolean>(false);
const { resetSkeleton } = useCalendar(); const { resetSkeleton } = useCalendar();
// Watch the popover state
watch(isDialogOpen, (hasOpened, _o) => {
if (hasOpened) {
resetSkeleton()
}
})
// Toggles the dialog // Toggles the dialog
function toggleDialog() { function toggleDialog() {
isDialogOpen.value = !isDialogOpen.value; isDialogOpen.value = !isDialogOpen.value;
@@ -45,7 +52,7 @@ const breakpoints = useBreakpoints(
class="max-md:translate-0 max-md:inset-0 max-md:w-full max-md:block" class="max-md:translate-0 max-md:inset-0 max-md:w-full max-md:block"
:trap-focus="true" :trap-focus="true"
@escape-key-down="handleClosing" @escape-key-down="handleClosing"
@pointer-down-outside.prevent="handleClosing" @pointer-down-outside="handleClosing"
> >
<UiDialogTitle class="max-md:mb-8"> <UiDialogTitle class="max-md:mb-8">
{{ $t("entity.calendar.event.addSingle") }} {{ $t("entity.calendar.event.addSingle") }}

View File

@@ -29,7 +29,7 @@ function handleClosing() {
@escape-key-down="handleClosing" @escape-key-down="handleClosing"
@focus-outside="handleClosing" @focus-outside="handleClosing"
@interact-outside="handleClosing" @interact-outside="handleClosing"
@pointer-down-outside="(e) => e.preventDefault()" @pointer-down-outside.prevent
> >
<header class="pl-8 grid gap-y-2 max-md:mb-8"> <header class="pl-8 grid gap-y-2 max-md:mb-8">
<UiDialogTitle> <UiDialogTitle>

View File

@@ -160,8 +160,8 @@ function deployDeleteModal() {
<template v-if="event.description"> <template v-if="event.description">
<hr class="border-border mt-2" > <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"> <div class="max-h-48 overflow-y-auto mt-2 text-sm opacity-80">
{{ event.description }} <div class="content-editor" v-html="event.description" />
</div> </div>
</template> </template>
</div> </div>
@@ -187,19 +187,19 @@ function deployDeleteModal() {
</menu> </menu>
<nav v-if="event.startDate && event.endDate" class="mt-2 flex gap-2"> <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> <UiBadge as-child>
<button <button
class="flex gap-1" class="flex gap-1 cursor-pointer"
title="Naviguer au début" :title="$t('entity.calendar.event.gotoStart')"
@click="handleJumpToDate(event.startDate!)" @click="handleJumpToDate(event.startDate!)"
> >
<PhHourglassHigh size="16" weight="fill" /> {{ $t('entity.calendar.event.isStart') }} <PhHourglassHigh size="16" weight="fill" /> {{ $t('entity.calendar.event.isStart') }}
</button> </button>
</UiBadge> </UiBadge>
<UiBadge class="hover:bg-indigo-400 dark:hover:bg-slate-300" as-child title="Naviguer à la fin"> <UiBadge as-child>
<button <button
class="flex gap-1" class="flex gap-1 cursor-pointer"
title="Naviguer à la fin" :title="$t('entity.calendar.event.gotoEnd')"
@click="handleJumpToDate(event.endDate!)" @click="handleJumpToDate(event.endDate!)"
> >
<PhHourglassLow size="16" weight="fill" /> {{ $t('entity.calendar.event.isEnd') }} <PhHourglassLow size="16" weight="fill" /> {{ $t('entity.calendar.event.isEnd') }}

View File

@@ -2,14 +2,17 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import type { RPGDate } from "@@/models/Date" import type { RPGDate } from "@@/models/Date"
import type { CalendarEvent } from "@@/models/CalendarEvent" import type { CalendarEvent } from "@@/models/CalendarEvent"
import { ja } from "zod/v4/locales";
const props = defineProps<{ const props = defineProps<{
event: CalendarEvent event: CalendarEvent
tileDate: RPGDate tileDate: RPGDate
}>() }>()
const { areDatesIdentical, revealEditEventModal, revealDeleteEventModal } = useCalendar() const buttonRef = useTemplateRef<HTMLButtonElement>('buttonRef')
const { lastActiveEvent } = storeToRefs(useCalendar())
const { areDatesIdentical, revealEditEventModal, revealDeleteEventModal, resetSkeleton } = useCalendar()
const { lastActiveEvent, eventSkeleton, isDraggingEvent } = storeToRefs(useCalendar())
const spansMultipleDays = computed(() => Boolean(props.event.startDate && props.event.endDate)) const spansMultipleDays = computed(() => Boolean(props.event.startDate && props.event.endDate))
const isStartEvent = computed(() => spansMultipleDays.value && areDatesIdentical(props.tileDate, props.event.startDate)) const isStartEvent = computed(() => spansMultipleDays.value && areDatesIdentical(props.tileDate, props.event.startDate))
@@ -49,13 +52,29 @@ onMounted(() => {
handleDelete() handleDelete()
}) })
}) })
// Draggable logic
const isTileDragging = shallowRef(false)
function handleDragStart() {
isTileDragging.value = true
isDraggingEvent.value = true
eventSkeleton.value = structuredClone(toRaw(props.event))
handleClosePopover()
}
function handleDragEnd() {
isTileDragging.value = false
isDraggingEvent.value = false
}
</script> </script>
<template> <template>
<UiPopover v-model:open="isPopoverDetailsOpen"> <UiPopover v-model:open="isPopoverDetailsOpen">
<UiPopoverTrigger as-child> <UiPopoverTrigger as-child>
<button <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" ref="buttonRef"
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-opacity outline-offset-1 cursor-pointer"
:draggable="event.endDate ? 'false' : 'true'"
:class=" :class="
cn( cn(
`element-${event.category?.color}`, `element-${event.category?.color}`,
@@ -63,11 +82,17 @@ onMounted(() => {
'is-hidden': event.hidden, 'is-hidden': event.hidden,
'rounded-r-none': isStartEvent, 'rounded-r-none': isStartEvent,
'rounded-l-none': isEndEvent, 'rounded-l-none': isEndEvent,
},
{
'opacity-60': isTileDragging,
'opacity-100': !isTileDragging
} }
) )
" "
@dblclick="handleDoubleClick" @dblclick="handleDoubleClick"
@keydown.delete="handleDelete" @keydown.delete="handleDelete"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
> >
<div class="line-clamp-2 [overflow-wrap:anywhere] hyphens-auto"> <div class="line-clamp-2 [overflow-wrap:anywhere] hyphens-auto">
{{ eventTitle }} {{ eventTitle }}

View File

@@ -16,6 +16,9 @@ onMounted(() => {
type FormTabs = "global" | "months" | "today" type FormTabs = "global" | "months" | "today"
const activeTab = ref<FormTabs>("global") const activeTab = ref<FormTabs>("global")
const newCalendarName = shallowRef<HTMLInputElement>()
useFocus(newCalendarName, { initialValue: true })
/** /**
* === Current date === * === Current date ===
*/ */
@@ -103,13 +106,14 @@ function handleFormCancel() {
</UiTabsList> </UiTabsList>
<UiTabsContent value="global" class="grid gap-4"> <UiTabsContent value="global" class="grid gap-4">
<input <input
ref="newCalendarName"
id="new-calendar-name" id="new-calendar-name"
v-model="calendarSkeleton.name" v-model="calendarSkeleton.name"
type="text" type="text"
name="new-calendar-name" name="new-calendar-name"
required required
:placeholder="$t('common.title')" :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" class="w-full py-2 -mx-1 px-1 text-xl border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-primary invalid:border-destructive"
@input="handleNameChange" @input="handleNameChange"
> >

View File

@@ -66,7 +66,7 @@ function handleCancel() {
:placeholder="$t('entity.calendar.event.title')" :placeholder="$t('entity.calendar.event.title')"
:maxlength="120" :maxlength="120"
pattern="([A-Za-zÀ-ÖØ-öø-ÿ0-9\s\&\-\~]+){3,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" 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-primary invalid:border-destructive"
> >
<div class="mt-2 mb-1 text-xs opacity-50"> <div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.title') }} {{ t('entity.calendar.event.patterns.title') }}
@@ -74,14 +74,11 @@ function handleCancel() {
</div> </div>
<div class="col-span-2 my-2 pl-8"> <div class="col-span-2 my-2 pl-8">
<textarea <InputEditor
id="new-event-description" disable-blocks
v-model="eventSkeleton.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"> <div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.description') }} {{ t('entity.calendar.event.patterns.description') }}
</div> </div>
@@ -132,7 +129,7 @@ function handleCancel() {
:placeholder="$t('entity.calendar.event.addLocation')" :placeholder="$t('entity.calendar.event.addLocation')"
:maxlength="160" :maxlength="160"
pattern="([A-Za-zÀ-ÖØ-öø-ÿ0-9\s\&\-\~]+){3,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" class="w-full -my-1 py-2 px-2 text-sm border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-primary invalid:border-destructive"
> >
<div class="mt-2 mb-1 text-xs opacity-50"> <div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.location') }} {{ t('entity.calendar.event.patterns.location') }}

View File

@@ -17,6 +17,9 @@ onMounted(() => {
type FormTabs = "global" | "months" | "today" type FormTabs = "global" | "months" | "today"
const activeTab = ref<FormTabs>("global") const activeTab = ref<FormTabs>("global")
const newCalendarName = shallowRef<HTMLInputElement>()
useFocus(newCalendarName, { initialValue: true })
/** /**
* === Form Validation === * === Form Validation ===
*/ */
@@ -85,13 +88,14 @@ function handleFormCancel() {
</UiTabsList> </UiTabsList>
<UiTabsContent value="global" class="grid gap-4"> <UiTabsContent value="global" class="grid gap-4">
<input <input
ref="newCalendarName"
id="new-calendar-name" id="new-calendar-name"
v-model="calendarSkeleton.name" v-model="calendarSkeleton.name"
type="text" type="text"
name="new-calendar-name" name="new-calendar-name"
required required
:placeholder="$t('common.title')" :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" class="w-full py-2 -mx-1 px-1 text-xl border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-primary invalid:border-destructive"
@input="handleNameChange" @input="handleNameChange"
> >

View File

@@ -82,7 +82,7 @@ function handleCancel() {
:placeholder="$t('entity.calendar.event.title')" :placeholder="$t('entity.calendar.event.title')"
:minlength="3" :minlength="3"
:maxlength="120" :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" 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-primary invalid:border-destructive"
> >
<div class="mt-2 mb-1 text-xs opacity-50"> <div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.title') }} {{ t('entity.calendar.event.patterns.title') }}
@@ -92,14 +92,11 @@ function handleCancel() {
</div> </div>
<div class="col-span-2 my-2 ml-8"> <div class="col-span-2 my-2 ml-8">
<textarea <InputEditor
id="new-event-description" disable-blocks
v-model="eventSkeleton.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"> <div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.description') }} {{ t('entity.calendar.event.patterns.description') }}
</div> </div>
@@ -152,7 +149,7 @@ function handleCancel() {
:placeholder="$t('entity.calendar.event.addLocation')" :placeholder="$t('entity.calendar.event.addLocation')"
:minlength="3" :minlength="3"
:maxlength="160" :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" class="w-full -my-1 py-2 px-2 text-sm border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-primary invalid:border-destructive"
> >
<div class="mt-2 mb-1 text-xs opacity-50"> <div class="mt-2 mb-1 text-xs opacity-50">

View File

@@ -37,7 +37,7 @@ const filteredCategories = computed(() =>
role="combobox" role="combobox"
:aria-expanded="isPopoverOpen" :aria-expanded="isPopoverOpen"
aria-controls="event-categories" aria-controls="event-categories"
class="relative w-full max-w-full h-fit justify-between" class="relative w-full max-w-full h-fit justify-between hover:bg-secondary hover:text-primary-foreground"
> >
<template v-if="!model.length"> <template v-if="!model.length">
{{ props.placeholder }} {{ props.placeholder }}
@@ -57,7 +57,7 @@ const filteredCategories = computed(() =>
</UiPopoverTrigger> </UiPopoverTrigger>
<UiPopoverContent <UiPopoverContent
id="event-categories" id="event-categories"
align="start" :align="'start'"
side="bottom" side="bottom"
:collision-padding="50" :collision-padding="50"
class="w-fit h-[33vh] p-0" class="w-fit h-[33vh] p-0"

View File

@@ -36,7 +36,7 @@ const filteredCategories = computed(() =>
role="combobox" role="combobox"
:aria-expanded="isPopoverOpen" :aria-expanded="isPopoverOpen"
aria-controls="event-category" aria-controls="event-category"
class="w-full max-w-full justify-between" class="w-full max-w-full justify-between hover:bg-secondary hover:text-primary-foreground"
> >
<template v-if="!model"> <template v-if="!model">
{{ props.placeholder }} {{ props.placeholder }}

View File

@@ -4,6 +4,7 @@ import type { RPGDate } from "@@/models/Date";
import { import {
PhXCircle PhXCircle
} from "@phosphor-icons/vue" } from "@phosphor-icons/vue"
import { cn } from "~/lib/utils";
const model = defineModel<RPGDate | null>() const model = defineModel<RPGDate | null>()
@@ -117,7 +118,6 @@ function handleValueReset() {
v-if="model" v-if="model"
align="start" align="start"
side="bottom" 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"> <div class="grid grid-cols-2 items-center gap-x-2 gap-y-3">
<UiSelect @update:model-value="handleMonthChange"> <UiSelect @update:model-value="handleMonthChange">
@@ -135,7 +135,7 @@ function handleValueReset() {
<UiInput v-model="model.year" type="number" /> <UiInput v-model="model.year" type="number" />
<hr class="col-span-2" > <hr class="col-span-2" />
<div class="col-span-2"> <div class="col-span-2">
<div v-if="monthData" class="grid grid-cols-7 gap-1"> <div v-if="monthData" class="grid grid-cols-7 gap-1">
@@ -143,10 +143,10 @@ function handleValueReset() {
v-for="day in monthData.days" v-for="day in monthData.days"
:key="`popover-day-grid-${id}-${day}`" :key="`popover-day-grid-${id}-${day}`"
class="aspect-square rounded-full text-[.8em] transition-colors" class="aspect-square rounded-full text-[.8em] transition-colors"
:class="{ :class="cn({
'hover:bg-indigo-200 dark:hover:bg-indigo-700': day !== model.day, 'hover:bg-accent hover:text-accent-foreground': day !== model.day,
'bg-indigo-500 hover:bg-indigo-700 text-white': day === model.day 'bg-primary text-primary-foreground hover:bg-accent hover:text-accent-foreground': day === model.day
}" })"
@click="setModelDay(day)" @click="setModelDay(day)"
> >
{{ day }} {{ day }}

View File

@@ -139,7 +139,7 @@ function toFutureFar(): void {
<template> <template>
<div class="flex gap-2"> <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"> <div class="grid items-center w-40 px-3 md:px-4 py-2 bg-background border-border border-x-[1px] border-t-[1px] rounded-t-sm text-sm max-md:text-xs transition-colors">
<ClientOnly> <ClientOnly>
<span>{{ currentDate.currentDateTitle }}</span> <span>{{ currentDate.currentDateTitle }}</span>

View File

@@ -16,10 +16,10 @@ const breakpoints = useBreakpoints(
</script> </script>
<template> <template>
<UiTooltipProvider :delay-duration="250"> <ClientOnly>
<UiTooltip> <UiTooltipProvider :delay-duration="250">
<UiTooltipTrigger as-child> <UiTooltip>
<ClientOnly> <UiTooltipTrigger as-child>
<UiButton <UiButton
:size="breakpoints.md.value ? 'default' : 'icon'" :size="breakpoints.md.value ? 'default' : 'icon'"
variant="secondary" variant="secondary"
@@ -31,11 +31,11 @@ const breakpoints = useBreakpoints(
{{ $t('entity.calendar.date.today') }} {{ $t('entity.calendar.date.today') }}
</span> </span>
</UiButton> </UiButton>
</ClientOnly> </UiTooltipTrigger>
</UiTooltipTrigger> <UiTooltipContent>
<UiTooltipContent> <p>{{ defaultDateFormatted }}</p>
<p>{{ defaultDateFormatted }}</p> </UiTooltipContent>
</UiTooltipContent> </UiTooltip>
</UiTooltip> </UiTooltipProvider>
</UiTooltipProvider> </ClientOnly>
</template> </template>

View File

@@ -195,10 +195,9 @@ function handleEntitySwitch() {
// Key combos to deploy modal // Key combos to deploy modal
const keys = useMagicKeys() const keys = useMagicKeys()
const controlPeriod = computed(() => keys.control_period?.value)
whenever(keys.control_period, () => { whenever(controlPeriod, openUiDialog)
openUiDialog()
})
const searchResultsRef = ref<HTMLElement | null>(null) const searchResultsRef = ref<HTMLElement | null>(null)
const { y: searchResultsY } = useScroll(searchResultsRef) const { y: searchResultsY } = useScroll(searchResultsRef)

View File

@@ -113,9 +113,7 @@ const dateDuration = computed<string | null>(() => props.event.endDate ? getRela
<div v-if="event.description" class="text-xs md:text-sm"> <div v-if="event.description" class="text-xs md:text-sm">
<hr class="my-3 md:my-2 border-white opacity-50" > <hr class="my-3 md:my-2 border-white opacity-50" >
<span class="opacity-75"> <div class="content-editor opacity-75" v-html="event.description" />
{{ event.description }}
</span>
</div> </div>
</button> </button>
</template> </template>

View File

@@ -6,6 +6,9 @@ import { storeToRefs } from "pinia"
import { computed, ref, type ComputedRef } from "vue" import { computed, ref, type ComputedRef } from "vue"
import CalendarEventButton from "../../event/Event.vue" import CalendarEventButton from "../../event/Event.vue"
import { useToast } from "@/components/ui/toast"
import type { APIError } from "@@/models/Errors"
import { ToastLifetime } from "@/components/ui/toast/use-toast"
const props = defineProps<{ const props = defineProps<{
date: RPGDate date: RPGDate
@@ -16,11 +19,14 @@ const emit = defineEmits<{
"on-open-create-dialog": [date: RPGDate] "on-open-create-dialog": [date: RPGDate]
}>() }>()
const { toast } = useToast()
const { t } = useI18n()
const calendarTile = ref() const calendarTile = ref()
const calendarEventsList = ref() const calendarEventsList = ref()
const { defaultDate, selectDate, areDatesIdentical, getFormattedDateTitle } = useCalendar() const { defaultDate, selectDate, areDatesIdentical, getFormattedDateTitle, updateEventFromSkeleton, resetSkeleton } = useCalendar()
const { selectedDate, currentEvents, isReadOnly } = storeToRefs(useCalendar()) const { selectedDate, currentEvents, isReadOnly, eventSkeleton, isDraggingEvent } = storeToRefs(useCalendar())
const breakpoints = useBreakpoints( const breakpoints = useBreakpoints(
breakpointsTailwind breakpointsTailwind
@@ -60,9 +66,17 @@ const { top: tileListTop } = useElementBounding(calendarEventsList)
const numberOfEventsToFit: ComputedRef<number> = computed(() => { const numberOfEventsToFit: ComputedRef<number> = computed(() => {
if (!eventsForTheDay.value.length) return 0 if (!eventsForTheDay.value.length) return 0
return Math.trunc((tileHeight.value - (tileListTop.value - tileTop.value)) / 40) let offset = 0
if (isDraggingEvent.value && !tileNotHovered.value && !skeletonInsideCurrentDay.value) {
offset = 1
}
return Math.trunc((tileHeight.value - (tileListTop.value - tileTop.value)) / 40) - offset
}) })
const skeletonInsideCurrentDay = computed(() => eventsForTheDay.value.find((e) => e.id === eventSkeleton.value.id))
/** /**
* Events that can fit in the tile's space * Events that can fit in the tile's space
*/ */
@@ -76,16 +90,54 @@ const eventsToDisplay: ComputedRef<CalendarEvent[]> = computed<CalendarEvent[]>(
* Used if not all events can be seen in the tile's space * 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) const eventsNotDisplayed: ComputedRef<number> = computed<number>(() => eventsForTheDay.value.length - eventsToDisplay.value.length)
// Handle drop events
const { isOutside: tileNotHovered } = useMouseInElement(calendarTile)
const isLoading = ref(false)
async function handleTileDrop() {
if (isLoading.value) return
if (skeletonInsideCurrentDay.value) return
isLoading.value = true
eventSkeleton.value.startDate = props.date
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
}
isLoading.value = false
resetSkeleton()
}
</script> </script>
<template> <template>
<div <div
ref="calendarTile" ref="calendarTile"
class="tile relative p-1 md:p-2 transition-colors" class="tile relative p-1 md:p-2 md:pt-1 transition-all"
:class="{ :class="{
'text-slate-300 dark:text-slate-500': props.faded, 'text-slate-300 dark:text-slate-500': props.faded,
'text-slate-500 dark:text-slate-300': !props.faded 'text-slate-500 dark:text-slate-300': !props.faded
}" }"
@drop.prevent="handleTileDrop"
@dragenter.prevent
@dragover.prevent
> >
<button <button
class="relative z-10 group block w-full text-2xs md:text-xs text-center cursor-pointer" class="relative z-10 group block w-full text-2xs md:text-xs text-center cursor-pointer"
@@ -93,10 +145,10 @@ const eventsNotDisplayed: ComputedRef<number> = computed<number>(() => eventsFo
> >
<ClientOnly> <ClientOnly>
<span <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="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-primary dark:group-hover:border-primary"
:class="{ :class="{
'text-white bg-slate-600 dark:bg-slate-800': isDefaultDate && !isSelectedDate, 'bg-secondary dark:bg-slate-700': isDefaultDate && !isSelectedDate,
'text-white bg-indigo-500': isSelectedDate 'bg-primary text-primary-foreground': isSelectedDate
}" }"
> >
{{ date.day }} {{ date.day }}
@@ -107,13 +159,25 @@ const eventsNotDisplayed: ComputedRef<number> = computed<number>(() => eventsFo
<ClientOnly> <ClientOnly>
<ul <ul
ref="calendarEventsList" 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="absolute top-8 md:top-10 bottom-1 md:bottom-2 inset-x-2 grid auto-rows-min gap-1 z-10 pointer-events-none transition-opacity"
:class="{ :class="{
'opacity-40': props.faded && !isSelectedDate 'opacity-40': props.faded && !isSelectedDate
}" }"
> >
<TransitionGroup name="event"> <li
<li v-for="event in eventsToDisplay" :key="event.id" class="grid w-full pointer-events-auto"> v-if="!tileNotHovered && isDraggingEvent && !skeletonInsideCurrentDay"
:key="eventSkeleton.id"
class="opacity-60"
>
<CalendarEventButton :event="eventSkeleton" :tile-date="date" />
</li>
<TransitionGroup :name="!isDraggingEvent ? 'event' : ''">
<li
v-for="(event, i) in eventsToDisplay"
:key="event.id"
class="grid w-full pointer-events-auto"
>
<CalendarEventButton :event :tile-date="date" /> <CalendarEventButton :event :tile-date="date" />
</li> </li>
</TransitionGroup> </TransitionGroup>
@@ -124,7 +188,12 @@ const eventsNotDisplayed: ComputedRef<number> = computed<number>(() => eventsFo
<button <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" 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' : '' }} <template v-if="eventsNotDisplayed === 1">
{{ $t('entity.calendar.event.oneOtherEvent') }}
</template>
<template v-else>
{{ $t('entity.calendar.event.multipleOtherEvents', { count: eventsNotDisplayed }) }}
</template>
</button> </button>
</UiPopoverTrigger> </UiPopoverTrigger>
<UiPopoverContent <UiPopoverContent
@@ -158,6 +227,7 @@ const eventsNotDisplayed: ComputedRef<number> = computed<number>(() => eventsFo
<ClientOnly> <ClientOnly>
<LazyCalendarDialogCreateEvent v-if="!isReadOnly && breakpoints.lg.value" :date btn-class="absolute inset-0 w-full h-full cursor-default z-0" /> <LazyCalendarDialogCreateEvent v-if="!isReadOnly && breakpoints.lg.value" :date btn-class="absolute inset-0 w-full h-full cursor-default z-0" />
<button <button
v-else-if="!isReadOnly && !breakpoints.lg.value" v-else-if="!isReadOnly && !breakpoints.lg.value"
class="absolute inset-0 w-full h-full cursor-default z-0" class="absolute inset-0 w-full h-full cursor-default z-0"

View File

@@ -2,6 +2,7 @@
import type { RPGDate } from "@@/models/Date" import type { RPGDate } from "@@/models/Date"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { computed, type ComputedRef } from "vue" import { computed, type ComputedRef } from "vue"
import { cn } from "~/lib/utils"
const { currentDate, defaultDate, selectDate, areDatesIdentical } = useCalendar() const { currentDate, defaultDate, selectDate, areDatesIdentical } = useCalendar()
const { selectedDate, currentEvents } = storeToRefs(useCalendar()) const { selectedDate, currentEvents } = storeToRefs(useCalendar())
@@ -38,14 +39,14 @@ const hasAtLeastOneEvent = computed<boolean>(() => {
<template> <template>
<button <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="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 cursor-pointer"
:class="{ :class="cn({
'text-slate-500 hover:border-indigo-300 hover:text-slate-900': !isDefaultDate && !isSelectedDate, 'text-foreground/75 hover:border-primary hover:text-foreground': !isDefaultDate && !isSelectedDate,
'font-semibold text-white bg-slate-600 dark:bg-slate-800 hover:border-indigo-400': isDefaultDate && !isSelectedDate, 'font-semibold text-foreground bg-secondary hover:border-primary/70': isDefaultDate && !isSelectedDate,
'font-semibold text-white bg-indigo-500 hover:bg-indigo-600 hover:border-indigo-500': isSelectedDate, 'font-semibold text-primary-foreground bg-primary hover:border-primary/70': isSelectedDate,
'after:bg-green-600': hasAtLeastOneEvent, 'after:bg-primary': hasAtLeastOneEvent,
'after:bg-slate-950': hasAtLeastOneEvent && isSelectedDate 'after:bg-primary-foreground': hasAtLeastOneEvent && isSelectedDate
}" })"
@click="selectDate(tileDate)" @click="selectDate(tileDate)"
> >
<span ref="tileRef" class="text-[.85em]">{{ dayNumber }}</span> <span ref="tileRef" class="text-[.85em]">{{ dayNumber }}</span>

View File

@@ -11,7 +11,7 @@ defineProps<{
<div class="font-medium"> <div class="font-medium">
{{ month.name }} {{ month.name }}
</div> </div>
<div class="grid grid-cols-7 gap-1"> <div class="grid grid-cols-7">
<CalendarStateYearlyDayTile <CalendarStateYearlyDayTile
v-for="day in month.days" v-for="day in month.days"
:key="day" :key="day"

View File

@@ -0,0 +1,39 @@
<template>
<header role="banner" class="sticky top-0 border-b-1 border-border py-4 px-2 z-50 backdrop-blur">
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between">
<div class="md:flex-1 flex justify-start" />
<nav class="md:flex-1">
<menu class="text-sm flex items-center justify-center gap-4">
<li>
<NuxtLink to="/" class="block p-1 font-medium text-foreground/80 dark:text-foreground/70 underline-offset-4 focus-visible:underline hover:underline dark:hover:text-foreground hover:text-foreground transition-colors" active-class="text-primary dark:text-primary">
{{ $t('breadcrumbs.home') }}
</NuxtLink>
</li>
<li>
<NuxtLink to="/explore" class="block p-1 font-medium text-foreground/80 dark:text-foreground/70 underline-offset-4 focus-visible:underline hover:underline dark:hover:text-foreground hover:text-foreground transition-colors" active-class="text-primary dark:text-primary">
{{ $t('pages.explore.menuLabel') }}
</NuxtLink>
</li>
<li>
<NuxtLink to="/about" class="block p-1 font-medium text-foreground/80 dark:text-foreground/70 underline-offset-4 focus-visible:underline hover:underline dark:hover:text-foreground hover:text-foreground transition-colors" active-class="text-primary dark:text-primary">
{{ $t('pages.about.menuLabel') }}
</NuxtLink>
</li>
</menu>
</nav>
<div class="md:flex-1 flex justify-end">
<UserDashboardLink size="xs" />
</div>
</div>
</div>
</header>
</template>
<style lang="scss" scoped>
header {
background-color: color-mix(in srgb, var(--color-background) 75%, transparent);
}
</style>

View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
import { PhCloudX } from '@phosphor-icons/vue';
</script>
<template>
<UiCard class="w-full h-full flex flex-col border-rose-200 bg-rose-50 dark:border-rose-900 dark:bg-rose-950">
<template v-if="$slots.title">
<UiCardHeader class="flex-row items-center gap-2">
<PhCloudX :size="32"/>
<UiCardTitle>
<slot name="title" />
</UiCardTitle>
</UiCardHeader>
</template>
<template v-if="$slots.content">
<UiCardContent class="grow grid gap-2">
<slot name="content" />
</UiCardContent>
</template>
</UiCard>
</template>

View File

@@ -13,7 +13,15 @@ const model = defineModel<RPGColor>({ default: "white" });
<template> <template>
<UiSelect v-model="model"> <UiSelect v-model="model">
<UiSelectTrigger :id :class="cn({ 'h-9': theme === 'subtle' })"> <UiSelectTrigger
:id
:class="cn(
['hover:bg-secondary hover:text-primary-foreground'],
{
'h-9': theme === 'subtle'
}
)"
>
<UiSelectValue <UiSelectValue
:placeholder="$t('ui.colors.selectOne')" :placeholder="$t('ui.colors.selectOne')"
class="bgc" class="bgc"

View File

@@ -0,0 +1,343 @@
<script lang="ts" setup>
import { useEditor, EditorContent, type Content } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { PhArrowUDownRight, PhArrowUUpLeft, PhListBullets, PhListNumbers, PhQuotes, PhTextB, PhTextItalic, PhTextStrikethrough, PhTextTSlash } from '@phosphor-icons/vue'
const props = defineProps<{
disableInlines?: boolean
disableBlocks?: boolean
}>()
const model = defineModel<any>()
const editor = useEditor({
content: model.value as Content,
extensions: [
StarterKit
],
onUpdate: ({ editor }) => {
model.value = editor.getHTML()
}
})
function clearFormatting() {
editor.value?.chain().focus().unsetAllMarks().run()
editor.value?.chain().focus().clearNodes().run()
}
</script>
<template>
<div v-if="editor">
<!-- Toolbar -->
<div class="flex gap-1.5">
<!-- Inline styles -->
<div class="flex" v-if="!props.disableInlines">
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().toggleBold().run()"
:disabled="!editor.can().chain().focus().toggleBold().run()"
:variant="editor.isActive('bold') ? 'default' : 'secondary'"
class="size-7 rounded-e-none"
size="icon"
>
<PhTextB weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.bold') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().toggleItalic().run()"
:disabled="!editor.can().chain().focus().toggleItalic().run()"
:variant="editor.isActive('italic') ? 'default' : 'secondary'"
class="size-7 rounded-none"
size="icon"
>
<PhTextItalic weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.italic') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().toggleStrike().run()"
:disabled="!editor.can().chain().focus().toggleStrike().run()"
:variant="editor.isActive('strike') ? 'default' : 'secondary'"
class="size-7 rounded-s-none"
size="icon"
>
<PhTextStrikethrough weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.strike') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
<!-- Block styles -->
<div class="flex" v-if="!props.disableBlocks">
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().toggleBulletList().run()"
:variant="editor.isActive('bulletList') ? 'default' : 'secondary'"
class="size-7 rounded-e-none"
size="icon"
>
<PhListBullets weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.ulist') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().toggleOrderedList().run()"
:variant="editor.isActive('orderedList') ? 'default' : 'secondary'"
class="size-7 rounded-none"
size="icon"
>
<PhListNumbers weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.olist') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().toggleBlockquote().run()"
:variant="editor.isActive('blockquote') ? 'default' : 'secondary'"
class="size-7 rounded-s-none"
size="icon"
>
<PhQuotes weight="fill" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.quote') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
<div class="flex">
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="clearFormatting"
variant="secondary"
class="size-7 rounded-e-none"
size="icon"
>
<PhTextTSlash weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.noFormat') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().undo().run()"
:disabled="!editor.can().chain().focus().undo().run()"
variant="secondary"
class="size-7 rounded-none"
size="icon"
>
<PhArrowUUpLeft weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.undo') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().redo().run()"
:disabled="!editor.can().chain().focus().redo().run()"
variant="secondary"
class="size-7 rounded-s-none"
size="icon"
>
<PhArrowUDownRight weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.redo') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
</div>
<EditorContent
:editor
class="overflow-y-auto md:text-sm border-b-1 border-border"
/>
</div>
</template>
<style lang="scss">
.tiptap,
.content-editor {
/* List styles */
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
/* Heading styles */
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
margin-top: 2.5rem;
text-wrap: pretty;
}
h1,
h2 {
margin-top: 3.5rem;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
/* Code and preformatted text styles */
code {
background-color: var(--purple-light);
border-radius: 0.4rem;
color: var(--black);
font-size: 0.85rem;
padding: 0.25em 0.3em;
}
pre {
background: var(--black);
border-radius: 0.5rem;
color: var(--white);
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
}
blockquote {
border-left: 3px solid var(--gray-3);
margin: 1.5rem 0;
padding-left: 1rem;
}
hr {
border: none;
border-top: 1px solid var(--gray-2);
margin: 2rem 0;
}
}
/* Basic editor styles */
.tiptap {
padding-block: 0.75rem;
min-height: 7rem;
max-height: 12rem;
overflow-y: auto;
:first-child {
margin-top: 0;
}
}
</style>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { PhInfo } from '@phosphor-icons/vue';
</script>
<template>
<div class="flex justify-between items-center gap-2">
<div class="grow">
<UserCTA />
</div>
<UiButton size="icon" class="size-8 hover:bg-secondary hover:text-secondary-foreground" variant="ghost" as-child>
<NuxtLink to="/about">
<PhInfo size="18" />
</NuxtLink>
</UiButton>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { PanelsLeftBottom } from 'lucide-vue-next';
</script>
<template>
<menu class="min-w-32 pt-16 flex flex-col gap-0.5">
<li class="w-full">
<UiButton
variant="ghost"
size="sm"
class="h-8 gap-1.5 hover:bg-secondary w-full justify-start"
as-child
>
<NuxtLink to="/my" active-class="active-link" exact-active-class="exact-link">
<PanelsLeftBottom :size="18" />
<span class="text-[.9em]">
{{ $t('ui.sidebarMenu.projects') }}
</span>
</NuxtLink>
</UiButton>
</li>
</menu>
</template>
<style lang="scss" scoped>
.active-link {
color: var(--color-secondary-foreground);
background-color: var(--color-secondary);
&:hover {
background-color: color-mix(in srgb, var(--color-primary) 33%, transparent);
}
}
.exact-link {
color: var(--color-primary-foreground);
background-color: color-mix(in srgb, var(--color-primary) 66%, transparent);
cursor: initial;
&:hover {
background-color: color-mix(in srgb, var(--color-primary) 66%, transparent);
}
}
.dark .exact-link {
background-color: var(--color-primary);
}
</style>

View File

@@ -1,190 +1,32 @@
<script lang="ts" setup> <script lang="ts" setup>
import { PhCompass, PhGlobeHemisphereEast, PhHurricane, PhInfo, PhX } from "@phosphor-icons/vue" import { PanelsLeftBottom } from "lucide-vue-next"
import type { SidebarMenuActionType, SidebarMenuIcon } from "./SidebarProps";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { breakpointsTailwind } from "@vueuse/core" import { PhInfo } from "@phosphor-icons/vue";
const { revealAdvancedSearch } = useCalendar() const { isSidebarOpened } = storeToRefs(useUiStore())
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> </script>
<template> <template>
<nav <nav
ref="sidebarRef" ref="sidebarRef"
:class="cn( :class="cn(
['md:relative md:isolate w-16 py-6 grid gap-4 grid-rows-[1fr_auto] justify-center md:transition-none'], // Base appearance ['max-md:hidden w-fit relative isolate p-2 grid gap-4 grid-rows-[1fr_auto] bg-background justify-center transition-colors'], // Base appearance
['after:opacity-50 after:contrast-125 dark:after:opacity-75 dark:after:contrast-175 after:-hue-rotate-60'], // After styling ['after:opacity-50 after:contrast-125 dark:after:opacity-75'], // 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 ['border-r-[1px] border-r-border dark:border-r-border'], // 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"> <SidebarMenu />
<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"> <SidebarFooter />
<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> </nav>
</template>
<style lang="scss" scoped> <UiSheet v-model:open="isSidebarOpened">
nav { <UiSheetContent side="left" class="p-2 grid gap-4 grid-rows-[auto_1fr]">
&::after { <UiSheetHeader />
display: block;
content: ''; <SidebarMenu />
position: absolute; <SidebarFooter />
bottom: 0;
inset-inline: 0; </UiSheetContent>
height: 25rem; </UiSheet>
max-height: 100%; </template>
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

@@ -8,8 +8,9 @@ const { toggleSidebar } = useUiStore()
<UiButton <UiButton
size="icon" size="icon"
variant="outline" variant="outline"
class="size-7 border-border hover:bg-secondary hover:text-secondary-foreground"
@click="toggleSidebar" @click="toggleSidebar"
> >
<PhList size="19" weight="light" /> <PhList size="14" weight="light" />
</UiButton> </UiButton>
</template> </template>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from "vue" import { computed } from "vue"
import { PhCheckCircle, PhUser, PhLaptop, PhMoon, PhPalette, PhSignIn, PhSignOut, PhSun, PhTranslate, PhUserCircle } from "@phosphor-icons/vue" import { PhCheckCircle, PhUser, PhLaptop, PhMoon, PhPalette, PhSignIn, PhSignOut, PhSun, PhTranslate, PhUserCircle, PhInfo } from "@phosphor-icons/vue"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const router = useRouter() const router = useRouter()
@@ -44,32 +44,42 @@ async function handleLogout() {
console.log(error.message) console.log(error.message)
} }
} }
type AvailableRoutes = "/my" | "/my/settings"
function pushRoute(to: AvailableRoutes) {
router.push({ path: to })
closeMenu()
}
</script> </script>
<template> <template>
<ClientOnly> <ClientOnly>
<UiDropdownMenu v-model:open="menuOpened"> <UiDropdownMenu v-model:open="menuOpened">
<UiDropdownMenuTrigger> <UiDropdownMenuTrigger as-child>
<UiAvatar v-if="user" id="user-avatar" class="ring-[.2rem] ring-indigo-700 dark:ring-neutral-900 cursor-pointer"> <UiButton
<UiAvatarImage variant="ghost"
:src="userMeta?.avatar_url" class="w-full justify-start p-2 h-fit hover:text-secondary-foreground hover:bg-secondary"
:alt="userMeta?.full_name" :class="cn({ 'bg-secondary': menuOpened })"
referrerpolicy="no-referrer" >
/> <TransitionGroup name="fade-group" appear>
<UiAvatarFallback> <div
{{ $t('ui.sidebarMenu.avatarFallback') }} v-if="user"
</UiAvatarFallback> class="flex gap-2 items-center"
</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"> <UiAvatar
<PhUserCircle size="24" /> id="user-avatar"
size="sm"
class="rounded-sm ring-[.2rem] ring-primary transition-all cursor-pointer"
>
<UiAvatarImage
:src="userMeta?.avatar_url"
:alt="userMeta?.full_name"
referrerpolicy="no-referrer"
/>
<UiAvatarFallback>
{{ $t('ui.sidebarMenu.avatarFallback') }}
</UiAvatarFallback>
</UiAvatar>
<div class="text-xs">
{{ userMeta?.name }}
</div>
</div>
</TransitionGroup>
</UiButton> </UiButton>
</UiDropdownMenuTrigger> </UiDropdownMenuTrigger>
@@ -78,15 +88,6 @@ function pushRoute(to: AvailableRoutes) {
<p class="p-2 text-[.7em] opacity-75"> <p class="p-2 text-[.7em] opacity-75">
{{ $t('ui.greeting', { user: user?.email }) }} {{ $t('ui.greeting', { user: user?.email }) }}
</p> </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>
<template v-else> <template v-else>

View File

@@ -0,0 +1,43 @@
<script lang="ts" setup>
import type { PrimitiveProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '@/components/ui/button'
const { auth } = useSupabaseClient()
const user = useSupabaseUser()
const profileUrl: string = `${useRequestURL().origin}/my/`
interface Props extends PrimitiveProps {
size?: ButtonVariants["size"]
}
defineProps<Props>()
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)
}
}
</script>
<template>
<UiButton v-if="user" :size as-child>
<NuxtLink to="/my">
Dashboard
</NuxtLink>
</UiButton>
<UiButton v-else @click="handleGoogleLogin" :size>
Log in
</UiButton>
</template>

View File

@@ -31,7 +31,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="forwarded" v-bind="forwarded"
:class=" :class="
cn( 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', '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-popover-border bg-popover 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, props.class,
) )
" "

View File

@@ -9,9 +9,9 @@ export const avatarVariant = cva(
{ {
variants: { variants: {
size: { size: {
sm: "h-10 w-10 text-xs", sm: "size-6 text-xs",
base: "h-16 w-16 text-2xl", base: "size-14 text-2xl",
lg: "h-32 w-32 text-5xl", lg: "size-24 text-5xl",
}, },
shape: { shape: {
circle: "rounded-full", circle: "rounded-full",

View File

@@ -9,7 +9,7 @@ const props = defineProps<{
<template> <template>
<ol <ol
:class="cn('flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5', props.class)" :class="cn('flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground transition-colors sm:gap-2.5', props.class)"
> >
<slot /> <slot />
</ol> </ol>

View File

@@ -12,7 +12,7 @@ const props = defineProps<{
role="link" role="link"
aria-disabled="true" aria-disabled="true"
aria-current="page" aria-current="page"
:class="cn('font-normal text-foreground', props.class)" :class="cn('font-normal text-foreground transition-colors', props.class)"
> >
<slot /> <slot />
</span> </span>

View File

@@ -7,11 +7,11 @@ export const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/70",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input hover:bg-accent hover:text-accent-foreground", outline: "border border-input hover:bg-primary hover:text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-primary/20", secondary: "bg-secondary text-secondary-foreground hover:bg-primary/40",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-primary/70 hover:text-primary-foreground",
link: "text-primary underline-offset-4 hover:underline" link: "text-primary underline-offset-4 hover:underline"
}, },
size: { size: {

View File

@@ -33,7 +33,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="forwarded" v-bind="forwarded"
:class=" :class="
cn( cn(
'fixed left-1/2 top-1/2 z-50 grid w-[calc(100%-2rem)] max-w-4xl -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background border-indigo-200 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', 'fixed left-1/2 top-1/2 z-50 grid w-[calc(100%-2rem)] max-w-4xl -translate-x-1/2 -translate-y-1/2 gap-4 border border-popover-border bg-popover 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 props.class
) )
" "

View File

@@ -32,7 +32,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="forwarded" v-bind="forwarded"
:class=" :class="
cn( cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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 transition-all',
props.class props.class
) )
" "

View File

@@ -19,6 +19,6 @@ const delegatedProps = computed(() => {
<template> <template>
<DropdownMenuSeparator <DropdownMenuSeparator
v-bind="delegatedProps" v-bind="delegatedProps"
:class="cn('-mx-1 my-1 h-px bg-muted', props.class)" :class="cn('-mx-1 my-1 h-px bg-muted transition-colors', props.class)"
/> />
</template> </template>

View File

@@ -25,7 +25,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="forwarded" v-bind="forwarded"
:class=" :class="
cn( cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 transition-all',
props.class props.class
) )
" "

View File

@@ -24,7 +24,7 @@ const forwardedProps = useForwardProps(delegatedProps)
v-bind="forwardedProps" v-bind="forwardedProps"
:class=" :class="
cn( cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent data-[state=open]:bg-accent', 'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:bg-accent transition-colors',
props.class props.class
) )
" "

View File

@@ -37,7 +37,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="{ ...forwarded, ...$attrs }" v-bind="{ ...forwarded, ...$attrs }"
:class=" :class="
cn( cn(
'z-50 w-72 rounded-md border bg-popover p-4 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 w-72 rounded-md border border-popover-border bg-popover p-4 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',
props.class props.class
) )
" "

View File

@@ -39,7 +39,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
style="max-height: var(--radix-select-content-available-height)" style="max-height: var(--radix-select-content-available-height)"
:class=" :class="
cn( cn(
'select-content relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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', 'select-content relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border border-popover-border bg-popover text-popover-foreground shadow-md 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',
position === 'popper' && position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class props.class

View File

@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
v-bind="forwardedProps" v-bind="forwardedProps"
:class=" :class="
cn( cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background dark:bg-slate-950 contrast-more:dark:bg-slate-900 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background hover:bg-secondary hover:text-primary-foreground dark:hover:text-primary px-3 py-2 text-sm ring-offset-background transition-colors placeholder:text-muted-foreground focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50',
props.class props.class
) )
" "

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
data-slot="sheet"
v-bind="forwarded"
>
<slot />
</DialogRoot>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="sheet-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import SheetOverlay from "./SheetOverlay.vue"
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes["class"]
side?: "top" | "right" | "bottom" | "left"
}
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<SheetContentProps>(), {
side: "right",
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class", "side")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<SheetOverlay />
<DialogContent
data-slot="sheet-content"
:class="cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right'
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full border-l max-w-sm',
side === 'left'
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-full border-r max-w-sm',
side === 'top'
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom'
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
props.class)"
v-bind="{ ...forwarded, ...$attrs }"
>
<slot />
</DialogContent>
</DialogPortal>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogDescription } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogDescription
data-slot="sheet-description"
:class="cn('text-muted-foreground text-sm', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogDescription>
</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
data-slot="sheet-footer"
:class="cn('mt-auto flex flex-col gap-2 p-4', props.class)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import {
DialogClose
} from "reka-ui"
import { PhX } from "@phosphor-icons/vue";
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
data-slot="sheet-header"
:class="cn('flex flex-col gap-1.5 p-4', props.class)"
>
<slot />
<DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 disabled:pointer-events-none"
as-child
>
<UiButton
size="icon"
variant="outline"
class="size-7 border-border bg-background hover:bg-secondary hover:text-secondary-foreground"
>
<PhX size="14" />
<span class="sr-only">{{ $t('ui.actions.close') }}</span>
</UiButton>
</DialogClose>
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogOverlay
data-slot="sheet-overlay"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/70', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogOverlay>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogTitle
data-slot="sheet-title"
:class="cn('text-foreground font-semibold', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogTitle>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="sheet-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>

View File

@@ -0,0 +1,8 @@
export { default as Sheet } from "./Sheet.vue"
export { default as SheetClose } from "./SheetClose.vue"
export { default as SheetContent } from "./SheetContent.vue"
export { default as SheetDescription } from "./SheetDescription.vue"
export { default as SheetFooter } from "./SheetFooter.vue"
export { default as SheetHeader } from "./SheetHeader.vue"
export { default as SheetTitle } from "./SheetTitle.vue"
export { default as SheetTrigger } from "./SheetTrigger.vue"

View File

@@ -47,7 +47,7 @@ const updatedAt = computed<string>(() => props.world.updatedAt ? DateTime.fromIS
</UiCardHeader> </UiCardHeader>
<UiCardContent class="grow"> <UiCardContent class="grow">
<p class="italic">{{ world.description }}</p> <div class="md:text-sm" v-html="world.description"></div>
<div <div
v-if="isCardHovered || isCardFocused" v-if="isCardHovered || isCardFocused"

View File

@@ -11,6 +11,9 @@ onMounted(() => {
worldSkeleton.value = { ...defaultWorld } worldSkeleton.value = { ...defaultWorld }
}) })
const newWorldName = shallowRef<HTMLInputElement>()
useFocus(newWorldName, { initialValue: true })
const isLoading = ref<boolean>(false) const isLoading = ref<boolean>(false)
/** /**
@@ -61,22 +64,20 @@ function handleFormCancel() {
<form class="h-full grid grid-rows-[1fr_auto]" @submit.prevent="handleSubmit"> <form class="h-full grid grid-rows-[1fr_auto]" @submit.prevent="handleSubmit">
<div class="grid gap-4"> <div class="grid gap-4">
<input <input
ref="newWorldName"
id="new-world-name" id="new-world-name"
v-model="worldSkeleton.name" v-model="worldSkeleton.name"
type="text" type="text"
name="new-world-name" name="new-world-name"
required required
:placeholder="$t('common.title')" :placeholder="$t('common.title')"
class="w-full -my-1 mb-4 py-2 -mx-1 px-1 text-xl border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-blue-600" class="w-full -my-1 py-2 -mx-1 px-1 text-xl border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-primary invalid:border-destructive"
@input="handleNameChange" @input="handleNameChange"
> >
<textarea <InputEditor
id="new-world-description" disable-blocks
v-model="worldSkeleton.description" v-model="worldSkeleton.description"
name="new-world-description"
:placeholder="$t('entity.addDescription')"
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"
/> />
<div class="-mx-1 grid gap-3"> <div class="-mx-1 grid gap-3">

View File

@@ -19,6 +19,9 @@ onMounted(() => {
worldSkeleton.value = { ...props.world } as World worldSkeleton.value = { ...props.world } as World
}) })
const newWorldName = shallowRef<HTMLInputElement>()
useFocus(newWorldName, { initialValue: true })
const isLoading = ref<boolean>(false) const isLoading = ref<boolean>(false)
/** /**
@@ -38,6 +41,8 @@ async function handleSubmit() {
if (error) { if (error) {
console.log(error.message) console.log(error.message)
isLoading.value = false
return
} }
toast({ toast({
@@ -73,22 +78,20 @@ function handleFormCancel() {
<form class="h-full grid grid-rows-[1fr_auto]" @submit.prevent="handleSubmit"> <form class="h-full grid grid-rows-[1fr_auto]" @submit.prevent="handleSubmit">
<div class="grid gap-4"> <div class="grid gap-4">
<input <input
ref="newWorldName"
id="new-world-name" id="new-world-name"
v-model="worldSkeleton.name" v-model="worldSkeleton.name"
type="text" type="text"
name="new-world-name" name="new-world-name"
required required
:placeholder="$t('common.title')" :placeholder="$t('common.title')"
class="w-full -my-1 py-2 -mx-1 px-1 text-xl border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-blue-600" class="w-full -my-1 py-2 -mx-1 px-1 text-xl border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-primary invalid:border-destructive"
@input="handleNameChange" @input="handleNameChange"
> >
<textarea <InputEditor
id="new-world-description" disable-blocks
v-model="worldSkeleton.description" v-model="worldSkeleton.description"
name="new-world-description"
:placeholder="$t('entity.addDescription')"
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"
/> />
<div class="-mx-1 grid gap-3"> <div class="-mx-1 grid gap-3">

View File

@@ -60,38 +60,32 @@ switch (statusCode) {
<template> <template>
<div class="h-screen"> <div class="h-screen">
<div class="h-full grid grid-cols-[auto_1fr] dark:bg-black transition-colors"> <div class="h-full w-full grid place-items-center transition-colors">
<Sidebar /> <Head>
<Title>{{ $t(titleKey) }}</Title>
</Head>
<div class="wrapper shadow-body-light dark:shadow-body-dark transition-all"> <div class="grid text-center justify-items-center opacity-80">
<div class="h-full w-full grid place-items-center"> <PhImageBroken v-if="statusCode === 404" size="100" class="opacity-60" />
<Head> <PhLinkBreak v-else-if="statusCode === 500" size="100" class="opacity-60" />
<Title>{{ $t(titleKey) }}</Title> <PhBugBeetle v-else size="100" class="opacity-60" />
</Head>
<div class="grid text-center justify-items-center opacity-80"> <Heading level="h0">
<PhImageBroken v-if="statusCode === 404" size="100" class="opacity-60" /> {{ $t(titleKey) }}
<PhLinkBreak v-else-if="statusCode === 500" size="100" class="opacity-60" /> </Heading>
<PhBugBeetle v-else size="100" class="opacity-60" />
<Heading level="h0"> <div class="mt-6 md:text-lg">
{{ $t(titleKey) }} <p>{{ $t(descriptionKey) }}</p>
</Heading> <p>{{ $t(subDescriptionKey) }}</p>
<div class="mt-6 md:text-lg">
<p>{{ $t(descriptionKey) }}</p>
<p>{{ $t(subDescriptionKey) }}</p>
</div>
<UiButton variant="default" class="mt-6 gap-2" as-child>
<RouterLink to="/">
<PhArrowBendDoubleUpLeft size="24" />
{{ $t('ui.backToHome') }}
</RouterLink>
</UiButton>
</div>
</div> </div>
<UiButton variant="default" class="mt-6 gap-2" as-child>
<RouterLink to="/">
<PhArrowBendDoubleUpLeft size="24" />
{{ $t('ui.backToHome') }}
</RouterLink>
</UiButton>
</div> </div>
</div> </div>
</div> </div>

13
app/layouts/default.vue Normal file
View File

@@ -0,0 +1,13 @@
<template>
<div
class="h-full grid md:grid-cols-[auto_1fr] bg-background transition-colors after:absolute after:transition-colors"
>
<Sidebar />
<div
class="wrapper max-h-screen transition-all overflow-y-auto"
>
<slot />
</div>
</div>
</template>

36
app/layouts/public.vue Normal file
View File

@@ -0,0 +1,36 @@
<template>
<div
class="wrapper max-h-screen h-full transition-all overflow-y-auto grid grid-rows-[auto_1fr]"
>
<Navbar />
<div class="inner-content">
<slot />
</div>
</div>
</template>
<style lang="scss" scoped>
.inner-content {
position: relative;
&::before {
position: absolute;
inset-inline: 0;
top: 0;
display: block;
content: '';
height: .1rem;
max-width: 100%;
background: linear-gradient(
90deg,
transparent 0%,
transparent 10%,
color-mix(in srgb, var(--color-primary) 50%, transparent) 25%,
color-mix(in srgb, var(--color-primary) 50%, transparent) 75%,
transparent 90%,
transparent 100%
);
}
}
</style>

View File

@@ -1,4 +1,5 @@
import { type ClassValue, clsx } from "clsx" import type { ClassValue } from "clsx"
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {

View File

@@ -1,7 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { PhCircleNotch } from '@phosphor-icons/vue'
const { locale } = useI18n() const { locale } = useI18n()
const { data: page, refresh } = await useAsyncData(`about-content-${locale}`, () => { definePageMeta({
layout: "public"
})
const { data: page, refresh, status } = useLazyAsyncData(`about-content-${locale}`, () => {
return queryCollection("sections").path(`/sections/${locale.value}/legal`).first() return queryCollection("sections").path(`/sections/${locale.value}/legal`).first()
}) })
@@ -9,25 +15,19 @@ watch(locale, () => refresh())
</script> </script>
<template> <template>
<main class="py-8 px-5 md:px-8 overflow-y-auto"> <Head>
<Head> <Title>{{ $t("pages.about.title") }}</Title>
<Title>{{ $t("pages.about.title") }}</Title> </Head>
</Head>
<div class="container max-md:px-0"> <main class="overflow-y-auto py-8 px-5 md:px-8 grid gap-4 grid-rows-[auto_1fr]">
<Spacing size="lg"> <Heading level="h1">
<div class="flex items-center gap-3"> {{ $t("pages.about.title") }}
<div class="md:hidden"> </Heading>
<SidebarToggle />
</div>
<Heading level="h1"> <div v-if="status === 'pending'" class="grid place-items-center">
{{ $t("pages.about.title") }} <PhCircleNotch size="50" class="opacity-33 animate-spin"/>
</Heading>
</div>
<ContentRenderer v-if="page" :value="page" class="content" />
</Spacing>
</div> </div>
<LazyContentRenderer v-else-if="status === 'success' && page" :value="page" class="content max-w-4xl" />
</main> </main>
</template> </template>

View File

@@ -44,7 +44,7 @@ watch(isLoading, (n) => {
<p> <p>
{{ $t('entity.calendar.isLoading') }} {{ $t('entity.calendar.isLoading') }}
</p> </p>
<PhCircleNotch size="50" class="animate-spin"/> <PhCircleNotch size="50" class="opacity-33 animate-spin"/>
</div> </div>
</div> </div>

View File

@@ -2,12 +2,13 @@
import type { Calendar } from "@@/models/CalendarConfig"; import type { Calendar } from "@@/models/CalendarConfig";
definePageMeta({ definePageMeta({
middleware: ["reset-menu"] layout: "public"
}) })
const { data: availableCalendars, status: calendarStatus } = await useLazyFetch<{ data: Calendar[] }>("/api/calendars/query", { key: "explore-calendars", query: { full: true } }) // const { data: availableCalendars, status: calendarStatus } = useLazyFetch<{ data: Calendar[] }>("/api/calendars/query", { key: "explore-calendars", query: { full: true } })
const { data: calendars, status } = useLazyAsyncData<{ data: Calendar[] }>("explore-calendars", () => {
const isLoading = computed(() => calendarStatus.value === "pending") return $fetch("/api/calendars/query", { query: { full: true }})
})
</script> </script>
<template> <template>
@@ -17,29 +18,26 @@ const isLoading = computed(() => calendarStatus.value === "pending")
</Head> </Head>
<Spacing size="lg"> <Spacing size="lg">
<div class="flex items-center gap-3"> <Heading level="h1">
<div class="md:hidden"> {{ $t("pages.explore.title") }}
<SidebarToggle /> </Heading>
</div>
<Heading level="h1">
{{ $t("pages.explore.title") }}
</Heading>
</div>
<Spacing size="lg"> <Spacing size="lg">
<Heading level="h2"> <Heading level="h2">
{{ $t("entity.calendar.namePublicPlural") }} {{ $t("entity.calendar.namePublicPlural") }}
</Heading> </Heading>
<div v-if="isLoading" class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2"> <div v-if="status === 'pending'" class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2">
<LoadingCard /> <LazyCardLoading />
</div> </div>
<ul v-else-if="availableCalendars?.data" class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2">
<li v-for="calendar in availableCalendars.data" :key="calendar.shortId"> <template v-else-if="status === 'success' && calendars?.data">
<CalendarPreviewCard :calendar="calendar" :gm-id="calendar.world?.gmId" /> <ul class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2">
</li> <li v-for="calendar in calendars.data" :key="calendar.shortId">
</ul> <LazyCalendarPreviewCard :calendar="calendar" :gm-id="calendar.world?.gmId" />
</li>
</ul>
</template>
</Spacing> </Spacing>
</Spacing> </Spacing>
</main> </main>

View File

@@ -4,12 +4,12 @@ useHead({
}) })
definePageMeta({ definePageMeta({
middleware: ["reset-menu"] layout: "public"
}) })
</script> </script>
<template> <template>
<main class="py-8 px-5 md:px-8"> <main class="h-full grid place-items-center py-8 px-5 md:px-8">
<Head> <Head>
<Title> <Title>
TTTools {{ $t('head.title') }} TTTools {{ $t('head.title') }}
@@ -17,6 +17,17 @@ definePageMeta({
<Meta name="description" :content="$t('head.description')" /> <Meta name="description" :content="$t('head.description')" />
</Head> </Head>
<Heading level="h1">TTTools</Heading> <div class="py-8 md:py-24 px-4">
<div class="flow text-center max-w-4xl mx-auto">
<h1 class="text-3xl md:text-5xl font-bold md:font-semibold">
{{ $t('pages.home.h1') }}
</h1>
<p class="text-sm">
{{ $t('pages.home.tagline') }}
</p>
<UserDashboardLink />
</div>
</div>
</main> </main>
</template> </template>

3
app/pages/my.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>

View File

@@ -51,7 +51,7 @@ watch([calendar], () => {
<p> <p>
{{ $t('entity.calendar.isLoading') }} {{ $t('entity.calendar.isLoading') }}
</p> </p>
<PhCircleNotch size="50" class="animate-spin"/> <PhCircleNotch size="50" class="opacity-33 animate-spin"/>
</div> </div>
</div> </div>

View File

@@ -9,10 +9,10 @@ definePageMeta({
middleware: ["auth-guard", "reset-menu"] middleware: ["auth-guard", "reset-menu"]
}) })
const { data: worlds, status: worldStatus } = await useLazyFetch<{ data: World[] }>("/api/worlds/query", { query: { gmId: user?.value!.id }, key: "user-worlds", keepalive: true }) const { data: worlds, status: worldStatus, refresh } = await useLazyFetch<{ data: World[] }>("/api/worlds/query", { query: { gmId: user?.value!.id }, key: "user-worlds", keepalive: true })
const sortedWorlds = computed(() => worlds.value?.data ? [...worlds.value.data].sort((a, b) => (a.id ?? 0) - (b.id ?? 0)) : []) const sortedWorlds = computed(() => worlds.value?.data ? worlds.value.data.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)) : [])
const isLoading = computed(() => worldStatus.value === "idle") const isLoading = computed(() => worldStatus.value === "pending")
// Redirect user back home when they log out on the page // Redirect user back home when they log out on the page
watch(user, (n) => { watch(user, (n) => {
@@ -33,49 +33,13 @@ function hideCreateDialog() {
/** Active world channel */ /** Active world channel */
let worldChannel: RealtimeChannel let worldChannel: RealtimeChannel
/** Handles world insertion realtime events */
function handleInsertedWorld(newWorld: WorldChannelPayload) {
if (!worlds.value?.data) return
newWorld.createdAt = newWorld.created_at;
newWorld.gmId = newWorld.gm_id;
worlds.value?.data.push(newWorld)
}
/** Handles world deletion realtime events */
function handleDeletedWorld(id: number) {
if (!worlds.value?.data) return
worlds.value.data.splice(worlds.value.data.findIndex(w => w.id === id), 1)
}
onMounted(() => { onMounted(() => {
worldChannel = supabase.channel("realtime-world-channel") worldChannel = supabase.channel("realtime-worlds-channel")
.on( .on(
"postgres_changes", "postgres_changes",
{ event: "*", schema: "public", table: "worlds" }, { event: "*", schema: "public", table: "worlds" },
async (payload) => { async (payload) => {
switch (payload.eventType) { refresh()
case "INSERT":
handleInsertedWorld(payload.new as World)
break
case "DELETE":
handleDeletedWorld(payload.old.id)
break
case "UPDATE":
if (!worlds.value?.data) return
worlds.value.data = (await $fetch("/api/worlds/query", { query: { gmId: user?.value!.id } })).data as World[]
break
default:
console.log("Unknown event has been triggered. This should not happen unless Supabase added one somehow.")
console.log(payload)
break
}
} }
) )
.subscribe() .subscribe()
@@ -136,21 +100,21 @@ function hideEditModal() {
</div> </div>
<div v-if="isLoading" class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2"> <div v-if="isLoading" class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2">
<LoadingCard /> <LazyCardLoading />
</div> </div>
<ul v-else-if="worlds?.data" class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2"> <ul v-else-if="worlds?.data" class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2">
<li v-for="world in sortedWorlds" :key="world.id"> <li v-for="world in sortedWorlds" :key="world.id">
<WorldPreviewCard :world="world" @on-edit="() => deployEditModal(world)" @on-delete="() => deployDeleteModal(world)" /> <WorldPreviewCard :world="world" @on-edit="() => deployEditModal(world)" @on-delete="() => deployDeleteModal(world)" />
</li> </li>
<li class="xl:w-fit"> <li class="xl:w-fit">
<AddCard @on-click="() => isCreateWorldModalOpen = true"> <LazyCardAdd @on-click="() => isCreateWorldModalOpen = true">
<template v-if="worlds?.data?.length > 0"> <template v-if="worlds?.data?.length > 0">
{{ $t('entity.world.addSingle') }} {{ $t('entity.world.addSingle') }}
</template> </template>
<template v-else> <template v-else>
{{ $t('entity.world.addSingleFirst') }} {{ $t('entity.world.addSingleFirst') }}
</template> </template>
</AddCard> </LazyCardAdd>
</li> </li>
</ul> </ul>
</Spacing> </Spacing>

View File

@@ -8,13 +8,6 @@ const supabase = useSupabaseClient()
const route = useRoute() const route = useRoute()
const id = route.params.id const id = route.params.id
const { data: world, status } = await useFetch<{ data: World }>("/api/worlds/query", { query: { id, full: true } })
const sortedCalendars = computed(() => world.value?.data.calendars ? [...world.value.data.calendars].sort((a, b) => (a.id ?? 0) - (b.id ?? 0)) : [])
definePageMeta({
middleware: ["auth-guard"]
})
const user = useSupabaseUser() const user = useSupabaseUser()
// Redirect user back home when they log out on the page // Redirect user back home when they log out on the page
@@ -24,105 +17,45 @@ watch(user, (n) => {
} }
}) })
// Set custom menu definePageMeta({
// This should be reserved for actions, not for breadcrumbs middleware: ["auth-guard"]
// })
// const { setCurrentMenu } = useUiStore()
// setCurrentMenu([ const { data: world, status, refresh } = await useAsyncData<{ data: World }>('user-world', () => {
// { return $fetch("/api/worlds/query", { query: { id }})
// tooltip: t("entity.world.backToMy"), })
// to: "/my", const worldGmId = world.value?.data.gmId
// phIcon: "universe",
// highlight: true const { data: calendars, status: calendarStatus, refresh: refreshCalendars } = useLazyFetch<{ data: Calendar[] }>('/api/calendars/query', { query: { worldId: world.value?.data.id, full: true }})
// }, const sortedCalendars = computed(() => calendars.value?.data ? calendars.value.data.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)) : [])
// ])
/** /**
* === Subscriptions === * === Subscriptions ===
*/ */
/** Active calendar channel */
let calendarChannel: RealtimeChannel let calendarChannel: RealtimeChannel
/** Active world channel */
let worldChannel: RealtimeChannel let worldChannel: RealtimeChannel
/** Handles calendar insertion realtime events */ // Register channels
function handleInsertedCalendar(newCalendar: CalendarChannelPayload) {
if (!world.value) return
newCalendar.createdAt = newCalendar.created_at
newCalendar.eventNb = [{ count: 0 }]
world.value.data.calendars?.push(newCalendar)
}
/** Handles calendar deletion realtime events */
function handleDeletedCalendar(id: number) {
if (!world.value) return
world.value.data.calendars?.splice(world.value.data.calendars.findIndex(c => c.id === id), 1)
}
onMounted(() => {
calendarChannel = supabase.channel("realtime-calendar-channel")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "calendars" },
async (payload) => {
switch (payload.eventType) {
case "INSERT":
handleInsertedCalendar(payload.new as Calendar)
break
case "DELETE":
handleDeletedCalendar(payload.old.id)
break
// Maybe this case could be handled better than doing a separate API call
case "UPDATE":
if (!world.value?.data) return
world.value.data = (await $fetch<{ data: World }>("/api/worlds/query", { query: { id, full: true } })).data
break
default:
console.log("Unknown event has been triggered. This should not happen unless Supabase added one somehow.")
break
}
}
)
.subscribe()
})
onUnmounted(() => {
// Unsubscribe from realtime
supabase.removeChannel(calendarChannel)
})
onMounted(() => { onMounted(() => {
worldChannel = supabase.channel("realtime-world-channel") worldChannel = supabase.channel("realtime-world-channel")
.on( .on(
"postgres_changes", "postgres_changes",
{ event: "*", schema: "public", table: "worlds" }, { event: "*", schema: "public", table: "worlds" },
async (payload) => { async () => refresh()
switch (payload.eventType) { )
case "UPDATE": .subscribe()
if (!world.value?.data) return calendarChannel = supabase.channel("realtime-calendar-channel")
.on(
world.value.data = (await $fetch<{ data: World }>("/api/worlds/query", { query: { id, full: true } })).data "postgres_changes",
break { event: "*", schema: "public", table: "calendars" },
async () => refreshCalendars()
default:
console.log("Unknown event has been triggered. This should not happen unless Supabase added one somehow.")
console.log(payload)
break
}
}
) )
.subscribe() .subscribe()
}) })
// Unsubscribe from realtime
onUnmounted(() => { onUnmounted(() => {
// Unsubscribe from realtime supabase.removeChannel(calendarChannel)
supabase.removeChannel(worldChannel) supabase.removeChannel(worldChannel)
}) })
@@ -169,11 +102,60 @@ function hideEditModal() {
<Title>{{ $t("entity.world.namePlural") }}</Title> <Title>{{ $t("entity.world.namePlural") }}</Title>
</Head> </Head>
<Heading level="h1"> <header class="mb-8">
{{ $t('entity.isLoading') }} <Spacing size="lg">
</Heading> <Breadcrumb
:items="[]"
/>
<div class="lg:w-1/2">
<div class="flex items-center gap-2 mb-2">
<div class="flex items-center gap-3">
<UiSkeleton class="h-10 w-32 max-md:max-w-full" />
</div>
<UiButton size="icon" class="rounded-full size-8" disabled>
<PhPencil size="17" weight="fill" />
</UiButton>
</div>
<div class="grid gap-1">
<UiSkeleton class="h-6 w-full max-md:max-w-full" />
<UiSkeleton class="h-6 w-8/12 max-md:max-w-full" />
<UiSkeleton class="h-6 w-7/12 max-md:max-w-full" />
</div>
</div>
</Spacing>
</header>
</template> </template>
<template v-else-if="world?.data"> <template v-else-if="!world?.data && status === 'error'">
<div class="h-full w-full grid place-items-center">
<Head>
<Title>{{ $t("entity.world.notFound") }}</Title>
</Head>
<div class="grid justify-items-center opacity-80">
<PhGlobeHemisphereWest size="75" class="opacity-60" weight="fill" />
<Heading level="h1">
{{ $t("entity.world.notFound") }}
</Heading>
<p>
{{ $t('entity.world.notFoundDescription') }}
</p>
<UiButton variant="default" class="mt-4 gap-2" as-child>
<RouterLink to="/my">
<PhArrowBendDoubleUpLeft size="24" />
{{ $t('entity.world.backToList') }}
</RouterLink>
</UiButton>
</div>
</div>
</template>
<template v-else-if="world?.data && status === 'success'">
<Head> <Head>
<Title>{{ world.data.name }}</Title> <Title>{{ world.data.name }}</Title>
</Head> </Head>
@@ -208,74 +190,75 @@ function hideEditModal() {
</UiTooltipProvider> </UiTooltipProvider>
</div> </div>
<p>{{ world.data.description }}</p> <div class="content-editor" v-html="world.data.description" />
</div> </div>
</Spacing> </Spacing>
</header> </header>
</template>
<section> <template v-if="world?.data">
<Spacing size="lg"> <LazyWorldDialogEdit :world="world.data" :modal-state="isEditWorldModalOpen" @on-close="hideEditModal" />
<div class="flex items-center gap-3"> <LazyCalendarDialogCreate :world="world.data" :modal-state="isCreateCalendarModalOpen" @on-close="hideCreateDialog" />
<Heading level="h2"> <LazyCalendarDialogUpdate :world="world.data" :calendar="markedCalendar" :modal-state="isUpdateCalendarModalOpen" @on-close="hideUpdateDialog" />
{{ $t('entity.calendar.namePlural') }} <LazyCalendarDialogDelete :calendar="markedCalendar" :modal-state="isDeleteCalendarModalOpen" @on-close="hideDeleteCalendarModal"/>
</Heading> </template>
<section>
<Spacing size="lg">
<div class="flex items-center gap-3">
<Heading level="h2">
{{ $t('entity.calendar.namePlural') }}
</Heading>
</div>
<template v-if="calendarStatus === 'pending'">
<ul class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2">
<li>
<LazyCardLoading />
</li>
<li class="w-64 max-w-full">
<LazyCardLoading />
</li>
</ul>
</template>
<template v-else-if="!calendars?.data && calendarStatus === 'error'">
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
<LazyCardError>
<template #title>
{{ $t('error.default.title') }}
</template>
<template #content>
{{ $t('entity.calendar.error.cannotFindAny') }}
</template>
</LazyCardError>
</div> </div>
</template>
<template v-else-if="calendars?.data && calendarStatus === 'success' && worldGmId">
<ul class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2"> <ul class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2">
<li v-for="calendar in sortedCalendars" :key="calendar.id"> <li v-for="calendar in sortedCalendars" :key="calendar.id">
<CalendarPreviewCard <LazyCalendarPreviewCard
:calendar="calendar" :calendar="calendar"
:gm-id="world.data.gmId" :gm-id="worldGmId"
show-actions show-actions
@on-edit="() => deployUpdateDialog(calendar)" @on-edit="() => deployUpdateDialog(calendar)"
@on-delete="() => deployDeleteCalendarModal(calendar)" /> @on-delete="() => deployDeleteCalendarModal(calendar)" />
</li> </li>
<li class="xl:w-fit"> <li class="xl:w-fit">
<AddCard @on-click="() => isCreateCalendarModalOpen = true"> <LazyCardAdd @on-click="() => isCreateCalendarModalOpen = true">
<template v-if="sortedCalendars.length > 0"> <template v-if="sortedCalendars.length > 0">
{{ $t('entity.calendar.addSingle') }} {{ $t('entity.calendar.addSingle') }}
</template> </template>
<template v-else> <template v-else>
{{ $t('entity.calendar.addSingleFirst') }} {{ $t('entity.calendar.addSingleFirst') }}
</template> </template>
</AddCard> </LazyCardAdd>
</li> </li>
</ul> </ul>
</Spacing> </template>
</section> </Spacing>
</section>
<WorldDialogEdit :world="world.data" :modal-state="isEditWorldModalOpen" @on-close="hideEditModal" />
<CalendarDialogCreate :world="world.data" :modal-state="isCreateCalendarModalOpen" @on-close="hideCreateDialog" />
<CalendarDialogUpdate :world="world.data" :calendar="markedCalendar" :modal-state="isUpdateCalendarModalOpen" @on-close="hideUpdateDialog" />
<CalendarDialogDelete :calendar="markedCalendar" :modal-state="isDeleteCalendarModalOpen" @on-close="hideDeleteCalendarModal"/>
</template>
<template v-else>
<div class="h-full w-full grid place-items-center">
<Head>
<Title>{{ $t("entity.world.notFound") }}</Title>
</Head>
<div class="grid justify-items-center opacity-80">
<PhGlobeHemisphereWest size="75" class="opacity-60" weight="fill" />
<Heading level="h1">
{{ $t("entity.world.notFound") }}
</Heading>
<p>
{{ $t('entity.world.notFoundDescription') }}
</p>
<UiButton variant="default" class="mt-4 gap-2" as-child>
<RouterLink to="/my">
<PhArrowBendDoubleUpLeft size="24" />
{{ $t('entity.world.backToList') }}
</RouterLink>
</UiButton>
</div>
</div>
</template>
</main> </main>
</template> </template>

View File

@@ -130,7 +130,7 @@ export const useCalendar = defineStore("calendar", () => {
return Number(params.month) return Number(params.month)
}) })
const currentMonthData = computed<CalendarMonth>(() => { const currentMonthData = computed<CalendarMonth>(() => {
return sortedMonths.value[currentMonth.value] return sortedMonths.value[currentMonth.value]!
}) })
// Gets the label from currentMonth index // Gets the label from currentMonth index
const currentMonthName = computed<string>(() => getMonthName(currentMonth.value)) const currentMonthName = computed<string>(() => getMonthName(currentMonth.value))
@@ -605,7 +605,7 @@ export const useCalendar = defineStore("calendar", () => {
// Else, we need to accelerate and decelerate // Else, we need to accelerate and decelerate
else { else {
// First, get all the remaining days in the current month // First, get all the remaining days in the current month
const currentMonth = sortedMonths.value[datePivot.month] const currentMonth = sortedMonths.value[datePivot.month]!
dateAcc.day = currentMonth.days - datePivot.day dateAcc.day = currentMonth.days - datePivot.day
if (direction === "future") { if (direction === "future") {
datePivot.month = getNextViewMonth(datePivot.month) datePivot.month = getNextViewMonth(datePivot.month)
@@ -756,7 +756,7 @@ export const useCalendar = defineStore("calendar", () => {
// Loop over all event once to convert the structure to a usable one // Loop over all event once to convert the structure to a usable one
for (let i = 0; i < allEvents.value.length; i++) { for (let i = 0; i < allEvents.value.length; i++) {
const e: CalendarEvent = allEvents.value[i] const e: CalendarEvent = allEvents.value[i]!
// Estimate distance from pivot // Estimate distance from pivot
const startDateDays: number = convertDateToDays(e.startDate) const startDateDays: number = convertDateToDays(e.startDate)
@@ -830,6 +830,7 @@ export const useCalendar = defineStore("calendar", () => {
const isCreatingEvent = ref<boolean>(false) const isCreatingEvent = ref<boolean>(false)
const isUpdatingEvent = ref<boolean>(false) const isUpdatingEvent = ref<boolean>(false)
const isDeletingEvent = ref<boolean>(false) const isDeletingEvent = ref<boolean>(false)
const isDraggingEvent = ref<boolean>(false)
const operationInProgress = computed<boolean>(() => isCreatingEvent.value || isUpdatingEvent.value || isDeletingEvent.value) const operationInProgress = computed<boolean>(() => isCreatingEvent.value || isUpdatingEvent.value || isDeletingEvent.value)
let abortController: AbortController | null = null let abortController: AbortController | null = null
@@ -984,6 +985,7 @@ export const useCalendar = defineStore("calendar", () => {
isCreatingEvent, isCreatingEvent,
isUpdatingEvent, isUpdatingEvent,
isDeletingEvent, isDeletingEvent,
isDraggingEvent,
operationInProgress, operationInProgress,
eventSkeleton, eventSkeleton,
resetSkeleton, resetSkeleton,

View File

@@ -1,17 +1,20 @@
{ {
"$schema": "https://shadcn-vue.com/schema.json", "$schema": "https://shadcn-vue.com/schema.json",
"style": "default", "style": "new-york",
"typescript": true, "typescript": true,
"tsConfigPath": ".nuxt/tsconfig.json",
"tailwind": { "tailwind": {
"config": "tailwind.config.js", "config": "tailwind.config.js",
"css": "assets/main.css", "css": "app/assets/main.css",
"baseColor": "slate", "baseColor": "slate",
"cssVariables": true "cssVariables": true,
"prefix": ""
}, },
"framework": "nuxt",
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils" "composables": "@/composables",
} "utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
} }

View File

@@ -32,6 +32,7 @@ export default defineI18nConfig(() => ({
save: "Save", save: "Save",
delete: "Delete", delete: "Delete",
edit: "Edit", edit: "Edit",
close: "Close"
}, },
colors: { colors: {
label: "Color", label: "Color",
@@ -69,12 +70,14 @@ export default defineI18nConfig(() => ({
backToHome: "Back to home", backToHome: "Back to home",
sidebarMenu: { sidebarMenu: {
profile: "Profile", profile: "Profile",
projects: "Projects",
appearance: "Appearance", appearance: "Appearance",
language: "Language", language: "Language",
account: "Account", account: "Account",
login: "Log in", login: "Log in",
logout: "Log out", logout: "Log out",
avatarFallback: "Profile", avatarFallback: "Profile",
about: "About",
}, },
dark: "Dark", dark: "Dark",
light: "Light", light: "Light",
@@ -86,6 +89,17 @@ export default defineI18nConfig(() => ({
createdAt: "Created {createdAt}", createdAt: "Created {createdAt}",
updatedAt: "Last updated {updatedAt}", updatedAt: "Last updated {updatedAt}",
}, },
editor: {
bold: "Bold",
italic: "Italic",
strike: "Strikethrough",
ulist: "Bullet list",
olist: "Numbered list",
quote: "Quote",
noFormat: "Remove formatting",
undo: "Undo",
redo: "Redo"
},
entity: { entity: {
category: { category: {
nameSingular: "Category", nameSingular: "Category",
@@ -127,7 +141,7 @@ export default defineI18nConfig(() => ({
world: { world: {
nameSingular: "World", nameSingular: "World",
namePlural: "Worlds", namePlural: "Worlds",
backToMy: "Back to my universe", backToMy: "Back to projects",
backToSingle: "Back to {world}", backToSingle: "Back to {world}",
addSingle: "Add a world", addSingle: "Add a world",
addSingleFirst: "Add your first world !", addSingleFirst: "Add your first world !",
@@ -158,7 +172,7 @@ export default defineI18nConfig(() => ({
nameSingular: "Calendar", nameSingular: "Calendar",
namePlural: "Calendars", namePlural: "Calendars",
namePublicSingular: "Public Calendar", namePublicSingular: "Public Calendar",
namePublicPlural: "Public Calendars", namePublicPlural: "Calendars",
addSingle: "Add a calendar", addSingle: "Add a calendar",
addSingleFirst: "Add your first calendar !", addSingleFirst: "Add your first calendar !",
notFound: "Calendar not found", notFound: "Calendar not found",
@@ -206,11 +220,15 @@ export default defineI18nConfig(() => ({
title: "Event title", title: "Event title",
isStart: "Start", isStart: "Start",
isEnd: "End", isEnd: "End",
gotoStart: "Go to the start of this event",
gotoEnd: "Go to the end of this event",
isHidden: "Hidden", isHidden: "Hidden",
isPublic: "Public", isPublic: "Public",
hiddenLabel: "Hide this event", hiddenLabel: "Hide this event",
hiddenTooltip: "This event is visible only to game masters.", hiddenTooltip: "This event is visible only to game masters.",
addLocation: "Add a place", addLocation: "Add a place",
oneOtherEvent: "1 other",
multipleOtherEvents: "{count} others",
prevPage: "Previous page with events", prevPage: "Previous page with events",
nextPage: "Next page with events", nextPage: "Next page with events",
outOfBoundsTitle: "No next or previous events were found", outOfBoundsTitle: "No next or previous events were found",
@@ -241,6 +259,9 @@ export default defineI18nConfig(() => ({
title: "The event \"{event}\" has been successfuly deleted.", title: "The event \"{event}\" has been successfuly deleted.",
}, },
}, },
error: {
cannotFindAny: "We couldn't fetch your calendars…"
},
actionDialog: { actionDialog: {
tabs: { tabs: {
general: { general: {
@@ -311,13 +332,17 @@ export default defineI18nConfig(() => ({
} }
}, },
pages: { pages: {
home: {
h1: "A home for your creativity",
tagline: "Visualize your fantasy or sci-fi worlds with our interactive calendars"
},
explore: { explore: {
menuLabel: "Explore", menuLabel: "Explore",
title: "Explore worlds", title: "Explore worlds",
}, },
profile: { profile: {
title: "{user} — My universe", title: "{user} — Projects",
metaTitle: "My universe", metaTitle: "Projects",
}, },
about: { about: {
title: "About this app", title: "About this app",
@@ -328,7 +353,7 @@ export default defineI18nConfig(() => ({
breadcrumbs: { breadcrumbs: {
home: "Home", home: "Home",
explore: "Explore", explore: "Explore",
profile: "Universe", profile: "Projects",
world: "World", world: "World",
calendar: "Calendar" calendar: "Calendar"
} }
@@ -362,6 +387,7 @@ export default defineI18nConfig(() => ({
save: "Sauvegarder", save: "Sauvegarder",
delete: "Supprimer", delete: "Supprimer",
edit: "Modifier", edit: "Modifier",
close: "Fermer"
}, },
colors: { colors: {
label: "Couleur", label: "Couleur",
@@ -399,12 +425,14 @@ export default defineI18nConfig(() => ({
backToHome: "Retourner à l'accueil", backToHome: "Retourner à l'accueil",
sidebarMenu: { sidebarMenu: {
profile: "Profil", profile: "Profil",
projects: "Projets",
appearance: "Apparence", appearance: "Apparence",
language: "Langue", language: "Langue",
account: "Compte", account: "Compte",
login: "Connexion", login: "Connexion",
logout: "Déconnexion", logout: "Déconnexion",
avatarFallback: "Profil", avatarFallback: "Profil",
about: "À propos",
}, },
dark: "Sombre", dark: "Sombre",
light: "Clair", light: "Clair",
@@ -416,6 +444,17 @@ export default defineI18nConfig(() => ({
createdAt: "Créé le {createdAt}", createdAt: "Créé le {createdAt}",
updatedAt: "Dernière modification le {updatedAt}", updatedAt: "Dernière modification le {updatedAt}",
}, },
editor: {
bold: "Gras",
italic: "Italique",
strike: "Barré",
ulist: "Liste à puces",
olist: "Liste numerotée",
quote: "Citation",
noFormat: "Effacer le formattage",
undo: "Annuler",
redo: "Rétablir"
},
entity: { entity: {
category: { category: {
nameSingular: "Catégorie", nameSingular: "Catégorie",
@@ -457,7 +496,7 @@ export default defineI18nConfig(() => ({
world: { world: {
nameSingular: "Monde", nameSingular: "Monde",
namePlural: "Mondes", namePlural: "Mondes",
backToMy: "Retourner à mon univers", backToMy: "Retourner à Projets",
backToSingle: "Retourner sur {world}", backToSingle: "Retourner sur {world}",
addSingle: "Ajouter un monde", addSingle: "Ajouter un monde",
addSingleFirst: "Ajouter votre premier monde !", addSingleFirst: "Ajouter votre premier monde !",
@@ -536,11 +575,15 @@ export default defineI18nConfig(() => ({
title: "Titre de l'évènement", title: "Titre de l'évènement",
isStart: "Début", isStart: "Début",
isEnd: "Fin", isEnd: "Fin",
gotoStart: "Aller au début de l'évènement",
gotoEnd: "Aller à la fin de l'évènement",
isHidden: "Privé", isHidden: "Privé",
isPublic: "Public", isPublic: "Public",
hiddenLabel: "Rendre l'évènement privé", hiddenLabel: "Rendre l'évènement privé",
hiddenTooltip: "Cet évènement est uniquement visible pour les maîtres du jeu.", hiddenTooltip: "Cet évènement est uniquement visible pour les maîtres du jeu.",
addLocation: "Ajouter un endroit", addLocation: "Ajouter un endroit",
oneOtherEvent: "1 autre",
multipleOtherEvents: "{count} autres",
prevPage: "Précédente page à évènements", prevPage: "Précédente page à évènements",
nextPage: "Prochaine page à évènements", nextPage: "Prochaine page à évènements",
outOfBoundsTitle: "Aucun évènement suivant ou précédent trouvé", outOfBoundsTitle: "Aucun évènement suivant ou précédent trouvé",
@@ -571,6 +614,9 @@ export default defineI18nConfig(() => ({
title: "L'évènement \"{event}\" a été supprimé avec succès.", title: "L'évènement \"{event}\" a été supprimé avec succès.",
}, },
}, },
error: {
cannotFindAny: "Nous n'avons pas pu récupérer vos calendriers…"
},
actionDialog: { actionDialog: {
tabs: { tabs: {
general: { general: {
@@ -645,13 +691,17 @@ export default defineI18nConfig(() => ({
} }
}, },
pages: { pages: {
home: {
h1: "Un espace pour votre créativité",
tagline: "Visualisez vos mondes fantasy ou de science-fiction grâce à nos calendriers interactifs"
},
explore: { explore: {
menuLabel: "Explorer", menuLabel: "Explorer",
title: "Explorer les mondes", title: "Explorer les mondes",
}, },
profile: { profile: {
title: "{user} — Mon univers", title: "{user} — Projets",
metaTitle: "Mon univers", metaTitle: "Projets",
}, },
about: { about: {
title: "À propos", title: "À propos",
@@ -662,7 +712,7 @@ export default defineI18nConfig(() => ({
breadcrumbs: { breadcrumbs: {
home: "Accueil", home: "Accueil",
explore: "Explorer", explore: "Explorer",
profile: "Univers", profile: "Projets",
world: "Monde", world: "Monde",
calendar: "Calendrier" calendar: "Calendrier"
} }

View File

@@ -12,8 +12,9 @@ export default defineNuxtConfig({
"@nuxt/eslint", "@nuxt/eslint",
"shadcn-nuxt", "shadcn-nuxt",
"@nuxtjs/i18n", "@nuxtjs/i18n",
"@nuxthub/core",
"@nuxt/content", "@nuxt/content",
"@nuxthub/core",
"@nuxt/image",
], ],
css: ["@/assets/main.css"], css: ["@/assets/main.css"],
@@ -67,5 +68,5 @@ export default defineNuxtConfig({
}, },
eslint: {}, eslint: {},
compatibilityDate: "2025-04-16", compatibilityDate: "latest",
}) })

View File

@@ -15,49 +15,55 @@
"dependencies": { "dependencies": {
"@intlify/message-compiler": "^11.1.11", "@intlify/message-compiler": "^11.1.11",
"@nuxt/content": "3.6.3", "@nuxt/content": "3.6.3",
"@nuxt/eslint": "^1.7.1", "@nuxt/eslint": "^1.9.0",
"@nuxt/image": "1.11.0",
"@nuxthub/core": "^0.9.0", "@nuxthub/core": "^0.9.0",
"@nuxtjs/i18n": "^10.0.3", "@nuxtjs/i18n": "^10.0.6",
"@phosphor-icons/vue": "^2.2.1", "@phosphor-icons/vue": "^2.2.1",
"@pinia/nuxt": "^0.11.2", "@pinia/nuxt": "^0.11.2",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.12",
"@vueuse/core": "^13.5.0", "@tiptap/pm": "^3.3.0",
"@vueuse/integrations": "^13.5.0", "@tiptap/starter-kit": "^3.3.0",
"@vueuse/nuxt": "^13.5.0", "@tiptap/vue-3": "^3.3.0",
"@vueuse/core": "^13.7.0",
"@vueuse/integrations": "^13.7.0",
"@vueuse/nuxt": "^13.7.0",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^0.526.0", "lucide-vue-next": "^0.541.0",
"luxon": "^3.7.1", "luxon": "^3.7.1",
"nuxt": "^4.0.1", "nuxt": "^4.0.3",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"radix-vue": "^1.9.17", "radix-vue": "^1.9.17",
"reka-ui": "^2.4.1",
"shadcn-nuxt": "^2.2.0", "shadcn-nuxt": "^2.2.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vue": "^3.5.18", "tw-animate-css": "^1.3.7",
"vue": "^3.5.19",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"zod": "^4.0.10" "zod": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@nuxtjs/color-mode": "^3.5.2", "@nuxtjs/color-mode": "^3.5.2",
"@nuxtjs/supabase": "^1.6.0", "@nuxtjs/supabase": "^1.6.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
"@stylistic/eslint-plugin-js": "^4.4.1", "@stylistic/eslint-plugin-js": "^4.4.1",
"@types/luxon": "^3.6.2", "@types/luxon": "^3.7.1",
"@typescript-eslint/eslint-plugin": "^8.38.0", "@typescript-eslint/eslint-plugin": "^8.40.0",
"@typescript-eslint/parser": "^8.38.0", "@typescript-eslint/parser": "^8.40.0",
"eslint": "^9.32.0", "eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.3", "eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^10.3.0", "eslint-plugin-vue": "^10.4.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"sass": "^1.89.2", "sass": "^1.90.0",
"supabase": "^2.31.8", "supabase": "^2.34.3",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.12",
"typescript": "^5.8.3", "typescript": "^5.9.2",
"wrangler": "^4.26.0" "wrangler": "^4.32.0"
} }
} }

4724
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -41,12 +41,7 @@ export default defineEventHandler(async (event) => {
state, state,
createdAt:created_at, createdAt:created_at,
updatedAt:updated_at, updatedAt:updated_at,
calendars ( gmId:gm_id
createdAt:created_at,
updatedAt:updated_at,
months:calendar_months(*),
eventNb:calendar_events(count)
)
` `
let output let output

View File

@@ -43,7 +43,7 @@ create table public.worlds (
id bigint generated by default as identity primary key, id bigint generated by default as identity primary key,
name text not null, name text not null,
description text, description text,
color app_colors default 'black', color app_colors default 'emerald',
state world_state default 'draft', state world_state default 'draft',
created_at timestamptz default now(), created_at timestamptz default now(),
updated_at timestamptz, updated_at timestamptz,
@@ -69,7 +69,7 @@ create table public.calendars (
short_id text unique, short_id text unique,
name text not null, name text not null,
today json not null, today json not null,
color app_colors default 'black', color app_colors default 'emerald',
state calendar_state default 'draft', state calendar_state default 'draft',
created_at timestamptz default now(), created_at timestamptz default now(),
updated_at timestamptz, updated_at timestamptz,