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

+112 -48
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { 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 ArticleTable from '@/components/ArticleTable.vue'
import ArticleCardList from '@/components/ArticleCardList.vue'
@@ -19,8 +21,8 @@ const route = useRoute()
const router = useRouter()
const { request } = useApi()
const { listArticles } = useArticles()
const overlay = useOverlay()
const toast = useToast()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
const articles = ref<Article[]>([])
const loading = ref(true)
@@ -54,7 +56,9 @@ const activeTab = computed<'' | 'online' | 'draft'>({
get: () =>
route.query.status === 'online' || route.query.status === 'draft' ? route.query.status : '',
set: (value) => {
router.replace({ query: { ...route.query, status: value || undefined } })
router.replace({
query: { ...route.query, status: value || undefined },
})
},
})
@@ -68,9 +72,24 @@ const counts = computed(() => {
})
const tabItems = computed(() => [
{ label: `All ${counts.value.all}`, value: '' },
{ label: `Online ${counts.value.online}`, value: 'online' },
{ label: `Drafts ${counts.value.drafts}`, value: 'draft' },
{
get label() {
return t('All {0}', [counts.value.all])
},
value: '',
},
{
get label() {
return t('Online {0}', [counts.value.online])
},
value: 'online',
},
{
get label() {
return t('Drafts {0}', [counts.value.drafts])
},
value: 'draft',
},
])
const statusFilteredArticles = computed(() => {
@@ -156,8 +175,12 @@ function notFoundToast(article: Article) {
id: `article-not-found-${article.id}`,
color: 'info' as const,
icon: 'i-lucide-info',
title: 'That article no longer exists',
description: 'It was deleted somewhere else, so we removed it from the list.',
get title() {
return t('That article no longer exists')
},
get description() {
return t('It was deleted somewhere else, so we removed it from the list.')
},
}
}
@@ -171,30 +194,45 @@ async function setOnline(article: Article, makeOnline: boolean) {
articles.value = articles.value.map((existing) =>
existing.id === updated.id ? updated : existing,
)
toast.add({
toast.add(() => ({
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: makeOnline ? 'Article published' : 'Article unpublished',
description: makeOnline
? `"${article.title}" is now online.`
: `"${article.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.', [article.title])
: t('\u0022{0}\u0022 is now offline.', [article.title])
},
}))
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id)
toast.add(notFoundToast(article))
toast.add(() => notFoundToast(article))
return
}
toast.add({
toast.add(() => ({
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${article.id}`,
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(article, makeOnline) }],
})
actions: [
{
get label() {
return t('Retry')
},
onClick: () => setOnline(article, makeOnline),
},
],
}))
} finally {
setBusy(article.id, false)
}
@@ -222,48 +260,62 @@ async function handleCopyUrl(id: number) {
const url = `${window.location.origin}/v1/article/${id}`
try {
await navigator.clipboard.writeText(url)
toast.add({
toast.add(() => ({
id: `article-link-copied-${id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Link copied',
description: `The API URL for "${article.title}" is on your clipboard.`,
})
get title() {
return t('Link copied')
},
get description() {
return t('The API URL for \u0022{0}\u0022 is on your clipboard.', [article.title])
},
}))
} catch {
toast.add({
toast.add(() => ({
id: `article-link-copy-failed-${id}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't copy the link",
})
get title() {
return t("Couldn't copy the link")
},
}))
}
}
function deleteDescription(article: Article) {
const count = bodyImageCount(article.body)
const imagesPhrase =
count === 0 ? '' : ' Its images stay in your library.'
return `"${article.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.title,
imagesPhrase,
])
}
async function performDelete(article: Article) {
try {
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
removeArticle(article.id)
toast.add({
toast.add(() => ({
id: `article-deleted-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: `"${article.title}" is gone.`,
})
get title() {
return t('Article deleted')
},
get description() {
return t('\u0022{0}\u0022 is gone.', [article.title])
},
}))
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id)
toast.add(notFoundToast(article))
toast.add(() => notFoundToast(article))
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."),
)
}
}
@@ -283,11 +335,21 @@ async function handleDelete(article: Article) {
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
title: 'Delete this article?',
description: deleteDescription(article),
confirmLabel: 'Delete article',
cancelLabel: 'Cancel',
pendingLabel: 'Deleting…',
get title() {
return t('Delete this article?')
},
get description() {
return deleteDescription(article)
},
get confirmLabel() {
return t('Delete article')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Deleting…')
},
tone: 'danger',
action: () => performDelete(article),
},
@@ -311,7 +373,7 @@ function clearSearch() {
debouncedSearch.value = ''
router.replace({ query: { ...route.query, q: undefined } })
nextTick(() => {
document.querySelector<HTMLInputElement>('input[placeholder="Search by title"]')?.focus()
document.querySelector<HTMLInputElement>('input[type="search"]')?.focus()
})
}
@@ -340,16 +402,18 @@ onMounted(loadArticles)
tabindex="-1"
class="text-xl font-semibold text-highlighted"
>
Articles
{{ t('Articles') }}
</h1>
<p class="text-muted">Everything you've written, online and in draft.</p>
<p class="text-muted">
{{ t("Everything you've written, online and in draft.") }}
</p>
</div>
<UButton
icon="i-lucide-plus"
class="hidden h-8 px-3 sm:flex"
@click="openNewArticleModal"
>
New article
{{ t('New article') }}
</UButton>
</div>
@@ -366,7 +430,7 @@ onMounted(loadArticles)
v-model="searchInput"
type="search"
icon="i-lucide-search"
placeholder="Search by title"
:placeholder="t('Search by title')"
class="w-full sm:w-65"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
/>
@@ -407,7 +471,7 @@ onMounted(loadArticles)
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover) sm:hidden"
@click="openNewArticleModal"
>
New article
{{ t('New article') }}
</UButton>
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />