277 lines
7.0 KiB
Vue
277 lines
7.0 KiB
Vue
<script setup lang="ts">
|
|
import { t, translate } from '@/i18n'
|
|
|
|
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 t('Use at least 3 characters.')
|
|
}
|
|
if (titleLength.value > TITLE_MAX_LENGTH) {
|
|
return t('Use 3 to 200 characters.')
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const titleError = computed(() => titleServerError.value || titleLengthError.value)
|
|
|
|
const bodyError = computed(() =>
|
|
bodyTouched.value && !bodyValid.value
|
|
? t('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 t("Something in the form isn't valid. Check the highlighted fields.")
|
|
}
|
|
if (formAlert.value === 'failed') {
|
|
return t("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="${t(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/?format=html', {
|
|
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="t('New article')"
|
|
:description="t('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>
|
|
<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="t('Title')"
|
|
name="title"
|
|
:error="translate(titleError) || undefined"
|
|
>
|
|
<template #hint>
|
|
<CharacterCounter
|
|
:value="titleLength"
|
|
:max="TITLE_MAX_LENGTH"
|
|
:min="titleCounterMin"
|
|
plain-text
|
|
/>
|
|
</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"
|
|
/>
|
|
{{ t(error) }}
|
|
</span>
|
|
</template>
|
|
<template v-else-if="showHelp" #help>{{
|
|
t('3 to 200 characters.')
|
|
}}</template>
|
|
<UInput
|
|
v-model="title"
|
|
:placeholder="t(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="t('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"
|
|
/>
|
|
{{ t(error) }}
|
|
</span>
|
|
</template>
|
|
<template v-else-if="showHelp" #help>
|
|
{{ t('You can format it and add images in the editor.') }}
|
|
</template>
|
|
<UTextarea
|
|
v-model="body"
|
|
:placeholder="t('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 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"
|
|
>
|
|
{{ t('Cancel') }}
|
|
</UButton>
|
|
<UButton
|
|
block
|
|
:ui="{ base: 'h-11 md:h-8' }"
|
|
class="sm:w-auto"
|
|
:loading="submitting"
|
|
:disabled="submitting"
|
|
@click="onSubmit"
|
|
>
|
|
{{ submitting ? t('Creating…') : t('Create draft') }}
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UModal>
|
|
</template>
|