feat: article creation modal

This commit is contained in:
fsylvain committed 2026-09-27 06:26:41 +02:00
1 parent a4734b2c91
commit c47d33eaa9
8 files changed
+1030 -116

No files matched your search

+240 -22
View File
@@ -1,42 +1,260 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import { ApiError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
const TITLE_MIN_LENGTH = 3
const TITLE_MAX_LENGTH = 200
const TITLE_PLACEHOLDER = 'e.g. Choosing tiles for a small bathroom'
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ created: [article: Article] }>()
const api = useApi()
const title = ref('')
const body = ref('')
const titleTouched = ref(false)
const bodyTouched = ref(false)
const titleFocused = ref(false)
const submitting = ref(false)
const titleServerError = ref('')
const formAlert = ref<'' | 'invalid' | 'failed'>('')
const titleLength = computed(() => [...title.value].length)
const titleValid = computed(
() => titleLength.value >= TITLE_MIN_LENGTH && titleLength.value <= TITLE_MAX_LENGTH,
)
const bodyValid = computed(() => body.value.length > 0)
const titleLengthError = computed(() => {
if (!titleTouched.value) {
return ''
}
if (titleLength.value < TITLE_MIN_LENGTH) {
return 'Use at least 3 characters.'
}
if (titleLength.value > TITLE_MAX_LENGTH) {
return 'Use 3 to 200 characters.'
}
return ''
})
const titleError = computed(() => titleServerError.value || titleLengthError.value)
const bodyError = computed(() =>
bodyTouched.value && !bodyValid.value
? 'Add a first paragraph. It can be a single sentence.'
: '',
)
const titleCounterMin = computed(() => (titleTouched.value ? TITLE_MIN_LENGTH : 0))
const showHelp = computed(() => !submitting.value && formAlert.value === '')
const alertMessage = computed(() => {
if (formAlert.value === 'invalid') {
return "Something in the form isn't valid. Check the highlighted fields."
}
if (formAlert.value === 'failed') {
return "Couldn't create the article. Your text is still here. Try again in a moment."
}
return ''
})
function resetForm() {
title.value = ''
body.value = ''
titleTouched.value = false
bodyTouched.value = false
titleFocused.value = false
submitting.value = false
titleServerError.value = ''
formAlert.value = ''
}
async function focusTitleField() {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>(`input[placeholder="${TITLE_PLACEHOLDER}"]`)?.focus()
titleFocused.value = true
}
watch(
open,
(isOpen) => {
if (!isOpen) {
return
}
resetForm()
focusTitleField()
},
{ immediate: true },
)
function onTitleBlur() {
titleTouched.value = true
titleFocused.value = false
}
function onBodyBlur() {
bodyTouched.value = true
}
async function onSubmit() {
titleTouched.value = true
bodyTouched.value = true
titleFocused.value = false
if (!titleValid.value || !bodyValid.value) {
return
}
titleServerError.value = ''
formAlert.value = ''
submitting.value = true
try {
const article = await api.request<Article>('/v1/article/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.value, body: body.value }),
})
open.value = false
emit('created', article)
} catch (error) {
if (error instanceof ApiError && error.status === 400) {
titleServerError.value = 'Check this title and try again.'
formAlert.value = 'invalid'
} else {
formAlert.value = 'failed'
}
} finally {
submitting.value = false
}
}
function onCancel() {
if (submitting.value) {
return
}
resetForm()
open.value = false
}
</script>
<template>
<UModal v-model:open="open" title="New article">
<UModal
v-model:open="open"
title="New article"
description="It's saved as a draft. Titles can't be changed later, so check it now."
:dismissible="!submitting"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-140 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
wrapper: 'pe-10',
title: 'text-lg/6 font-semibold',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}"
>
<template #body>
<UForm :state="{ title, body }" class="space-y-4">
<UFormField label="Title" name="title">
<UInput
v-model="title"
placeholder="Article title"
class="w-full"
/>
</UFormField>
<UFormField label="Body" name="body">
<UTextarea
v-model="body"
placeholder="Start writing…"
class="w-full"
/>
</UFormField>
</UForm>
<div class="flex flex-col gap-4">
<UAlert
v-if="alertMessage"
role="alert"
color="error"
variant="soft"
:close="false"
icon="i-lucide-circle-x"
:ui="{ root: 'gap-2 px-3 py-2.5' }"
:description="alertMessage"
/>
<UForm :state="{ title, body }" class="flex flex-col gap-4">
<UFormField label="Title" name="title" :error="titleError || undefined">
<template #hint>
<CharacterCounter
:value="titleLength"
:max="TITLE_MAX_LENGTH"
:min="titleCounterMin"
/>
</template>
<template v-if="titleError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<template v-else-if="showHelp" #help>3 to 200 characters.</template>
<UInput
v-model="title"
:placeholder="TITLE_PLACEHOLDER"
:disabled="submitting"
color="primary"
:highlight="titleFocused || !!titleError"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-base' }"
class="w-full"
@focus="titleFocused = true"
@blur="onTitleBlur"
/>
</UFormField>
<UFormField label="First paragraph" name="body" :error="bodyError || undefined">
<template v-if="bodyError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<template v-else-if="showHelp" #help>
You can format it and add images in the editor.
</template>
<UTextarea
v-model="body"
placeholder="Start writing. You'll continue in the editor."
:disabled="submitting"
:highlight="!!bodyError"
:rows="3"
:ui="{
base: 'h-30 text-lg disabled:bg-elevated disabled:text-dimmed disabled:opacity-100 md:h-24 md:text-base',
}"
class="w-full"
@blur="onBodyBlur"
/>
</UFormField>
</UForm>
</div>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton color="neutral" variant="outline" @click="onCancel"
>Cancel</UButton
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
color="neutral"
variant="outline"
block
:ui="{ base: 'h-11 md:h-8' }"
class="sm:w-auto"
:disabled="submitting"
@click="onCancel"
>
<UButton disabled>Create article</UButton>
Cancel
</UButton>
<UButton
block
:ui="{ base: 'h-11 md:h-8' }"
class="sm:w-auto"
:loading="submitting"
:disabled="submitting"
@click="onSubmit"
>
{{ submitting ? 'Creating…' : 'Create draft' }}
</UButton>
</div>
</template>
</UModal>