Merge branch 'code/cleanup' into dev

This commit is contained in:
Alexis
2025-02-28 21:55:57 +01:00
16 changed files with 469 additions and 147 deletions

View File

@@ -15,7 +15,7 @@ function handleMenuItemAction(actionType: SidebarMenuActionType) {
</script> </script>
<template> <template>
<nav class="w-16 py-6 border-r-[1px] bg-indigo-700 dark:bg-black text-white border-r-indigo-700 dark:border-r-indigo-950 grid grid-rows-[1fr_auto] justify-center transition-colors"> <nav class="w-16 py-6 border-r-[1px] bg-indigo-700 dark:bg-black text-white border-r-indigo-700 dark:border-r-indigo-950 grid grid-rows-[1fr_auto] justify-center transition-colors after:opacity-50 after:contrast-125 dark:after:opacity-75 dark:after:contrast-175 after:-hue-rotate-60">
<menu class="flex flex-col gap-4"> <menu class="flex flex-col gap-4">
<li class="mb-12"> <li class="mb-12">
<UiButton variant="ghost" size="icon" class="rounded-full" @click="console.log"> <UiButton variant="ghost" size="icon" class="rounded-full" @click="console.log">
@@ -66,3 +66,26 @@ function handleMenuItemAction(actionType: SidebarMenuActionType) {
<UserCTA /> <UserCTA />
</nav> </nav>
</template> </template>
<style lang="scss" scoped>
nav {
position: relative;
isolation: isolate;
&::after {
display: block;
content: '';
position: absolute;
bottom: 0;
inset-inline: 0;
height: 25rem;
max-height: 100%;
background-image: url("/images/sidebar.png");
background-position: bottom;
background-size: cover;
background-repeat: no-repeat;
mask-image: linear-gradient(to top, black 25%, transparent 50%, transparent);
z-index: -1;
}
}
</style>

View File

@@ -60,7 +60,7 @@ function pushRoute(to: AvailableRoutes) {
<ClientOnly> <ClientOnly>
<UiDropdownMenu v-model:open="menuOpened"> <UiDropdownMenu v-model:open="menuOpened">
<UiDropdownMenuTrigger> <UiDropdownMenuTrigger>
<UiAvatar v-if="user" class="cursor-pointer"> <UiAvatar v-if="user" id="user-avatar" class="ring-[.2rem] ring-indigo-700 dark:ring-neutral-900 cursor-pointer">
<UiAvatarImage <UiAvatarImage
:src="userMeta?.avatar_url" :src="userMeta?.avatar_url"
:alt="userMeta?.full_name" :alt="userMeta?.full_name"

View File

@@ -0,0 +1,53 @@
<script lang="ts" setup>
import { PhX } from "@phosphor-icons/vue";
import type { World } from "~/models/World";
const props = defineProps<{
modalState?: boolean
world: World | null
}>()
const worldSkeletonName = ref<string>(props.world?.name ?? "")
watch(() => props.world, (newWorld) => {
worldSkeletonName.value = newWorld?.name ?? ""
})
function onChangedName(newName: string) {
worldSkeletonName.value = newName
}
const emit = defineEmits(["on-close"])
function handleClose() {
emit("on-close")
}
</script>
<template>
<UiAlertDialog :open="modalState">
<UiAlertDialogContent class="gap-4">
<header>
<UiAlertDialogTitle>
<span class="text-2xl">
<span v-if="worldSkeletonName">
{{ $t('entity.world.editDialog.title') }} {{ worldSkeletonName }}
</span>
<span v-else>
{{ $t('entity.world.editDialog.title') }}
</span>
</span>
</UiAlertDialogTitle>
<UiAlertDialogDescription>
{{ $t('entity.world.editDialog.subtitle') }}
</UiAlertDialogDescription>
</header>
<UiButton size="icon" variant="ghost" class="absolute top-4 right-4" title="Fermer la fenêtre" @click="handleClose">
<PhX size="20" />
</UiButton>
<WorldFormEdit :world @on-changed-name="onChangedName" @on-close="handleClose" />
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -0,0 +1,113 @@
<script lang="ts" setup>
import { PhCircleNotch } from "@phosphor-icons/vue";
import { useToast } from "~/components/ui/toast";
import type { World } from "~/models/World";
const { toast } = useToast()
const { t } = useI18n()
const user = useSupabaseUser()
const props = defineProps<{
world: World | null
}>()
const worldSkeleton = ref<World>({ ...props.world } as World)
onMounted(() => {
worldSkeleton.value = { ...props.world } as World
})
const isLoading = ref<boolean>(false)
/**
* === Form Validation ===
*/
/** Whether the skeleton has a valid name */
const validSkeleton = computed(() => worldSkeleton.value.name)
async function handleSubmit() {
if (!user.value || !worldSkeleton.value) return
try {
isLoading.value = true
await $fetch(`/api/worlds/${worldSkeleton.value.id}`, { method: "PATCH", body: { ...worldSkeleton.value } })
toast({
title: t("entity.world.updatedToast.title", { world: worldSkeleton.value.name }),
variant: "success",
duration: 3000
})
emit("on-close")
} catch (err) {
console.log(err)
} finally {
isLoading.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", worldSkeleton.value.name)
}, 400)
function handleFormCancel() {
emit("on-close")
}
</script>
<template>
<template v-if="worldSkeleton">
<form class="h-full grid grid-rows-[1fr_auto]" @submit.prevent="handleSubmit">
<div>
<input
id="new-world-name"
v-model="worldSkeleton.name"
type="text"
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"
@input="handleNameChange"
>
<div class="-mx-1 mb-4">
<InputColor v-model="worldSkeleton.color" />
</div>
<textarea
id="new-world-description"
v-model="worldSkeleton.description"
name="new-world-description"
:placeholder="$t('entity.addDescription')"
class="w-full -my-1 py-1 -mx-1 px-1 min-h-24 max-h-36 text-sm border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600"
/>
</div>
<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 || isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="opacity-50 animate-spin"/>
</Transition>
{{ $t('ui.action.save') }}
</UiButton>
</footer>
</form>
</template>
</template>

View File

@@ -40,6 +40,7 @@ export default defineI18nConfig(() => ({
}, },
greeting: "Connected as {user}", greeting: "Connected as {user}",
anonymousGreeting: "Preferences", anonymousGreeting: "Preferences",
backToProfile: "Back to profile",
sidebarMenu: { sidebarMenu: {
profile: "Profile", profile: "Profile",
appearance: "Appearance", appearance: "Appearance",
@@ -80,14 +81,24 @@ export default defineI18nConfig(() => ({
nameSingular: "World", nameSingular: "World",
namePlural: "Worlds", namePlural: "Worlds",
addSingle: "Add a world", addSingle: "Add a world",
notFound: "World not found",
notFoundDescription: "The link is not valid or the world has been deleted / archived.",
backToList: "Go back to worlds",
createDialog: { createDialog: {
title: "Create a world", title: "Create a world",
subtitle: "Worlds are the building blocks which hold all your characters, your calendars…" subtitle: "Worlds are the building blocks which hold all your characters, your calendars…"
}, },
editDialog: {
title: "Edit world",
subtitle: "Update world data",
},
deleteDialog: { deleteDialog: {
title: "Delete this world ?", title: "Delete this world ?",
subtitle: "This world will be deleted permanently, and all of its associated data will be lost !", subtitle: "This world will be deleted permanently, and all of its associated data will be lost !",
}, },
updatedToast: {
title: "The world \"{world}\" has been successfuly updated.",
},
deletedToast: { deletedToast: {
title: "The world \"{world}\" has been successfuly deleted.", title: "The world \"{world}\" has been successfuly deleted.",
}, },
@@ -101,6 +112,7 @@ export default defineI18nConfig(() => ({
notFound: "Calendar not found", notFound: "Calendar not found",
notFoundDescription: "The link is not valid or the calendar has been deleted / archived.", notFoundDescription: "The link is not valid or the calendar has been deleted / archived.",
notFoundForWorld: "No calendar for this world… yet !", notFoundForWorld: "No calendar for this world… yet !",
backToList: "Go back to calendars",
isLoading: "Calendar is loading…", isLoading: "Calendar is loading…",
hasXEvents: "This calendar has {count} public events", hasXEvents: "This calendar has {count} public events",
date: { date: {
@@ -278,6 +290,7 @@ export default defineI18nConfig(() => ({
}, },
greeting: "Connecté en tant que {user}", greeting: "Connecté en tant que {user}",
anonymousGreeting: "Préférences", anonymousGreeting: "Préférences",
backToProfile: "Retour au profil",
sidebarMenu: { sidebarMenu: {
profile: "Profil", profile: "Profil",
appearance: "Apparence", appearance: "Apparence",
@@ -318,14 +331,24 @@ export default defineI18nConfig(() => ({
nameSingular: "Monde", nameSingular: "Monde",
namePlural: "Mondes", namePlural: "Mondes",
addSingle: "Ajouter un monde", addSingle: "Ajouter un monde",
notFound: "Aucun monde trouvé",
notFoundDescription: "Le lien n'est pas valide ou le monde a été supprimé / archivé.",
backToList: "Retourner aux mondes",
createDialog: { createDialog: {
title: "Créer un monde", title: "Créer un monde",
subtitle: "Un monde est la brique de base qui contient vos personnages, vos calendriers…" subtitle: "Un monde est la brique de base qui contient vos personnages, vos calendriers…"
}, },
editDialog: {
title: "Modifier le monde",
subtitle: "Mettre à jour les données du monde",
},
deleteDialog: { deleteDialog: {
title: "Supprimer ce monde ?", title: "Supprimer ce monde ?",
subtitle: "Le monde sera supprimé définitivement, vous perdrez toutes les données associées !", subtitle: "Le monde sera supprimé définitivement, vous perdrez toutes les données associées !",
}, },
updatedToast: {
title: "Le monde \"{world}\" a été modifié avec succès.",
},
deletedToast: { deletedToast: {
title: "Le monde \"{world}\" a été supprimé avec succès.", title: "Le monde \"{world}\" a été supprimé avec succès.",
}, },
@@ -339,6 +362,7 @@ export default defineI18nConfig(() => ({
notFound: "Aucun calendrier trouvé", notFound: "Aucun calendrier trouvé",
notFoundDescription: "Le lien n'est pas valide ou le calendrier a été supprimé / archivé.", notFoundDescription: "Le lien n'est pas valide ou le calendrier a été supprimé / archivé.",
notFoundForWorld: "Aucun calendrier pour ce monde… pour l'instant !", notFoundForWorld: "Aucun calendrier pour ce monde… pour l'instant !",
backToList: "Retourner aux calendriers",
isLoading: "Chargement du calendrier…", isLoading: "Chargement du calendrier…",
hasXEvents: "Ce calendrier contient {count} évènements publiques", hasXEvents: "Ce calendrier contient {count} évènements publiques",
date: { date: {

View File

@@ -2,6 +2,8 @@ import { z } from "zod"
import type { Calendar } from "./CalendarConfig" import type { Calendar } from "./CalendarConfig"
import { rpgColorSchema, type RPGColor } from "./Color" import { rpgColorSchema, type RPGColor } from "./Color"
export type WorldState = "published" | "draft" | "archived"
export interface World { export interface World {
id?: number id?: number
name: string name: string
@@ -9,6 +11,7 @@ export interface World {
color?: RPGColor, color?: RPGColor,
calendars?: Calendar[], calendars?: Calendar[],
gmId?: string gmId?: string
state?: WorldState
} }
export const postWorldSchema = z.object({ export const postWorldSchema = z.object({
@@ -16,4 +19,5 @@ export const postWorldSchema = z.object({
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
color: rpgColorSchema, color: rpgColorSchema,
gmId: z.string().optional().nullable(), gmId: z.string().optional().nullable(),
state: z.string().optional().nullable().default("draft"),
}) })

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { PhCalendarX, PhCircleNotch } from "@phosphor-icons/vue"; import { PhArrowBendDoubleUpLeft, PhCalendarX, PhCircleNotch } from "@phosphor-icons/vue";
import type { Calendar } from "~/models/CalendarConfig"; import type { Calendar } from "~/models/CalendarConfig";
import type { Category } from "~/models/Category"; import type { Category } from "~/models/Category";
@@ -8,8 +8,29 @@ const shortId = route.params.id
const user = useSupabaseUser() const user = useSupabaseUser()
const { data: calendarData, pending: calPending, refresh: calRefresh } = await useLazyFetch<{ data: Calendar }>("/api/calendars/query", { query: { shortId, full: true } }) const {
const { data: catData, pending: catPending, refresh: catRefresh } = await useLazyFetch<{ data: Category[] }>("/api/calendars/categories/query") data: calendar,
status: calendarStatus,
refresh: calRefresh
} = await useLazyFetch<{ data: Calendar }>("/api/calendars/query",
{
key: `calendar-${shortId}`,
query: {
shortId,
full: true
}
}
)
const {
data: categories,
status: categoriesStatus,
refresh: catRefresh
} = await useLazyFetch<{ data: Category[] }>("/api/calendars/categories/query",
{ key: `categories-${shortId}` }
)
const isLoading = computed(() => calendarStatus.value === "pending" || categoriesStatus.value === "pending")
watch(user, () => { watch(user, () => {
calRefresh() calRefresh()
@@ -18,7 +39,7 @@ watch(user, () => {
</script> </script>
<template> <template>
<div v-if="calPending || catPending" class="h-full w-full grid place-items-center"> <div v-if="isLoading" class="h-full w-full grid place-items-center">
<Head> <Head>
<Title>{{ $t("entity.calendar.nameSingular") }}</Title> <Title>{{ $t("entity.calendar.nameSingular") }}</Title>
</Head> </Head>
@@ -31,12 +52,12 @@ watch(user, () => {
</div> </div>
</div> </div>
<div v-else-if="calendarData && catData" class="h-full w-full"> <div v-else-if="calendar?.data && categories?.data" class="h-full w-full">
<Head> <Head>
<Title>{{ calendarData.data.name }}</Title> <Title>{{ calendar.data.name }}</Title>
</Head> </Head>
<Calendar :calendar-data="calendarData.data" :categories="catData.data" /> <Calendar :calendar-data="calendar.data" :categories="categories.data" />
</div> </div>
<div v-else class="h-full w-full grid place-items-center"> <div v-else class="h-full w-full grid place-items-center">
@@ -54,6 +75,14 @@ watch(user, () => {
<p> <p>
{{ $t('entity.calendar.notFoundDescription') }} {{ $t('entity.calendar.notFoundDescription') }}
</p> </p>
<UiButton variant="default" class="mt-4 gap-2" as-child>
<RouterLink to="/explore">
<PhArrowBendDoubleUpLeft size="24" />
{{ $t('entity.calendar.backToList') }}
</RouterLink>
</UiButton>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,11 +0,0 @@
<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>

View File

@@ -1,9 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Calendar } from "~/models/CalendarConfig"; import type { Calendar } from "~/models/CalendarConfig";
const { data } = await useFetch("/api/calendars/query", { key: "explore-calendars" }) const { data: availableCalendars } = await useLazyFetch<{ data: Calendar[] }>("/api/calendars/query", { key: "explore-calendars" })
const availableCalendars = data.value?.data as Calendar[]
</script> </script>
<template> <template>
@@ -22,8 +20,8 @@ const availableCalendars = data.value?.data as Calendar[]
{{ $t("entity.calendar.namePublicPlural") }} {{ $t("entity.calendar.namePublicPlural") }}
</Heading> </Heading>
<ul v-if="availableCalendars && availableCalendars?.length > 0" class="grid md:grid-cols-3 gap-2"> <ul v-if="availableCalendars?.data" class="grid md:grid-cols-3 gap-2">
<li v-for="calendar in availableCalendars" :key="calendar.shortId"> <li v-for="calendar in availableCalendars.data" :key="calendar.shortId">
<UiCard <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" 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}`" :link="`/calendars/${calendar.shortId}`"

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { PhCircleNotch } from "@phosphor-icons/vue"; import { PhArrowBendDoubleUpLeft, PhCalendarX, PhCircleNotch } from "@phosphor-icons/vue";
import type { Calendar } from "~/models/CalendarConfig"; import type { Calendar } from "~/models/CalendarConfig";
import type { Category } from "~/models/Category"; import type { Category } from "~/models/Category";
@@ -8,6 +8,7 @@ definePageMeta({
}) })
const user = useSupabaseUser() const user = useSupabaseUser()
// Redirect user back home when they log out on the page // Redirect user back home when they log out on the page
watch(user, (n) => { watch(user, (n) => {
if (!n) { if (!n) {
@@ -18,15 +19,31 @@ watch(user, (n) => {
const route = useRoute() const route = useRoute()
const id = route.params.id const id = route.params.id
const { data: calendarData, pending: calPending } = await useLazyFetch("/api/calendars/query", { key: `calendar-${id}`, query: { id, full: true } }) const {
const { data: catData, pending: catPending } = await useLazyFetch("/api/calendars/categories/query", { key: `categories-${id}` }) data: calendar,
status: calendarStatus
} = await useLazyFetch<{ data: Calendar }>("/api/calendars/query",
{
key: `calendar-${id}`,
query: {
id,
full: true
}
}
)
const cal = computed<Calendar>(() => calendarData?.value?.data as Calendar) const {
const categories = computed<Category[]>(() => catData?.value?.data as Category[]) data: categories,
status: categoriesStatus
} = await useLazyFetch<{ data: Category[] }>("/api/calendars/categories/query",
{ key: `categories-${id}` }
)
const isLoading = computed(() => calendarStatus.value === "pending" || categoriesStatus.value === "pending")
</script> </script>
<template> <template>
<div v-if="calPending || catPending" class="h-full w-full grid place-items-center"> <div v-if="isLoading" class="h-full w-full grid place-items-center">
<Head> <Head>
<Title>{{ $t("entity.calendar.nameSingular") }}</Title> <Title>{{ $t("entity.calendar.nameSingular") }}</Title>
</Head> </Head>
@@ -39,11 +56,36 @@ const categories = computed<Category[]>(() => catData?.value?.data as Category[]
</div> </div>
</div> </div>
<div v-else-if="cal && categories" class="h-full w-full"> <div v-else-if="calendar?.data && categories?.data" class="h-full w-full">
<Head> <Head>
<Title>{{ cal.name }}</Title> <Title>{{ calendar.data.name }}</Title>
</Head> </Head>
<Calendar :calendar-data="cal" :categories /> <Calendar :calendar-data="calendar.data" :categories="categories.data" />
</div> </div>
</template>
<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>
<UiButton variant="default" class="mt-4 gap-2" as-child>
<RouterLink to="/my">
<PhArrowBendDoubleUpLeft size="24" />
{{ $t('entity.calendar.backToList') }}
</RouterLink>
</UiButton>
</div>
</div></template>

View File

@@ -1,13 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { PhPlus, PhTrash } from "@phosphor-icons/vue"; import { PhPencil, PhPlus, PhTrash } from "@phosphor-icons/vue";
import type { RealtimeChannel } from "@supabase/supabase-js" import type { RealtimeChannel } from "@supabase/supabase-js"
import type { World } from "~/models/World"; import type { World } from "~/models/World";
const supabase = useSupabaseClient() const supabase = useSupabaseClient()
const { data: res } = await useFetch("/api/worlds/query") const { data: worlds } = await useLazyFetch<{ data: World[] }>("/api/worlds/query")
const worlds = ref<World[]>(res.value?.data as World[])
definePageMeta({ definePageMeta({
middleware: ["auth-guard"] middleware: ["auth-guard"]
@@ -36,8 +34,10 @@ let worldChannel: RealtimeChannel
/** Handles world insertion realtime events */ /** Handles world insertion realtime events */
function handleInsertedWorld(newWorld: World) { function handleInsertedWorld(newWorld: World) {
if (!worlds.value?.data) return
try { try {
worlds.value.push(newWorld) worlds.value?.data.push(newWorld)
} catch (err) { } catch (err) {
console.log(err) console.log(err)
} }
@@ -45,8 +45,10 @@ function handleInsertedWorld(newWorld: World) {
/** Handles world deletion realtime events */ /** Handles world deletion realtime events */
function handleDeletedWorld(id: number) { function handleDeletedWorld(id: number) {
if (!worlds.value?.data) return
try { try {
worlds.value.splice(worlds.value.findIndex(w => w.id === id)) worlds.value.data.splice(worlds.value.data.findIndex(w => w.id === id))
} catch (err) { } catch (err) {
console.log(err) console.log(err)
} }
@@ -68,7 +70,9 @@ onMounted(() => {
break break
case "UPDATE": case "UPDATE":
worlds.value = (await $fetch("/api/worlds/query")).data as World[] if (!worlds.value?.data) return
worlds.value.data = (await $fetch("/api/worlds/query")).data as World[]
break break
default: default:
@@ -87,21 +91,32 @@ onUnmounted(() => {
}) })
const markedWorld = ref<World | null>(null) const markedWorld = ref<World | null>(null)
const isEditWorldModalOpen = ref<boolean>(false)
const isDeleteWorldModalOpen = ref<boolean>(false) const isDeleteWorldModalOpen = ref<boolean>(false)
function deployDeleteModal(calendar: World) { function deployDeleteModal(world: World) {
isDeleteWorldModalOpen.value = true isDeleteWorldModalOpen.value = true
markedWorld.value = calendar markedWorld.value = world
} }
function hideDeleteModal() { function hideDeleteModal() {
isDeleteWorldModalOpen.value = false isDeleteWorldModalOpen.value = false
markedWorld.value = null markedWorld.value = null
} }
function deployEditModal(world: World) {
isEditWorldModalOpen.value = true
markedWorld.value = world
}
function hideEditModal() {
isEditWorldModalOpen.value = false
markedWorld.value = null
}
</script> </script>
<template> <template>
<main class="p-8 after:fill-red-400"> <main class="p-8">
<Head> <Head>
<Title>{{ $t("entity.world.namePlural") }}</Title> <Title>{{ $t("entity.world.namePlural") }}</Title>
</Head> </Head>
@@ -131,8 +146,8 @@ function hideDeleteModal() {
</UiTooltipProvider> </UiTooltipProvider>
</div> </div>
<ul class="grid lg:grid-cols-3 gap-2"> <ul v-if="worlds?.data" class="grid lg:grid-cols-3 gap-2">
<li v-for="world in worlds" :key="world.id"> <li v-for="world in worlds.data" :key="world.id">
<UiCard <UiCard
class="w-full transition-all" class="w-full transition-all"
:link="`/my/worlds/${world.id}`" :link="`/my/worlds/${world.id}`"
@@ -165,9 +180,15 @@ function hideDeleteModal() {
<UiCardContent> <UiCardContent>
<p class="italic">{{ world.description }}</p> <p class="italic">{{ world.description }}</p>
<UiButton 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="deployDeleteModal(world)"> <div class="flex gap-1 absolute top-4 right-4 z-20">
<PhTrash size="16" /> <UiButton size="icon" variant="ghost" class=" hover:text-white hover:bg-indigo-400 dark:hover:bg-indigo-700" @click="deployEditModal(world)">
</UiButton> <PhPencil size="16" />
</UiButton>
<UiButton size="icon" variant="ghost" class=" hover:text-white hover:bg-rose-400 dark:hover:bg-rose-700" @click="deployDeleteModal(world)">
<PhTrash size="16" />
</UiButton>
</div>
</UiCardContent> </UiCardContent>
</UiCard> </UiCard>
</li> </li>
@@ -176,32 +197,7 @@ function hideDeleteModal() {
</section> </section>
<WorldDialogCreate :modal-state="isCreateWorldModalOpen" @on-close="hideCreateDialog" /> <WorldDialogCreate :modal-state="isCreateWorldModalOpen" @on-close="hideCreateDialog" />
<WorldDialogEdit :world="markedWorld" :modal-state="isEditWorldModalOpen" @on-close="hideEditModal" />
<WorldDialogDelete :world="markedWorld" :modal-state="isDeleteWorldModalOpen" @on-close="hideDeleteModal" /> <WorldDialogDelete :world="markedWorld" :modal-state="isDeleteWorldModalOpen" @on-close="hideDeleteModal" />
</main> </main>
</template> </template>
<style lang="scss" scoped>
main {
position: relative;
isolation: isolate;
overflow: clip;
&::after {
display: block;
content: '';
position: absolute;
right: 2.4rem;
bottom: -5%;
height: 75%;
width: 100%;
background-image: url('/images/galaxy.svg');
background-size: contain;
background-repeat: no-repeat;
background-position-y: bottom;
background-position-x: right;
z-index: -1;
mask-image: radial-gradient(ellipse 100% 100% at 120% 80%, black, transparent);
opacity: .3;
}
}
</style>

View File

@@ -2,15 +2,13 @@
import type { RealtimeChannel } from "@supabase/supabase-js" import type { RealtimeChannel } from "@supabase/supabase-js"
import type { World } from "~/models/World"; import type { World } from "~/models/World";
import type { Calendar } from "~/models/CalendarConfig"; import type { Calendar } from "~/models/CalendarConfig";
import { PhPlus, PhTrash } from "@phosphor-icons/vue"; import { PhArrowBendDoubleUpLeft, PhGlobeHemisphereWest, PhPlus, PhTrash } from "@phosphor-icons/vue";
const supabase = useSupabaseClient() const supabase = useSupabaseClient()
const route = useRoute() const route = useRoute()
const id = route.params.id const id = route.params.id
const { data: res, pending } = await useFetch("/api/worlds/query", { query: { id, full: true } }) const { data: world, status } = await useFetch<{ data: World }>("/api/worlds/query", { query: { id, full: true } })
const world = ref<World>(res.value?.data as World)
definePageMeta({ definePageMeta({
middleware: ["auth-guard"] middleware: ["auth-guard"]
@@ -40,8 +38,10 @@ let calendarChannel: RealtimeChannel
/** Handles calendar insertion realtime events */ /** Handles calendar insertion realtime events */
function handleInsertedCalendar(newCalendar: Calendar) { function handleInsertedCalendar(newCalendar: Calendar) {
if (!world.value) return
try { try {
world.value.calendars?.push(newCalendar) world.value.data.calendars?.push(newCalendar)
} catch (err) { } catch (err) {
console.log(err) console.log(err)
} }
@@ -49,8 +49,10 @@ function handleInsertedCalendar(newCalendar: Calendar) {
/** Handles calendar deletion realtime events */ /** Handles calendar deletion realtime events */
function handleDeletedCalendar(id: number) { function handleDeletedCalendar(id: number) {
if (!world.value) return
try { try {
world.value.calendars?.splice(world.value.calendars.findIndex(c => c.id === id)) world.value.data.calendars?.splice(world.value.data.calendars.findIndex(c => c.id === id))
} catch (err) { } catch (err) {
console.log(err) console.log(err)
} }
@@ -101,7 +103,7 @@ function hideDeleteModal() {
<template> <template>
<main class="p-8"> <main class="p-8">
<template v-if="pending"> <template v-if="status === 'pending'">
<Head> <Head>
<Title>{{ $t("entity.world.namePlural") }}</Title> <Title>{{ $t("entity.world.namePlural") }}</Title>
</Head> </Head>
@@ -110,16 +112,16 @@ function hideDeleteModal() {
{{ $t('entity.isLoading') }} {{ $t('entity.isLoading') }}
</Heading> </Heading>
</template> </template>
<template v-else-if="world"> <template v-else-if="world?.data">
<Head> <Head>
<Title>{{ world.name }}</Title> <Title>{{ world.data.name }}</Title>
</Head> </Head>
<header class="lg:w-1/2 mb-8"> <header class="lg:w-1/2 mb-8">
<Spacing> <Spacing>
<Heading level="h1">{{ world.name }}</Heading> <Heading level="h1">{{ world.data.name }}</Heading>
<p>{{ world.description }}</p> <p>{{ world.data.description }}</p>
</Spacing> </Spacing>
</header> </header>
@@ -146,8 +148,8 @@ function hideDeleteModal() {
</UiTooltipProvider> </UiTooltipProvider>
</div> </div>
<ul v-if="world.calendars && world.calendars?.length > 0" class="grid md:grid-cols-3 gap-2"> <ul v-if="world.data.calendars && world.data.calendars?.length > 0" class="grid md:grid-cols-3 gap-2">
<li v-for="calendar in world.calendars" :key="calendar.id"> <li v-for="calendar in world.data.calendars" :key="calendar.id">
<UiCard <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" 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="`/my/calendars/${calendar.id}`" :link="`/my/calendars/${calendar.id}`"
@@ -173,35 +175,36 @@ function hideDeleteModal() {
</template> </template>
</Spacing> </Spacing>
</section> </section>
</template>
<CalendarDialogCreate :world :modal-state="isCreateCalendarModalOpen" @on-close="hideCreateDialog" /> <CalendarDialogCreate :world="world.data" :modal-state="isCreateCalendarModalOpen" @on-close="hideCreateDialog" />
<CalendarDialogDelete :calendar="markedCalendar" :modal-state="isDeleteCalendarModalOpen" @on-close="hideDeleteModal" /> <CalendarDialogDelete :calendar="markedCalendar" :modal-state="isDeleteCalendarModalOpen" @on-close="hideDeleteModal" />
</template>
<template v-else>
<div class="h-full w-full grid place-items-center">
<Head>
<Title>{{ $t("entity.world.notFound") }}</Title>
</Head>
<div class="grid justify-items-center opacity-80">
<PhGlobeHemisphereWest size="75" class="opacity-60" weight="fill" />
<Heading level="h1">
{{ $t("entity.world.notFound") }}
</Heading>
<p>
{{ $t('entity.world.notFoundDescription') }}
</p>
<UiButton variant="default" class="mt-4 gap-2" as-child>
<RouterLink to="/my">
<PhArrowBendDoubleUpLeft size="24" />
{{ $t('entity.world.backToList') }}
</RouterLink>
</UiButton>
</div>
</div>
</template>
</main> </main>
</template> </template>
<style lang="scss" scoped>
main {
position: relative;
isolation: isolate;
overflow: clip;
&::after {
display: block;
content: '';
position: absolute;
right: 2.4rem;
bottom: -5%;
height: 75%;
width: 100%;
background-image: url('/images/planet.svg');
background-size: contain;
background-repeat: no-repeat;
background-position-y: bottom;
background-position-x: right;
z-index: -1;
mask-image: radial-gradient(ellipse 100% 100% at 120% 80%, black, transparent);
opacity: .3;
}
}
</style>

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#6366f1" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 611.999 611.999" xml:space="preserve">
<g>
<path d="M611.502,203.367c-3.889-53.216-30.921-100.838-93.276-125.006c-69.361-26.884-151.96-13.033-216.77,16.813
c-67.784,31.216-151.313,93.935-171.682,171.605c-7.962,30.357-5.303,63.94,10.093,91.288
c19.789,35.152,59.002,56.863,99.094,61.324c55.248,6.147,112.773-16.493,155.435-50.741c24.317-19.521,46.5-44.992,58.997-73.774
c11.212-25.821,13.864-56.419,1.925-81.911c-16.243-34.679-54.683-49.835-91.121-44.132c-4.787,0.735-9.337,1.776-13.657,3.016
c-4.322,1.234-8.419,2.662-12.295,4.256c-7.765,3.148-14.653,6.919-20.773,10.925c-6.109,4.03-11.447,8.348-16.022,12.747
c-1.037,1.043-2.056,2.067-3.054,3.071c-1.08,1.074-2.021,2.094-2.964,3.1c-1.877,2.012-3.588,3.995-5.207,5.882
c-3.208,3.799-5.955,7.307-8.307,10.499c-4.731,6.35-8.047,11.323-10.284,14.671c-1.1,1.684-1.934,2.961-2.494,3.817
c-0.55,0.863-0.829,1.301-0.829,1.301c-0.92,1.449-0.97,3.367,0.036,4.894c1.366,2.076,4.157,2.652,6.233,1.285
c0,0,0.433-0.285,1.288-0.846c0.846-0.567,2.108-1.413,3.772-2.529c3.307-2.238,8.187-5.589,14.48-9.753
c6.333-4.129,14.012-9.048,23.248-13.695c2.175-1.069,4.431-2.096,6.846-3.028c2.399-0.946,4.893-1.86,7.522-2.638
c5.229-1.598,10.883-2.863,16.803-3.589c2.961-0.352,5.978-0.597,9.027-0.671c3.05-0.067,6.128,0.03,9.183,0.299
c10.759,1.003,24.149,4.372,30.659,13.714c4.245,6.092,4.815,14.123,3.391,21.409c-2.471,12.644-10.28,23.585-18.883,33.174
c-30.278,33.744-74.119,56.946-119.526,60.718c-37.814,2.947-73.075-14.422-78.121-55.732
c-3.607-29.523,10.253-58.809,29.349-81.612c26.954-32.186,60.518-59.114,98.754-76.011c14.09-6.227,28.7-11.46,43.667-15.311
c39.803-10.321,86.856-13.345,125.381,3.353c36.924,16.004,60.458,52.144,59.714,92.483c-0.223,11.808-2.412,23.851-6.114,35.878
c-3.66,12.037-8.996,24.017-15.499,35.56c-6.539,11.542-14.238,22.664-22.764,33.124C454.222,364.819,387.62,404.605,326.223,430.7
c-74.109,31.498-215.664,68.734-268.588-17.843c-27.588-45.131-17.468-102.863-2.502-150.461c0.541-1.722,0.006-3.684-1.497-4.867
c-1.942-1.528-4.754-1.193-6.282,0.748c0,0-1.277,1.622-3.752,4.764c-0.624,0.768-1.287,1.718-2.039,2.741
c-0.746,1.027-1.563,2.153-2.451,3.375C17.106,299.452,2.396,336.089,0.264,373.622c-1.962,34.546,6.992,69.97,26.952,98.236
c23.939,33.9,62.223,55.79,102.375,66.286c63.644,16.637,134.014,11.022,196.48-7.885c14.716-4.454,29.379-9.645,43.893-15.61
c14.505-5.984,28.826-12.8,42.955-20.315c51.202-27.233,99.882-64.099,136.409-109.452
C588.79,335.885,616.063,265.779,611.502,203.367z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none" /><path fill="#6366f1" d="M245.11,60.68c-7.65-13.19-27.85-16.16-58.5-8.66A96,96,0,0,0,32.81,140.3C5.09,169,5.49,186,10.9,195.32,16,204.16,26.64,208,40.64,208a124.11,124.11,0,0,0,28.79-4,96,96,0,0,0,153.78-88.25c12.51-13,20.83-25.35,23.66-35.92C248.83,72.51,248.24,66.07,245.11,60.68Zm-13.69,15c-6.11,22.78-48.65,57.31-87.52,79.64-67.81,39-113.62,41.52-119.16,32-1.46-2.51-.65-7.24,2.22-13a80.06,80.06,0,0,1,10.28-15.05,95.53,95.53,0,0,0,6.23,14.18,4,4,0,0,0,4,2.12,122.14,122.14,0,0,0,16.95-3.32c21.23-5.55,46.63-16.48,71.52-30.78s47-30.66,62.45-46.15A122.74,122.74,0,0,0,209.7,82.45a4,4,0,0,0,.17-4.52,96.26,96.26,0,0,0-9.1-12.46c14.21-2.35,27.37-2.17,30.5,3.24C232.19,70.28,232.24,72.63,231.42,75.69Z"/></svg>

Before

Width:  |  Height:  |  Size: 817 B

BIN
public/images/sidebar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

View File

@@ -0,0 +1,77 @@
import { z } from "zod"
import { serverSupabaseClient } from "#supabase/server"
import { postWorldSchema, type World } from "~/models/World"
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 => postWorldSchema.safeParse(body))
if (paramsError) {
throw createError({
cause: "Utilisateur",
fatal: false,
message: "L'identifiant du monde est manquant ou mal renseigné.",
status: 401,
})
}
if (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("worlds")
.update(
{
name: bodyData.name,
description: bodyData.description,
state: bodyData.state,
gm_id: bodyData.gmId,
color: bodyData.color
} as never
)
.eq("id", params.id)
.select(`
id,
name,
description,
color,
gm_id,
state
`)
.single<World>()
if (error) throw error
return data
} catch (err) {
throw createError({
cause: "Serveur",
status: 500,
fatal: false,
message: "Une erreur inconnue est survenue."
})
}
})