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

+122 -67
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
import { useToast } from '@nuxt/ui/composables'
import { useLocalizedToast } from '@/i18n/presentation'
import BulkDeleteMediaDialog from '@/components/BulkDeleteMediaDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import DeleteMediaDialog from '@/components/DeleteMediaDialog.vue'
@@ -68,7 +70,7 @@ const OFFLINE_COPY =
const route = useRoute()
const router = useRouter()
const toast = useToast()
const toast = useLocalizedToast()
const media = useMedia()
const selection = useMediaSelection(media.items)
const upload = useMediaUpload({ onAdded: refreshInBackground })
@@ -90,7 +92,8 @@ const bulkDeleteOpen = ref(false)
const bulkTargets = ref<MediaItem[]>([])
const mergeTarget = ref<{ group: DuplicateGroup; keep: MediaItem } | null>(null)
const dragDepth = ref(0)
const announcement = ref('')
const announcementSource = ref<() => string>(() => '')
const announcement = computed(() => announcementSource.value())
const usage = ref<MediaUsageItem[]>([])
const copies = ref<MediaItem[]>([])
let opener: HTMLElement | null = null
@@ -127,13 +130,16 @@ const emptyVariant = computed(() =>
isDuplicates.value && controls.value.q === '' ? 'no-duplicates' : 'no-results',
)
const filterLabel = computed(
() => ({ all: 'All', unused: 'Unused', duplicates: 'Duplicates' })[controls.value.filter],
() =>
({ all: t('All'), unused: t('Unused'), duplicates: t('Duplicates') })[
controls.value.filter
],
)
const showList = computed(() => ready.value && !nothingToShow.value && !emptyLibrary.value)
const dragging = computed(() => dragDepth.value > 0)
const uploadBlocked = computed(() => offline.value || upload.limited.value)
const uploadLabel = computed(() =>
upload.limited.value ? `Try again in ${upload.countdown.value}` : 'Upload',
upload.limited.value ? t('Try again in {0}', [upload.countdown.value]) : t('Upload'),
)
const uploadIcon = computed(() => (upload.limited.value ? 'i-lucide-timer' : 'i-lucide-upload'))
const dialogOpen = computed(
@@ -158,7 +164,9 @@ const gridSelected = computed(() => selection.selectedIds.value)
const showBulkBar = computed(() => hasSelection.value && !emptyLibrary.value)
watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), { immediate: true })
watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), {
immediate: true,
})
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
function canonicalizeUrl() {
@@ -182,8 +190,8 @@ function clearFilters() {
updateControls({ q: '', filter: 'all' })
}
function announce(text: string) {
announcement.value = text
function announce(source: () => string) {
announcementSource.value = source
}
canonicalizeUrl()
@@ -210,7 +218,11 @@ watch(
(next, previous) => {
const settledNow = next.phase === 'settled' && previous?.phase === 'working'
if (settledNow && upload.rows.value.length > 0) {
announce(next.summary ?? `${next.title}. ${next.sub}`)
announce(
() =>
upload.header.value.summary ??
`${upload.header.value.title}. ${upload.header.value.sub}`,
)
}
},
)
@@ -339,13 +351,17 @@ function removeAndRefocus(item: MediaItem) {
}
function notFoundToast(id: number) {
toast.add({
toast.add(() => ({
id: `media-not-found-${id}`,
color: 'info',
icon: 'i-lucide-info',
title: 'That image no longer exists',
description: 'It was deleted somewhere else, so we removed it from the library.',
})
get title() {
return t('That image no longer exists')
},
get description() {
return t('It was deleted somewhere else, so we removed it from the library.')
},
}))
}
function handleGone(item: MediaItem) {
@@ -363,14 +379,18 @@ async function copyUrl() {
} catch {
return
}
toast.add({
toast.add(() => ({
id: `media-url-copied-${item.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'URL copied',
description: 'Paste it wherever you need the image address.',
get title() {
return t('URL copied')
},
get description() {
return t('Paste it wherever you need the image address.')
},
duration: TOAST_DURATION_MS,
})
}))
}
async function renameAction(stem: string) {
@@ -380,14 +400,18 @@ async function renameAction(stem: string) {
}
try {
const renamed = await media.renameItem(item.id, stem)
toast.add({
toast.add(() => ({
id: `media-renamed-${item.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image renamed',
description: `It's now "${renamed.filename}". The URL is the same.`,
get title() {
return t('Image renamed')
},
get description() {
return t("It's now \u0022{0}\u0022. The URL is the same.", [renamed.filename])
},
duration: TOAST_DURATION_MS,
})
}))
} catch (error) {
const failure = classifyMediaError(error)
if (failure === 'handled') {
@@ -397,7 +421,7 @@ async function renameAction(stem: string) {
handleGone(item)
return
}
throw new Error(RENAME_FAILED_COPY)
throw new Error(t(RENAME_FAILED_COPY))
}
}
@@ -418,32 +442,40 @@ async function deleteAction() {
handleGone(item)
return
}
throw new Error(DELETE_FAILED_COPY)
throw new Error(t(DELETE_FAILED_COPY))
}
closeEverything()
focusAfterRemoval(index)
toast.add({
toast.add(() => ({
id: `media-deleted-${item.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image deleted',
description: `"${item.filename}" is gone from your library.`,
get title() {
return t('Image deleted')
},
get description() {
return t('\u0022{0}\u0022 is gone from your library.', [item.filename])
},
duration: TOAST_DURATION_MS,
})
}))
refreshInBackground()
}
function imageCount(total: number) {
return `${total} ${total === 1 ? 'image' : 'images'}`
return t(total === 1 ? '{0} image' : '{0} images', [total])
}
function announceSelection(filename: string, selected: boolean) {
if (!selected && selection.count.value === 0) {
announce(SELECTION_CLEARED)
announce(() => t(SELECTION_CLEARED))
return
}
announce(
`${filename} ${selected ? 'selected' : 'unselected'}, ${selection.count.value} selected`,
announce(() =>
t('{0} {1}, {2} selected', [
filename,
selected ? t('selected') : t('unselected'),
selection.count.value,
]),
)
}
@@ -459,7 +491,9 @@ function toggleSelected(id: number) {
function toggleAllSelected() {
selection.toggleAll()
announce(hasSelection.value ? `${selection.count.value} selected` : SELECTION_CLEARED)
announce(() =>
hasSelection.value ? t('{0} selected', [selection.count.value]) : t(SELECTION_CLEARED),
)
}
function clearSelection() {
@@ -467,7 +501,7 @@ function clearSelection() {
return
}
selection.clear()
announce(SELECTION_CLEARED)
announce(() => t(SELECTION_CLEARED))
}
function selectExisting(item: MediaItem) {
@@ -531,7 +565,7 @@ function onFilesChosen(event: Event) {
}
function carriesFiles(event: DragEvent) {
return Array.from(event.dataTransfer?.types ?? []).includes('Files')
return Array.from(event.dataTransfer?.types ?? []).includes(t('Files'))
}
useEventListener(window, 'dragenter', (event: DragEvent) => {
@@ -576,20 +610,31 @@ function openBulkDelete() {
function bulkFailedDescription(failed: MediaItem[]) {
const names = failed.map((item) => item.filename)
const deleted = bulkDeletedTotal === 1 ? '1 image was' : `${bulkDeletedTotal} images were`
return `${deleted} deleted. ${joinNames(names)} ${names.length === 1 ? 'is' : 'are'} still in your library. Try again.`
const deleted =
bulkDeletedTotal === 1 ? t('1 image was') : t('{0} images were', [bulkDeletedTotal])
return t('{0} deleted. {1} {2} still in your library. Try again.', [
deleted,
joinNames(names),
t(names.length === 1 ? 'is' : 'are'),
])
}
function bulkFailedToast(failed: MediaItem[]) {
const content = {
color: 'error' as const,
icon: 'i-lucide-circle-x',
title: `Couldn't delete ${imageCount(failed.length)}`,
description: bulkFailedDescription(failed),
get title() {
return t("Couldn't delete {0}", [imageCount(failed.length)])
},
get description() {
return bulkFailedDescription(failed)
},
duration: PERSISTENT_TOAST,
actions: [
{
label: 'Retry',
get label() {
return t('Retry')
},
icon: 'i-lucide-rotate-cw',
color: 'neutral' as const,
variant: 'outline' as const,
@@ -600,11 +645,11 @@ function bulkFailedToast(failed: MediaItem[]) {
],
}
if (failureToastShown) {
toast.update(BULK_FAILED_TOAST_ID, content)
toast.update(BULK_FAILED_TOAST_ID, () => content)
return
}
failureToastShown = true
toast.add({ id: BULK_FAILED_TOAST_ID, ...content })
toast.add(() => ({ id: BULK_FAILED_TOAST_ID, ...content }))
}
function dismissBulkFailedToast() {
@@ -623,15 +668,19 @@ function finishBulkDelete(result: BulkDeleteResult, firstIndex: number) {
} else {
dismissBulkFailedToast()
selection.clear()
announce(`${imageCount(bulkDeletedTotal)} deleted`)
toast.add({
announce(() => t('{0} deleted', [imageCount(bulkDeletedTotal)]))
toast.add(() => ({
id: 'media-bulk-deleted',
color: 'success',
icon: 'i-lucide-circle-check',
title: `${imageCount(bulkDeletedTotal)} deleted`,
description: "They're gone from your library.",
get title() {
return t('{0} deleted', [imageCount(bulkDeletedTotal)])
},
get description() {
return t("They're gone from your library.")
},
duration: TOAST_DURATION_MS,
})
}))
focusAfterRemoval(firstIndex)
}
refreshInBackground()
@@ -652,7 +701,7 @@ async function bulkDeleteAction() {
return false
}
if (result.deleted.length === 0) {
throw new Error(BULK_DELETE_FAILED_COPY)
throw new Error(t(BULK_DELETE_FAILED_COPY))
}
bulkDeleteOpen.value = false
finishBulkDelete(result, firstIndex)
@@ -672,9 +721,10 @@ function openMerge(group: DuplicateGroup, keep: MediaItem) {
}
function mergeText(merged: number, articlesUpdated: number) {
const copiesText = merged === 1 ? '1 copy' : `${merged} copies`
const articlesText = articlesUpdated === 1 ? '1 article' : `${articlesUpdated} articles`
return `${copiesText} merged. ${articlesText} updated.`
const copiesText = merged === 1 ? t('1 copy') : t('{0} copies', [merged])
const articlesText =
articlesUpdated === 1 ? t('1 article') : t('{0} articles', [articlesUpdated])
return t('{0} merged. {1} updated.', [copiesText, articlesText])
}
async function mergeAction() {
@@ -688,16 +738,19 @@ async function mergeAction() {
const result = await media.mergeCopies(keep.id, mergeIds)
mergeTarget.value = null
media.removeLocalMany(group.copies.map((copy) => copy.id))
const text = mergeText(result.merged, result.articles_updated)
announce(text)
toast.add({
announce(() => mergeText(result.merged, result.articles_updated))
toast.add(() => ({
id: `media-merged-${keep.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Copies merged',
description: text,
get title() {
return t('Copies merged')
},
get description() {
return mergeText(result.merged, result.articles_updated)
},
duration: TOAST_DURATION_MS,
})
}))
focusElement('[data-testid="media-heading"]')
refreshInBackground()
} catch (error) {
@@ -713,9 +766,9 @@ async function mergeAction() {
}
if (error instanceof ApiError && error.status === HTTP_CONFLICT) {
refreshInBackground()
throw new Error(MERGE_CONFLICT_COPY)
throw new Error(t(MERGE_CONFLICT_COPY))
}
throw new Error(MERGE_FAILED_COPY)
throw new Error(t(MERGE_FAILED_COPY))
}
}
</script>
@@ -723,7 +776,7 @@ async function mergeAction() {
<template>
<DashboardShell
compact-padding
:offline-message="OFFLINE_COPY"
:offline-message="t(OFFLINE_COPY)"
offline-icon="i-lucide-wifi-off"
>
<div class="flex flex-col gap-4">
@@ -735,10 +788,10 @@ async function mergeAction() {
tabindex="-1"
class="text-xl font-semibold text-highlighted"
>
Media
{{ t('Media') }}
</h1>
<p class="text-muted">
Every image you've uploaded, ready to use in any article.
{{ t("Every image you've uploaded, ready to use in any article.") }}
</p>
</div>
<UButton
@@ -770,7 +823,9 @@ async function mergeAction() {
:limited="upload.limited.value"
@action="openChooser"
/>
<p class="self-center text-xs text-muted">PNG, JPEG, WEBP, SVG · max 10 MB each</p>
<p class="self-center text-xs text-muted">
{{ t('PNG, JPEG, WEBP, SVG · max 10 MB each') }}
</p>
</template>
<template v-else>
@@ -781,8 +836,8 @@ async function mergeAction() {
variant="soft"
:close="false"
icon="i-lucide-timer"
:title="RATE_LIMITED_TITLE"
:description="RATE_LIMITED_TEXT"
:title="t(RATE_LIMITED_TITLE)"
:description="t(RATE_LIMITED_TEXT)"
/>
<UAlert
@@ -793,8 +848,8 @@ async function mergeAction() {
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
title="Couldn't load your images"
description="Check your connection and try again."
:title="t('Couldn\'t load your images')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
@@ -806,7 +861,7 @@ async function mergeAction() {
:ui="{ leadingIcon: 'size-3.5' }"
@click="media.load(controls)"
>
Try again
{{ t('Try again') }}
</UButton>
</template>
</UAlert>