feat(SPA): i18n internationzalization, english and french
This commit is contained in:
1 parent
7f1e8e572d
commit
721311a643
88 files changed
+4438
-1148
No files matched your search
+122
-67
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user