feat(SPA): i18n internationzalization, english and french

This commit is contained in:
fsylvain committed 2026-10-03 01:53:49 +02:00
1 parent 7f1e8e572d
commit 721311a643
88 files changed
+4438 -1148

No files matched your search

+136 -60
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import DashboardShell from '@/components/DashboardShell.vue'
import ErrorView from '@/components/ErrorView.vue'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
@@ -28,8 +30,8 @@ type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
const route = useRoute()
const router = useRouter()
const { request } = useApi()
const overlay = useOverlay()
const toast = useToast()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
const articleId = Number(route.params.id)
const draft = useArticleDraft(articleId)
@@ -91,11 +93,13 @@ const publishDisabled = computed(() => overLimit.value || draft.state.value ===
async function runSave() {
const outcome = await draft.save()
if (outcome === 'not-found') {
toast.add({
toast.add(() => ({
color: 'error',
icon: 'i-lucide-circle-x',
title: 'That article no longer exists.',
})
get title() {
return t('That article no longer exists.')
},
}))
}
}
@@ -121,36 +125,51 @@ async function setOnline(makeOnline: boolean) {
if (!article.value) {
return
}
publishPending.value = makeOnline ? 'Publishing…' : 'Unpublishing…'
publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…')
try {
const updated = await request<Article>(
`/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}`,
{ method: 'PATCH' },
)
article.value = updated
toast.add({
toast.add(() => ({
id: `article-${makeOnline ? 'published' : 'unpublished'}-${articleId}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: makeOnline ? 'Article published' : 'Article unpublished',
description: makeOnline
? `"${updated.title}" is now online.`
: `"${updated.title}" is now offline.`,
})
get title() {
return makeOnline ? t('Article published') : t('Article unpublished')
},
get description() {
return makeOnline
? t('\u0022{0}\u0022 is now online.', [updated.title])
: t('\u0022{0}\u0022 is now offline.', [updated.title])
},
}))
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
loadState.value = 'notFound'
return
}
toast.add({
toast.add(() => ({
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${articleId}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: makeOnline ? "Couldn't publish" : "Couldn't unpublish",
description: "The server didn't respond. Nothing changed.",
get title() {
return makeOnline ? t("Couldn't publish") : t("Couldn't unpublish")
},
get description() {
return t("The server didn't respond. Nothing changed.")
},
duration: 0,
actions: [{ label: 'Retry', onClick: () => setOnline(makeOnline) }],
})
actions: [
{
get label() {
return t('Retry')
},
onClick: () => setOnline(makeOnline),
},
],
}))
} finally {
publishPending.value = undefined
}
@@ -166,28 +185,36 @@ function handleUnpublish() {
function deleteDescription() {
const count = bodyImageCount(article.value?.body ?? '')
const imagesPhrase =
count === 0 ? '' : ' Its images stay in your library.'
return `"${article.value?.title ?? ''}" will be deleted.${imagesPhrase} This can't be undone.`
const imagesPhrase = count === 0 ? '' : t(' Its images stay in your library.')
return t("\u0022{0}\u0022 will be deleted.{1} This can't be undone.", [
article.value?.title ?? '',
imagesPhrase,
])
}
async function performDelete() {
try {
await request(`/v1/article/${articleId}`, { method: 'DELETE' })
toast.add({
toast.add(() => ({
id: `article-deleted-${articleId}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: `"${article.value?.title ?? ''}" is gone.`,
})
get title() {
return t('Article deleted')
},
get description() {
return t('\u0022{0}\u0022 is gone.', [article.value?.title ?? ''])
},
}))
router.push('/article')
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
loadState.value = 'notFound'
return
}
throw new Error("Couldn't delete the article, so it's still there. Try again in a moment.")
throw new Error(
t("Couldn't delete the article, so it's still there. Try again in a moment."),
)
}
}
@@ -198,11 +225,21 @@ async function handleDelete() {
await overlay
.create(ConfirmDialog, {
props: {
title: 'Delete this article?',
description: deleteDescription(),
confirmLabel: 'Delete article',
cancelLabel: 'Cancel',
pendingLabel: 'Deleting…',
get title() {
return t('Delete this article?')
},
get description() {
return deleteDescription()
},
get confirmLabel() {
return t('Delete article')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Deleting…')
},
tone: 'danger',
action: performDelete,
},
@@ -229,12 +266,16 @@ function onEditorDrop(event: DragEvent) {
}
function announceImageAdded() {
toast.add({
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image added',
description: 'Save to keep it in the article.',
})
get title() {
return t('Image added')
},
get description() {
return t('Save to keep it in the article.')
},
}))
}
function onImageUploaded(item: MediaItem) {
@@ -251,12 +292,16 @@ function onImageInserted(item: MediaItem, alt: string) {
async function performRemoveImage(imageId: number) {
editorRef.value?.removeImage(imageId)
toast.add({
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image removed',
description: "It's still in your library.",
})
get title() {
return t('Image removed')
},
get description() {
return t("It's still in your library.")
},
}))
}
function removalPreview(imageId: number) {
@@ -272,12 +317,23 @@ async function onRequestRemoveImage(imageId: number) {
await overlay
.create(ConfirmDialog, {
props: {
title: 'Remove this image from the article?',
description:
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
confirmLabel: 'Remove image',
cancelLabel: 'Cancel',
pendingLabel: 'Removing…',
get title() {
return t('Remove this image from the article?')
},
get description() {
return t(
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
)
},
get confirmLabel() {
return t('Remove image')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Removing…')
},
tone: 'neutral',
preview: removalPreview(imageId),
action: () => performRemoveImage(imageId),
@@ -314,11 +370,23 @@ onBeforeRouteLeave(async () => {
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
title: 'Leave without saving?',
description: `Your changes to "${article.value?.title ?? ''}" haven't been saved.`,
confirmLabel: 'Keep editing',
cancelLabel: 'Discard changes',
pendingLabel: 'Keep editing',
get title() {
return t('Leave without saving?')
},
get description() {
return t("Your changes to \u0022{0}\u0022 haven't been saved.", [
article.value?.title ?? '',
])
},
get confirmLabel() {
return t('Keep editing')
},
get cancelLabel() {
return t('Discard changes')
},
get pendingLabel() {
return t('Keep editing')
},
tone: 'neutral',
action: async () => {},
dismissResolvesTo: 'confirm',
@@ -330,10 +398,18 @@ onBeforeRouteLeave(async () => {
</script>
<template>
<DashboardShell mobile-title="Edit article" full-bleed>
<DashboardShell :mobile-title="t('Edit article')" full-bleed>
<template v-if="loadState === 'loaded'" #breadcrumb>
<UBreadcrumb
:items="[{ label: 'Articles', to: '/article' }, { label: article?.title ?? '' }]"
:items="[
{
get label() {
return t('Articles')
},
to: '/article',
},
{ label: article?.title ?? '' },
]"
color="neutral"
class="hidden lg:block"
/>
@@ -356,7 +432,7 @@ onBeforeRouteLeave(async () => {
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
aria-label="Back to Articles"
:aria-label="t('Back to Articles')"
@click="goBack"
/>
<USkeleton class="h-5 w-80 rounded-none" data-testid="skeleton-title" />
@@ -393,13 +469,13 @@ onBeforeRouteLeave(async () => {
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
aria-label="Back to Articles"
:aria-label="t('Back to Articles')"
@click="goBack"
/>
<div class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5">
<h1
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
title="The title can't be changed after creation."
:title="t('The title can\'t be changed after creation.')"
>
{{ article.title }}
</h1>
@@ -413,7 +489,7 @@ onBeforeRouteLeave(async () => {
<div class="hidden items-center gap-3 lg:flex">
<USwitch
v-model="previewOn"
label="Preview"
:label="t('Preview')"
:ui="{
root: 'items-center gap-2',
container: 'h-auto',
@@ -432,7 +508,7 @@ onBeforeRouteLeave(async () => {
:disabled="!saveEnabled"
@click="performSave"
>
Save
{{ t('Save') }}
</UButton>
<PublishControl
class="h-8 gap-1.5 px-3"
@@ -450,7 +526,7 @@ onBeforeRouteLeave(async () => {
class="h-8 gap-1.5 px-3"
@click="handleDelete"
>
Delete
{{ t('Delete') }}
</UButton>
</div>
</div>
@@ -513,7 +589,7 @@ onBeforeRouteLeave(async () => {
color="neutral"
variant="outline"
class="size-11 justify-center"
aria-label="Details"
:aria-label="t('Details')"
@click="detailsOpen = true"
/>
<UButton
@@ -524,7 +600,7 @@ onBeforeRouteLeave(async () => {
:disabled="!saveEnabled"
@click="performSave"
>
Save
{{ t('Save') }}
</UButton>
<PublishControl
class="h-11 flex-1 justify-center"