Merge pull request #69 from AlexisNP/features/draft-published-state

Features/draft published state
This commit is contained in:
AlexisNP
2025-03-10 21:58:21 +01:00
committed by GitHub
26 changed files with 583 additions and 83 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { PhCalendarDots, PhFilePlus, PhPencilSimpleLine, PhTrash } from "@phosphor-icons/vue";
import { PhArchive, PhCalendarDots, PhFile, PhFileDashed, PhFilePlus, PhPencil, PhPencilSimpleLine, PhTrash } from "@phosphor-icons/vue";
import { DateTime } from "luxon";
import type { Calendar } from "~/models/CalendarConfig";
@@ -9,7 +9,7 @@ const props = defineProps<{
showActions?: boolean,
}>()
const emit = defineEmits(["on-delete"])
const emit = defineEmits(["on-edit", "on-delete"])
const { locale } = useI18n();
@@ -23,38 +23,76 @@ const calendarLink = computed(() => isOwner.value ? `/my/calendars/${props.calen
<template>
<UiCard
class="w-full h-full transition-all hover:bg-slate-50 dark:bg-gray-950 dark:hover:bg-indigo-950 dark:focus-within:outline-gray-900"
class="w-full h-full flex flex-col transition-all hover:bg-slate-50 dark:bg-gray-950 dark:hover:bg-indigo-950 dark:focus-within:outline-gray-900"
:link="calendarLink"
:class="{
'hover:bg-slate-50 dark:hover:bg-sky-950 dark:focus-within:outline-sky-900': !calendar.color,
'bg-red-100 dark:bg-red-950 border-red-200 hover:bg-red-50 dark:hover:bg-red-900 dark:border-red-900 dark:focus-within:outline-red-900': calendar.color === 'red',
'bg-orange-100 dark:bg-orange-950 border-orange-200 hover:bg-orange-50 dark:hover:bg-orange-900 dark:border-orange-900 dark:focus-within:outline-orange-900': calendar.color === 'orange',
'bg-amber-100 dark:bg-amber-950 border-amber-200 hover:bg-amber-50 dark:hover:bg-amber-900 dark:border-amber-900 dark:focus-within:outline-amber-900': calendar.color === 'amber',
'bg-yellow-100 dark:bg-yellow-950 border-yellow-200 hover:bg-yellow-50 dark:hover:bg-yellow-900 dark:border-yellow-900 dark:focus-within:outline-yellow-900': calendar.color === 'yellow',
'bg-lime-100 dark:bg-lime-950 border-lime-200 hover:bg-lime-50 dark:hover:bg-lime-900 dark:border-lime-900 dark:focus-within:outline-lime-900': calendar.color === 'lime',
' bg-green-100 dark:bg-green-950 border-green-200 hover:bg-green-50 dark:hover:bg-green-900 dark:border-green-900 dark:focus-within:outline-green-900': calendar.color === 'green',
'bg-emerald-100 dark:bg-emerald-950 border-emerald-200 hover:bg-emerald-50 dark:hover:bg-emerald-900 dark:border-emerald-900 dark:focus-within:outline-emerald-900': calendar.color === 'emerald',
'bg-teal-100 dark:bg-teal-950 border-teal-200 hover:bg-teal-50 dark:hover:bg-teal-900 dark:border-teal-900 dark:focus-within:outline-teal-900': calendar.color === 'teal',
'bg-cyan-100 dark:bg-cyan-950 border-cyan-200 hover:bg-cyan-50 dark:hover:bg-cyan-900 dark:border-cyan-900 dark:focus-within:outline-cyan-900': calendar.color === 'cyan',
'bg-sky-100 dark:bg-sky-950 border-sky-200 hover:bg-sky-50 dark:hover:bg-sky-900 dark:border-sky-900 dark:focus-within:outline-sky-900': calendar.color === 'sky',
'bg-blue-100 dark:bg-blue-950 border-blue-200 hover:bg-blue-50 dark:hover:bg-blue-900 dark:border-blue-900 dark:focus-within:outline-blue-900': calendar.color === 'blue',
'bg-indigo-100 dark:bg-indigo-950 border-indigo-200 hover:bg-indigo-50 dark:hover:bg-indigo-900 dark:border-indigo-900 dark:focus-within:outline-indigo-900': calendar.color === 'indigo',
'bg-violet-100 dark:bg-violet-950 border-violet-200 hover:bg-violet-50 dark:hover:bg-violet-900 dark:border-violet-900 dark:focus-within:outline-violet-900': calendar.color === 'violet',
'bg-purple-100 dark:bg-purple-950 border-purple-200 hover:bg-purple-50 dark:hover:bg-purple-900 dark:border-purple-900 dark:focus-within:outline-purple-900': calendar.color === 'purple',
'bg-fuchsia-100 dark:bg-fuchsia-950 border-fuchsia-200 hover:bg-fuchsia-50 dark:hover:bg-fuchsia-900 dark:border-fuchsia-900 dark:focus-within:outline-fuchsia-900': calendar.color === 'fuchsia',
'bg-pink-100 dark:bg-pink-950 border-pink-200 hover:bg-pink-50 dark:hover:bg-pink-900 dark:border-pink-900 dark:focus-within:outline-pink-900': calendar.color === 'pink',
'bg-pink-100 dark:bg-rose-950 border-rose-200 hover:bg-rose-50 dark:hover:bg-rose-900 dark:border-rose-900 dark:focus-within:outline-rose-900': calendar.color === 'rose',
'text-slate-100 bg-slate-900 border-slate-700 hover:bg-slate-700 dark:hover:bg-slate-800 dark:border-slate-900 dark:focus-within:outline-slate-900': calendar.color === 'black',
' hover:bg-slate-50 dark:hover:text-slate-900 dark:hover:bg-slate-200 dark:border-slate-700 dark:focus-within:outline-slate-100': calendar.color === 'white',
}"
>
<UiCardHeader>
<UiCardHeader class="gap-4">
<UiCardTitle class="text-xl pr-12">{{ calendar.name }}</UiCardTitle>
<div v-if="calendar.state === 'published'" class="flex items-center gap-1 text-sm">
<PhFile size="20" weight="fill" />
<span>{{ $t('ui.contentState.published') }}</span>
</div>
<div v-if="calendar.state === 'draft'" class="flex items-center gap-1 text-sm">
<PhFileDashed size="20" weight="fill" />
<span>{{ $t('ui.contentState.draft') }}</span>
</div>
<div v-if="calendar.state === 'archived'" class="flex items-center gap-1 text-sm">
<PhArchive size="20" weight="fill" />
<span>{{ $t('ui.contentState.archived') }}</span>
</div>
</UiCardHeader>
<UiCardContent>
<UiCardContent class="grow">
<p class="flex items-center gap-1">
<PhCalendarDots size="24" weight="fill" />
<span>{{ $t("entity.calendar.hasXEvents", { count: calendar.eventNb?.[0].count }) }}</span>
</p>
<UiButton
<div
v-if="isOwner && showActions"
size="icon"
variant="ghost"
class="absolute top-2 right-2 z-20 hover:text-white hover:bg-rose-400 dark:hover:bg-rose-700"
@click="emit('on-delete')"
class="flex gap-1 absolute top-4 right-4 z-20"
>
<PhTrash size="16" />
</UiButton>
<UiButton size="icon" variant="ghost" class=" hover:text-white hover:bg-indigo-400 dark:hover:bg-indigo-700" @click="emit('on-edit')">
<PhPencil size="16" />
</UiButton>
<UiButton size="icon" variant="ghost" class=" hover:text-white hover:bg-rose-400 dark:hover:bg-rose-700" @click="emit('on-delete')">
<PhTrash size="16" />
</UiButton>
</div>
</UiCardContent>
<UiCardFooter>
<ul class="grid gap-2">
<ul class="grid gap-1 text-sm">
<li class="flex gap-1 items-center">
<PhFilePlus size="20" />
<PhFilePlus size="18" />
<span>{{ $t('common.createdAt', { createdAt }) }}</span>
</li>
<li v-if="updatedAt" class="flex gap-1 items-center">
<PhPencilSimpleLine size="20" />
<PhPencilSimpleLine size="18" />
<span>{{ $t('common.updatedAt', { updatedAt }) }}</span>
</li>
</ul>

View File

@@ -17,6 +17,7 @@ const emit = defineEmits(["on-close"])
function handleClose() {
emit("on-close")
setTimeout(() => calendarSkeletonName.value = "", 100)
}
</script>

View File

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

View File

@@ -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: [], state: "draft" }
const defaultSkeleton: Calendar = { name: "", today: { day: 1, month: 0, year: 0 }, months: [], events: [], state: "draft", color: "white" }
const calendarSkeleton = ref<Calendar>({ ...defaultSkeleton })
onMounted(() => {
@@ -79,25 +79,25 @@ function handleFormCancel() {
<div class="flex items-center gap-1">
<PhWrench size="18" weight="fill" />
{{ $t('entity.calendar.createDialog.tabs.general.title') }}
{{ $t('entity.calendar.actionDialog.tabs.general.title') }}
</div>
</UiTabsTrigger>
<UiTabsTrigger value="months" class="font-bold">
<div class="flex items-center gap-1">
<PhCalendarDots size="18" weight="fill" />
{{ $t('entity.calendar.createDialog.tabs.months.title') }}
{{ $t('entity.calendar.actionDialog.tabs.months.title') }}
</div>
</UiTabsTrigger>
<UiTabsTrigger value="today" class="font-bold">
<div class="flex items-center gap-1">
<PhAlarm size="18" weight="fill" />
{{ $t('entity.calendar.createDialog.tabs.today.title') }}
{{ $t('entity.calendar.actionDialog.tabs.today.title') }}
</div>
</UiTabsTrigger>
</UiTabsList>
<UiTabsContent value="global">
<UiTabsContent value="global" class="grid gap-4">
<input
id="new-calendar-name"
v-model="calendarSkeleton.name"
@@ -105,9 +105,25 @@ function handleFormCancel() {
name="new-calendar-name"
required
: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-none focus-visible:border-blue-600"
class="w-full py-2 -mx-1 px-1 text-xl border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600"
@input="handleNameChange"
>
<div class="-mx-1 px-1 grid gap-3">
<UiLabel for="new-calendar-state">
{{ $t('ui.contentState.label') }}
</UiLabel>
<InputContentState id="new-calendar-state" v-model="calendarSkeleton.state" />
</div>
<div class="-mx-1 grid gap-3">
<UiLabel for="new-calendar-color">
{{ $t('ui.colors.label') }}
</UiLabel>
<InputColor id="new-calendar-color" v-model="calendarSkeleton.color" />
</div>
</UiTabsContent>
<UiTabsContent value="months">
<CalendarInputMonthList v-model:model-value="calendarSkeleton.months" />

View File

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

View File

@@ -6,7 +6,7 @@ const emit = defineEmits(["on-click"])
<template>
<UiCard
class="h-full md:w-fit transition-all bg-transparent dark:hover:bg-gray-950 dark:focus-within:outline-gray-900 hover:-translate-y-0"
class="h-full md:w-fit transition-all bg-transparent hover:text-slate-900 text-slate-500 dark:hover:text-slate-100 dark:text-slate-500 dark:focus-within:outline-gray-900 hover:-translate-y-0"
has-click
@on-click="emit('on-click')"
>

View File

@@ -2,12 +2,16 @@
import { cn } from "@/lib/utils";
import { type RPGColor, rpgColors } from "~/models/Color";
defineProps<{
id: string
}>();
const model = defineModel<RPGColor>({ default: "white" });
</script>
<template>
<UiSelect v-model="model">
<UiSelectTrigger>
<UiSelectTrigger :id>
<UiSelectValue
:placeholder="$t('ui.colors.selectOne')"
class="input-color"

View File

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

View File

@@ -17,7 +17,7 @@ const delegatedProps = computed(() => {
v-bind="delegatedProps"
:class="
cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer',
props.class,
)
"

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { PhFilePlus, PhPencil, PhPencilSimpleLine, PhTrash } from "@phosphor-icons/vue";
import { PhArchive, PhFile, PhFileDashed, PhFilePlus, PhPencil, PhPencilSimpleLine, PhTrash } from "@phosphor-icons/vue";
import type { World } from "~/models/World";
import { DateTime } from "luxon";
@@ -16,7 +16,7 @@ const updatedAt = computed<string>(() => props.world.updatedAt ? DateTime.fromIS
<template>
<UiCard
class="w-full h-full transition-all"
class="w-full h-full flex flex-col transition-all"
:link="`/my/worlds/${world.id}`"
:class="{
'hover:bg-slate-50 dark:hover:bg-sky-950 dark:focus-within:outline-sky-900': !world.color,
@@ -38,13 +38,28 @@ const updatedAt = computed<string>(() => props.world.updatedAt ? DateTime.fromIS
'bg-pink-100 dark:bg-pink-950 border-pink-200 hover:bg-pink-50 dark:hover:bg-pink-900 dark:border-pink-900 dark:focus-within:outline-pink-900': world.color === 'pink',
'bg-pink-100 dark:bg-rose-950 border-rose-200 hover:bg-rose-50 dark:hover:bg-rose-900 dark:border-rose-900 dark:focus-within:outline-rose-900': world.color === 'rose',
'text-slate-100 bg-slate-900 border-slate-700 hover:bg-slate-700 dark:hover:bg-slate-800 dark:border-slate-900 dark:focus-within:outline-slate-900': world.color === 'black',
' hover:bg-slate-50 dark:hover:text-slate-900 dark:hover:bg-slate-300 dark:border-slate-500 dark:focus-within:outline-slate-100': world.color === 'white',
' hover:bg-slate-50 dark:hover:text-slate-900 dark:hover:bg-slate-200 dark:border-slate-700 dark:focus-within:outline-slate-100': world.color === 'white',
}"
>
<UiCardHeader>
<UiCardTitle>{{ world.name }}</UiCardTitle>
<UiCardHeader class="gap-4">
<UiCardTitle>
{{ world.name }}
</UiCardTitle>
<div v-if="world.state === 'published'" class="flex items-center gap-1 text-sm">
<PhFile size="20" weight="fill" />
<span>{{ $t('ui.contentState.published') }}</span>
</div>
<div v-if="world.state === 'draft'" class="flex items-center gap-1 text-sm">
<PhFileDashed size="20" weight="fill" />
<span>{{ $t('ui.contentState.draft') }}</span>
</div>
<div v-if="world.state === 'archived'" class="flex items-center gap-1 text-sm">
<PhArchive size="20" weight="fill" />
<span>{{ $t('ui.contentState.archived') }}</span>
</div>
</UiCardHeader>
<UiCardContent>
<UiCardContent class="grow">
<p class="italic">{{ world.description }}</p>
<div class="flex gap-1 absolute top-4 right-4 z-20">
@@ -59,13 +74,13 @@ const updatedAt = computed<string>(() => props.world.updatedAt ? DateTime.fromIS
</UiCardContent>
<UiCardFooter>
<ul class="grid gap-2">
<ul class="grid gap-1 text-sm">
<li class="flex gap-1 items-center">
<PhFilePlus size="20" />
<PhFilePlus size="18" />
<span>{{ $t('common.createdAt', { createdAt }) }}</span>
</li>
<li v-if="updatedAt" class="flex gap-1 items-center">
<PhPencilSimpleLine size="20" />
<PhPencilSimpleLine size="18" />
<span>{{ $t('common.updatedAt', { updatedAt }) }}</span>
</li>
</ul>

View File

@@ -48,7 +48,7 @@ function handleClose() {
<PhX size="20" />
</UiButton>
<WorldFormEdit :world @on-changed-name="onChangedName" @on-close="handleClose" />
<WorldFormUpdate :world @on-changed-name="onChangedName" @on-close="handleClose" />
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -57,7 +57,7 @@ function handleFormCancel() {
<template>
<template v-if="worldSkeleton">
<form class="h-full grid grid-rows-[1fr_auto]" @submit.prevent="handleSubmit">
<div>
<div class="grid gap-4">
<input
id="new-world-name"
v-model="worldSkeleton.name"
@@ -69,10 +69,6 @@ function handleFormCancel() {
@input="handleNameChange"
>
<div class="-mx-1 mb-4">
<InputColor v-model="worldSkeleton.color" />
</div>
<textarea
id="new-world-description"
v-model="worldSkeleton.description"
@@ -80,6 +76,22 @@ function handleFormCancel() {
: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-none focus-visible:border-blue-600"
/>
<div class="-mx-1 grid gap-3">
<UiLabel for="new-world-state">
{{ $t('ui.contentState.label') }}
</UiLabel>
<InputContentState id="new-world-state" v-model="worldSkeleton.state" />
</div>
<div class="-mx-1 grid gap-3">
<UiLabel for="new-world-color">
{{ $t('ui.colors.label') }}
</UiLabel>
<InputColor id="new-world-color" v-model="worldSkeleton.color" />
</div>
</div>
<footer class="flex justify-end gap-2 mt-6">

View File

@@ -71,7 +71,7 @@ function handleFormCancel() {
<template>
<template v-if="worldSkeleton">
<form class="h-full grid grid-rows-[1fr_auto]" @submit.prevent="handleSubmit">
<div>
<div class="grid gap-4">
<input
id="new-world-name"
v-model="worldSkeleton.name"
@@ -79,14 +79,10 @@ function handleFormCancel() {
name="new-world-name"
required
: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-none 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-none focus-visible:border-blue-600"
@input="handleNameChange"
>
<div class="-mx-1 mb-4">
<InputColor v-model="worldSkeleton.color" />
</div>
<textarea
id="new-world-description"
v-model="worldSkeleton.description"
@@ -94,6 +90,22 @@ function handleFormCancel() {
: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-none focus-visible:border-blue-600"
/>
<div class="-mx-1 grid gap-3">
<UiLabel for="new-world-state">
{{ $t('ui.contentState.label') }}
</UiLabel>
<InputContentState id="new-world-state" v-model="worldSkeleton.state" />
</div>
<div class="-mx-1 grid gap-3">
<UiLabel for="new-world-color">
{{ $t('ui.colors.label') }}
</UiLabel>
<InputColor id="new-world-color" v-model="worldSkeleton.color" />
</div>
</div>
<footer class="flex justify-end gap-2 mt-6">

View File

@@ -17,6 +17,7 @@ export default defineI18nConfig(() => ({
edit: "Edit",
},
colors: {
label: "Color",
selectOne: "Select a color",
red: "Red",
orange: "Orange",
@@ -38,6 +39,13 @@ export default defineI18nConfig(() => ({
black: "Black",
white: "White",
},
contentState: {
label: "Status",
selectOne: "Select a publication state",
published: "Published",
draft: "Draft",
archived: "Archived",
},
greeting: "Connected as {user}",
anonymousGreeting: "Preferences",
backToProfile: "Back to profile",
@@ -189,8 +197,7 @@ export default defineI18nConfig(() => ({
title: "The event \"{event}\" has been successfuly deleted.",
},
},
createDialog: {
title: "Create a calendar",
actionDialog: {
tabs: {
general: {
title: "General",
@@ -203,6 +210,9 @@ export default defineI18nConfig(() => ({
},
}
},
createDialog: {
title: "Create a calendar",
},
deleteDialog: {
title: "Are you sure you want to delete this calendar ?",
subtitle: "Its events won't be accessible anymore and you won't be able to retrieve the deleted data !",
@@ -272,6 +282,7 @@ export default defineI18nConfig(() => ({
edit: "Modifier",
},
colors: {
label: "Couleur",
selectOne: "Sélectionner une couleur",
red: "Rouge",
orange: "Orange",
@@ -293,6 +304,13 @@ export default defineI18nConfig(() => ({
black: "Noir",
white: "Blanc",
},
contentState: {
label: "État de publication",
selectOne: "Modifier l'état de publication",
published: "Publié",
draft: "Brouillon",
archived: "Archivé",
},
greeting: "Connecté en tant que {user}",
anonymousGreeting: "Préférences",
backToProfile: "Retour au profil",
@@ -444,8 +462,7 @@ export default defineI18nConfig(() => ({
title: "L'évènement \"{event}\" a été supprimé avec succès.",
},
},
createDialog: {
title: "Créer un calendrier",
actionDialog: {
tabs: {
general: {
title: "Général",
@@ -458,6 +475,12 @@ export default defineI18nConfig(() => ({
},
}
},
createDialog: {
title: "Créer un calendrier",
},
updateDialog: {
title: "Modifier le calendrier",
},
deleteDialog: {
title: "Êtes-vous sûr de supprimer ce calendrier ?",
subtitle: "Les évènements ne seront plus accessibles et vous ne pourrez plus récupérer les données !",

View File

@@ -3,8 +3,9 @@ import type { CalendarEvent } from "./CalendarEvent"
import { calendarMonthSchema, type CalendarMonth } from "./CalendarMonth"
import { dateSchema, type RPGDate } from "./Date"
import type { World } from "./World"
import type { ContentState } from "./Entity"
import type { RPGColor } from "./Color"
export type CalendarState = "published" | "draft" | "archived"
export interface CalendarConfig {
months: CalendarMonth[]
@@ -17,17 +18,21 @@ export interface Calendar extends CalendarConfig {
name: string
events: CalendarEvent[]
eventNb?: Array<{ count: number }>
state: CalendarState
color?: string
state: ContentState
color?: RPGColor
world?: World
createdAt?: string
updatedAt?: string
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type CalendarChannelPayload = Calendar & Record<string, any>
export const postCalendarSchema = z.object({
name: z.string(),
today: dateSchema.optional().nullable(),
color: z.string().optional().nullable(),
months: z.array(calendarMonthSchema).min(1),
worldId: z.number().int(),
state: z.string().optional().nullable().default("draft"),
})

2
models/Entity.ts Normal file
View File

@@ -0,0 +1,2 @@
export const contentStates = ["published", "draft", "archived"] as const
export type ContentState = typeof contentStates[number]

View File

@@ -1,8 +1,7 @@
import { z } from "zod"
import type { Calendar } from "./CalendarConfig"
import { rpgColorSchema, type RPGColor } from "./Color"
export type WorldState = "published" | "draft" | "archived"
import type { ContentState } from "./Entity"
export interface World {
id?: number
@@ -11,11 +10,14 @@ export interface World {
color?: RPGColor
calendars?: Calendar[]
gmId?: string
state?: WorldState
state?: ContentState
createdAt?: string
updatedAt?: string
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WorldChannelPayload = World & Record<string, any>
export const postWorldSchema = z.object({
name: z.string(),
description: z.string().optional().nullable(),

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { RealtimeChannel } from "@supabase/supabase-js"
import type { World } from "~/models/World";
import type { World, WorldChannelPayload } from "~/models/World";
const supabase = useSupabaseClient()
const user = useSupabaseUser()
@@ -31,10 +31,14 @@ function hideCreateDialog() {
/** Active world channel */
let worldChannel: RealtimeChannel
/** Handles world insertion realtime events */
function handleInsertedWorld(newWorld: World) {
function handleInsertedWorld(newWorld: WorldChannelPayload) {
if (!worlds.value?.data) return
newWorld.createdAt = newWorld.created_at;
newWorld.gmId = newWorld.gm_id;
try {
worlds.value?.data.push(newWorld)
} catch (err) {
@@ -71,7 +75,7 @@ onMounted(() => {
case "UPDATE":
if (!worlds.value?.data) return
worlds.value.data = (await $fetch("/api/worlds/query", { query: { gmId: user?.value!.id } })).data as World[]
worlds.value.data = (await $fetch("/api/worlds/query", { query: { gmId: user?.value!.id } })).data as World[]
break
default:

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { RealtimeChannel } from "@supabase/supabase-js"
import type { World } from "~/models/World";
import type { Calendar } from "~/models/CalendarConfig";
import type { Calendar, CalendarChannelPayload } from "~/models/CalendarConfig";
import { PhArrowBendDoubleUpLeft, PhGlobeHemisphereWest, PhPencil } from "@phosphor-icons/vue";
const supabase = useSupabaseClient()
@@ -24,12 +24,6 @@ watch(user, (n) => {
}
})
const isCreateCalendarModalOpen = ref<boolean>(false)
function hideCreateDialog() {
isCreateCalendarModalOpen.value = false
}
/**
* === Subscriptions ===
*/
@@ -40,9 +34,12 @@ let calendarChannel: RealtimeChannel
let worldChannel: RealtimeChannel
/** Handles calendar insertion realtime events */
function handleInsertedCalendar(newCalendar: Calendar) {
function handleInsertedCalendar(newCalendar: CalendarChannelPayload) {
if (!world.value) return
newCalendar.createdAt = newCalendar.created_at;
newCalendar.eventNb = [{ count: 0 }];
try {
world.value.data.calendars?.push(newCalendar)
} catch (err) {
@@ -66,7 +63,7 @@ onMounted(() => {
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "calendars" },
(payload) => {
async (payload) => {
switch (payload.eventType) {
case "INSERT":
handleInsertedCalendar(payload.new as Calendar)
@@ -76,6 +73,13 @@ onMounted(() => {
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
@@ -117,14 +121,28 @@ onUnmounted(() => {
})
const markedCalendar = ref<Calendar | null>(null)
const isDeleteCalendarModalOpen = ref<boolean>(false)
const isEditWorldModalOpen = ref<boolean>(false)
const isCreateCalendarModalOpen = ref<boolean>(false)
const isUpdateCalendarModalOpen = ref<boolean>(false)
const isDeleteCalendarModalOpen = ref<boolean>(false)
function hideCreateDialog() {
isCreateCalendarModalOpen.value = false
}
function deployUpdateDialog(calendar: Calendar) {
markedCalendar.value = calendar
isUpdateCalendarModalOpen.value = true
}
function hideUpdateDialog() {
isUpdateCalendarModalOpen.value = false
}
function deployDeleteCalendarModal(calendar: Calendar) {
isDeleteCalendarModalOpen.value = true
markedCalendar.value = calendar
}
function hideDeleteCalendarModal() {
isDeleteCalendarModalOpen.value = false
markedCalendar.value = null
@@ -133,7 +151,6 @@ function hideDeleteCalendarModal() {
function deployEditModal() {
isEditWorldModalOpen.value = true
}
function hideEditModal() {
isEditWorldModalOpen.value = false
}
@@ -190,7 +207,12 @@ function hideEditModal() {
<ul class="grid md:grid-cols-3 gap-2">
<li v-for="calendar in sortedCalendars" :key="calendar.id">
<CalendarPreviewCard :calendar="calendar" :gm-id="world.data.gmId" show-actions @on-delete="() => deployDeleteCalendarModal(calendar)" />
<CalendarPreviewCard
:calendar="calendar"
:gm-id="world.data.gmId"
show-actions
@on-edit="() => deployUpdateDialog(calendar)"
@on-delete="() => deployDeleteCalendarModal(calendar)" />
</li>
<li class="md:w-fit">
@@ -209,6 +231,7 @@ function hideEditModal() {
<WorldDialogEdit :world="world.data" :modal-state="isEditWorldModalOpen" @on-close="hideEditModal" />
<CalendarDialogCreate :world="world.data" :modal-state="isCreateCalendarModalOpen" @on-close="hideCreateDialog" />
<CalendarDialogUpdate v-if="markedCalendar?.id" :world="world.data" :calendar="markedCalendar" :modal-state="isUpdateCalendarModalOpen" @on-close="hideUpdateDialog" />
<CalendarDialogDelete :calendar="markedCalendar" :modal-state="isDeleteCalendarModalOpen" @on-close="hideDeleteCalendarModal"/>
</template>
<template v-else>

View File

@@ -0,0 +1,81 @@
import { z } from "zod"
import { serverSupabaseClient } from "#supabase/server"
import type { Calendar} from "~/models/CalendarConfig";
import { postCalendarSchema } from "~/models/CalendarConfig"
const paramsSchema = z.object({
id: z.number({ coerce: true }).positive().int()
})
export default defineEventHandler(async (event) => {
const client = await serverSupabaseClient(event)
const { data: params, error: paramsError} = await getValidatedRouterParams(event, paramsSchema.safeParse)
const { data: bodyData, error: bodyError } = await readValidatedBody(event, body => postCalendarSchema.safeParse(body))
if (paramsError) {
throw createError({
cause: "Utilisateur",
fatal: false,
message: "L'identifiant du calendrier est manquant ou mal renseigné.",
status: 401,
})
}
if (bodyError) {
console.log(bodyData)
console.log(bodyError)
const error = createError({
cause: "Utilisateur",
fatal: false,
statusCode: 401,
statusMessage: "Validation Error",
message: "Erreur de validation du schéma, probablement dûe à une erreur utilisateur.",
data: {
errors: bodyError.issues.map(issue => ({
path: issue.path,
message: issue.message,
code: issue.code
}))
}
})
throw error
}
try {
const { data, error } = await client
.from("calendars")
.update(
{
name: bodyData.name,
world_id: bodyData.worldId,
today: bodyData.today,
state: bodyData.state,
color: bodyData.color,
} as never
)
.eq("id", params.id)
.select(`
id,
name,
color,
state,
createdAt:created_at,
updatedAt:updated_at
`)
.single<Calendar>()
if (error) throw error
return data
} catch (err) {
console.log(err)
throw createError({
cause: "Serveur",
status: 500,
fatal: false,
message: "Une erreur inconnue est survenue."
})
}
})

View File

@@ -29,6 +29,7 @@ export default defineEventHandler(async (event) => {
name: bodyData.name,
today: bodyData.today,
color: bodyData.color,
state: bodyData.state,
world_id: bodyData.worldId
} as never
)

View File

@@ -19,9 +19,15 @@ export default defineEventHandler(async (event) => {
today,
months:calendar_months (*),
state,
color,
createdAt:created_at,
updatedAt:updated_at,
eventNb:calendar_events(count)
eventNb:calendar_events(count),
world:worlds (
id,
name,
gmId:gm_id
)
`
const fullFields = `
@@ -31,6 +37,7 @@ export default defineEventHandler(async (event) => {
today,
months:calendar_months (*),
state,
color,
createdAt:created_at,
updatedAt:updated_at,
events:calendar_events (
@@ -48,6 +55,7 @@ export default defineEventHandler(async (event) => {
eventNb:calendar_events(count),
world:worlds (
id,
name,
gmId:gm_id
)
`

View File

@@ -58,7 +58,9 @@ export default defineEventHandler(async (event) => {
name,
description,
color,
gm_id,
createdAt:created_at,
updatedAt:updated_at,
gmId:gm_id,
state
`)
.single<World>()

View File

@@ -33,6 +33,8 @@ export default defineEventHandler(async (event) => {
name,
description,
color,
createdAt:created_at,
updatedAt:updated_at,
gmId:gm_id
`)
.single<World>()

View File

@@ -17,6 +17,7 @@ export default defineEventHandler(async (event) => {
name,
description,
color,
state,
createdAt:created_at,
updatedAt:updated_at,
gmId:gm_id,
@@ -25,6 +26,8 @@ export default defineEventHandler(async (event) => {
name,
color,
today,
state,
months:calendar_months(*),
createdAt:created_at,
updatedAt:updated_at,
eventNb:calendar_events(count)
@@ -35,11 +38,13 @@ export default defineEventHandler(async (event) => {
name,
description,
color,
state,
createdAt:created_at,
updatedAt:updated_at,
calendars (
createdAt:created_at,
updatedAt:updated_at,
months:calendar_months(*),
eventNb:calendar_events(count)
)
`

View File

@@ -214,8 +214,9 @@ create policy "Allow individual update access" on public.users for update using
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
create policy "Allow public access to published worlds" on public.worlds
for select
to authenticated, anon
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 );
@@ -223,44 +224,60 @@ create policy "Allow GMs to edit their worlds" on public.worlds for update using
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
create policy "Allow public access to published calendars" on public.calendars
for select
using (state = 'published');
to authenticated, anon
using (
exists (
select 1
from public.worlds
where worlds.id = calendars.world_id
and worlds.state = 'published'
and calendars.state = 'published'
)
);
create policy "Allow GMs to see their calendars" on public.calendars for select using (
exists (
select 1 from worlds
where worlds.id = calendars.world_id
and worlds.gm_id = auth.uid()
)
);
create policy "Allow GMs to add calendars to their worldd" on public.calendars for insert with check (
create policy "Allow GMs to add calendars to their world" on public.calendars for insert with check (
exists (
select 1 from worlds
where worlds.id = calendars.world_id
and worlds.gm_id = auth.uid()
)
);
create policy "Allow GMs to edit their calendars" on public.calendars for update with check (
create policy "Allow GMs to edit their calendars" on public.calendars for update using (
exists (
select 1 from worlds
where worlds.id = calendars.world_id
and worlds.gm_id = auth.uid()
)
);
create policy "Allow GMs to delete their calendars" on public.calendars for delete using (
exists (
select 1 from worlds
where worlds.id = calendars.world_id
and worlds.gm_id = auth.uid()
)
);
-- Month policies
create policy "Allow anonymous access to months in published calendars" ON public.calendar_months
create policy "Allow anonymous access to months in published calendars" on public.calendar_months
for select
to authenticated, anon
using (
exists (
select 1
from public.calendars
where calendars.id = calendar_months.calendar_id
and calendars.state = 'published'
from public.calendars c
join public.worlds w on w.id = c.world_id
where c.id = calendar_months.calendar_id
and c.state = 'published'
and w.state = 'published'
)
);
@@ -318,8 +335,9 @@ create policy "Allow GMs to delete their calendar's months"
);
-- Event policies
create policy "Allow anonymous access to non-hidden events in published calendars" ON public.calendar_events
create policy "Allow anonymous access to non-hidden events in published calendars" on public.calendar_events
for select
to authenticated, anon
using (
not hidden and exists (
select 1