Added world creation dialog

This commit is contained in:
Alexis
2024-09-09 22:09:59 +02:00
parent f6898da2f2
commit c19262e3c2
12 changed files with 403 additions and 62 deletions

View File

@@ -40,7 +40,7 @@ const isCreatingCalendar = ref<boolean>(false)
async function handleSubmit() {
try {
isCreatingCalendar.value = true
await $fetch("/api/calendars/create", { method: "POST", body: {...calendarSkeleton.value, worldId: 1 } })
await $fetch("/api/calendars/create", { method: "POST", body: { ...calendarSkeleton.value, worldId: 1 } })
emit("on-close")
} catch (err) {
@@ -104,7 +104,7 @@ function handleFormCancel() {
type="text"
name="new-calendar-name"
required
placeholder="Titre"
: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"
@input="handleNameChange"
>

View File

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

View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
import { type RPGColor, rpgColors } from "~/models/Color";
const model = defineModel<RPGColor>()
</script>
<template>
<UiSelect v-model="model">
<UiSelectTrigger>
<UiSelectValue :placeholder="$t('ui.colors.selectOne')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectGroup>
<UiSelectItem v-for="color in rpgColors" :key="color" :value="color">
{{ $t(`ui.colors.${color}`) }}
</UiSelectItem>
</UiSelectGroup>
</UiSelectContent>
</UiSelect>
</template>

View File

@@ -0,0 +1,48 @@
<script lang="ts" setup>
import { PhX } from "@phosphor-icons/vue";
defineProps<{
modalState?: boolean
}>()
const worldSkeletonName = ref<string>("")
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.createDialog.title') }} {{ worldSkeletonName }}
</span>
<span v-else>
{{ $t('entity.world.createDialog.title') }}
</span>
</span>
</UiAlertDialogTitle>
<UiAlertDialogDescription>
{{ $t('entity.world.createDialog.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>
<WorldFormCreate @on-changed-name="onChangedName" @on-close="handleClose" />
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -0,0 +1,100 @@
<script lang="ts" setup>
import { PhCircleNotch } from "@phosphor-icons/vue";
import type { World } from "~/models/World";
const user = useSupabaseUser()
const defaultWorld: World = { name: "", description: "", gmId: user.value?.id }
const worldSkeleton = ref<World>({ ...defaultWorld })
onMounted(() => {
worldSkeleton.value = { ...defaultWorld }
})
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) return
try {
isLoading.value = true
await $fetch("/api/worlds/create", { method: "POST", body: { ...worldSkeleton.value } })
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

@@ -16,6 +16,28 @@ export default defineI18nConfig(() => ({
delete: "Delete",
edit: "Edit",
},
colors: {
selectOne: "Select a color",
red: "Red",
orange: "Orange",
amber: "Amber",
yellow: "Yellow",
lime: "Lime",
green: "Green",
emerald: "Emerald",
teal: "Teal",
cyan: "Cyan",
sky: "Sky",
blue: "Blue",
indigo: "Indigo",
violet: "Violet",
purple: "Purple",
fuchsia: "Fuchsia",
pink: "Pink",
rose: "Rose",
black: "Black",
white: "White",
},
greeting: "Connected as {user}",
anonymousGreeting: "Preferences",
sidebarMenu: {
@@ -43,6 +65,7 @@ export default defineI18nConfig(() => ({
addPrimary: "Add a primary category",
addSecondaries: "Add secondary categories"
},
isLoading: "Loading in progress…",
addDescription: "Add a description",
deleteOne: "Delete \"{entity}\"",
advancedSearch: {
@@ -55,6 +78,11 @@ export default defineI18nConfig(() => ({
world: {
nameSingular: "World",
namePlural: "Worlds",
addSingle: "Add a world",
createDialog: {
title: "Create a world",
subtitle: "Worlds are the building blocks which hold all your characters, your calendars…"
},
},
calendar: {
nameSingular: "Calendar",
@@ -114,7 +142,7 @@ export default defineI18nConfig(() => ({
},
},
createDialog: {
title: "New calendar",
title: "Create a calendar",
tabs: {
general: {
title: "General",
@@ -189,6 +217,28 @@ export default defineI18nConfig(() => ({
delete: "Supprimer",
edit: "Modifier",
},
colors: {
selectOne: "Sélectionner une couleur",
red: "Rouge",
orange: "Orange",
amber: "Ambre",
yellow: "Jaune",
lime: "Citron",
green: "Vert",
emerald: "Émeraude",
teal: "Turquoise",
cyan: "Cyan",
sky: "Bleu clair",
blue: "Bleu",
indigo: "Indigo",
violet: "Violet",
purple: "Pourpre",
fuchsia: "Fuchsia",
pink: "Rose",
rose: "Magenta",
black: "Noir",
white: "Blanc",
},
greeting: "Connecté en tant que {user}",
anonymousGreeting: "Préférences",
sidebarMenu: {
@@ -216,6 +266,7 @@ export default defineI18nConfig(() => ({
addPrimary: "Ajouter une catégorie principale",
addSecondaries: "Ajouter des catégories secondaires"
},
isLoading: "Chargement en cours…",
addDescription: "Ajouter une description",
deleteOne: "Supprimer \"{entity}\"",
advancedSearch: {
@@ -228,6 +279,11 @@ export default defineI18nConfig(() => ({
world: {
nameSingular: "Monde",
namePlural: "Mondes",
addSingle: "Ajouter un monde",
createDialog: {
title: "Créer un monde",
subtitle: "Un monde est la brique de base qui contient vos personnages, vos calendriers…"
},
},
calendar: {
nameSingular: "Calendriers",
@@ -287,7 +343,7 @@ export default defineI18nConfig(() => ({
},
},
createDialog: {
title: "Nouveau calendrier",
title: "Créer un calendrier",
tabs: {
general: {
title: "Général",

View File

@@ -1 +1,7 @@
export type RPGColor = "red" | "orange" | "amber" | "yellow" | "lime" | "green" | "emerald" | "teal" | "cyan" | "sky" | "blue" | "indigo" | "violet" | "purple" | "fuchsia" | "pink" | "rose" | "black" | "white"
import { z } from "zod"
export const rpgColors = ["red", "orange", "amber", "yellow", "lime", "green", "emerald", "teal", "cyan", "sky", "blue", "indigo", "violet", "purple", "fuchsia", "pink", "rose", "black", "white"] as const
export type RPGColor = typeof rpgColors[number];
export const rpgColorSchema = z.enum(rpgColors)

View File

@@ -1,10 +1,19 @@
import { z } from "zod"
import type { Calendar } from "./CalendarConfig"
import { rpgColorSchema, type RPGColor } from "./Color"
export interface World {
id: number
uuid: string
id?: number
name: string
description?: string
color?: string,
calendars?: Calendar[]
color?: RPGColor,
calendars?: Calendar[],
gmId?: string
}
export const postWorldSchema = z.object({
name: z.string(),
description: z.string().optional().nullable(),
color: rpgColorSchema,
gmId: z.string().optional().nullable(),
})

View File

@@ -13,6 +13,6 @@ useHead({
<Meta name="description" :content="$t('head.description')" />
</Head>
<Heading>TTTools</Heading>
<Heading level="h1">TTTools</Heading>
</main>
</template>

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { PhPlus } from "@phosphor-icons/vue";
import type { RealtimeChannel } from "@supabase/supabase-js"
import type { World } from "~/models/World";
@@ -21,6 +22,12 @@ watch(user, (n) => {
}
})
const isCreateWorldModalOpen = ref<boolean>(false)
function hideCreateDialog() {
isCreateWorldModalOpen.value = false
}
/**
* === World subscriptions ===
*/
@@ -46,7 +53,7 @@ function handleDeletedWorld(id: number) {
}
onMounted(() => {
worldChannel = supabase.channel("custom-insert-channel")
worldChannel = supabase.channel("realtime-world-channel")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "worlds" },
@@ -86,12 +93,30 @@ onUnmounted(() => {
<Title>{{ $t("entity.world.namePlural") }}</Title>
</Head>
<Heading>{{ user?.user_metadata.full_name }}</Heading>
<Heading level="h1">{{ user?.user_metadata.full_name }}</Heading>
<section v-if="worlds" class="mt-4">
<h2 class="mb-4 text-lg font-bold">
<section class="mt-4">
<Spacing size="lg">
<div class="flex items-center gap-3">
<Heading level="h2">
{{ $t('entity.world.namePlural') }}
</h2>
</Heading>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton size="icon" class="rounded-full h-8 w-8" @click="() => isCreateWorldModalOpen = true">
<PhPlus size="17"/>
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent :side-offset="10">
<p>
{{ $t('entity.world.addSingle') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
<ul class="grid lg:grid-cols-2 gap-2">
<li v-for="world in worlds" :key="world.id">
@@ -130,7 +155,13 @@ onUnmounted(() => {
</UiCard>
</li>
</ul>
</Spacing>
</section>
<WorldDialogCreate
:modal-state="isCreateWorldModalOpen"
@on-close="hideCreateDialog"
/>
</main>
</template>

View File

@@ -25,10 +25,10 @@ watch(user, (n) => {
}
})
const isCreateEventModalOpen = ref<boolean>(false)
const isCreateCalendarModalOpen = ref<boolean>(false)
function hideCreateDialog() {
isCreateEventModalOpen.value = false
isCreateCalendarModalOpen.value = false
}
/**
@@ -57,7 +57,7 @@ function handleDeletedCalendar(id: number) {
}
onMounted(() => {
calendarChannel = supabase.channel("custom-insert-channel")
calendarChannel = supabase.channel("realtime-calendar-channel")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "calendars" },
@@ -106,7 +106,9 @@ function hideDeleteModal() {
<Title>{{ $t("entity.world.namePlural") }}</Title>
</Head>
<Heading>Chargement...</Heading>
<Heading level="h1">
{{ $t('entity.isLoading') }}
</Heading>
</template>
<template v-else-if="world">
<Head>
@@ -115,7 +117,7 @@ function hideDeleteModal() {
<header class="lg:w-1/2 mb-8">
<Spacing>
<Heading>{{ world.name }}</Heading>
<Heading level="h1">{{ world.name }}</Heading>
<p>{{ world.description }}</p>
</Spacing>
@@ -124,14 +126,14 @@ function hideDeleteModal() {
<section>
<Spacing size="lg">
<div class="flex items-center gap-3">
<Heading>
<Heading level="h2">
{{ $t('entity.calendar.namePlural') }}
</Heading>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton size="icon" class="rounded-full h-8 w-8" @click="() => isCreateEventModalOpen = true">
<UiButton size="icon" class="rounded-full h-8 w-8" @click="() => isCreateCalendarModalOpen = true">
<PhPlus size="17"/>
</UiButton>
</UiTooltipTrigger>
@@ -173,7 +175,7 @@ function hideDeleteModal() {
</section>
</template>
<CalendarDialogCreate :world :modal-state="isCreateEventModalOpen" @on-close="hideCreateDialog" />
<CalendarDialogCreate :world :modal-state="isCreateCalendarModalOpen" @on-close="hideCreateDialog" />
<CalendarDialogDelete :calendar="markedCalendar" :modal-state="isDeleteEventModalOpen" @on-close="hideDeleteModal" />
</main>
</template>

View File

@@ -0,0 +1,51 @@
import { serverSupabaseClient } from "#supabase/server";
// import type { Calendar} from "~/models/CalendarConfig";
import type { World } from "~/models/World";
import { postWorldSchema } from "~/models/World";
export default defineEventHandler(async (event) => {
const client = await serverSupabaseClient(event)
const { data: bodyData, error: schemaError } = await readValidatedBody(event, body => postWorldSchema.safeParse(body))
if (schemaError) {
console.log(schemaError)
throw createError({
cause: "Utilisateur",
fatal: false,
message: "Le schéma de la requête n'est pas complet ou mal renseigné.",
status: 401,
})
}
try {
const { data, error } = await client
.from("worlds")
.insert(
{
name: bodyData.name,
description: bodyData.description,
color: bodyData.color,
gm_id: bodyData.gmId,
} as never
)
.select(`
id,
name,
description,
color,
gmId:gm_id
`)
.single<World>()
if (error) throw error
return data
} catch (err) {
throw createError({
cause: "Serveur",
status: 500,
fatal: false,
message: "Une erreur inconnue est survenue."
})
}
})