36 Commits
main ... update

Author SHA1 Message Date
Alexis
8eecad6488 Fixed calendar event issues ? 2025-02-27 22:25:28 +01:00
Alexis
8ff5d5941a Updated major versions 2025-02-14 16:55:47 +01:00
Alexis
fe9a58b0ea Updated minor versions 2025-02-14 16:44:45 +01:00
Alexis
dcb637021e Hotfix : Added RLS for world 2024-12-20 17:44:54 +01:00
AlexisNP
5689d2689d Merge pull request #60 from AlexisNP/bugfix/quick-create-event-modal
Bugfix/quick create event modal
2024-12-20 11:49:14 +01:00
Alexis
91048d1b8e Watched for user change in the page instead 2024-12-20 11:41:17 +01:00
Alexis
3db1654682 Removed console log 2024-12-20 11:31:40 +01:00
Alexis
9f6d318a15 Removed duplicate closing calls for create event popover 2024-12-20 11:29:13 +01:00
Alexis
eedbf1decb Removed user watcher for now 2024-12-20 11:16:41 +01:00
Alexis
bc6cbd839f Added event count for calendars 2024-12-19 20:18:41 +01:00
Alexis
bf8257e60f Added explorer page 2024-12-19 19:38:57 +01:00
Alexis
199cfb6634 Added not found page for calendars 2024-12-19 16:46:22 +01:00
Alexis
df8af541c1 Added updated toast 2024-12-19 16:28:38 +01:00
Alexis
dde3f8504a Fixed updating modal not closing as well 2024-12-19 16:16:45 +01:00
AlexisNP
01fe9f3281 Merge pull request #59 from AlexisNP/bugfix/modal-issues 2024-12-19 15:55:52 +01:00
Alexis
bc7a400826 Fixed quick create event modal not closing on submit
This was caused by a missing event handler
2024-12-19 14:03:20 +01:00
Alexis
c99059d9a8 Fixed event popover not closing on submit
This was caused by a random stray isLoading
2024-12-19 14:01:05 +01:00
Alexis
cd965f61e8 Fixed event creation popover appearing for anon users 2024-12-19 13:52:15 +01:00
AlexisNP
9d5ff6a0b8 Merge pull request #57 from AlexisNP/features/calendar-public-links
Fixed calendar having null as visibility
2024-12-19 09:21:07 +01:00
Alexis
06fccd83ca Fixed calendar having null as visibility 2024-12-19 09:13:42 +01:00
AlexisNP
5f8ae1fb6c Merge pull request #56 from AlexisNP/features/calendar-public-links
Features/calendar public links
2024-12-18 20:17:43 +01:00
Alexis
535a73162c Added user change validation and read status
This might be better to just refetch the data with anonymous authorizations
2024-12-17 17:17:13 +01:00
Alexis
49574d8ef1 Added first draft of story 2024-12-09 23:58:16 +01:00
AlexisNP
323eedec56 Merge pull request #54 from AlexisNP/features/add-create-event-button
Features/add create event button
2024-12-03 15:31:54 +01:00
Alexis
4ddbfd9855 Changed organization for 2 mixed components 2024-12-03 15:29:06 +01:00
Alexis
bb6df1d440 Fixed organization 2024-12-03 15:21:36 +01:00
Alexis
dd1cb1f420 Add quick create event button 2024-12-03 14:53:24 +01:00
AlexisNP
11252c168e Merge pull request #50 from AlexisNP/bugfix/fix-leftover-hardcoded-logic
Bugfix/fix leftover hardcoded logic
2024-12-02 18:34:55 +01:00
Alexis
acd0476970 Removed paged component nav
As this will be moved to their own files and on the server side, I see little reason to refactor them, just so they'll be refactored again down the line.
2024-12-02 18:31:08 +01:00
Alexis
24c048df4d Fixed monthsPerYear leftover data 2024-11-27 19:36:37 +01:00
AlexisNP
c55d690f8b Merge pull request #49 from AlexisNP/bugfix/event-details-loses-focus-twice
Added prevent on focus outside event for event details
2024-11-27 17:18:11 +01:00
Alexis
39e4ac33b7 Added prevent on focus outside event for event details 2024-11-27 17:16:10 +01:00
AlexisNP
568efa2251 Merge pull request #48 from AlexisNP/features/nav-should-toast
Added toast when navigating out of bounds of event pages
2024-11-27 14:10:57 +01:00
Alexis
6a68105a0f Added toast when navigating out of bounds of event pages 2024-11-27 14:07:18 +01:00
AlexisNP
bba01fdcf1 Merge pull request #47 from AlexisNP/bugfix/one-event-fills-the-stack
Removed overkill pattern
2024-11-26 20:03:30 +01:00
Alexis
76a6468a04 Removed overkill patterns
This fixes the stack being trashed up
2024-11-26 19:59:18 +01:00
28 changed files with 5245 additions and 6236 deletions

View File

@@ -20,8 +20,7 @@ const { setActiveCalendar } = useCalendar()
setActiveCalendar(props.calendarData, props.categories)
const { currentConfig, jumpToDate, selectedDate } = useCalendar()
// const { setCharacters } = useCharacters()
const { isReadOnly } = storeToRefs(useCalendar())
const currentViewComponent: ComputedRef<Component> = computed<Component>(() => {
switch (currentConfig.viewType) {
@@ -53,8 +52,8 @@ onMounted(() => {
<component :is="currentViewComponent" />
<CalendarSearch />
<CalendarFormUpdateEvent />
<CalendarFormDeleteEvent />
<CalendarDialogUpdateEvent v-if="!isReadOnly" />
<CalendarDialogDeleteEvent v-if="!isReadOnly" />
</div>
</div>
</template>

View File

@@ -15,7 +15,7 @@ import {
} from "@phosphor-icons/vue"
const { defaultDate, getFormattedDateTitle, jumpToDate, getRelativeString, revealEditEventModal, revealDeleteEventModal } = useCalendar()
const { lastActiveEvent } = storeToRefs(useCalendar())
const { lastActiveEvent, isReadOnly } = storeToRefs(useCalendar())
const props = defineProps<{
event: CalendarEvent
@@ -46,6 +46,7 @@ function deployEditModal() {
lastActiveEvent.value = { ...props.event }
revealEditEventModal()
commandMenuOpened.value = false
emit("query:close-popover")
}
/**
@@ -55,12 +56,13 @@ function deployDeleteModal() {
lastActiveEvent.value = { ...props.event }
revealDeleteEventModal()
commandMenuOpened.value = false
emit("query:close-popover")
}
</script>
<template>
<UiPopoverContent
class=" w-96 "
class="w-96"
:align="'center'"
:align-offset="50"
:side="'left'"
@@ -72,6 +74,7 @@ function deployDeleteModal() {
'event-details': event.category
}
)"
@focus-outside.prevent
>
<div ref="eventDetails" class="grid gap-1">
<header class="pr-12">
@@ -148,7 +151,7 @@ function deployDeleteModal() {
</template>
</div>
<menu class="absolute top-4 right-4" :class="cn({ 'top-6': event.hidden })">
<menu v-if="!isReadOnly" class="absolute top-4 right-4" :class="cn({ 'top-6': event.hidden })">
<UiPopover v-model:open="commandMenuOpened">
<UiPopoverTrigger as-child>
<UiButton size="icon" variant="outline" class="mix-blend-luminosity bg-gray-300 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600">

View File

@@ -4,13 +4,17 @@ import { useCalendar } from "@/stores/CalendarStore"
import { PhMagnifyingGlass } from "@phosphor-icons/vue"
const { revealAdvancedSearch } = useCalendar()
const { isReadOnly } = storeToRefs(useCalendar())
</script>
<template>
<header class="pt-4 border-slate-400 dark:border-slate-700 border-b-[1px]">
<div class="px-6 flex justify-between">
<menu class="flex items-center gap-2">
<li class="flex items-center">
<li v-if="!isReadOnly">
<CalendarDialogQuickCreateEvent />
</li>
<li>
<CalendarMenuToday />
</li>
<li>

View File

@@ -1,63 +1,66 @@
<script lang="ts" setup>
import { type RPGDate } from "@/models/Date"
const { currentDate } = useCalendar()
import { PhArrowLineLeft, PhArrowLineRight } from "@phosphor-icons/vue"
// const { toast } = useToast()
// const { t } = useI18n()
const { currentDate, currentConfig, jumpToDate, getRelativeEventFromDate } = useCalendar()
// function handleGotoPreviousEventPage(position: "next" | "prev" = "next") {
// let fromDate: RPGDate
function handleGotoPreviousEventPage(position: "next" | "prev" = "next") {
let fromDate: RPGDate
// // To modify, obviously
// const daysPerMonth = 32
// To modify, obviously
const daysPerMonth = 32
const monthsPerYear = 10
// const toDay = position === "next" ? daysPerMonth : 1
// const toMonth = position === "next" ? monthsPerYear : 0
const toDay = position === "next" ? daysPerMonth : 1
const toMonth = position === "next" ? monthsPerYear : 0
// switch (currentConfig.viewType) {
// case "month":
// fromDate = {
// day: toDay,
// month: currentDate.currentMonth,
// year: currentDate.currentYear
// }
// break
switch (currentConfig.viewType) {
case "month":
fromDate = {
day: toDay,
month: currentDate.currentMonth,
year: currentDate.currentYear
}
break
// case "year":
// fromDate = {
// day: toDay,
// month: toMonth,
// year: currentDate.currentYear
// }
// break
case "year":
fromDate = {
day: toDay,
month: toMonth,
year: currentDate.currentYear
}
break
// case "decade":
// fromDate = {
// day: toDay,
// month: currentDate.currentMonth,
// year: currentDate.currentYear
// }
// break
case "decade":
fromDate = {
day: toDay,
month: currentDate.currentMonth,
year: currentDate.currentYear
}
break
// case "century":
// default:
// fromDate = {
// day: toDay,
// month: currentDate.currentMonth,
// year: currentDate.currentYear
// }
// break
// }
case "century":
default:
fromDate = {
day: toDay,
month: currentDate.currentMonth,
year: currentDate.currentYear
}
break
}
// try {
// const { targetDate } = getRelativeEventFromDate(fromDate, position)
try {
const { targetDate } = getRelativeEventFromDate(fromDate, position)
jumpToDate(targetDate)
} catch (err) {
console.log(err)
}
}
// jumpToDate(targetDate)
// } catch (err) {
// toast({
// title: t("entity.calendar.event.outOfBoundsTitle"),
// variant: "default",
// description: t("entity.calendar.event.outOfBoundsMessage"),
// duration: 4000,
// })
// }
// }
</script>
<template>
@@ -73,7 +76,7 @@ function handleGotoPreviousEventPage(position: "next" | "prev" = "next") {
</template>
</ClientOnly>
</div>
<div>
<!-- <div>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
@@ -114,6 +117,6 @@ function handleGotoPreviousEventPage(position: "next" | "prev" = "next") {
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
</div> -->
</div>
</template>

View File

@@ -14,7 +14,7 @@ const buttonDisabledState: ComputedRef<boolean> = computed<boolean>(() => {
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton size="sm" :disabled="buttonDisabledState" @click="jumpToDefaultDate">
<UiButton size="sm" variant="secondary" :disabled="buttonDisabledState" @click="jumpToDefaultDate">
{{ $t('entity.calendar.date.today') }}
</UiButton>
</UiTooltipTrigger>

View File

@@ -0,0 +1,62 @@
<script lang="ts" setup>
import type { RPGDate } from "~/models/Date";
const { eventSkeleton, operationInProgress } = storeToRefs(useCalendar())
const { resetSkeleton } = useCalendar()
const popoverOpen = ref(false)
const props = defineProps<{
date?: RPGDate
btnClass?: string
}>()
/**
* Opens event creation's popover
*/
function openEventCreatePopover() {
// If another operation is in progress, whether it's another create popup or a modal, don't bother opening it
if (operationInProgress.value) {
popoverOpen.value = false
return
}
resetSkeleton()
popoverOpen.value = true
// Set skeleton initial startDate if it's known
if (props.date) {
eventSkeleton.value.startDate = { ...props.date } // We need to clone it otherwise the props ends up mutating (?)
}
}
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing(e: Event) {
e.preventDefault()
popoverOpen.value = false
}
</script>
<template>
<UiPopover v-model:open="popoverOpen">
<UiPopoverTrigger as-child>
<button :class="btnClass" @click="openEventCreatePopover()" />
</UiPopoverTrigger>
<UiPopoverContent
:align="'center'"
:side="'right'"
:collision-padding="60"
:disable-outside-pointer-events="true"
:trap-focus="true"
class="pl-3 w-[30rem] max-w-full border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
@escape-key-down="handleClosing"
@pointer-down-outside="handleClosing"
>
<CalendarFormCreateEvent @event-created="handleClosing" />
</UiPopoverContent>
</UiPopover>
</template>

View File

@@ -0,0 +1,45 @@
<script lang="ts" setup>
const { isDeleteEventModalOpen, eventSkeleton, lastActiveEvent } = storeToRefs(useCalendar())
const isLoading = ref<boolean>(false)
// Watch the popover state
watch(isDeleteEventModalOpen, (hasOpened, _o) => {
if (hasOpened && lastActiveEvent.value) {
eventSkeleton.value = { ...lastActiveEvent.value }
}
})
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing(e: Event): void {
if (isLoading.value) {
e.preventDefault()
}
}
</script>
<template>
<UiAlertDialog v-model:open="isDeleteEventModalOpen">
<UiAlertDialogContent
:disable-outside-pointer-events="true"
:trap-focus="true"
class="min-w-96 border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
@escape-key-down="handleClosing"
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@pointer-down-outside="handleClosing"
>
<UiAlertDialogTitle>{{ $t('entity.calendar.event.deleteDialog.title') }}</UiAlertDialogTitle>
<UiAlertDialogDescription>
{{ $t('entity.calendar.event.deleteDialog.subtitle') }}
</UiAlertDialogDescription>
<CalendarFormDeleteEvent />
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { PhPlus } from "@phosphor-icons/vue";
const isDialogOpen = ref<boolean>(false);
// Toggles the dialog
function toggleDialog() {
isDialogOpen.value = !isDialogOpen.value;
};
</script>
<template>
<UiButton @click="toggleDialog">
<PhPlus size="18" weight="bold" />
<strong class="font-semibold">
{{ $t("entity.calendar.event.newEvent") }}
</strong>
</UiButton>
<UiDialog v-model:open="isDialogOpen">
<UiDialogContent class="border-indigo-200 dark:bg-slate-950 dark:border-indigo-950">
<UiDialogTitle>
{{ $t("entity.calendar.event.addSingle") }}
</UiDialogTitle>
<CalendarFormCreateEvent @event-created="toggleDialog" />
</UiDialogContent>
</UiDialog>
</template>

View File

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

View File

@@ -2,7 +2,7 @@
import type { Calendar } from "~/models/CalendarConfig";
import { PhAlarm, PhCalendarDots, PhCircleNotch, PhWrench } from "@phosphor-icons/vue";
const defaultSkeleton: Calendar = { name: "", today: { day: 1, month: 0, year: 0 }, months: [], events: []}
const defaultSkeleton: Calendar = { name: "", today: { day: 1, month: 0, year: 0 }, months: [], events: [], state: "draft" }
const calendarSkeleton = ref<Calendar>({ ...defaultSkeleton })
onMounted(() => {

View File

@@ -2,9 +2,10 @@
import type { RPGDate } from "~/models/Date";
import { PhAlarm, PhCircleNotch, PhEye, PhEyeClosed, PhMapPinArea, PhTag } from "@phosphor-icons/vue"
const { eventSkeleton, operationInProgress } = storeToRefs(useCalendar())
const { resetSkeleton, submitSkeleton, cancelLatestRequest } = useCalendar()
const popoverOpen = ref(false)
const emit = defineEmits(["event-created"])
const { eventSkeleton } = storeToRefs(useCalendar())
const { submitSkeleton, cancelLatestRequest } = useCalendar()
const { t } = useI18n()
@@ -19,26 +20,6 @@ const props = defineProps<{
btnClass?: string
}>()
/**
* Opens event creation's popover
*/
function openEventCreatePopover() {
// If another operation is in progress, whether it's another create popup or a modal, don't bother opening it
if (operationInProgress.value) {
popoverOpen.value = false
return
}
resetSkeleton()
popoverOpen.value = true
// Set skeleton initial startDate if it's known
if (props.date) {
eventSkeleton.value.startDate = { ...props.date } // We need to clone it otherwise the props ends up mutating (?)
}
}
async function handleSubmit() {
// Prevent form submission if already loading
if (isLoading.value) return
@@ -48,7 +29,7 @@ async function handleSubmit() {
try {
await submitSkeleton()
popoverOpen.value = false
emit("event-created")
} catch (err) {
if (err instanceof Error) {
formErrors.message = err.message
@@ -58,17 +39,6 @@ async function handleSubmit() {
}
}
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing(e: Event) {
if (isLoading.value) {
e.preventDefault()
}
}
/**
* Click on the cancel button
*
@@ -81,160 +51,141 @@ function handleCancel() {
</script>
<template>
<UiPopover v-model:open="popoverOpen">
<UiPopoverTrigger as-child>
<button :class="btnClass" @click="openEventCreatePopover()" />
</UiPopoverTrigger>
<UiPopoverContent
:align="'center'"
:side="'right'"
:collision-padding="60"
:disable-outside-pointer-events="true"
:trap-focus="true"
class="pl-3 w-[30rem] max-w-full border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
@escape-key-down="handleClosing"
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@pointer-down-outside="handleClosing"
>
<form @submit.prevent="handleSubmit">
<div class="grid grid-cols-2 gap-y-3">
<div class="col-span-2 pl-8">
<form @submit.prevent="handleSubmit">
<div class="grid grid-cols-2 gap-y-3">
<div class="col-span-2 pl-8">
<input
id="new-event-title"
v-model="eventSkeleton.title"
type="text"
name="new-event-title"
required
:placeholder="$t('entity.calendar.event.title')"
:maxlength="120"
pattern="([A-Za-zÀ-ÖØ-öø-ÿ0-9\s\&\-\~]+){3,120}"
class="w-full -my-1 py-1 -mx-1 px-1 text-lg border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600 invalid:border-red-500"
>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.title') }}
</div>
</div>
<div class="col-span-2 my-2 pl-8">
<textarea
id="new-event-description"
v-model="eventSkeleton.description"
name="new-event-description"
:placeholder="$t('entity.addDescription')"
:maxlength="1200"
class="w-full -my-1 py-1 -mx-1 px-1 min-h-24 max-h-36 text-sm border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600 invalid:border-red-500"
/>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.description') }}
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhAlarm size="18" weight="fill" />
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.startDate"
:placeholder="$t('entity.calendar.date.start')"
:initial-date="props.date"
:required="true"
/>
<span></span>
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.endDate"
:placeholder="$t('entity.calendar.date.end')"
:initial-date="props.date"
/>
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhTag size="18" weight="fill" />
<CalendarInputEventCategory
v-model="eventSkeleton.category"
:placeholder="$t('entity.category.addPrimary')"
/>
</div>
</div>
<!-- <div class="col-span-2">
<div class="flex items-center gap-4">
<PhTag size="18" weight="fill" />
<CalendarInputEventCategories v-model="eventSkeleton.secondaryCategories" :placeholder="$t('entity.category.addSecondaries')" />
</div>
</div> -->
<div class="col-span-2 mb-2">
<div class="flex items-center gap-4">
<PhMapPinArea size="18" weight="fill" />
<div class="grow">
<input
id="new-event-title"
v-model="eventSkeleton.title"
id="new-event-location"
v-model="eventSkeleton.location"
type="text"
name="new-event-title"
required
:placeholder="$t('entity.calendar.event.title')"
:maxlength="120"
pattern="([A-Za-zÀ-ÖØ-öø-ÿ0-9\s\&\-\~]+){3,120}"
class="w-full -my-1 py-1 -mx-1 px-1 text-lg border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600 invalid:border-red-500"
name="new-event-location"
:placeholder="$t('entity.calendar.event.addLocation')"
:maxlength="160"
pattern="([A-Za-zÀ-ÖØ-öø-ÿ0-9\s\&\-\~]+){3,160}"
class="w-full -my-1 py-2 px-2 text-sm border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600"
>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.title') }}
{{ t('entity.calendar.event.patterns.location') }}
</div>
</div>
<div class="col-span-2 my-2 pl-8">
<textarea
id="new-event-description"
v-model="eventSkeleton.description"
name="new-event-description"
:placeholder="$t('entity.addDescription')"
:maxlength="1200"
class="w-full -my-1 py-1 -mx-1 px-1 min-h-24 max-h-36 text-sm border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600 invalid:border-red-500"
/>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.description') }}
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhAlarm size="18" weight="fill" />
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.startDate"
:placeholder="$t('entity.calendar.date.start')"
:initial-date="props.date"
:required="true"
/>
<span></span>
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.endDate"
:placeholder="$t('entity.calendar.date.end')"
:initial-date="props.date"
/>
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhTag size="18" weight="fill" />
<CalendarInputEventCategory
v-model="eventSkeleton.category"
:placeholder="$t('entity.category.addPrimary')"
/>
</div>
</div>
<!-- <div class="col-span-2">
<div class="flex items-center gap-4">
<PhTag size="18" weight="fill" />
<CalendarInputEventCategories v-model="eventSkeleton.secondaryCategories" :placeholder="$t('entity.category.addSecondaries')" />
</div>
</div> -->
<div class="col-span-2 mb-2">
<div class="flex items-center gap-4">
<PhMapPinArea size="18" weight="fill" />
<div class="grow">
<input
id="new-event-location"
v-model="eventSkeleton.location"
type="text"
name="new-event-location"
:placeholder="$t('entity.calendar.event.addLocation')"
:maxlength="160"
pattern="([A-Za-zÀ-ÖØ-öø-ÿ0-9\s\&\-\~]+){3,160}"
class="w-full -my-1 py-2 px-2 text-sm border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600"
>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.location') }}
</div>
</div>
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhEye v-if="!eventSkeleton.hidden" size="18" weight="fill" />
<PhEyeClosed v-else size="18" />
<div class="flex items-center gap-x-2">
<UiSwitch id="new-event-visibility" v-model:checked="eventSkeleton.hidden" />
<UiLabel for="new-event-visibility">
<template v-if="!eventSkeleton.hidden">
{{ $t('entity.calendar.event.isPublic') }}
</template>
<template v-else>
{{ $t('entity.calendar.event.isHidden') }}
</template>
</UiLabel>
</div>
</div>
</div>
<div class="text-red-500 pl-8">
<span class="text-sm">
{{ formErrors.message }}
</span>
</div>
<div class="flex gap-2 justify-end">
<Transition name="fade-delay">
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
{{ $t('ui.action.cancel') }}
</UiButton>
</Transition>
<UiButton size="sm" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="opacity-50 animate-spin"/>
</Transition>
{{ $t('ui.action.save') }}
</UiButton>
</div>
</div>
</form>
</UiPopoverContent>
</UiPopover>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhEye v-if="!eventSkeleton.hidden" size="18" weight="fill" />
<PhEyeClosed v-else size="18" />
<div class="flex items-center gap-x-2">
<UiSwitch id="new-event-visibility" v-model:checked="eventSkeleton.hidden" />
<UiLabel for="new-event-visibility">
<template v-if="!eventSkeleton.hidden">
{{ $t('entity.calendar.event.isPublic') }}
</template>
<template v-else>
{{ $t('entity.calendar.event.isHidden') }}
</template>
</UiLabel>
</div>
</div>
</div>
<div class="text-red-500 pl-8">
<span class="text-sm">
{{ formErrors.message }}
</span>
</div>
<div class="flex gap-2 justify-end">
<Transition name="fade-delay">
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
{{ $t('ui.action.cancel') }}
</UiButton>
</Transition>
<UiButton size="sm" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="opacity-50 animate-spin"/>
</Transition>
{{ $t('ui.action.save') }}
</UiButton>
</div>
</div>
</form>
</template>

View File

@@ -3,7 +3,7 @@ import { PhCircleNotch } from "@phosphor-icons/vue";
import { useToast } from "~/components/ui/toast";
const { resetSkeleton, deleteEventFromSkeleton, cancelLatestRequest } = useCalendar()
const { isDeleteEventModalOpen, eventSkeleton, lastActiveEvent } = storeToRefs(useCalendar())
const { isDeleteEventModalOpen, eventSkeleton } = storeToRefs(useCalendar())
const { toast } = useToast()
const { t } = useI18n()
@@ -14,13 +14,6 @@ const formErrors = reactive<{ message: string | null }>({
message: null
})
// Watch the popover state
watch(isDeleteEventModalOpen, (hasOpened, _o) => {
if (hasOpened && lastActiveEvent.value) {
eventSkeleton.value = { ...lastActiveEvent.value }
}
})
async function handleAction(): Promise<void> {
if (isLoading.value) return
@@ -48,17 +41,6 @@ async function handleAction(): Promise<void> {
}
}
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing(e: Event): void {
if (isLoading.value) {
e.preventDefault()
}
}
/**
* Click on the cancel button
*
@@ -71,53 +53,35 @@ function handleCancel(): void {
</script>
<template>
<UiAlertDialog v-model:open="isDeleteEventModalOpen">
<UiAlertDialogContent
:disable-outside-pointer-events="true"
:trap-focus="true"
class="min-w-96 border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
@escape-key-down="handleClosing"
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@pointer-down-outside="handleClosing"
>
<UiAlertDialogTitle>{{ $t('entity.calendar.event.deleteDialog.title') }}</UiAlertDialogTitle>
<form @submit.prevent="handleAction">
<div class="grid grid-cols-2 gap-y-4">
<div class="text-red-500 ml-8">
<span class="text-sm">
{{ formErrors.message }}
</span>
</div>
</div>
<UiAlertDialogDescription>
{{ $t('entity.calendar.event.deleteDialog.subtitle') }}
</UiAlertDialogDescription>
<footer class="flex gap-2 justify-between">
<UiButton type="button" size="sm" variant="outline" @click="() => isDeleteEventModalOpen = false">
{{ $t('ui.action.back') }}
</UiButton>
<form @submit.prevent="handleAction">
<div class="grid grid-cols-2 gap-y-4">
<div class="text-red-500 ml-8">
<span class="text-sm">
{{ formErrors.message }}
</span>
</div>
</div>
<footer class="flex gap-2 justify-between">
<UiButton type="button" size="sm" variant="outline" @click="() => isDeleteEventModalOpen = false">
{{ $t('ui.action.back') }}
<div class="flex gap-2 justify-end">
<Transition name="fade-delay">
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
{{ $t('ui.action.cancel') }}
</UiButton>
</Transition>
<div class="flex gap-2 justify-end">
<Transition name="fade-delay">
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
{{ $t('ui.action.cancel') }}
</UiButton>
</Transition>
<UiButton size="sm" variant="destructive" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
</Transition>
<UiButton size="sm" variant="destructive" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
</Transition>
{{ $t('ui.action.delete') }}
</UiButton>
</div>
</footer>
</form>
</UiAlertDialogContent>
</UiAlertDialog>
{{ $t('ui.action.delete') }}
</UiButton>
</div>
</footer>
</form>
</template>

View File

@@ -3,8 +3,10 @@ import { PhAlarm, PhCircleNotch, PhEye, PhEyeClosed, PhMapPinArea, PhPencilSimpl
import { useToast } from "~/components/ui/toast";
import type { APIError } from "~/models/Errors";
const emit = defineEmits(["event-updated"])
const { resetSkeleton, updateEventFromSkeleton, cancelLatestRequest } = useCalendar()
const { eventSkeleton, lastActiveEvent, isEditEventModalOpen } = storeToRefs(useCalendar())
const { eventSkeleton, lastActiveEvent } = storeToRefs(useCalendar())
const { toast } = useToast()
const { t } = useI18n()
@@ -15,13 +17,6 @@ const formErrors = reactive<{ message: string | null }>({
message: null
})
// Watch the popover state
watch(isEditEventModalOpen, (hasOpened, _o) => {
if (hasOpened && lastActiveEvent.value) {
eventSkeleton.value = { ...lastActiveEvent.value }
}
})
async function handleAction() {
if (isLoading.value) return
@@ -29,6 +24,14 @@ async function handleAction() {
try {
await updateEventFromSkeleton()
emit("event-updated")
toast({
title: t("entity.calendar.event.updatedToast.title", { event: eventSkeleton.value.title }),
variant: "success",
duration: 3000
})
} catch (err) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const apiError = (err as any).data as APIError
@@ -44,18 +47,6 @@ async function handleAction() {
} finally {
resetSkeleton()
isLoading.value = false
isEditEventModalOpen.value = false
}
}
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing(e: Event) {
if (isLoading.value) {
e.preventDefault()
}
}
@@ -71,174 +62,152 @@ function handleCancel() {
</script>
<template>
<UiDialog v-model:open="isEditEventModalOpen">
<UiDialogContent
:disable-outside-pointer-events="true"
:trap-focus="true"
class="pl-3 min-w-96 border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
@escape-key-down="handleClosing"
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@pointer-down-outside="(e) => e.preventDefault()"
>
<header class="pl-8 grid gap-y-2">
<UiDialogTitle>
{{ $t('entity.calendar.event.editDialog.title') }}
</UiDialogTitle>
<form @submit.prevent="handleAction">
<div class="grid grid-cols-2 gap-y-3">
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhPencilSimpleLine size="20" weight="fill" />
<UiDialogDescription>
{{ $t('entity.calendar.event.editDialog.subtitle') }}
</UiDialogDescription>
</header>
<form @submit.prevent="handleAction">
<div class="grid grid-cols-2 gap-y-3">
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhPencilSimpleLine size="20" weight="fill" />
<div class="grow">
<input
id="new-event-title"
v-model="eventSkeleton.title"
type="text"
name="new-event-title"
required
:placeholder="$t('entity.calendar.event.title')"
:maxlength="120"
pattern="([A-Za--ÖØ-öø-ÿ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-none focus-visible:border-blue-600 invalid:border-red-500"
>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.title') }}
</div>
</div>
</div>
</div>
<div class="col-span-2 my-2 ml-8">
<textarea
id="new-event-description"
v-model="eventSkeleton.description"
name="new-event-description"
:placeholder="$t('entity.addDescription')"
:maxlength="1200"
class="w-full -my-1 py-1 -mx-1 px-1 min-h-24 max-h-36 text-sm border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600 invalid:border-red-500"
/>
<div class="grow">
<input
id="new-event-title"
v-model="eventSkeleton.title"
type="text"
name="new-event-title"
required
:placeholder="$t('entity.calendar.event.title')"
:minlength="3"
:maxlength="120"
class="w-full -my-1 py-1 -mx-1 px-1 text-lg border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600 invalid:border-red-500"
>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.description') }}
{{ t('entity.calendar.event.patterns.title') }}
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhAlarm size="18" weight="fill" />
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.startDate"
:placeholder="$t('entity.calendar.date.start')"
:initial-date="lastActiveEvent?.startDate"
:required="true"
/>
<span>—</span>
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.endDate"
:placeholder="$t('entity.calendar.date.end')"
:initial-date="lastActiveEvent?.endDate"
/>
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhTag size="18" weight="fill" />
<div class="w-1/2">
<CalendarInputEventCategory
v-model="eventSkeleton.category"
:placeholder="$t('entity.category.addPrimary')"
/>
</div>
</div>
</div>
<!-- <div class="col-span-2">
<div class="flex items-center gap-4">
<PhTag size="18" weight="fill" />
<div class="w-1/2">
<CalendarInputEventCategories v-model="eventSkeleton.secondaryCategories" placeholder="Ajouter des catégories secondaires" />
</div>
</div>
</div> -->
<div class="col-span-2 mb-2">
<div class="flex items-center gap-4">
<PhMapPinArea size="18" weight="fill" />
<div class="grow">
<input
id="new-event-location"
v-model="eventSkeleton.location"
type="text"
name="new-event-location"
:placeholder="$t('entity.calendar.event.addLocation')"
:maxlength="160"
pattern="([A-Za--ÖØ-öø-ÿ0-9\s\&\-\~]+){3,160}"
class="w-full -my-1 py-2 px-2 text-sm border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600 invalid:border-red-500"
>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.location') }}
</div>
</div>
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhEye v-if="!eventSkeleton.hidden" size="18" weight="fill" />
<PhEyeClosed v-else size="18" />
<div class="flex items-center gap-x-2">
<UiSwitch id="new-event-visibility" v-model:checked="eventSkeleton.hidden" />
<UiLabel for="new-event-visibility">
<template v-if="!eventSkeleton.hidden">
{{ $t('entity.calendar.event.isPublic') }}
</template>
<template v-else>
{{ $t('entity.calendar.event.isHidden') }}
</template>
</UiLabel>
</div>
</div>
</div>
<div class="text-red-500 ml-8">
<span class="text-sm">
{{ formErrors.message }}
</span>
</div>
</div>
</div>
<footer class="flex gap-2 justify-end">
<Transition name="fade-delay">
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
{{ $t('ui.action.cancel') }}
</UiButton>
</Transition>
<div class="col-span-2 my-2 ml-8">
<textarea
id="new-event-description"
v-model="eventSkeleton.description"
name="new-event-description"
:placeholder="$t('entity.addDescription')"
:maxlength="1200"
class="w-full -my-1 py-1 -mx-1 px-1 min-h-24 max-h-36 text-sm border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600 invalid:border-red-500"
/>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.description') }}
</div>
</div>
<UiButton size="sm" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
</Transition>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhAlarm size="18" weight="fill" />
{{ $t('ui.action.save') }}
</UiButton>
</footer>
</form>
</UiDialogContent>
</UiDialog>
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.startDate"
:placeholder="$t('entity.calendar.date.start')"
:initial-date="lastActiveEvent?.startDate"
:required="true"
/>
<span></span>
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.endDate"
:placeholder="$t('entity.calendar.date.end')"
:initial-date="lastActiveEvent?.endDate"
/>
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhTag size="18" weight="fill" />
<div class="w-1/2">
<CalendarInputEventCategory
v-model="eventSkeleton.category"
:placeholder="$t('entity.category.addPrimary')"
/>
</div>
</div>
</div>
<!-- <div class="col-span-2">
<div class="flex items-center gap-4">
<PhTag size="18" weight="fill" />
<div class="w-1/2">
<CalendarInputEventCategories v-model="eventSkeleton.secondaryCategories" placeholder="Ajouter des catégories secondaires" />
</div>
</div>
</div> -->
<div class="col-span-2 mb-2">
<div class="flex items-center gap-4">
<PhMapPinArea size="18" weight="fill" />
<div class="grow">
<input
id="new-event-location"
v-model="eventSkeleton.location"
type="text"
name="new-event-location"
:placeholder="$t('entity.calendar.event.addLocation')"
:minlength="3"
:maxlength="160"
class="w-full -my-1 py-2 px-2 text-sm border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600 invalid:border-red-500"
>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.location') }}
</div>
</div>
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhEye v-if="!eventSkeleton.hidden" size="18" weight="fill" />
<PhEyeClosed v-else size="18" />
<div class="flex items-center gap-x-2">
<UiSwitch id="new-event-visibility" v-model:checked="eventSkeleton.hidden" />
<UiLabel for="new-event-visibility">
<template v-if="!eventSkeleton.hidden">
{{ $t('entity.calendar.event.isPublic') }}
</template>
<template v-else>
{{ $t('entity.calendar.event.isHidden') }}
</template>
</UiLabel>
</div>
</div>
</div>
<div class="text-red-500 ml-8">
<span class="text-sm">
{{ formErrors.message }}
</span>
</div>
</div>
<footer class="flex gap-2 justify-end">
<Transition name="fade-delay">
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
{{ $t('ui.action.cancel') }}
</UiButton>
</Transition>
<UiButton size="sm" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
</Transition>
{{ $t('ui.action.save') }}
</UiButton>
</footer>
</form>
</template>

View File

@@ -16,7 +16,7 @@ const calendarTile = ref()
const calendarEventsList = ref()
const { defaultDate, selectDate, areDatesIdentical } = useCalendar()
const { selectedDate, currentEvents } = storeToRefs(useCalendar())
const { selectedDate, currentEvents, isReadOnly } = storeToRefs(useCalendar())
/**
* All events with a startDate / endDate that starts or ends on the tile
@@ -143,7 +143,7 @@ const eventsNotDisplayed: ComputedRef<number> = computed<number>(() => eventsFo
</ClientOnly>
<ClientOnly>
<LazyCalendarFormCreateEvent :date btn-class="absolute inset-0 w-full h-full cursor-default z-0" />
<LazyCalendarDialogCreateEvent v-if="!isReadOnly" :date btn-class="absolute inset-0 w-full h-full cursor-default z-0" />
</ClientOnly>
</div>
</template>

View File

@@ -1,11 +1,11 @@
<script lang="ts" setup>
import { PhGlobeHemisphereWest, PhList } from "@phosphor-icons/vue"
import { PhCompass, PhList } from "@phosphor-icons/vue"
import type { SidebarMenuActionType } from "./SidebarProps";
const { revealAdvancedSearch } = useCalendar()
const { currentMenu } = storeToRefs(useUiStore())
const user = useSupabaseUser()
// const user = useSupabaseUser()
function handleMenuItemAction(actionType: SidebarMenuActionType) {
if (actionType === "event-search") {
@@ -23,19 +23,19 @@ function handleMenuItemAction(actionType: SidebarMenuActionType) {
</UiButton>
</li>
<li v-if="user">
<li>
<UiTooltipProvider :delay-duration="50">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton variant="ghost" size="icon" class="rounded-full" as-child>
<RouterLink to="/my">
<PhGlobeHemisphereWest size="24" weight="fill" />
<RouterLink to="/explore">
<PhCompass size="24" weight="fill" />
</RouterLink>
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent :side="'right'" :side-offset="6">
<p>
{{ $t('entity.world.namePlural') }}
{{ $t('pages.explore.menuLabel') }}
</p>
</UiTooltipContent>
</UiTooltip>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { computed } from "vue"
import { PhCheckCircle, PhGear, PhGlobeHemisphereWest, PhLaptop, PhMoon, PhPalette, PhSignIn, PhSignOut, PhSun, PhTranslate, PhUserCircle } from "@phosphor-icons/vue"
import { PhCheckCircle, PhGear, PhUser, PhLaptop, PhMoon, PhPalette, PhSignIn, PhSignOut, PhSun, PhTranslate, PhUserCircle } from "@phosphor-icons/vue"
import { cn } from "~/lib/utils";
const router = useRouter()
@@ -82,9 +82,9 @@ function pushRoute(to: AvailableRoutes) {
</p>
<UiDropdownMenuItem class="flex gap-[.5ch] items-center rounded-none" @click="pushRoute('/my')">
<PhGlobeHemisphereWest size="20" weight="fill" />
<PhUser size="20" weight="fill" />
<span>
{{ $t('entity.world.namePlural') }}
{{ $t('ui.sidebarMenu.profile') }}
</span>
</UiDropdownMenuItem>

View File

@@ -41,6 +41,7 @@ export default defineI18nConfig(() => ({
greeting: "Connected as {user}",
anonymousGreeting: "Preferences",
sidebarMenu: {
profile: "Profile",
appearance: "Appearance",
language: "Language",
account: "Account",
@@ -94,9 +95,14 @@ export default defineI18nConfig(() => ({
calendar: {
nameSingular: "Calendar",
namePlural: "Calendars",
namePublicSingular: "Public Calendar",
namePublicPlural: "Public Calendars",
addSingle: "Add a calendar",
notFound: "Calendar not found",
notFoundDescription: "The link is not valid or the calendar has been deleted / archived.",
notFoundForWorld: "No calendar for this world… yet !",
isLoading: "Calendar is loading…",
hasXEvents: "This calendar has {count} public events",
date: {
start: "Start date",
end: "End date",
@@ -127,6 +133,8 @@ export default defineI18nConfig(() => ({
event: {
nameSingular: "Event",
namePlural: "Events",
addSingle: "Add an event",
newEvent: "New event",
title: "Event title",
isStart: "Start",
isEnd: "End",
@@ -136,15 +144,20 @@ export default defineI18nConfig(() => ({
addLocation: "Add a place",
prevPage: "Previous page with events",
nextPage: "Next page with events",
outOfBoundsTitle: "No next or previous events were found",
outOfBoundsMessage: "Maybe the event is the first or last one ?",
patterns: {
title: "Between 3 and 120 caracters, without special symbols, except & - ~ ()",
title: "Between 3 and 120 caracters.",
description: "1200 caracters max.",
location: "Between 3 and 160 caracters, without special symbols, except & - ~ ()",
location: "Between 3 and 160 caracters.",
},
editDialog: {
title: "Edit event",
subtitle: "Update event data",
},
updatedToast: {
title: "Event \"{event}\" has been successfuly updated.",
},
editErrors: {
toastTitle: "Event wasn't updated.",
title_too_big: "Title should be less than 120 characters long.",
@@ -220,6 +233,12 @@ export default defineI18nConfig(() => ({
nameSingular: "Character",
namePlural: "Characters",
}
},
pages: {
explore: {
menuLabel: "Explore",
title: "Explore worlds",
}
}
},
fr: {
@@ -260,6 +279,7 @@ export default defineI18nConfig(() => ({
greeting: "Connecté en tant que {user}",
anonymousGreeting: "Préférences",
sidebarMenu: {
profile: "Profil",
appearance: "Apparence",
language: "Langue",
account: "Compte",
@@ -313,9 +333,14 @@ export default defineI18nConfig(() => ({
calendar: {
nameSingular: "Calendriers",
namePlural: "Calendrier",
namePublicSingular: "Calendrier Public",
namePublicPlural: "Calendriers Publics",
addSingle: "Ajouter un calendrier",
notFound: "Aucun calendrier trouvé",
notFoundDescription: "Le lien n'est pas valide ou le calendrier a été supprimé / archivé.",
notFoundForWorld: "Aucun calendrier pour ce monde… pour l'instant !",
isLoading: "Chargement du calendrier…",
hasXEvents: "Ce calendrier contient {count} évènements publiques",
date: {
start: "Date de début",
end: "Date de fin",
@@ -346,6 +371,8 @@ export default defineI18nConfig(() => ({
event: {
nameSingular: "Évènement",
namePlural: "Évènements",
addSingle: "Ajouter un évènement",
newEvent: "Nouvel évènement",
title: "Titre de l'évènement",
isStart: "Début",
isEnd: "Fin",
@@ -355,15 +382,20 @@ export default defineI18nConfig(() => ({
addLocation: "Ajouter un endroit",
prevPage: "Précédente page à évènements",
nextPage: "Prochaine page à évènements",
outOfBoundsTitle: "Aucun évènement suivant ou précédent trouvé",
outOfBoundsMessage: "Peut-être l'évènement se situe au début ou à la fin du calendrier ?",
patterns: {
title: "Entre 3 et 120 charactères, sans symboles spéciaux, sauf & - ~ ()",
title: "Entre 3 et 120 charactères.",
description: "Maximum 1200 charactères.",
location: "Entre 3 et 160 charactères, sans symboles spéciaux, sauf & - ~ ()",
location: "Entre 3 et 160 charactères.",
},
editDialog: {
title: "Modifier l'évènement",
subtitle: "Mettre à jour les données de l'évènement",
},
updatedToast: {
title: "L'évènement \"{event}\" a été modifié avec succès.",
},
editErrors: {
toastTitle: "L'évènement n'a pas été modifié",
title_too_big: "Le titre doit être inférieur à 120 caractères.",
@@ -440,6 +472,12 @@ export default defineI18nConfig(() => ({
nameSingular: "Personnage",
namePlural: "Personnages",
}
},
pages: {
explore: {
menuLabel: "Explorer",
title: "Explorer les mondes",
}
}
}
}

View File

@@ -2,6 +2,9 @@ import { z } from "zod"
import type { CalendarEvent } from "./CalendarEvent"
import { calendarMonthSchema, type CalendarMonth } from "./CalendarMonth"
import { dateSchema, type RPGDate } from "./Date"
import type { World } from "./World"
export type CalendarState = "published" | "draft" | "archived"
export interface CalendarConfig {
months: CalendarMonth[]
@@ -10,9 +13,13 @@ export interface CalendarConfig {
export interface Calendar extends CalendarConfig {
id?: number
shortId?: string
name: string
events: CalendarEvent[]
eventNb?: Array<{ count: number }>
state: CalendarState
color?: string
world?: World
}
export const postCalendarSchema = z.object({

View File

@@ -1,6 +1,6 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: false },
devtools: { enabled: true },
modules: [
"@nuxtjs/supabase",
@@ -13,6 +13,7 @@ export default defineNuxtConfig({
"@nuxtjs/i18n",
"@nuxthub/core"
],
css: ["~/assets/main.css"],
i18n: {
@@ -37,7 +38,11 @@ export default defineNuxtConfig({
supabase: {
redirectOptions: {
login: "/",
callback: "/my/"
callback: "/my/",
exclude: [
"/calendars(/*)?",
"/explore(/*)?"
]
}
},
@@ -47,14 +52,18 @@ export default defineNuxtConfig({
autoprefixer: {},
},
},
shadcn: {
prefix: "Ui",
componentDir: "./components/ui"
},
colorMode: {
classSuffix: "",
preference: "dark",
fallback: "dark",
},
eslint: {}
eslint: {},
compatibilityDate: "2025-02-14"
})

View File

@@ -13,44 +13,44 @@
"lint:prettier": "prettier . --check"
},
"dependencies": {
"@nuxt/eslint": "^0.3.12",
"@nuxthub/core": "^0.8.7",
"@nuxtjs/i18n": "^8.5.1",
"@nuxt/eslint": "^1.1.0",
"@nuxthub/core": "^0.8.17",
"@nuxtjs/i18n": "^9.2.1",
"@phosphor-icons/vue": "^2.2.1",
"@pinia/nuxt": "^0.5.1",
"@vueuse/core": "^10.9.0",
"@vueuse/integrations": "^10.11.0",
"@vueuse/nuxt": "^10.9.0",
"class-variance-authority": "^0.7.0",
"@pinia/nuxt": "^0.10.1",
"@vueuse/core": "^12.7.0",
"@vueuse/integrations": "^12.7.0",
"@vueuse/nuxt": "^12.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.364.0",
"nuxt": "^3.7.0",
"pinia": "^2.1.7",
"radix-vue": "^1.7.4",
"shadcn-nuxt": "^0.10.4",
"sortablejs": "^1",
"tailwind-merge": "^2.3.0",
"lucide-vue-next": "^0.475.0",
"nuxt": "^3.15.4",
"pinia": "^3.0.1",
"radix-vue": "^1.9.16",
"shadcn-nuxt": "^0.11.4",
"sortablejs": "^1.15.6",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"vue": "^3.4.27",
"vue-router": "^4.3.2",
"zod": "^3.23.8"
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@nuxtjs/color-mode": "^3.4.1",
"@nuxtjs/supabase": "^1.2.2",
"@nuxtjs/tailwindcss": "^6.12.0",
"@stylistic/eslint-plugin-js": "^2.1.0",
"@typescript-eslint/eslint-plugin": "^7.8.0",
"@typescript-eslint/parser": "^7.8.0",
"eslint": "^8.49.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-vue": "^9.26.0",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"sass": "^1.72.0",
"tailwindcss": "^3.4.3",
"typescript": "~5.4.0",
"wrangler": "^3.90.0"
"@nuxtjs/color-mode": "^3.5.2",
"@nuxtjs/supabase": "^1.4.6",
"@nuxtjs/tailwindcss": "^6.13.1",
"@stylistic/eslint-plugin-js": "^3.1.0",
"@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^8.25.0",
"eslint": "^9.21.0",
"eslint-config-prettier": "^10.0.2",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-vue": "^9.32.0",
"postcss": "^8.5.3",
"prettier": "^3.5.2",
"sass": "^1.85.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"wrangler": "^3.111.0"
}
}

59
pages/calendars/[id].vue Normal file
View File

@@ -0,0 +1,59 @@
<script lang="ts" setup>
import { PhCalendarX, PhCircleNotch } from "@phosphor-icons/vue";
import type { Calendar } from "~/models/CalendarConfig";
import type { Category } from "~/models/Category";
const route = useRoute()
const shortId = route.params.id
const user = useSupabaseUser()
const { data: calendarData, pending: calPending, refresh: calRefresh } = await useLazyFetch<{ data: Calendar }>("/api/calendars/query", { query: { shortId, full: true } })
const { data: catData, pending: catPending, refresh: catRefresh } = await useLazyFetch<{ data: Category[] }>("/api/calendars/categories/query")
watch(user, () => {
calRefresh()
catRefresh()
})
</script>
<template>
<div v-if="calPending || catPending" class="h-full w-full grid place-items-center">
<Head>
<Title>{{ $t("entity.calendar.nameSingular") }}</Title>
</Head>
<div class="grid gap-2 justify-items-center opacity-50">
<p>
{{ $t('entity.calendar.isLoading') }}
</p>
<PhCircleNotch size="50" class="animate-spin"/>
</div>
</div>
<div v-else-if="calendarData && catData" class="h-full w-full">
<Head>
<Title>{{ calendarData.data.name }}</Title>
</Head>
<Calendar :calendar-data="calendarData.data" :categories="catData.data" />
</div>
<div v-else class="h-full w-full grid place-items-center">
<Head>
<Title>{{ $t("entity.calendar.notFound") }}</Title>
</Head>
<div class="grid justify-items-center opacity-80">
<PhCalendarX size="75" class="opacity-60" />
<Heading level="h1">
{{ $t("entity.calendar.notFound") }}
</Heading>
<p>
{{ $t('entity.calendar.notFoundDescription') }}
</p>
</div>
</div>
</template>

11
pages/calendars/index.vue Normal file
View File

@@ -0,0 +1,11 @@
<script lang="ts" setup></script>
<template>
<main class="p-8">
<Head>
<Title>{{ $t("entity.world.namePlural") }}</Title>
</Head>
<Heading level="h1">Calendriers publics</Heading>
</main>
</template>

46
pages/explore.vue Normal file
View File

@@ -0,0 +1,46 @@
<script lang="ts" setup>
import type { Calendar } from "~/models/CalendarConfig";
const { data } = await useFetch("/api/calendars/query", { key: "explore-calendars" })
const availableCalendars = data.value?.data as Calendar[]
</script>
<template>
<main class="p-8">
<Head>
<Title>{{ $t("pages.explore.title") }}</Title>
</Head>
<Spacing size="lg">
<Heading level="h1">
{{ $t("pages.explore.title") }}
</Heading>
<Spacing size="lg">
<Heading level="h2">
{{ $t("entity.calendar.namePublicPlural") }}
</Heading>
<ul v-if="availableCalendars && availableCalendars?.length > 0" class="grid md:grid-cols-3 gap-2">
<li v-for="calendar in availableCalendars" :key="calendar.shortId">
<UiCard
class="w-full transition-all hover:bg-slate-50 dark:bg-gray-950 dark:hover:bg-indigo-950 dark:focus-within:outline-gray-900"
:link="`/calendars/${calendar.shortId}`"
>
<UiCardHeader>
<UiCardTitle class="text-xl pr-12">{{ calendar.name }}</UiCardTitle>
</UiCardHeader>
<UiCardContent>
<p>
{{ $t("entity.calendar.hasXEvents", { count: calendar.eventNb?.[0].count }) }}
</p>
</UiCardContent>
</UiCard>
</li>
</ul>
</Spacing>
</Spacing>
</main>
</template>

10016
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import type { Calendar } from "~/models/CalendarConfig";
const querySchema = z.object({
id: z.number({ coerce: true }).positive().int().optional(),
shortId: z.string().optional(),
full: z.boolean({ coerce: true }).optional()
})
@@ -11,20 +12,23 @@ export default defineEventHandler(async (event) => {
const client = await serverSupabaseClient(event)
const query = await getValidatedQuery(event, querySchema.parse)
setHeader(event, "Cache-Control", "public, max-age=3600, s-maxage=7200")
const partialFields = `
id,
shortId:short_id,
name,
today,
months:calendar_months (*)
months:calendar_months (*),
state,
eventNb:calendar_events(count)
`
const fullFields = `
id,
shortId:short_id,
name,
today,
months:calendar_months (*),
state,
events:calendar_events (
id,
title,
@@ -36,6 +40,11 @@ export default defineEventHandler(async (event) => {
wiki,
category:calendar_event_categories!calendar_events_category_fkey (*),
secondaryCategories:calendar_event_categories!calendar_event_categories_links (*)
),
eventNb:calendar_events(count),
world:worlds (
id,
gmId:gm_id
)
`
@@ -47,6 +56,10 @@ export default defineEventHandler(async (event) => {
output = client.from("calendars").select(partialFields)
}
if (query.shortId) {
return output.eq("short_id", query.shortId).limit(1).single<Calendar>()
}
if (query.id) {
return output.eq("id", query.id).limit(1).single<Calendar>()
}

View File

@@ -33,6 +33,7 @@ type DateDirectionTranslationKeys = {
export const useCalendar = defineStore("calendar", () => {
const { t } = useI18n()
const user = useSupabaseUser()
/**
* Static calendar config
@@ -45,7 +46,14 @@ export const useCalendar = defineStore("calendar", () => {
"year"
])
const activeCalendar = ref<{ id: number; name: string; today: RPGDate} | null>(null)
const activeCalendar = ref<{ id: number; name: string; today: RPGDate, gmId: string | undefined } | null>(null)
const isReadOnly = ref<boolean>(true)
function setReadStatus(gmId: string) {
// If the user is not logged in, or the calendar is not owned by the user, it's read-only
isReadOnly.value = (!user) || (gmId !== user.value?.id)
}
/**
* Month list (queried from API)
@@ -59,11 +67,13 @@ export const useCalendar = defineStore("calendar", () => {
activeCalendar.value = {
id: calendarData.id,
name: calendarData.name,
today: calendarData.today
today: calendarData.today,
gmId: calendarData.world?.gmId
}
setDefaultDate(activeCalendar.value.today)
selectDate(activeCalendar.value.today)
setReadStatus(activeCalendar.value.gmId!)
if (!params.day) {
params.day = defaultDate.value.day.toString()
@@ -901,6 +911,8 @@ export const useCalendar = defineStore("calendar", () => {
}
return {
isReadOnly,
setReadStatus,
setActiveCalendar,
activeCalendar,
months,

View File

@@ -7,6 +7,9 @@ create type public.app_permission as enum ('events.see.hidden', 'users.ban');
create type public.app_role as enum ('sa', 'admin', 'moderator', 'user');
create type public.app_colors as enum ('red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose', 'black', 'white');
create type public.world_state as enum ('published', 'draft', 'archived');
create type public.calendar_state as enum ('published', 'draft', 'archived');
-- DATA STRUCTURES
-- Users
create table public.users (
@@ -40,16 +43,29 @@ create table public.worlds (
name text not null,
description text,
color app_colors default 'black',
state world_state default 'draft',
gm_id uuid references public.users on delete cascade
);
comment on table public.worlds is 'Worlds belonging to a single user ; a game master.';
-- World Players (join table)
create table public.world_players (
world_id bigint references public.worlds on delete cascade,
player_id uuid references public.users on delete cascade,
joined_at timestamptz default now(),
primary key (world_id, player_id)
);
comment on table public.world_players is 'Players (users) that belong to specific worlds';
comment on column public.world_players.joined_at is 'Timestamp when the player joined the world';
-- World Calendars
create table public.calendars (
id bigint generated by default as identity primary key,
short_id text unique,
name text not null,
today json not null,
color app_colors default 'black',
state calendar_state default 'draft',
world_id bigint references public.worlds on delete cascade not null
);
comment on table public.calendars is 'Calendar settings and configuration attached to a single world.';
@@ -82,7 +98,7 @@ create table public.calendar_events (
start_date json not null,
end_date json,
category bigint references public.calendar_event_categories on delete cascade,
hidden boolean,
hidden boolean default false,
wiki text,
calendar_id bigint references public.calendars on delete cascade not null
);
@@ -160,6 +176,7 @@ alter table public.users enable row level security;
alter table public.user_roles enable row level security;
alter table public.role_permissions enable row level security;
alter table public.worlds enable row level security;
alter table public.world_players enable row level security;
alter table public.character_categories enable row level security;
alter table public.character_categories_links enable row level security;
alter table public.characters enable row level security;
@@ -175,18 +192,26 @@ alter publication supabase_realtime add table worlds;
alter publication supabase_realtime add table calendars;
-- User policies
create policy "Allow public read access" on public.users for select to authenticated, anon using ( true );
create policy "Allow logged-in read access" on public.users for select using ( auth.role() = 'authenticated' );
create policy "Allow individual insert access" on public.users for insert with check ( auth.uid() = id );
create policy "Allow individual update access" on public.users for update using ( auth.uid() = id );
create policy "Allow individual read access" on public.user_roles for select using ( auth.uid() = user_id );
-- World policies
create policy "Allow anonymous access to published worlds" on public.worlds
for select
using (state = 'published');
create policy "Allow GMs to see their worlds" on public.worlds for select using ( ( auth.uid() = gm_id ) );
create policy "Allow GMs to create worlds" on public.worlds for insert with check ( auth.uid() = gm_id );
create policy "Allow GMs to edit their worlds" on public.worlds for update using ( auth.uid() = gm_id );
create policy "Allow GMs to delete their worlds" on public.worlds for delete using ( auth.uid() = gm_id );
-- Calendar policies
create policy "Allow anonymous access to published calendars" on public.calendars
for select
using (state = 'published');
create policy "Allow GMs to see their calendars" on public.calendars for select using (
exists (
select 1 from worlds
@@ -213,6 +238,17 @@ create policy "Allow GMs to delete their calendars" on public.calendars for dele
);
-- Month policies
create policy "Allow anonymous access to months in published calendars" ON public.calendar_months
for select
using (
exists (
select 1
from public.calendars
where calendars.id = calendar_months.calendar_id
and calendars.state = 'published'
)
);
create policy "Allow GMs to see their calendar's months"
on public.calendar_months
for select
@@ -267,6 +303,17 @@ create policy "Allow GMs to delete their calendar's months"
);
-- Event policies
create policy "Allow anonymous access to non-hidden events in published calendars" ON public.calendar_events
for select
using (
not hidden and exists (
select 1
from public.calendars
where calendars.id = calendar_events.calendar_id
and calendars.state = 'published'
)
);
create policy "Allow GMs to see their events"
on public.calendar_events
for select
@@ -342,8 +389,8 @@ create policy "Allow individual update access for GMs" on public.characters for
-- Categories are public to view but not to insert
-- Needs to be refactored maybe, if in the future we want a default set AND user defined ones
create policy "Allow logged-in read access" on public.calendar_event_categories for select using ( auth.role() = 'authenticated' );
create policy "Allow logged-in read access" on public.calendar_event_categories_links for select using ( auth.role() = 'authenticated' );
create policy "Allow all read access" on public.calendar_event_categories for select to authenticated, anon using ( true );
create policy "Allow all read access" on public.calendar_event_categories_links for select to authenticated, anon using ( true );
create policy "Allow logged-in read access" on public.character_categories for select using ( auth.role() = 'authenticated' );
create policy "Allow logged-in read access" on public.character_categories_links for select using ( auth.role() = 'authenticated' );
@@ -391,3 +438,48 @@ begin
return user_id;
end;
$$ language plpgsql;
-- Add short IDs to calendars
-- Function to generate YouTube-like IDs
CREATE OR REPLACE FUNCTION public.generate_short_id()
RETURNS text AS $$
DECLARE
characters text := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
result text := '';
i integer;
BEGIN
FOR i IN 1..11 LOOP
result := result || substr(characters, floor(random() * length(characters) + 1)::integer, 1);
END LOOP;
RETURN result;
END;
$$ LANGUAGE plpgsql VOLATILE;
-- Trigger function to set unique short ID
CREATE OR REPLACE FUNCTION public.set_calendar_short_id()
RETURNS TRIGGER AS $$
DECLARE
new_id text;
attempts integer := 0;
BEGIN
LOOP
new_id := generate_short_id();
BEGIN
NEW.short_id = new_id;
RETURN NEW;
EXCEPTION WHEN unique_violation THEN
attempts := attempts + 1;
IF attempts > 5 THEN
RAISE EXCEPTION 'Could not generate unique ID after 5 attempts';
END IF;
CONTINUE;
END;
END LOOP;
END;
$$ LANGUAGE plpgsql;
-- Create trigger
CREATE TRIGGER set_calendar_short_id
BEFORE INSERT ON calendars
FOR EACH ROW
EXECUTE FUNCTION public.set_calendar_short_id();

View File

@@ -36,10 +36,10 @@ insert into public.character_categories (name, color) values ('Activiste', 'sky'
insert into public.character_categories (name, color) values ('Commerçant', 'amber');
-- Worlds
insert into public.worlds (name, description, color) values ('Léïm', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis aliquet congue aliquet. Curabitur eu iaculis diam. Nunc iaculis nibh orci, eu semper nunc congue congue. Praesent euismod tortor eget metus tristique lobortis vel in risus. In volutpat ligula orci, id pharetra lectus egestas at.', 'black');
insert into public.worlds (name, description, color, state) values ('Léïm', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis aliquet congue aliquet. Curabitur eu iaculis diam. Nunc iaculis nibh orci, eu semper nunc congue congue. Praesent euismod tortor eget metus tristique lobortis vel in risus. In volutpat ligula orci, id pharetra lectus egestas at.', 'black', 'published');
-- Worlds' calendars
insert into public.calendars (world_id, name, today) values (1, 'Calendrier solaire', '{ "day": 23, "month": 8, "year": 3209 }');
insert into public.calendars (world_id, name, today, state) values (1, 'Calendrier solaire', '{ "day": 23, "month": 8, "year": 3209 }', 'published');
-- Calendar's months
insert into public.calendar_months (calendar_id, name, days, position) values (1, 'Jalen', 32, 1);