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

+18 -6
View File
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import type { ColorModePreference } from '@/composables/useColorMode'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
import LanguagePicker from '@/components/LanguagePicker.vue'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"]'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="combobox"]'
const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] text-highlighted'
const props = defineProps<{
@@ -43,6 +46,7 @@ function logOut() {
}
function moveFocus(event: KeyboardEvent) {
if ((event.target as HTMLElement).closest('[role="combobox"]')) return
const items = Array.from(menu.value?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
if (items.length === 0) {
return
@@ -68,8 +72,8 @@ function moveFocus(event: KeyboardEvent) {
<USlideover
v-model:open="open"
side="bottom"
title="Account"
description="Your account menu"
:title="t('Account')"
:description="t('Your account menu')"
:close="false"
:ui="{
content: 'max-h-none',
@@ -78,7 +82,13 @@ function moveFocus(event: KeyboardEvent) {
}"
>
<template #body>
<div ref="menu" role="menu" aria-label="Account" class="pt-2 pb-6" @keydown="moveFocus">
<div
ref="menu"
role="menu"
:aria-label="t('Account')"
class="pt-2 pb-6"
@keydown="moveFocus"
>
<div class="mx-auto mb-2 h-1 w-9 bg-accented" aria-hidden="true" />
<div class="mb-1 flex items-center gap-3 border-b border-default px-4 pt-2 pb-3.5">
<UAvatar
@@ -107,9 +117,11 @@ function moveFocus(event: KeyboardEvent) {
:ui="{ leadingIcon: 'size-4.5' }"
@click="goToProfile"
>
Profile
{{ t('Profile') }}
</UButton>
<USeparator class="my-1" />
<LanguagePicker mobile />
<USeparator class="my-1" />
<UButton
@@ -142,7 +154,7 @@ function moveFocus(event: KeyboardEvent) {
:ui="{ leadingIcon: 'size-4.5' }"
@click="logOut"
>
Log out
{{ t('Log out') }}
</UButton>
</div>
</template>
+4 -2
View File
@@ -1,7 +1,9 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { t } from '@/i18n'
</script>
<template>
<div class="space-y-4 p-6" role="status" aria-label="Loading">
<div class="space-y-4 p-6" role="status" :aria-label="t('Loading')">
<USkeleton class="h-6 w-40" />
<USkeleton class="h-32 w-full" />
<USkeleton class="h-32 w-full" />
+17 -6
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatRelativeDate } from '@/lib/relativeDate'
@@ -39,12 +41,16 @@ function actionsFor(article: Article) {
return [
[
{
label: 'Edit',
get label() {
return t('Edit')
},
icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)),
},
{
label: article.is_online ? 'Unpublish' : 'Publish',
get label() {
return article.is_online ? t('Unpublish') : t('Publish')
},
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article),
onSelect: () => {
@@ -56,14 +62,18 @@ function actionsFor(article: Article) {
},
},
{
label: 'Copy API URL',
get label() {
return t('Copy API URL')
},
icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id),
},
],
[
{
label: 'Delete…',
get label() {
return t('Delete…')
},
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete', article),
@@ -122,7 +132,8 @@ function onRowClick(article: Article, event: MouseEvent) {
:pending="pendingFor(article)"
/>
<span :title="article.updated_at"
>Updated {{ formatRelativeDate(article.updated_at, now) }}</span
>{{ t('Updated') }}
{{ formatRelativeDate(article.updated_at, now) }}</span
>
</div>
</div>
@@ -135,7 +146,7 @@ function onRowClick(article: Article, event: MouseEvent) {
icon="i-lucide-ellipsis-vertical"
color="neutral"
variant="ghost"
:aria-label="`Actions for ${article.title}`"
:aria-label="t('Actions for {0}', [article.title])"
class="size-11 justify-center text-toned"
@click.stop
/>
+50 -31
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { t, locale, formatNumber } from '@/i18n'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { computed } from 'vue'
import { useToast } from '@nuxt/ui/composables'
import { useLocalizedToast } from '@/i18n/presentation'
import type { Article } from '@/composables/useArticles'
import { bodyImageCount } from '@/lib/bodyImageIds'
@@ -8,7 +11,7 @@ const props = defineProps<{ article: Article }>()
const emit = defineEmits<{ 'copy-url': [] }>()
const toast = useToast()
const toast = useLocalizedToast()
const open = defineModel<boolean>('open', { default: false })
@@ -43,9 +46,11 @@ function formatDetailDate(iso: string, relative = false) {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
if (relative && isSameDay(date, new Date())) {
return `Today, ${hours}:${minutes}`
return t('Today, {0}:{1}', [hours, minutes])
}
return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${hours}:${minutes}`
return locale.value === 'fr'
? `${formatAbsoluteDateFromIso(iso)}, ${hours}:${minutes}`
: `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${hours}:${minutes}`
}
const createdLabel = computed(() => formatDetailDate(props.article.created_at))
@@ -54,29 +59,37 @@ const publicUrl = computed(() => `/v1/article/${props.article.id}`)
const imagesCount = computed(() => bodyImageCount(props.article.body))
const apiNote = computed(() =>
props.article.is_online
? 'Live. Your website can read this article.'
: 'Returns 404 until the article is online.',
? t('Live. Your website can read this article.')
: t('Returns 404 until the article is online.'),
)
async function copyUrl() {
const url = `${window.location.origin}${publicUrl.value}`
try {
await navigator.clipboard.writeText(url)
toast.add({
toast.add(() => ({
id: `article-link-copied-${props.article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Link copied',
description: `The API URL for "${props.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.', [
props.article.title,
])
},
}))
emit('copy-url')
} catch {
toast.add({
toast.add(() => ({
id: `article-link-copy-failed-${props.article.id}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't copy the link",
})
get title() {
return t("Couldn't copy the link")
},
}))
}
}
</script>
@@ -93,20 +106,24 @@ async function copyUrl() {
}"
>
<template #header>
<h2 class="text-base font-semibold text-highlighted">Details</h2>
<h2 class="text-base font-semibold text-highlighted">
{{ t('Details') }}
</h2>
</template>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted">Created</dt>
<dt class="text-muted">{{ t('Created') }}</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd>
<dt class="text-muted">Updated</dt>
<dt class="text-muted">{{ t('Updated') }}</dt>
<dd class="text-highlighted">{{ updatedLabel }}</dd>
<dt class="text-muted">ID</dt>
<dd class="font-mono text-highlighted">{{ article.id }}</dd>
<dt class="text-muted">Images</dt>
<dd class="text-highlighted">{{ imagesCount }}</dd>
<dt class="text-muted">{{ t('Images') }}</dt>
<dd class="text-highlighted">
{{ formatNumber(imagesCount) }}
</dd>
</dl>
<div class="flex flex-col gap-1.5 text-sm">
<p class="text-muted">Public API URL</p>
<p class="text-muted">{{ t('Public API URL') }}</p>
<div class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs">
<span class="flex-1">{{ publicUrl }}</span>
<UButton
@@ -114,7 +131,7 @@ async function copyUrl() {
color="neutral"
variant="ghost"
size="sm"
aria-label="Copy the public API URL"
:aria-label="t('Copy the public API URL')"
class="size-5 p-0"
@click="copyUrl"
/>
@@ -122,28 +139,30 @@ async function copyUrl() {
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div>
<div class="flex flex-col gap-1 border-t border-default pt-3 text-sm">
<p class="text-muted">Title</p>
<p class="text-xs/[18px] text-toned">{{ TITLE_NOTE }}</p>
<p class="text-muted">{{ t('Title') }}</p>
<p class="text-xs/[18px] text-toned">{{ t(TITLE_NOTE) }}</p>
</div>
</UCard>
<USlideover v-model:open="open" side="right" title="Details" class="lg:hidden">
<USlideover v-model:open="open" side="right" :title="t('Details')" class="lg:hidden">
<template #body>
<div class="flex flex-col gap-4 text-sm">
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
<dt class="text-muted">Created</dt>
<dt class="text-muted">{{ t('Created') }}</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd>
<dt class="text-muted">Updated</dt>
<dt class="text-muted">{{ t('Updated') }}</dt>
<dd class="text-highlighted">{{ updatedLabel }}</dd>
<dt class="text-muted">ID</dt>
<dd class="font-mono text-highlighted">
{{ article.id }}
</dd>
<dt class="text-muted">Images</dt>
<dd class="text-highlighted">{{ imagesCount }}</dd>
<dt class="text-muted">{{ t('Images') }}</dt>
<dd class="text-highlighted">
{{ formatNumber(imagesCount) }}
</dd>
</dl>
<div class="flex flex-col gap-1.5">
<p class="text-muted">Public API URL</p>
<p class="text-muted">{{ t('Public API URL') }}</p>
<div
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
>
@@ -153,7 +172,7 @@ async function copyUrl() {
color="neutral"
variant="ghost"
size="sm"
aria-label="Copy the public API URL"
:aria-label="t('Copy the public API URL')"
class="size-5 p-0"
@click="copyUrl"
/>
@@ -161,9 +180,9 @@ async function copyUrl() {
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div>
<div class="flex flex-col gap-1 border-t border-default pt-3">
<p class="text-muted">Title</p>
<p class="text-muted">{{ t('Title') }}</p>
<p class="text-xs/[18px] text-toned">
{{ TITLE_NOTE }}
{{ t(TITLE_NOTE) }}
</p>
</div>
</div>
+127 -29
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core'
@@ -42,10 +44,10 @@ function onUpdate(value: string) {
function currentBlockLabel(editor: Editor) {
for (let level = 1; level <= 4; level += 1) {
if (editor.isActive('heading', { level })) {
return `Heading ${level}`
return t('Heading {0}', [level])
}
}
return 'Paragraph'
return t('Paragraph')
}
function isDefaultAlignment(editor: Editor) {
@@ -58,12 +60,20 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
{
kind: 'undo',
icon: 'i-lucide-undo-2',
tooltip: { text: 'Undo' },
tooltip: {
get text() {
return t('Undo')
},
},
},
{
kind: 'redo',
icon: 'i-lucide-redo-2',
tooltip: { text: 'Redo' },
tooltip: {
get text() {
return t('Redo')
},
},
},
],
[
@@ -72,40 +82,70 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
label: currentBlockLabel(editor),
trailingIcon: 'i-lucide-chevron-down',
ui: { trailingIcon: 'size-3' },
tooltip: { text: 'Paragraph style' },
tooltip: {
get text() {
return t('Paragraph style')
},
},
items: [
{
icon: 'i-lucide-pilcrow',
label: 'Paragraph',
get label() {
return t('Paragraph')
},
onSelect: () => editor.chain().focus().setParagraph().run(),
},
{
kind: 'heading',
level: 1,
icon: 'i-lucide-heading-1',
label: 'Heading 1',
tooltip: { text: 'Heading 1' },
get label() {
return t('Heading 1')
},
tooltip: {
get text() {
return t('Heading 1')
},
},
},
{
kind: 'heading',
level: 2,
icon: 'i-lucide-heading-2',
label: 'Heading 2',
tooltip: { text: 'Heading 2' },
get label() {
return t('Heading 2')
},
tooltip: {
get text() {
return t('Heading 2')
},
},
},
{
kind: 'heading',
level: 3,
icon: 'i-lucide-heading-3',
label: 'Heading 3',
tooltip: { text: 'Heading 3' },
get label() {
return t('Heading 3')
},
tooltip: {
get text() {
return t('Heading 3')
},
},
},
{
kind: 'heading',
level: 4,
icon: 'i-lucide-heading-4',
label: 'Heading 4',
tooltip: { text: 'Heading 4' },
get label() {
return t('Heading 4')
},
tooltip: {
get text() {
return t('Heading 4')
},
},
},
],
},
@@ -115,25 +155,41 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
kind: 'mark',
mark: 'bold',
icon: 'i-lucide-bold',
tooltip: { text: 'Bold' },
tooltip: {
get text() {
return t('Bold')
},
},
},
{
kind: 'mark',
mark: 'italic',
icon: 'i-lucide-italic',
tooltip: { text: 'Italic' },
tooltip: {
get text() {
return t('Italic')
},
},
},
{
kind: 'mark',
mark: 'underline',
icon: 'i-lucide-underline',
tooltip: { text: 'Underline' },
tooltip: {
get text() {
return t('Underline')
},
},
},
{
kind: 'mark',
mark: 'strike',
icon: 'i-lucide-strikethrough',
tooltip: { text: 'Strikethrough' },
tooltip: {
get text() {
return t('Strikethrough')
},
},
},
],
[
@@ -142,46 +198,78 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
align: 'left',
active: isDefaultAlignment(editor),
icon: 'i-lucide-align-left',
tooltip: { text: 'Align left' },
tooltip: {
get text() {
return t('Align left')
},
},
},
{
kind: 'textAlign',
align: 'center',
icon: 'i-lucide-align-center',
tooltip: { text: 'Align center' },
tooltip: {
get text() {
return t('Align center')
},
},
},
{
kind: 'textAlign',
align: 'right',
icon: 'i-lucide-align-right',
tooltip: { text: 'Align right' },
tooltip: {
get text() {
return t('Align right')
},
},
},
{
kind: 'textAlign',
align: 'justify',
icon: 'i-lucide-align-justify',
tooltip: { text: 'Justify' },
tooltip: {
get text() {
return t('Justify')
},
},
},
],
[
{
kind: 'bulletList',
icon: 'i-lucide-list',
tooltip: { text: 'Bullet list' },
tooltip: {
get text() {
return t('Bullet list')
},
},
},
{
kind: 'orderedList',
icon: 'i-lucide-list-ordered',
tooltip: { text: 'Numbered list' },
tooltip: {
get text() {
return t('Numbered list')
},
},
},
{
icon: 'i-lucide-indent-decrease',
tooltip: { text: 'Outdent' },
tooltip: {
get text() {
return t('Outdent')
},
},
onClick: () => editor.chain().focus().outdent().run(),
},
{
icon: 'i-lucide-indent-increase',
tooltip: { text: 'Indent' },
tooltip: {
get text() {
return t('Indent')
},
},
onClick: () => editor.chain().focus().indent().run(),
},
],
@@ -189,12 +277,22 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
{
kind: 'clearFormatting',
icon: 'i-lucide-remove-formatting',
tooltip: { text: 'Clear formatting' },
tooltip: {
get text() {
return t('Clear formatting')
},
},
},
{
icon: 'i-lucide-image-plus',
label: 'Image',
tooltip: { text: 'Add an image' },
get label() {
return t('Image')
},
tooltip: {
get text() {
return t('Add an image')
},
},
onClick: () => emit('request-image'),
},
],
+15 -4
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import { articleImageId } from '@/lib/articleImage'
@@ -18,7 +20,14 @@ interface ImageNodeAttrs {
const imageAttrs = computed(() => {
const { src, alt, title, width, height } = props.node.attrs as ImageNodeAttrs
return { src, alt, title, width, height, 'data-image-id': props.node.attrs['data-image-id'] }
return {
src,
alt,
title,
width,
height,
'data-image-id': props.node.attrs['data-image-id'],
}
})
const failed = ref(false)
@@ -56,8 +65,10 @@ function onDeleteClick() {
class="flex min-h-40 flex-col items-center justify-center gap-2 border border-dashed border-accented bg-elevated p-6 text-center font-sans text-sm text-muted"
>
<UIcon name="i-lucide-image-off" class="size-6" />
<span class="font-medium text-highlighted">This image is no longer available</span>
<span>It was deleted from the library. Remove it from the article.</span>
<span class="font-medium text-highlighted">{{
t('This image is no longer available')
}}</span>
<span>{{ t('It was deleted from the library. Remove it from the article.') }}</span>
</div>
<img
v-else
@@ -77,7 +88,7 @@ function onDeleteClick() {
@mousedown.prevent
@click="onDeleteClick"
>
Remove image
{{ t('Remove image') }}
</UButton>
</NodeViewWrapper>
</template>
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
withDefaults(
defineProps<{
online: boolean
@@ -21,10 +23,10 @@ withDefaults(
:class="size === 'lg' ? undefined : 'px-2'"
:ui="{ leadingIcon: 'animate-spin' }"
>
{{ pending === 'publish' ? 'Publishing…' : 'Unpublishing…' }}
{{ pending === 'publish' ? t('Publishing…') : t('Unpublishing…') }}
</UBadge>
<UBadge v-else-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
Online
{{ t('Online') }}
</UBadge>
<UBadge
v-else
@@ -34,6 +36,6 @@ withDefaults(
:size="size"
:class="size === 'lg' ? undefined : 'px-2'"
>
Draft
{{ t('Draft') }}
</UBadge>
</template>
+54 -15
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
@@ -27,10 +29,37 @@ const columnWidths =
const sorting = ref([{ id: 'updated_at', desc: true }])
const columns = [
{ accessorKey: 'title', header: 'Title', enableSorting: true },
{ accessorKey: 'is_online', header: 'Status', size: 110, enableSorting: true },
{ accessorKey: 'created_at', header: 'Created', size: 130, enableSorting: true },
{ accessorKey: 'updated_at', header: 'Updated', size: 140, enableSorting: true },
{
accessorKey: 'title',
get header() {
return t('Title')
},
enableSorting: true,
},
{
accessorKey: 'is_online',
get header() {
return t('Status')
},
size: 110,
enableSorting: true,
},
{
accessorKey: 'created_at',
get header() {
return t('Created')
},
size: 130,
enableSorting: true,
},
{
accessorKey: 'updated_at',
get header() {
return t('Updated')
},
size: 140,
enableSorting: true,
},
{ id: 'actions', header: '', size: 44 },
]
@@ -64,12 +93,16 @@ function actionsFor(article: Article) {
return [
[
{
label: 'Edit',
get label() {
return t('Edit')
},
icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)),
},
{
label: article.is_online ? 'Unpublish' : 'Publish',
get label() {
return article.is_online ? t('Unpublish') : t('Publish')
},
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article),
onSelect: () => {
@@ -81,14 +114,18 @@ function actionsFor(article: Article) {
},
},
{
label: 'Copy API URL',
get label() {
return t('Copy API URL')
},
icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id),
},
],
[
{
label: 'Delete…',
get label() {
return t('Delete…')
},
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete', article),
@@ -104,7 +141,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<template>
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
<span role="status" class="sr-only">Loading articles</span>
<span role="status" class="sr-only">{{ t('Loading articles') }}</span>
<div
v-for="n in 5"
:key="n"
@@ -131,7 +168,9 @@ function onSelect(_event: Event, row: { original: Article }) {
>
<template #caption>
<caption class="sr-only">
Articles, sorted by last updated, newest first
{{
t('Articles, sorted by last updated, newest first')
}}
</caption>
</template>
@@ -141,7 +180,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Title
{{ t('Title') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
@@ -152,7 +191,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Status
{{ t('Status') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
@@ -163,7 +202,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Created
{{ t('Created') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
@@ -174,7 +213,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Updated
{{ t('Updated') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
@@ -217,7 +256,7 @@ function onSelect(_event: Event, row: { original: Article }) {
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
:aria-label="`Actions for ${row.original.title}`"
:aria-label="t('Actions for {0}', [row.original.title])"
class="size-8 justify-center text-toned"
@click.stop
/>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue'
import AppLogo from '@/components/AppLogo.vue'
import { isInAppNavigation } from '@/composables/useAuthShellNavigation'
@@ -72,7 +74,7 @@ watch(
if (!title) {
return
}
document.title = `${title} · RenewCMS`
document.title = t('{0} · RenewCMS', [title])
},
{ immediate: true },
)
+22 -14
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaBadge from '@/components/MediaBadge.vue'
@@ -16,7 +18,7 @@ const props = defineProps<{
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const total = computed(() => props.items.length)
const imageLabel = computed(() => `${total.value} ${total.value === 1 ? 'image' : 'images'}`)
const imageLabel = computed(() => t(total.value === 1 ? '{0} image' : '{0} images', [total.value]))
const inUse = computed(() => props.items.filter((item) => item.used_in > 0))
const articleCount = computed(() => inUse.value.reduce((sum, item) => sum + item.used_in, 0))
const description = computed(() => {
@@ -25,28 +27,34 @@ const description = computed(() => {
const articles = pluralArticles(articleCount.value)
if (usedCount === 0) {
return total.value === 1
? `It isn't used in any article. ${FINAL_WARNING}`
: `None of them are used in any article. ${FINAL_WARNING}`
? t("It isn't used in any article. {0}", [t(FINAL_WARNING)])
: t('None of them are used in any article. {0}', [t(FINAL_WARNING)])
}
if (unusedCount === 0) {
const subject = total.value === 1 ? "It's" : `All ${total.value} are`
return `${subject} used in ${articles}, ${BROKEN_IMAGE} ${FINAL_WARNING}`
const subject = total.value === 1 ? t("It's") : t('All {0} are', [total.value])
return t('{0} used in {1}, {2} {3}', [subject, articles, t(BROKEN_IMAGE), t(FINAL_WARNING)])
}
const rest =
unusedCount === 1
? "The other one isn't used anywhere."
: `The other ${unusedCount} aren't used anywhere.`
return `${usedCount} of them are used in ${articles}, ${BROKEN_IMAGE} ${rest} ${FINAL_WARNING}`
? t("The other one isn't used anywhere.")
: t("The other {0} aren't used anywhere.", [unusedCount])
return t('{0} of them are used in {1}, {2} {3} {4}', [
usedCount,
articles,
t(BROKEN_IMAGE),
rest,
t(FINAL_WARNING),
])
})
</script>
<template>
<ConfirmDialog
:title="`Delete ${imageLabel}?`"
:title="t('Delete {0}?', [imageLabel])"
:description="description"
:confirm-label="`Delete ${imageLabel}`"
cancel-label="Cancel"
pending-label="Deleting…"
:confirm-label="t('Delete {0}', [imageLabel])"
:cancel-label="t('Cancel')"
:pending-label="t('Deleting…')"
tone="danger"
:action="action"
@close="emit('close', $event)"
@@ -66,9 +74,9 @@ const description = computed(() => {
<MediaBadge
v-if="item.used_in > 0"
kind="used"
:label="`Used in ${item.used_in}`"
:label="t('Used in {0}', [item.used_in])"
/>
<MediaBadge v-else kind="unused" label="Unused" />
<MediaBadge v-else kind="unused" :label="t('Unused')" />
</li>
</ul>
</template>
+11 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, locale, formatNumber } from '@/i18n'
import { computed, ref, watch } from 'vue'
const props = withDefaults(
@@ -19,6 +21,7 @@ const props = withDefaults(
)
function formatThousands(count: number) {
if (locale.value === 'fr') return formatNumber(count)
return count.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
}
@@ -38,11 +41,11 @@ const status = computed<'normal' | 'warn' | 'over' | 'under'>(() => {
const overText = computed(() => {
const suffix = props.plainText
? 'Shorten the text.'
: 'Shorten the text or remove an image. Formatting counts too.'
? t('Shorten the text.')
: t('Shorten the text or remove an image. Formatting counts too.')
return overBy.value === 1
? `1 character over. ${suffix}`
: `${formatThousands(overBy.value)} characters over. ${suffix}`
? t('1 character over. {0}', [suffix])
: t('{0} characters over. {1}', [formatThousands(overBy.value), suffix])
})
const warnText = 'Close to the limit. Images and formatting count too.'
@@ -52,21 +55,21 @@ const message = computed(() => {
return overText.value
}
if (status.value === 'warn') {
return warnText
return t(warnText)
}
return ''
})
const announcement = ref('')
watch(status, (next, previous) => {
if (next === previous) {
watch([status, locale], ([next], [previous]) => {
if (next === previous && !announcement.value) {
return
}
if (next === 'over') {
announcement.value = overText.value
} else if (next === 'warn') {
announcement.value = 'Almost at the length limit.'
announcement.value = t('Almost at the length limit.')
} else {
announcement.value = ''
}
+4 -2
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watchEffect } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { highlightToHtml, useShikiHighlighter } from '@/composables/useShikiHighlighter'
@@ -11,7 +13,7 @@ const props = defineProps<{
const { copied, copy } = useCopyToClipboard()
const buttonLabel = computed(() => (copied.value ? 'Copied' : 'Copy'))
const buttonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const buttonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
const highlightedHtml = ref<string | null>(null)
@@ -37,7 +39,7 @@ function onCopy() {
color="neutral"
variant="ghost"
size="xs"
:aria-label="`Copy ${label ?? lang} example`"
:aria-label="t('Copy {0} example', [label ?? lang])"
aria-live="polite"
class="gap-1.5 px-1.5 font-medium text-toned"
:ui="{ leadingIcon: 'size-3.5' }"
+10 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -28,7 +30,9 @@ const cancelButton = ref<{ $el: HTMLElement } | null>(null)
let settled = false
const previewDetail = computed(() =>
props.preview?.size ? `Stays in the library · ${props.preview.size}` : 'Stays in the library',
props.preview?.size
? t('Stays in the library · {0}', [props.preview.size])
: t('Stays in the library'),
)
const previewThumb = computed(() => ({
url: props.preview?.src ?? '',
@@ -111,11 +115,14 @@ function onCancel() {
v-if="error"
color="error"
icon="i-lucide-circle-x"
:description="error"
:description="translate(error)"
variant="soft"
:close="false"
role="alert"
:ui="{ root: 'gap-2 px-3 py-2.5', description: 'text-sm text-toned' }"
:ui="{
root: 'gap-2 px-3 py-2.5',
description: 'text-sm text-toned',
}"
/>
<div
v-if="preview"
+51 -10
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '@/stores/session'
@@ -50,19 +52,56 @@ const api = useApi()
const navItems = computed(() =>
[
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Media', to: '/media', icon: 'i-lucide-images' },
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings', slot: 'settings' },
{
get label() {
return t('Home')
},
to: '/home',
icon: 'i-lucide-home',
},
{
get label() {
return t('Articles')
},
to: '/article',
icon: 'i-lucide-file-text',
},
{
get label() {
return t('Media')
},
to: '/media',
icon: 'i-lucide-images',
},
{
get label() {
return t('Users')
},
to: '/users',
icon: 'i-lucide-users',
},
{
get label() {
return t('Integration')
},
to: '/integration',
icon: 'i-lucide-plug',
},
{
get label() {
return t('Settings')
},
to: '/settings',
icon: 'i-lucide-settings',
slot: 'settings',
},
].map((item) => ({
...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
})),
)
const routeTitle = computed(() => (route.meta.title as string) ?? '')
const routeTitle = computed(() => translate((route.meta.title as string) ?? ''))
const pageTitle = computed(() => props.mobileTitle ?? routeTitle.value)
const sidebarCollapsed = useSidebarCollapsed()
@@ -130,7 +169,9 @@ async function onLogout() {
<template>
<UDashboardGroup unit="rem">
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
<a href="#dashboard-content" class="sr-only focus:not-sr-only">{{
t('Skip to content')
}}</a>
<UDashboardSidebar
v-model:open="sidebarOpen"
@@ -155,7 +196,7 @@ async function onLogout() {
orientation="vertical"
:items="navItems"
:collapsed="sidebarCollapsed"
aria-label="Main"
:aria-label="t('Main')"
:ui="navUi"
>
<template #settings-trailing>
@@ -199,7 +240,7 @@ async function onLogout() {
<span
v-if="update.showCue"
role="img"
aria-label="Update available"
:aria-label="t('Update available')"
class="absolute top-2.25 right-2.25 size-2.5 border-2 border-(--ui-bg) bg-info"
/>
</span>
+14 -6
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
@@ -15,12 +17,18 @@ const emit = defineEmits<{ close: [confirmed: boolean] }>()
const inUse = computed(() => props.item.used_in > 0)
const title = computed(() =>
inUse.value ? "Delete an image that's in use?" : 'Delete this image?',
inUse.value ? t("Delete an image that's in use?") : t('Delete this image?'),
)
const description = computed(() =>
inUse.value
? `"${props.item.filename}" is used in ${pluralArticles(props.item.used_in)}. If you delete it, they will show a broken image. This can't be undone.`
: `"${props.item.filename}" will be deleted from your library. No article uses it. This can't be undone.`,
? t(
"\u0022{0}\u0022 is used in {1}. If you delete it, they will show a broken image. This can't be undone.",
[props.item.filename, pluralArticles(props.item.used_in)],
)
: t(
"\u0022{0}\u0022 will be deleted from your library. No article uses it. This can't be undone.",
[props.item.filename],
),
)
</script>
@@ -28,9 +36,9 @@ const description = computed(() =>
<ConfirmDialog
:title="title"
:description="description"
confirm-label="Delete image"
cancel-label="Cancel"
pending-label="Deleting…"
:confirm-label="t('Delete image')"
:cancel-label="t('Cancel')"
:pending-label="t('Deleting…')"
tone="danger"
:action="action"
@close="emit('close', $event)"
+13 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import type { DuplicateGroup } from '@/lib/mediaDuplicates'
@@ -18,8 +20,8 @@ const emit = defineEmits<{
}>()
function uploaded(copy: MediaItem) {
const by = copy.uploaded_by ? ` by ${copy.uploaded_by}` : ''
return `Uploaded ${formatMediaDate(copy.created_at)}${by}`
const by = copy.uploaded_by ? t(' by {0}', [copy.uploaded_by]) : ''
return t('Uploaded {0}{1}', [formatMediaDate(copy.created_at), by])
}
</script>
@@ -32,12 +34,14 @@ function uploaded(copy: MediaItem) {
class="border border-default bg-default"
>
<header class="flex flex-col gap-0.5 border-b border-default px-3.5 py-3">
<h3 class="font-semibold text-highlighted">Group {{ index + 1 }}</h3>
<h3 class="font-semibold text-highlighted">{{ t('Group') }} {{ index + 1 }}</h3>
<span class="text-muted">{{ group.header }}</span>
</header>
<div
class="grid md:grid-cols-(--columns) md:gap-4 md:p-4"
:style="{ '--columns': `repeat(${group.copies.length}, minmax(0, 1fr))` }"
:style="{
'--columns': `repeat(${group.copies.length}, minmax(0, 1fr))`,
}"
>
<div
v-for="copy in group.copies"
@@ -70,7 +74,7 @@ function uploaded(copy: MediaItem) {
<span
v-if="copy.id === group.suggested.id"
class="inline-flex h-5 items-center bg-primary px-1.5 text-xs font-medium text-inverted"
>Suggested</span
>{{ t('Suggested') }}</span
>
</div>
<span
@@ -88,7 +92,8 @@ function uploaded(copy: MediaItem) {
class="self-start font-medium underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
@click="emit('open', copy.id, $event.currentTarget as HTMLElement)"
>
Used in {{ pluralArticles(copy.used_in) }}
{{ t('Used in') }}
{{ pluralArticles(copy.used_in) }}
</button>
</div>
</div>
@@ -105,12 +110,12 @@ function uploaded(copy: MediaItem) {
"
@click="emit('merge', group, copy)"
>
Merge into this one
{{ t('Merge into this one') }}
</UButton>
</div>
</div>
<footer class="border-t border-default px-3.5 py-2.5 text-xs/5 text-muted">
{{ FOOTER }}
{{ t(FOOTER) }}
</footer>
</section>
</div>
+30 -12
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
@@ -18,30 +20,46 @@ const truncatedQuery = computed(() => {
const variants = computed(() => ({
'no-articles': {
icon: 'i-lucide-file-text',
title: 'No articles yet',
description: 'Write your first one. It stays a draft until you publish it.',
action: 'New article',
get title() {
return t('No articles yet')
},
get description() {
return t('Write your first one. It stays a draft until you publish it.')
},
action: t('New article'),
primary: true,
},
'no-results': {
icon: 'i-lucide-search',
title: `No articles match "${truncatedQuery.value}"`,
description: 'Check the spelling, or search for a shorter part of the title.',
action: 'Clear search',
get title() {
return t('No articles match \u0022{0}\u0022', [truncatedQuery.value])
},
get description() {
return t('Check the spelling, or search for a shorter part of the title.')
},
action: t('Clear search'),
primary: false,
},
'no-drafts': {
icon: 'i-lucide-pencil-line',
title: 'No drafts',
description: "Everything you've written is online.",
action: 'New article',
get title() {
return t('No drafts')
},
get description() {
return t("Everything you've written is online.")
},
action: t('New article'),
primary: false,
},
'no-online': {
icon: 'i-lucide-globe',
title: 'Nothing is online yet',
description: 'Publish a draft and it will appear on your website.',
action: 'Show drafts',
get title() {
return t('Nothing is online yet')
},
get description() {
return t('Publish a draft and it will appear on your website.')
},
action: t('Show drafts'),
primary: false,
},
}))
+8 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import CodeBlock from '@/components/CodeBlock.vue'
@@ -14,7 +16,12 @@ const activeTab = ref('fetch')
const tabItems = [
{ label: 'fetch', value: 'fetch' },
{ label: 'curl', value: 'curl' },
{ label: 'Response', value: 'response' },
{
get label() {
return t('Response')
},
value: 'response',
},
]
</script>
+30 -14
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -8,7 +10,7 @@ const props = withDefaults(
breadcrumb?: string
}>(),
{
breadcrumb: 'Home',
breadcrumb: undefined,
},
)
@@ -21,25 +23,39 @@ const router = useRouter()
const copy = computed(() => {
if (props.kind === 'not-found') {
return {
title: 'Page not found',
text: 'The link may be mistyped, or the page may have moved.',
primary: 'Go to Home',
secondary: 'All articles',
get title() {
return t('Page not found')
},
get text() {
return t('The link may be mistyped, or the page may have moved.')
},
primary: t('Go to Home'),
secondary: t('All articles'),
}
}
if (props.kind === 'article-not-found') {
return {
title: "This article doesn't exist.",
text: 'It may have been deleted, or the link is wrong.',
primary: 'All articles',
get title() {
return t("This article doesn't exist.")
},
get text() {
return t('It may have been deleted, or the link is wrong.')
},
primary: t('All articles'),
secondary: '',
}
}
return {
title: 'Something went wrong',
text: "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
primary: 'Try again',
secondary: 'Go to Home',
get title() {
return t('Something went wrong')
},
get text() {
return t(
"This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
)
},
primary: t('Try again'),
secondary: t('Go to Home'),
}
})
@@ -66,8 +82,8 @@ function onSecondary() {
<template>
<div role="alert" class="flex max-w-140 flex-col gap-3.5 p-8">
<nav v-if="kind !== 'article-not-found'" aria-label="Breadcrumb">
<span>{{ breadcrumb }}</span>
<nav v-if="kind !== 'article-not-found'" :aria-label="t('Breadcrumb')">
<span>{{ breadcrumb ?? t('Home') }}</span>
</nav>
<span
v-if="kind === 'article-not-found'"
+88 -39
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import MediaPicker from '@/components/MediaPicker.vue'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -72,8 +74,20 @@ const DROP_ZONE_UI = {
const FOOTER_BUTTON_CLASS = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
const TAB_ITEMS = [
{ label: 'Upload', value: 'upload', icon: 'i-lucide-upload' },
{ label: 'Library', value: 'library', icon: 'i-lucide-images' },
{
get label() {
return t('Upload')
},
value: 'upload',
icon: 'i-lucide-upload',
},
{
get label() {
return t('Library')
},
value: 'library',
icon: 'i-lucide-images',
},
]
const open = defineModel<boolean>('open', { default: false })
@@ -147,7 +161,7 @@ const duplicateText = computed(() =>
: '',
)
const cancelLabel = computed(() => (phase.value === 'uploading' ? 'Cancel upload' : 'Cancel'))
const cancelLabel = computed(() => (phase.value === 'uploading' ? t('Cancel upload') : t('Cancel')))
const showCancel = computed(
() =>
@@ -163,7 +177,9 @@ const showCropActions = computed(() => !onLibraryTab.value && phase.value === 'c
const wideModal = computed(() => showTabs.value || phase.value === 'crop')
const modalTitle = computed(() => (phase.value === 'crop' ? 'Crop and resize' : 'Add an image'))
const modalTitle = computed(() =>
phase.value === 'crop' ? t('Crop and resize') : t('Add an image'),
)
const modalDescription = computed(() => {
if (phase.value !== 'crop' || !chosenFile.value) {
@@ -203,9 +219,12 @@ const outputSizeText = computed(() =>
formatEstimatedSize(estimateOutputBytes(outputSize.value.width, outputSize.value.height)),
)
const monoLine = computed(
() =>
`Uploads as ${outputSize.value.width} × ${outputSize.value.height} px · about ${outputSizeText.value}`,
const monoLine = computed(() =>
t('Uploads as {0} × {1} px · about {2}', [
outputSize.value.width,
outputSize.value.height,
outputSizeText.value,
]),
)
const targetWidthPixels = computed(() => widthPresetPixels(widthLabel.value))
@@ -214,9 +233,11 @@ const showNarrowNote = computed(
() => targetWidthPixels.value !== null && targetWidthPixels.value > cropWidth.value,
)
const narrowNoteText = computed(
() =>
`The crop is ${cropWidth.value} px wide, so it's uploaded at that size. Enlarging it would make it blurry.`,
const narrowNoteText = computed(() =>
t(
"The crop is {0} px wide, so it's uploaded at that size. Enlarging it would make it blurry.",
[cropWidth.value],
),
)
const dimsLabel = computed(() => `${cropWidth.value} × ${cropHeight.value}`)
@@ -505,12 +526,18 @@ function stagePixelScale(): number {
return boundsWidth.value / box.width
}
function pointFromEvent(event: MouseEvent | TouchEvent): { x: number; y: number } {
function pointFromEvent(event: MouseEvent | TouchEvent): {
x: number
y: number
} {
if ('touches' in event && event.touches.length > 0) {
return { x: event.touches[0]!.clientX, y: event.touches[0]!.clientY }
}
if ('changedTouches' in event && event.changedTouches.length > 0) {
return { x: event.changedTouches[0]!.clientX, y: event.changedTouches[0]!.clientY }
return {
x: event.changedTouches[0]!.clientX,
y: event.changedTouches[0]!.clientY,
}
}
const mouseEvent = event as MouseEvent
return { x: mouseEvent.clientX, y: mouseEvent.clientY }
@@ -548,7 +575,11 @@ function onFrameDragMove(event: MouseEvent | TouchEvent) {
const dx = (point.x - moveOrigin.x) * scale
const dy = (point.y - moveOrigin.y) * scale
rect.value = clampRect(
{ ...moveOrigin.rect, x: moveOrigin.rect.x + dx, y: moveOrigin.rect.y + dy },
{
...moveOrigin.rect,
x: moveOrigin.rect.x + dx,
y: moveOrigin.rect.y + dy,
},
boundsWidth.value,
boundsHeight.value,
)
@@ -775,11 +806,13 @@ onUnmounted(() => {
<template #label>
{{
phase === 'dragging-over'
? 'Drop to upload'
: 'Drop an image here, or choose a file'
? t('Drop to upload')
: t('Drop an image here, or choose a file')
}}
</template>
<template #description>PNG, JPEG, WEBP, SVG · max 10 MB</template>
<template #description>{{
t('PNG, JPEG, WEBP, SVG · max 10 MB')
}}</template>
</UFileUpload>
</div>
@@ -795,13 +828,17 @@ onUnmounted(() => {
class="h-13.5 w-18 border border-default"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<p class="font-semibold text-highlighted">Already in your library</p>
<p class="text-xs text-toned">{{ duplicateText }}</p>
<p class="font-semibold text-highlighted">
{{ t('Already in your library') }}
</p>
<p class="text-xs text-toned">
{{ duplicateText }}
</p>
</div>
</div>
<div class="flex flex-col gap-2 md:flex-row">
<UButton class="h-11 justify-center md:h-8" @click="useExisting">
Use existing
{{ t('Use existing') }}
</UButton>
<UButton
color="neutral"
@@ -809,7 +846,7 @@ onUnmounted(() => {
class="h-11 justify-center border border-accented bg-default text-highlighted ring-0 md:h-8"
@click="uploadAnyway"
>
Upload anyway
{{ t('Upload anyway') }}
</UButton>
</div>
</div>
@@ -822,7 +859,9 @@ onUnmounted(() => {
<div
ref="imageBoxRef"
class="relative h-full max-w-full"
:style="{ aspectRatio: `${boundsWidth} / ${boundsHeight}` }"
:style="{
aspectRatio: `${boundsWidth} / ${boundsHeight}`,
}"
:aria-label="`${chosenFile?.name}, ${boundsWidth} × ${boundsHeight} px`"
role="img"
>
@@ -846,7 +885,11 @@ onUnmounted(() => {
data-testid="crop-frame"
tabindex="0"
role="group"
aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."
:aria-label="
t(
'Crop area. Arrow keys move it, Shift and arrow keys resize it.',
)
"
class="absolute cursor-move touch-none outline outline-white"
:style="{
left: `${(rect.x / boundsWidth) * 100}%`,
@@ -900,7 +943,9 @@ onUnmounted(() => {
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-1">
<span class="flex-1 font-medium text-highlighted">Aspect ratio</span>
<span class="flex-1 font-medium text-highlighted">{{
t('Aspect ratio')
}}</span>
<UButton
color="neutral"
variant="ghost"
@@ -908,7 +953,7 @@ onUnmounted(() => {
icon="i-lucide-rotate-ccw-square"
@click="rotateImage"
>
Rotate
{{ t('Rotate') }}
</UButton>
<UButton
color="neutral"
@@ -917,12 +962,12 @@ onUnmounted(() => {
icon="i-lucide-undo-2"
@click="resetCrop"
>
Reset
{{ t('Reset') }}
</UButton>
</div>
<div
role="radiogroup"
aria-label="Aspect ratio"
:aria-label="t('Aspect ratio')"
class="flex w-full border border-accented sm:w-max"
>
<button
@@ -940,16 +985,16 @@ onUnmounted(() => {
"
@click="selectRatio(ratio)"
>
{{ ratio }}
{{ ratio === 'Original' || ratio === 'Free' ? t(ratio) : ratio }}
</button>
</div>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">Width</span>
<span class="font-medium text-highlighted">{{ t('Width') }}</span>
<div
role="radiogroup"
aria-label="Width"
:aria-label="t('Width')"
class="flex w-full border border-accented sm:w-max"
>
<button
@@ -967,7 +1012,7 @@ onUnmounted(() => {
"
@click="selectWidth(widthOption)"
>
{{ widthOption }}
{{ widthOption === 'Original' ? t(widthOption) : widthOption }}
</button>
</div>
<span class="font-mono text-xs text-muted">{{ monoLine }}</span>
@@ -992,7 +1037,7 @@ onUnmounted(() => {
:class="succeeded ? 'size-6 text-success' : 'size-6 text-dimmed'"
/>
<p class="text-sm text-highlighted">
<template v-if="succeeded">Image added</template>
<template v-if="succeeded">{{ t('Image added') }}</template>
<template v-else>{{ chosenFile?.name }} {{ uploadPercent }}%</template>
</p>
<UProgress
@@ -1014,15 +1059,19 @@ onUnmounted(() => {
class="flex flex-col items-center gap-3 rounded-md border border-error px-6 py-8 text-center"
>
<UIcon name="i-lucide-file-x" class="size-6 text-error" />
<p class="text-sm font-semibold text-highlighted">{{ rejectedTitle }}</p>
<p class="text-sm font-semibold text-highlighted">
{{ rejectedTitle }}
</p>
<p class="text-sm text-toned">{{ rejectedText }}</p>
</div>
</div>
<div v-else-if="phase === 'failed'" class="flex flex-col gap-1">
<p class="text-sm font-semibold text-highlighted">Upload failed</p>
<p class="text-sm font-semibold text-highlighted">
{{ t('Upload failed') }}
</p>
<p class="text-sm text-toned">
The server didn't accept the file. Your article hasn't changed.
{{ t("The server didn't accept the file. Your article hasn't changed.") }}
</p>
</div>
</div>
@@ -1047,7 +1096,7 @@ onUnmounted(() => {
:disabled="!selected"
@click="insertSelected"
>
Insert image
{{ t('Insert image') }}
</UButton>
<UButton
v-if="showRejectedActions"
@@ -1055,7 +1104,7 @@ onUnmounted(() => {
:class="FOOTER_BUTTON_CLASS"
@click="chooseAnotherFile"
>
Choose another file
{{ t('Choose another file') }}
</UButton>
<UButton
v-if="showFailedActions"
@@ -1063,7 +1112,7 @@ onUnmounted(() => {
:class="FOOTER_BUTTON_CLASS"
@click="tryAgain"
>
Try again
{{ t('Try again') }}
</UButton>
<template v-if="showCropActions">
<UButton
@@ -1074,10 +1123,10 @@ onUnmounted(() => {
class="text-highlighted"
@click="chooseAnotherFile"
>
Choose another file
{{ t('Choose another file') }}
</UButton>
<UButton block :class="FOOTER_BUTTON_CLASS" @click="confirmCropUpload">
Upload image
{{ t('Upload image') }}
</UButton>
</template>
</div>
+11 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = withDefaults(
@@ -13,19 +15,25 @@ const props = withDefaults(
const badges = {
accepted: {
label: 'Accepted',
get label() {
return t('Accepted')
},
icon: 'i-lucide-circle-check',
color: 'success',
class: 'border border-(--rc-success-soft) bg-(--rc-success-soft) text-success',
},
pending: {
label: 'Pending',
get label() {
return t('Pending')
},
icon: 'i-lucide-mail',
color: 'warning',
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
},
expired: {
label: 'Expired',
get label() {
return t('Expired')
},
icon: 'i-lucide-clock-alert',
color: 'neutral',
class: 'border border-accented bg-transparent text-muted',
+20 -12
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useUsers, type CreatedInvite } from '@/composables/useUsers'
@@ -24,18 +26,18 @@ const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed'>('')
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
const emailError = computed(
() => serverError.value || (touched.value && !emailValid.value ? INVALID_EMAIL_MESSAGE : ''),
() => serverError.value || (touched.value && !emailValid.value ? t(INVALID_EMAIL_MESSAGE) : ''),
)
const alertMessage = computed(() => {
if (formAlert.value === 'invalid') {
return "Something in the form isn't valid. Check the highlighted fields."
return t("Something in the form isn't valid. Check the highlighted fields.")
}
if (formAlert.value === 'rateLimited') {
return 'Too many invites. Wait a moment, then try again.'
return t('Too many invites. Wait a moment, then try again.')
}
if (formAlert.value === 'failed') {
return "Couldn't send the invite. Try again in a moment."
return t("Couldn't send the invite. Try again in a moment.")
}
return ''
})
@@ -82,7 +84,9 @@ function onBlur() {
}
function messageForConflict(error: ApiError) {
return error.body === PENDING_INVITE_MESSAGE ? PENDING_INVITE_MESSAGE : HAS_ACCOUNT_MESSAGE
return error.body === PENDING_INVITE_MESSAGE
? t(PENDING_INVITE_MESSAGE)
: t(HAS_ACCOUNT_MESSAGE)
}
function showFailure(error: unknown) {
@@ -143,8 +147,12 @@ function onCancel() {
<template>
<UModal
:open="open"
title="Invite a user"
description="We'll email them a link to choose a username and password. The link expires after 3 days."
:title="t('Invite a user')"
:description="
t(
'We\'ll email them a link to choose a username and password. The link expires after 3 days.',
)
"
:dismissible="!submitting"
:close="{ color: 'neutral', variant: 'ghost', class: 'text-muted' }"
@update:open="onOpenChange"
@@ -173,9 +181,9 @@ function onCancel() {
/>
<UForm :state="{ email }" class="flex flex-col gap-4" @submit="onSubmit">
<UFormField
label="Email"
:label="t('Email')"
name="email"
:error="emailError || undefined"
:error="translate(emailError) || undefined"
:ui="{ error: 'mt-1.5 text-xs/5' }"
>
<template v-if="emailError" #error="{ error }">
@@ -184,7 +192,7 @@ function onCancel() {
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<UInput
@@ -215,7 +223,7 @@ function onCancel() {
:disabled="submitting"
@click="onCancel"
>
Cancel
{{ t('Cancel') }}
</UButton>
<UButton
block
@@ -225,7 +233,7 @@ function onCancel() {
:disabled="submitting"
@click="onSubmit"
>
{{ submitting ? 'Sending…' : 'Send invite' }}
{{ submitting ? t('Sending…') : t('Send invite') }}
</UButton>
</div>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import { locale, setLocale, t, type Locale } from '@/i18n'
defineProps<{ mobile?: boolean }>()
const languages = [
{ label: 'English', value: 'en' },
{ label: 'Français', value: 'fr' },
]
const selected = computed({
get: () => locale.value,
set: (value: string) => setLocale(value as Locale),
})
</script>
<template>
<div
:class="mobile ? 'flex items-center gap-3 px-4 py-2' : 'flex items-center gap-2 px-2 py-1'"
>
<UIcon name="i-lucide-languages" class="size-4 shrink-0 text-toned" />
<USelect
v-model="selected"
:items="languages"
:aria-label="t('Language')"
:content="{
side: mobile ? 'top' : 'right',
align: 'start',
collisionPadding: 16,
}"
:ui="{
content: 'w-40',
viewport: 'max-h-40 overflow-y-auto',
base: 'min-w-0 flex-1',
}"
:class="mobile ? 'h-11' : 'h-8'"
>
<template #default>
<span class="truncate">{{ t('Language') }}</span>
</template>
</USelect>
</div>
</template>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaBadge from '@/components/MediaBadge.vue'
defineProps<{
@@ -10,7 +12,7 @@ defineProps<{
<template>
<MediaBadge
:kind="published ? 'online' : 'draft'"
:label="published ? 'Online' : 'Draft'"
:label="published ? t('Online') : t('Draft')"
:size="size"
/>
</template>
+6 -4
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import { useElementBounding, useMediaQuery } from '@vueuse/core'
@@ -24,14 +26,14 @@ const centre = computed(() => `${content.left.value + content.width.value / 2}px
<template>
<div
role="region"
aria-label="Selection"
:aria-label="t('Selection')"
data-testid="media-bulk-bar"
class="fixed bottom-4 z-(--z-banner) flex items-center gap-2 bg-inverted py-2 pr-2 pl-4 text-inverted shadow-(--shadow-popover) max-md:inset-x-4 md:bottom-6 md:left-(--bulk-centre) md:w-100 md:-translate-x-1/2"
:class="{ invisible: obscured }"
:inert="obscured || undefined"
:style="isDesktop ? { '--bulk-centre': centre } : undefined"
>
<span class="flex-1 font-medium">{{ count }} selected</span>
<span class="flex-1 font-medium">{{ formatNumber(count) }} {{ t('selected') }}</span>
<UButton
color="error"
icon="i-lucide-trash-2"
@@ -40,7 +42,7 @@ const centre = computed(() => `${content.left.value + content.width.value / 2}px
class="h-11 px-3 md:h-8"
@click="emit('delete')"
>
Delete
{{ t('Delete') }}
</UButton>
<UButton
color="neutral"
@@ -49,7 +51,7 @@ const centre = computed(() => `${content.left.value + content.width.value / 2}px
class="h-11 border border-(--ui-text-inverted) bg-transparent px-3 text-inverted ring-0 hover:bg-transparent md:h-8"
@click="emit('clear')"
>
Clear
{{ t('Clear') }}
</UButton>
</div>
</template>
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaBadge from '@/components/MediaBadge.vue'
defineProps<{
@@ -7,7 +9,7 @@ defineProps<{
</script>
<template>
<MediaBadge v-if="item.duplicate" kind="duplicate" label="Duplicate" />
<MediaBadge v-if="item.used_in > 0" kind="used" :label="`Used in ${item.used_in}`" />
<MediaBadge v-else kind="unused" label="Unused" />
<MediaBadge v-if="item.duplicate" kind="duplicate" :label="t('Duplicate')" />
<MediaBadge v-if="item.used_in > 0" kind="used" :label="t('Used in {0}', [item.used_in])" />
<MediaBadge v-else kind="unused" :label="t('Unused')" />
</template>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaCardBadges from '@/components/MediaCardBadges.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -65,7 +67,7 @@ const emit = defineEmits<{
>
<MediaCheckbox
:state="selected.includes(item.id) ? 'checked' : 'unchecked'"
:label="`Select ${item.filename}`"
:label="t('Select {0}', [item.filename])"
:tabbable="false"
@toggle="emit('toggle', item.id)"
/>
+57 -16
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
@@ -47,11 +49,36 @@ const metadata = computed(() => {
return []
}
return [
{ label: 'Type', value: formatType(item.content_type) },
{ label: 'Size', value: formatSize(item.size) },
{ label: 'Dimensions', value: formatDimensions(item.width, item.height) },
{ label: 'Uploaded', value: formatMediaDate(item.created_at) },
{ label: 'Uploaded by', value: item.uploaded_by },
{
get label() {
return t('Type')
},
value: formatType(item.content_type),
},
{
get label() {
return t('Size')
},
value: formatSize(item.size),
},
{
get label() {
return t('Dimensions')
},
value: formatDimensions(item.width, item.height),
},
{
get label() {
return t('Uploaded')
},
value: formatMediaDate(item.created_at),
},
{
get label() {
return t('Uploaded by')
},
value: item.uploaded_by,
},
]
})
@@ -63,8 +90,18 @@ const duplicateAlert = computed(() => {
}
const names = props.copies.map((copy) => copy.filename)
return {
title: `Identical to ${props.copies.length} other ${props.copies.length === 1 ? 'image' : 'images'}`,
description: `${joinNames(names)} ${names.length === 1 ? 'has' : 'have'} the same content.`,
get title() {
return t('Identical to {0} other {1}', [
props.copies.length,
props.copies.length === 1 ? 'image' : 'images',
])
},
get description() {
return t('{0} {1} the same content.', [
joinNames(names),
names.length === 1 ? 'has' : 'have',
])
},
}
})
</script>
@@ -74,7 +111,7 @@ const duplicateAlert = computed(() => {
v-model:open="open"
side="right"
:title="item?.filename ?? ''"
description="Image details"
:description="t('Image details')"
:close="closeButton"
:ui="{
content: 'w-full max-w-none md:max-w-115',
@@ -102,7 +139,7 @@ const duplicateAlert = computed(() => {
:class="ACTION_BUTTON"
@click="emit('copy-url')"
>
Copy URL
{{ t('Copy URL') }}
</UButton>
<UButton
color="neutral"
@@ -112,7 +149,7 @@ const duplicateAlert = computed(() => {
:class="ACTION_BUTTON"
@click="emit('rename')"
>
Rename
{{ t('Rename') }}
</UButton>
<UButton
color="neutral"
@@ -123,7 +160,7 @@ const duplicateAlert = computed(() => {
class="text-error"
@click="emit('delete')"
>
Delete
{{ t('Delete') }}
</UButton>
</div>
@@ -149,7 +186,7 @@ const duplicateAlert = computed(() => {
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('review-duplicates')"
>
Review duplicates
{{ t('Review duplicates') }}
</UButton>
</template>
</UAlert>
@@ -169,8 +206,8 @@ const duplicateAlert = computed(() => {
<h3 class="text-base font-semibold text-highlighted">
{{
item.used_in > 0
? `Used in ${pluralArticles(item.used_in)}`
: 'Not used in any article'
? t('Used in {0}', [pluralArticles(item.used_in)])
: t('Not used in any article')
}}
</h3>
<template v-if="item.used_in > 0">
@@ -196,11 +233,15 @@ const duplicateAlert = computed(() => {
</li>
</ul>
<p class="text-xs text-muted">
Counted from the text of your articles each time you open this page.
{{
t(
'Counted from the text of your articles each time you open this page.',
)
}}
</p>
</template>
<p v-else class="text-muted">
This image is safe to delete. No article shows it.
{{ t('This image is safe to delete. No article shows it.') }}
</p>
</section>
</div>
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { DROP_TEXT, DROP_TITLE } from '@/lib/mediaUploadRows'
</script>
@@ -9,7 +10,7 @@ import { DROP_TEXT, DROP_TITLE } from '@/lib/mediaUploadRows'
class="pointer-events-none fixed inset-2 top-16 z-(--z-popover) flex flex-col items-center justify-center gap-2 border-2 border-dashed border-primary bg-secondary-200 text-center text-primary-900 md:inset-3 md:top-15 dark:bg-secondary-300"
>
<UIcon name="i-lucide-upload" class="size-7" />
<span class="text-lg font-semibold">{{ DROP_TITLE }}</span>
<span class="text-primary-600">{{ DROP_TEXT }}</span>
<span class="text-lg font-semibold">{{ t(DROP_TITLE) }}</span>
<span class="text-primary-600">{{ t(DROP_TEXT) }}</span>
</div>
</template>
+27 -11
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
@@ -19,26 +21,40 @@ const current = computed(() => {
if (props.variant === 'first-run') {
return {
icon: 'i-lucide-images',
title: 'No images yet',
description: 'Upload your first image. You can use it in any article.',
action: 'Upload an image',
get title() {
return t('No images yet')
},
get description() {
return t('Upload your first image. You can use it in any article.')
},
action: t('Upload an image'),
}
}
if (props.variant === 'no-duplicates') {
return {
icon: 'i-lucide-copy',
title: 'No duplicates',
description: 'Every image in your library is different.',
action: 'Back to all images',
get title() {
return t('No duplicates')
},
get description() {
return t('Every image in your library is different.')
},
action: t('Back to all images'),
}
}
return {
icon: 'i-lucide-search',
title: truncatedQuery.value
? `No images match "${truncatedQuery.value}"`
: 'No images match this filter',
description: `Nothing matches in ${props.filterLabel ?? 'All'}. Check the spelling, or clear the filters.`,
action: 'Clear filters',
get title() {
return truncatedQuery.value
? t('No images match \u0022{0}\u0022', [truncatedQuery.value])
: t('No images match this filter')
},
get description() {
return t('Nothing matches in {0}. Check the spelling, or clear the filters.', [
props.filterLabel ?? t('All'),
])
},
action: t('Clear filters'),
}
})
</script>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import MediaCardBadges from '@/components/MediaCardBadges.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue'
@@ -127,7 +129,7 @@ function onKeyup(event: KeyboardEvent) {
>
<MediaCheckbox
:state="selected.includes(item.id) ? 'checked' : 'unchecked'"
:label="`Select ${item.filename}`"
:label="t('Select {0}', [item.filename])"
:tabbable="false"
@toggle="emit('toggle', item.id)"
/>
+26 -19
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -55,7 +57,7 @@ const firstLoadSkeletons = computed(() =>
)
const searching = computed(() => props.query !== '')
const summary = computed(() =>
searching.value ? 'Newest first' : `Newest first · ${props.total} images`,
searching.value ? t('Newest first') : t('Newest first · {0} images', [props.total]),
)
const scrollHeight = computed(() => (props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'))
const showEmptyLibrary = computed(
@@ -66,7 +68,7 @@ const showNoResults = computed(
)
const showGrid = computed(() => props.status === 'ready' && props.items.length > 0)
const showLoadMore = computed(() => showGrid.value && props.hasMore && !props.selected)
const noResultsTitle = computed(() => `No images match "${props.query}"`)
const noResultsTitle = computed(() => t('No images match \u0022{0}\u0022', [props.query]))
function clearDebounce() {
if (debounceTimer !== null) {
@@ -133,10 +135,13 @@ onBeforeUnmount(clearDebounce)
v-model="searchText"
type="text"
icon="i-lucide-search"
placeholder="Search by filename"
aria-label="Search by filename"
:placeholder="t('Search by filename')"
:aria-label="t('Search by filename')"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm', leadingIcon: SEARCH_ICON_SIZE }"
:ui="{
base: 'h-11 text-lg md:h-8 md:text-sm',
leadingIcon: SEARCH_ICON_SIZE,
}"
>
<template v-if="searchText" #trailing>
<UButton
@@ -144,7 +149,7 @@ onBeforeUnmount(clearDebounce)
variant="link"
size="xs"
icon="i-lucide-x"
aria-label="Clear search"
:aria-label="t('Clear search')"
class="text-muted"
@click="clearSearch"
/>
@@ -159,8 +164,8 @@ onBeforeUnmount(clearDebounce)
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
title="Couldn't load your library"
description="Check your connection and try again."
:title="t('Couldn\'t load your library')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
@@ -172,7 +177,7 @@ onBeforeUnmount(clearDebounce)
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry')"
>
Try again
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
@@ -181,13 +186,13 @@ onBeforeUnmount(clearDebounce)
v-else-if="showEmptyLibrary"
variant="naked"
icon="i-lucide-images"
title="Your library is empty"
description="Images you upload appear here, ready to use in any article."
:title="t('Your library is empty')"
:description="t('Images you upload appear here, ready to use in any article.')"
:ui="EMPTY_STATE_UI"
>
<template #actions>
<UButton icon="i-lucide-upload" class="h-11 px-4 md:h-8" @click="emit('upload')">
Upload an image
{{ t('Upload an image') }}
</UButton>
</template>
</UEmpty>
@@ -197,7 +202,7 @@ onBeforeUnmount(clearDebounce)
variant="naked"
icon="i-lucide-search"
:title="noResultsTitle"
description="Check the spelling, or search for a shorter part of the filename."
:description="t('Check the spelling, or search for a shorter part of the filename.')"
:ui="EMPTY_STATE_UI"
>
<template #actions>
@@ -207,7 +212,7 @@ onBeforeUnmount(clearDebounce)
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
@click="clearSearch"
>
Clear search
{{ t('Clear search') }}
</UButton>
</template>
</UEmpty>
@@ -259,7 +264,7 @@ onBeforeUnmount(clearDebounce)
<span
v-if="isInArticle(item)"
class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-secondary-200 px-1.25 text-[0.6875rem] font-medium text-primary-900 dark:bg-secondary-300"
>In this article</span
>{{ t('In this article') }}</span
>
<span
v-if="isSelected(item)"
@@ -291,10 +296,12 @@ onBeforeUnmount(clearDebounce)
:icon="loadingMore ? 'i-lucide-loader-circle' : undefined"
:aria-disabled="loadingMore || undefined"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 aria-disabled:opacity-40 md:h-8"
:ui="{ leadingIcon: loadingMore ? 'size-4 animate-spin' : 'size-4' }"
:ui="{
leadingIcon: loadingMore ? 'size-4 animate-spin' : 'size-4',
}"
@click="emit('load-more')"
>
{{ loadingMore ? 'Loading…' : 'Load more' }}
{{ loadingMore ? t('Loading…') : t('Load more') }}
</UButton>
</div>
</template>
@@ -317,7 +324,7 @@ onBeforeUnmount(clearDebounce)
</div>
</div>
<UFormField
label="Alt text (optional)"
:label="t('Alt text (optional)')"
help="Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration."
:ui="{
label: 'font-medium text-highlighted',
@@ -327,7 +334,7 @@ onBeforeUnmount(clearDebounce)
<UInput
v-model="alt"
type="text"
placeholder="e.g. North wall of the kitchen before plastering"
:placeholder="t('e.g. North wall of the kitchen before plastering')"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
/>
+4 -2
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { SelectAllState } from '@/composables/useMediaSelection'
@@ -15,14 +17,14 @@ const modelValue = computed(() => {
}
return props.state === 'all'
})
const imageCount = computed(() => `${props.count} ${props.count === 1 ? 'image' : 'images'}`)
const imageCount = computed(() => t(props.count === 1 ? '{0} image' : '{0} images', [props.count]))
</script>
<template>
<div class="flex items-center gap-2.5 text-muted">
<UCheckbox
:model-value="modelValue"
label="Select all"
:label="t('Select all')"
color="primary"
class="-ml-2.75 md:ml-0"
:ui="{
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{
count: number
}>()
@@ -10,7 +12,7 @@ defineProps<{
data-testid="media-skeleton"
class="grid grid-cols-2 gap-2.5 md:grid-cols-5 md:gap-4"
>
<span role="status" class="sr-only">Loading images</span>
<span role="status" class="sr-only">{{ t('Loading images') }}</span>
<div
v-for="n in count"
:key="n"
+13 -11
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaBadge from '@/components/MediaBadge.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -44,19 +46,19 @@ function open(event: Event, id: number) {
<span class="flex justify-center">
<MediaCheckbox
size="sm"
label="Select all"
:label="t('Select all')"
:state="ALL_STATES[allState]"
@toggle="emit('toggle-all')"
/>
</span>
<span>Filename</span>
<span>Dimensions</span>
<span>Size</span>
<span>Type</span>
<span>Used in</span>
<span>Uploaded by</span>
<span>{{ t('Filename') }}</span>
<span>{{ t('Dimensions') }}</span>
<span>{{ t('Size') }}</span>
<span>{{ t('Type') }}</span>
<span>{{ t('Used in') }}</span>
<span>{{ t('Uploaded by') }}</span>
<span class="flex items-center gap-1 text-highlighted">
Uploaded
{{ t('Uploaded') }}
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
</span>
</div>
@@ -77,7 +79,7 @@ function open(event: Event, id: number) {
<span class="relative z-10 flex justify-center">
<MediaCheckbox
size="sm"
:label="`Select ${item.filename}`"
:label="t('Select {0}', [item.filename])"
:state="selected.includes(item.id) ? 'checked' : 'unchecked'"
@toggle="emit('toggle', item.id)"
/>
@@ -94,7 +96,7 @@ function open(event: Event, id: number) {
>
{{ item.filename }}
</button>
<MediaBadge v-if="item.duplicate" kind="duplicate" label="Duplicate" />
<MediaBadge v-if="item.duplicate" kind="duplicate" :label="t('Duplicate')" />
</span>
<span>{{ formatDimensions(item.width, item.height) }}</span>
<span>{{ formatSize(item.size) }}</span>
@@ -109,7 +111,7 @@ function open(event: Event, id: number) {
>
{{ pluralArticles(item.used_in) }}
</button>
<MediaBadge v-else kind="unused" label="Unused" />
<MediaBadge v-else kind="unused" :label="t('Unused')" />
</span>
<span class="truncate pr-2">{{ item.uploaded_by }}</span>
<span>{{ formatMediaDate(item.created_at) }}</span>
+60 -17
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { MediaCounts } from '@/composables/useMedia'
import type { MediaControls, MediaFilter, MediaSort, MediaView } from '@/lib/mediaQuery'
@@ -7,15 +9,35 @@ import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
const SEARCH_DEBOUNCE_MS = 120
const SORT_LABELS: Record<MediaSort, string> = {
newest: 'Newest',
oldest: 'Oldest',
name: 'Name',
largest: 'Largest',
get newest() {
return t('Newest')
},
get oldest() {
return t('Oldest')
},
get name() {
return t('Name')
},
get largest() {
return t('Largest')
},
}
const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [
{ value: 'grid', icon: 'i-lucide-layout-grid', label: 'Grid view' },
{ value: 'list', icon: 'i-lucide-list', label: 'List view' },
{
value: 'grid',
icon: 'i-lucide-layout-grid',
get label() {
return t('Grid view')
},
},
{
value: 'list',
icon: 'i-lucide-list',
get label() {
return t('List view')
},
},
]
const props = defineProps<{
@@ -68,9 +90,27 @@ function clearSearch() {
onBeforeUnmount(clearDebounce)
const tabItems = computed(() => [
{ label: 'All', value: 'all', count: props.counts?.all },
{ label: 'Unused', value: 'unused', count: props.counts?.unused },
{ label: 'Duplicates', value: 'duplicates', count: props.counts?.duplicates },
{
get label() {
return t('All')
},
value: 'all',
count: props.counts?.all,
},
{
get label() {
return t('Unused')
},
value: 'unused',
count: props.counts?.unused,
},
{
get label() {
return t('Duplicates')
},
value: 'duplicates',
count: props.counts?.duplicates,
},
])
const tab = computed({
@@ -115,12 +155,12 @@ const sortItems = computed(() =>
v-if="item.value === 'duplicates' && item.count"
data-testid="duplicates-count"
class="inline-flex h-4.5 min-w-4.5 items-center justify-center bg-(--rc-info-soft) px-1.25 font-mono text-[0.6875rem] font-medium text-info"
>{{ item.count }}</span
>{{ item.count === undefined ? undefined : formatNumber(item.count) }}</span
>
<span
v-else-if="item.value !== 'duplicates' && item.count !== undefined"
class="font-mono text-[0.6875rem] font-medium text-muted"
>{{ item.count }}</span
>{{ item.count === undefined ? undefined : formatNumber(item.count) }}</span
>
</span>
</template>
@@ -130,10 +170,13 @@ const sortItems = computed(() =>
v-model="searchText"
type="text"
icon="i-lucide-search"
placeholder="Search by filename"
aria-label="Search by filename"
:placeholder="t('Search by filename')"
:aria-label="t('Search by filename')"
class="order-1 w-full md:order-none md:mb-1.5 md:w-60"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm', leadingIcon: 'size-3.5' }"
:ui="{
base: 'h-11 text-lg md:h-8 md:text-sm',
leadingIcon: 'size-3.5',
}"
>
<template v-if="searchText" #trailing>
<UButton
@@ -141,7 +184,7 @@ const sortItems = computed(() =>
variant="link"
size="xs"
icon="i-lucide-x"
aria-label="Clear search"
:aria-label="t('Clear search')"
class="text-muted"
@click="clearSearch"
/>
@@ -154,7 +197,7 @@ const sortItems = computed(() =>
color="neutral"
variant="outline"
trailing-icon="i-lucide-chevron-down"
aria-label="Sort"
:aria-label="t('Sort')"
class="h-11 flex-1 justify-between border border-accented bg-default px-2.5 text-sm font-normal text-highlighted ring-0 md:h-8 md:w-32 md:flex-none"
:ui="{ trailingIcon: 'size-3.5 text-muted' }"
>
@@ -162,7 +205,7 @@ const sortItems = computed(() =>
</UButton>
</UDropdownMenu>
<div role="group" aria-label="View" class="flex shrink-0 border border-accented">
<div role="group" :aria-label="t('View')" class="flex shrink-0 border border-accented">
<UButton
v-for="option in VIEW_OPTIONS"
:key="option.value"
+17 -12
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaThumb from '@/components/MediaThumb.vue'
import type { UploadRow } from '@/composables/useMediaUpload'
import { formatUploadProgress } from '@/lib/imageUpload'
@@ -48,7 +50,7 @@ function percent(row: UploadRow) {
<template>
<section
data-testid="media-upload-panel"
aria-label="Uploads"
:aria-label="t('Uploads')"
class="flex flex-col border border-default bg-default"
>
<div class="flex items-center gap-3 px-3.5 py-3">
@@ -64,7 +66,7 @@ function percent(row: UploadRow) {
class="h-11 border border-accented bg-default px-3 text-xs text-highlighted ring-0 md:h-7"
@click="header.phase === 'working' ? emit('cancel-all') : emit('dismiss')"
>
{{ header.phase === 'working' ? 'Cancel all' : 'Dismiss' }}
{{ header.phase === 'working' ? t('Cancel all') : t('Dismiss') }}
</UButton>
</div>
@@ -83,7 +85,7 @@ function percent(row: UploadRow) {
</div>
<template v-if="row.status === 'waiting'">
<span class="text-muted">Waiting</span>
<span class="text-muted">{{ t('Waiting') }}</span>
</template>
<template v-else-if="row.status === 'uploading'">
@@ -91,19 +93,22 @@ function percent(row: UploadRow) {
<UProgress
:model-value="percent(row)"
size="sm"
:ui="{ base: 'rounded-none', indicator: 'rounded-none' }"
:ui="{
base: 'rounded-none',
indicator: 'rounded-none',
}"
/>
</template>
<template v-else-if="row.status === 'added'">
<span class="flex items-center gap-1.5 text-success">
<UIcon name="i-lucide-circle-check" class="size-3.5 shrink-0" />
{{ ADDED_TEXT }}
{{ t(ADDED_TEXT) }}
</span>
</template>
<template v-else-if="row.status === 'duplicate' && row.existing">
<span class="font-semibold text-highlighted">{{ DUPLICATE_TITLE }}</span>
<span class="font-semibold text-highlighted">{{ t(DUPLICATE_TITLE) }}</span>
<span class="text-xs/5 text-toned">{{
duplicateText(row.existing, formatMediaDate)
}}</span>
@@ -114,7 +119,7 @@ function percent(row: UploadRow) {
:class="BUTTON"
@click="emit('use-existing', row.key)"
>
Use existing
{{ t('Use existing') }}
</UButton>
<UButton
color="neutral"
@@ -122,7 +127,7 @@ function percent(row: UploadRow) {
:class="BUTTON"
@click="emit('upload-anyway', row.key)"
>
Upload anyway
{{ t('Upload anyway') }}
</UButton>
</div>
</template>
@@ -138,9 +143,9 @@ function percent(row: UploadRow) {
<template v-else-if="row.status === 'failed'">
<span class="flex items-center gap-1.5 font-semibold text-error">
<UIcon name="i-lucide-circle-x" class="size-3.5 shrink-0" />
{{ FAILED_TITLE }}
{{ t(FAILED_TITLE) }}
</span>
<span class="text-xs/5 text-toned">{{ FAILED_TEXT }}</span>
<span class="text-xs/5 text-toned">{{ t(FAILED_TEXT) }}</span>
<div class="flex">
<UButton
color="neutral"
@@ -150,7 +155,7 @@ function percent(row: UploadRow) {
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry', row.key)"
>
Try again
{{ t('Try again') }}
</UButton>
</div>
</template>
@@ -160,7 +165,7 @@ function percent(row: UploadRow) {
color="neutral"
variant="ghost"
icon="i-lucide-x"
:aria-label="`Cancel upload of ${row.name}`"
:aria-label="t('Cancel upload of {0}', [row.name])"
class="size-6 justify-center text-muted"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('cancel-row', row.key)"
+18 -9
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -15,21 +17,27 @@ const props = defineProps<{
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const otherCount = computed(() => props.copies.length - 1)
const others = computed(() => (otherCount.value === 1 ? 'copy' : `${otherCount.value} copies`))
const others = computed(() =>
otherCount.value === 1 ? t('copy') : t('{0} copies', [otherCount.value]),
)
const articles = computed(() => mergeArticleCount(props.copies, props.keep.id))
const description = computed(
() =>
`Articles that use the other ${others.value} will be switched to ${props.keep.filename}, and the other ${others.value} will be deleted. ${pluralArticles(articles.value)} will be updated. This can't be undone.`,
const description = computed(() =>
t(
"Articles that use the other {0} will be switched to {1}, and the other {2} will be deleted. {3} will be updated. This can't be undone.",
[others.value, props.keep.filename, others.value, pluralArticles(articles.value)],
),
)
</script>
<template>
<ConfirmDialog
:title="`Merge into ${keep.filename}?`"
:title="t('Merge into {0}?', [keep.filename])"
:description="description"
:confirm-label="`Merge ${otherCount === 1 ? '1 copy' : `${otherCount} copies`}`"
cancel-label="Cancel"
pending-label="Merging…"
:confirm-label="
t('Merge {0}', [otherCount === 1 ? t('1 copy') : t('{0} copies', [otherCount])])
"
:cancel-label="t('Cancel')"
:pending-label="t('Merging…')"
tone="danger"
:action="action"
@close="emit('close', $event)"
@@ -40,7 +48,8 @@ const description = computed(
<div class="flex min-w-0 flex-col">
<span class="truncate font-medium text-highlighted">{{ keep.filename }}</span>
<span class="text-xs/5 text-muted"
>Kept · used in {{ pluralArticles(keep.used_in) }} now</span
>{{ t('Kept · used in') }} {{ pluralArticles(keep.used_in) }}
{{ t('now') }}</span
>
</div>
</div>
+32 -18
View File
@@ -1,4 +1,6 @@
<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'
@@ -34,10 +36,10 @@ const titleLengthError = computed(() => {
return ''
}
if (titleLength.value < TITLE_MIN_LENGTH) {
return 'Use at least 3 characters.'
return t('Use at least 3 characters.')
}
if (titleLength.value > TITLE_MAX_LENGTH) {
return 'Use 3 to 200 characters.'
return t('Use 3 to 200 characters.')
}
return ''
})
@@ -46,7 +48,7 @@ const titleError = computed(() => titleServerError.value || titleLengthError.val
const bodyError = computed(() =>
bodyTouched.value && !bodyValid.value
? 'Add a first paragraph. It can be a single sentence.'
? t('Add a first paragraph. It can be a single sentence.')
: '',
)
@@ -56,10 +58,10 @@ const showHelp = computed(() => !submitting.value && formAlert.value === '')
const alertMessage = computed(() => {
if (formAlert.value === 'invalid') {
return "Something in the form isn't valid. Check the highlighted fields."
return t("Something in the form isn't valid. Check the highlighted fields.")
}
if (formAlert.value === 'failed') {
return "Couldn't create the article. Your text is still here. Try again in a moment."
return t("Couldn't create the article. Your text is still here. Try again in a moment.")
}
return ''
})
@@ -78,7 +80,9 @@ function resetForm() {
async function focusTitleField() {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>(`input[placeholder="${TITLE_PLACEHOLDER}"]`)?.focus()
document
.querySelector<HTMLInputElement>(`input[placeholder="${t(TITLE_PLACEHOLDER)}"]`)
?.focus()
titleFocused.value = true
}
@@ -147,8 +151,8 @@ function onCancel() {
<template>
<UModal
v-model:open="open"
title="New article"
description="It's saved as a draft. Titles can't be changed later, so check it now."
: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:
@@ -173,7 +177,11 @@ function onCancel() {
:description="alertMessage"
/>
<UForm :state="{ title, body }" class="flex flex-col gap-4">
<UFormField label="Title" name="title" :error="titleError || undefined">
<UFormField
:label="t('Title')"
name="title"
:error="translate(titleError) || undefined"
>
<template #hint>
<CharacterCounter
:value="titleLength"
@@ -188,13 +196,15 @@ function onCancel() {
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<template v-else-if="showHelp" #help>3 to 200 characters.</template>
<template v-else-if="showHelp" #help>{{
t('3 to 200 characters.')
}}</template>
<UInput
v-model="title"
:placeholder="TITLE_PLACEHOLDER"
:placeholder="t(TITLE_PLACEHOLDER)"
:disabled="submitting"
color="primary"
:highlight="titleFocused || !!titleError"
@@ -204,22 +214,26 @@ function onCancel() {
@blur="onTitleBlur"
/>
</UFormField>
<UFormField label="First paragraph" name="body" :error="bodyError || undefined">
<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"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<template v-else-if="showHelp" #help>
You can format it and add images in the editor.
{{ t('You can format it and add images in the editor.') }}
</template>
<UTextarea
v-model="body"
placeholder="Start writing. You'll continue in the editor."
:placeholder="t('Start writing. You\'ll continue in the editor.')"
:disabled="submitting"
:highlight="!!bodyError"
:rows="3"
@@ -244,7 +258,7 @@ function onCancel() {
:disabled="submitting"
@click="onCancel"
>
Cancel
{{ t('Cancel') }}
</UButton>
<UButton
block
@@ -254,7 +268,7 @@ function onCancel() {
:disabled="submitting"
@click="onSubmit"
>
{{ submitting ? 'Creating…' : 'Create draft' }}
{{ submitting ? t('Creating…') : t('Create draft') }}
</UButton>
</div>
</template>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { translate } from '@/i18n'
import { onMounted, onUnmounted, ref } from 'vue'
const DEFAULT_MESSAGE =
@@ -43,7 +45,7 @@ onUnmounted(() => {
v-if="isOffline"
color="neutral"
:icon="icon"
:title="message"
:title="translate(message)"
:ui="bleed ? BLEED_UI : undefined"
/>
</template>
+2 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
interface PasswordRule {
@@ -47,7 +48,7 @@ const items = computed(() =>
<li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]">
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
<span :class="item.textClass">{{ item.label }}</span>
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
<span class="sr-only">{{ t(item.met ? 'met' : 'not met') }}</span>
</li>
</ul>
</template>
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = withDefaults(
@@ -23,12 +25,12 @@ const label = computed(() => {
return props.pending
}
if (props.online) {
return 'Unpublish'
return t('Unpublish')
}
if (props.dirty) {
return 'Save and publish'
return t('Save and publish')
}
return 'Publish'
return t('Publish')
})
const icon = computed(() => {
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
defineProps<{
@@ -23,7 +25,7 @@ const open = ref(false)
class="min-h-11 self-start gap-1.5 p-0 font-medium text-highlighted md:min-h-8"
:ui="{ leadingIcon: 'size-3.5' }"
>
What's new in {{ version }}
{{ t("What's new in") }} {{ version }}
</UButton>
<template #content>
+19 -10
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import type { MediaItem } from '@/composables/useMedia'
@@ -18,10 +20,10 @@ const stem = ref(parts.stem)
const touched = ref(false)
const error = computed(() =>
touched.value && !isValidFilenameStem(stem.value) ? INVALID_FILENAME_MESSAGE : undefined,
touched.value && !isValidFilenameStem(stem.value) ? t(INVALID_FILENAME_MESSAGE) : undefined,
)
const help = computed(
() => `Letters, numbers, dashes and underscores. The ${parts.extension} ending stays.`,
const help = computed(() =>
t('Letters, numbers, dashes and underscores. The {0} ending stays.', [parts.extension]),
)
async function submit() {
@@ -35,11 +37,15 @@ async function submit() {
<template>
<ConfirmDialog
title="Rename image"
description="Only the filename changes. The URL stays the same, so articles that use this image keep working."
confirm-label="Rename"
cancel-label="Cancel"
pending-label="Renaming…"
:title="t('Rename image')"
:description="
t(
'Only the filename changes. The URL stays the same, so articles that use this image keep working.',
)
"
:confirm-label="t('Rename')"
:cancel-label="t('Cancel')"
:pending-label="t('Renaming…')"
tone="neutral"
:action="submit"
@close="emit('close', $event)"
@@ -47,11 +53,14 @@ async function submit() {
<template #body="{ pending, confirm }">
<form class="flex flex-col" novalidate @submit.prevent="confirm">
<UFormField
label="Filename"
:label="t('Filename')"
name="filename"
:error="error"
:help="help"
:ui="{ error: 'mt-1.5 text-xs/5', help: 'mt-1.5 text-xs/5' }"
:ui="{
error: 'mt-1.5 text-xs/5',
help: 'mt-1.5 text-xs/5',
}"
>
<template v-if="error" #error="{ error: message }">
<span class="flex items-center gap-1.5">
+28 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import AppLogo from '@/components/AppLogo.vue'
import type { RestartKind } from '@/composables/useUpdate'
@@ -21,19 +23,37 @@ const emit = defineEmits<{
const copy = computed(() => {
if (props.slow) {
return {
title: 'Still restarting',
text: 'This is taking longer than usual. Keep this page open. It reloads on its own when the site is back, or you can reload it now.',
get title() {
return t('Still restarting')
},
get text() {
return t(
'This is taking longer than usual. Keep this page open. It reloads on its own when the site is back, or you can reload it now.',
)
},
}
}
if (props.kind === 'update') {
return {
title: `Installing version ${props.version}`,
text: 'Your site is restarting with the new version. This takes about 10 seconds. This page reloads on its own when the site is back.',
get title() {
return t('Installing version {0}', [props.version])
},
get text() {
return t(
'Your site is restarting with the new version. This takes about 10 seconds. This page reloads on its own when the site is back.',
)
},
}
}
return {
title: 'Restarting your site',
text: 'This takes about 10 seconds. This page reloads on its own when the site is back.',
get title() {
return t('Restarting your site')
},
get text() {
return t(
'This takes about 10 seconds. This page reloads on its own when the site is back.',
)
},
}
})
@@ -62,7 +82,7 @@ const meta = computed(() =>
</div>
<div
role="progressbar"
aria-label="Restarting"
:aria-label="t('Restarting')"
class="h-1 w-full overflow-hidden bg-accented"
>
<div class="restart-bar h-1 w-7/20 bg-primary" />
@@ -75,7 +95,7 @@ const meta = computed(() =>
class="h-11 gap-1.5 border border-accented bg-default px-3.5 text-highlighted ring-0 md:h-8"
@click="emit('reload')"
>
Reload page
{{ t('Reload page') }}
</UButton>
</div>
</div>
+5 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{
@@ -17,8 +18,10 @@ const roleClass = computed(() =>
color="neutral"
variant="soft"
:class="roleClass"
:ui="{ base: 'h-5 rounded-none px-1.75 py-0 font-mono text-[0.6875rem] font-medium' }"
:ui="{
base: 'h-5 rounded-none px-1.75 py-0 font-mono text-[0.6875rem] font-medium',
}"
>
{{ role }}
{{ t(role) }}
</UBadge>
</template>
+13 -11
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps<{
@@ -36,24 +38,24 @@ function formatClock(date: Date) {
function formatElapsed(diffMs: number) {
const minutes = Math.floor(diffMs / MINUTE_MS)
if (minutes < 60) {
return `${minutes} min`
return t('{0} min', [minutes])
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours} h`
return t('{0} h', [hours])
}
return `${Math.floor(hours / 24)} d`
return t('{0} d', [Math.floor(hours / 24)])
}
const savedText = computed(() => {
if (!props.savedAt) {
return 'Saved'
return t('Saved')
}
const diffMs = now.value - props.savedAt.getTime()
if (diffMs < HOUR_MS) {
return `Saved at ${formatClock(props.savedAt)}`
return t('Saved at {0}', [formatClock(props.savedAt)])
}
return `Saved ${formatElapsed(diffMs)} ago`
return t('Saved {0} ago', [formatElapsed(diffMs)])
})
</script>
@@ -73,15 +75,15 @@ const savedText = computed(() => {
</template>
<template v-else-if="state === 'dirty'">
<UIcon name="i-lucide-circle-dot" class="size-3.5" />
<span>Unsaved changes</span>
<span>{{ t('Unsaved changes') }}</span>
</template>
<template v-else-if="state === 'saving'">
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
<span>Saving…</span>
<span>{{ t('Saving…') }}</span>
</template>
<template v-else-if="state === 'error'">
<UIcon name="i-lucide-circle-alert" class="size-3.5" />
<span>Save failed</span>
<span>{{ t('Save failed') }}</span>
<UButton
size="xs"
color="neutral"
@@ -90,12 +92,12 @@ const savedText = computed(() => {
class="h-6 gap-1 px-2 text-xs text-highlighted"
@click="$emit('retry')"
>
Retry
{{ t('Retry') }}
</UButton>
</template>
<template v-else-if="state === 'offline'">
<UIcon name="i-lucide-wifi-off" class="size-3.5" />
<span>Waiting for connection</span>
<span>{{ t('Waiting for connection') }}</span>
</template>
</span>
</template>
+9 -5
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
@@ -12,7 +14,7 @@ const emit = defineEmits<{
const { copied, copy } = useCopyToClipboard()
const buttonLabel = computed(() => (copied.value ? 'Copied' : 'Copy'))
const buttonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const buttonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
async function copyLine() {
@@ -27,7 +29,9 @@ async function copyLine() {
<template>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-highlighted">For whoever manages the server</span>
<span class="text-sm font-medium text-highlighted">{{
t('For whoever manages the server')
}}</span>
<div class="flex flex-col gap-2 md:flex-row md:items-start">
<code
class="min-w-0 flex-1 truncate bg-elevated px-3 py-2 font-mono text-code/5.5 text-highlighted"
@@ -46,8 +50,8 @@ async function copyLine() {
{{ buttonLabel }}
</UButton>
</div>
<span class="text-xs/5 text-muted"
>They run this once on the server. After that, Update and Restart appear here.</span
>
<span class="text-xs/5 text-muted">{{
t('They run this once on the server. After that, Update and Restart appear here.')
}}</span>
</div>
</template>
+19 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
export type UpdateBannerState = 'available' | 'ready'
@@ -11,14 +13,23 @@ const props = defineProps<{
const COPY = {
available: {
icon: 'i-lucide-circle-arrow-up',
title: (version: string) => `Version ${version} is available`,
description: 'Update it from Settings. It takes about 2 minutes and the site stays online.',
get title() {
return (version: string) => t('Version {0} is available', [version])
},
get description() {
return t('Update it from Settings. It takes about 2 minutes and the site stays online.')
},
},
ready: {
icon: 'i-lucide-circle-check',
title: (version: string) => `Version ${version} is ready to install`,
description:
'Restart from Settings to finish. The site is unavailable for about 10 seconds.',
get title() {
return (version: string) => t('Version {0} is ready to install', [version])
},
get description() {
return t(
'Restart from Settings to finish. The site is unavailable for about 10 seconds.',
)
},
},
} as const
@@ -33,7 +44,7 @@ const emit = defineEmits<{
<template>
<UAlert
role="region"
aria-label="Update available"
:aria-label="t('Update available')"
color="info"
variant="soft"
orientation="horizontal"
@@ -56,13 +67,13 @@ const emit = defineEmits<{
class="h-11 flex-1 justify-center border border-accented bg-default px-3 text-highlighted ring-0 md:h-8 md:flex-none"
@click="emit('go-to-settings')"
>
Go to Settings
{{ t('Go to Settings') }}
</UButton>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-x"
aria-label="Dismiss"
:aria-label="t('Dismiss')"
class="size-11 shrink-0 justify-center text-muted md:size-8"
:ui="{ leadingIcon: 'size-4' }"
@click="emit('dismiss')"
+10 -1
View File
@@ -1,3 +1,12 @@
<script setup lang="ts">
import { t } from '@/i18n'
</script>
<template>
<UBadge label="Update" color="info" variant="solid" class="ml-auto h-4.5 px-1.5 py-0 text-xs" />
<UBadge
:label="t('Update')"
color="info"
variant="solid"
class="ml-auto h-4.5 px-1.5 py-0 text-xs"
/>
</template>
+161 -51
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import ReleaseNotes from '@/components/ReleaseNotes.vue'
import ServerCommand from '@/components/ServerCommand.vue'
@@ -61,8 +63,12 @@ const READY_HINT =
const LIMITED_BLOCK: Block = {
tone: 'info',
icon: 'i-lucide-info',
title: 'Too many tries',
text: 'Wait a moment before trying again.',
get title() {
return t('Too many tries')
},
get text() {
return t('Wait a moment before trying again.')
},
}
const ZERO_PROGRESS = { percent: 0, downloaded_bytes: 0, total_bytes: 0 }
@@ -147,47 +153,90 @@ const applyBlocks = computed<Record<ApplyView, Block>>(() => ({
available: {
tone: 'info',
icon: 'i-lucide-circle-arrow-up',
title: `Version ${newVersion.value} is available`,
text: `You have ${props.status.current}. Update downloads the new version and checks it. Nothing changes until you restart.`,
get title() {
return t('Version {0} is available', [newVersion.value])
},
get text() {
return t(
'You have {0}. Update downloads the new version and checks it. Nothing changes until you restart.',
[props.status.current],
)
},
meta: lastChecked.value,
},
downloading: {
tone: 'info',
icon: 'i-lucide-download',
title: `Downloading version ${newVersion.value}…`,
text: 'You can leave this page. The download continues and your site stays online.',
get title() {
return t('Downloading version {0}…', [newVersion.value])
},
get text() {
return t('You can leave this page. The download continues and your site stays online.')
},
},
verifying: {
tone: 'info',
icon: 'i-lucide-shield-check',
title: 'Checking the download…',
text: 'Making sure the file is complete and safe to install. This takes a few seconds.',
get title() {
return t('Checking the download…')
},
get text() {
return t(
'Making sure the file is complete and safe to install. This takes a few seconds.',
)
},
},
ready: {
tone: 'success',
icon: 'i-lucide-circle-check',
title: `Version ${newVersion.value} is ready to install`,
text: 'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.',
meta: 'Nothing has changed yet.',
get title() {
return t('Version {0} is ready to install', [newVersion.value])
},
get text() {
return t(
'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.',
)
},
meta: t('Nothing has changed yet.'),
},
'download-failed': {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "The download didn't finish",
text: 'The connection dropped before the file was complete. Nothing was changed and your site is running as before.',
get title() {
return t("The download didn't finish")
},
get text() {
return t(
'The connection dropped before the file was complete. Nothing was changed and your site is running as before.',
)
},
},
'verification-failed': {
tone: 'warning',
icon: 'i-lucide-triangle-alert',
title: "We didn't use this update",
text: "The file didn't pass our safety check, so it was not installed. Nothing was changed. Try downloading it again. If this keeps happening, contact RenewCMS support.",
get title() {
return t("We didn't use this update")
},
get text() {
return t(
"The file didn't pass our safety check, so it was not installed. Nothing was changed. Try downloading it again. If this keeps happening, contact RenewCMS support.",
)
},
},
'rolled-back': {
tone: 'warning',
icon: 'i-lucide-triangle-alert',
title: "The update didn't install",
text: 'RenewCMS put the previous version back, so your site is running as before. Nothing was lost.',
meta: 'What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update.',
get title() {
return t("The update didn't install")
},
get text() {
return t(
'RenewCMS put the previous version back, so your site is running as before. Nothing was lost.',
)
},
meta: t(
'What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update.',
),
},
limited: LIMITED_BLOCK,
}))
@@ -196,13 +245,23 @@ const progressView = computed<ProgressView | null>(() => {
if (applyView.value === 'downloading') {
const progress = props.status.progress ?? ZERO_PROGRESS
return {
label: `Downloading version ${newVersion.value}`,
get label() {
return t('Downloading version {0}', [newVersion.value])
},
text: props.status.progress ? formatDownloadProgress(progress) : '0%',
value: progress.percent,
}
}
if (applyView.value === 'verifying') {
return { label: VERIFYING_ANNOUNCEMENT, text: 'Almost there', value: null }
return {
get label() {
return t(VERIFYING_ANNOUNCEMENT)
},
get text() {
return t('Almost there')
},
value: null,
}
}
return null
})
@@ -211,23 +270,25 @@ const announcement = computed(() => {
if (applyView.value === 'downloading') {
return downloadAnnouncement(newVersion.value, props.status.progress?.percent ?? 0)
}
return applyView.value === 'verifying' ? VERIFYING_ANNOUNCEMENT : ''
return applyView.value === 'verifying' ? t(VERIFYING_ANNOUNCEMENT) : ''
})
const hint = computed(() => {
if (applyView.value === 'available') {
return AVAILABLE_HINT
return t(AVAILABLE_HINT)
}
if (applyView.value === 'ready') {
return READY_HINT
return t(READY_HINT)
}
return showRestartHint.value ? RESTART_HINT : null
return showRestartHint.value ? t(RESTART_HINT) : null
})
function applyButton(view: ApplyView): ActionButton {
if (isStarting.value || view === 'downloading' || view === 'verifying') {
return {
label: 'Updating…',
get label() {
return t('Updating…')
},
icon: 'i-lucide-loader-circle',
primary: false,
disabled: true,
@@ -236,7 +297,9 @@ function applyButton(view: ApplyView): ActionButton {
}
if (view === 'ready') {
return {
label: 'Restart',
get label() {
return t('Restart')
},
icon: 'i-lucide-power',
primary: true,
disabled: false,
@@ -249,7 +312,9 @@ function applyButton(view: ApplyView): ActionButton {
}
if (view === 'available') {
return {
label: 'Update',
get label() {
return t('Update')
},
icon: 'i-lucide-download',
primary: true,
disabled: false,
@@ -257,7 +322,9 @@ function applyButton(view: ApplyView): ActionButton {
}
}
return {
label: 'Try again',
get label() {
return t('Try again')
},
icon: 'i-lucide-rotate-cw',
primary: true,
disabled: false,
@@ -270,8 +337,12 @@ const block = computed<Block>(() => {
return {
tone: 'neutral',
icon: 'i-lucide-loader-circle',
title: 'Checking for updates…',
text: 'This usually takes a few seconds.',
get title() {
return t('Checking for updates…')
},
get text() {
return t('This usually takes a few seconds.')
},
}
}
if (isOwner.value && props.phase === 'limited') {
@@ -281,8 +352,14 @@ const block = computed<Block>(() => {
return {
tone: 'warning',
icon: 'i-lucide-wifi-off',
title: "Couldn't check for updates",
text: "RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected.",
get title() {
return t("Couldn't check for updates")
},
get text() {
return t(
"RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected.",
)
},
meta: lastChecked.value,
}
}
@@ -293,8 +370,12 @@ const block = computed<Block>(() => {
return {
tone: 'success',
icon: 'i-lucide-circle-check',
title: `You're on version ${displayVersion(props.status.current)}`,
text: 'The update is installed and your site is running again.',
get title() {
return t("You're on version {0}", [displayVersion(props.status.current)])
},
get text() {
return t('The update is installed and your site is running again.')
},
meta: props.status.finished_at ? formatUpdatedAt(props.status.finished_at) : undefined,
}
}
@@ -302,25 +383,42 @@ const block = computed<Block>(() => {
return {
tone: 'info',
icon: 'i-lucide-info',
title: `Version ${newVersion.value} is available`,
text: "This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.",
get title() {
return t('Version {0} is available', [newVersion.value])
},
get text() {
return t(
"This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.",
)
},
}
}
if (props.status.available) {
return {
tone: 'info',
icon: 'i-lucide-circle-arrow-up',
title: `Version ${newVersion.value} is available`,
text: `You have ${props.status.current}. Only the owner can update RenewCMS. Ask the owner of this site.`,
get title() {
return t('Version {0} is available', [newVersion.value])
},
get text() {
return t(
'You have {0}. Only the owner can update RenewCMS. Ask the owner of this site.',
[props.status.current],
)
},
}
}
return {
tone: 'success',
icon: 'i-lucide-circle-check',
title: "You're up to date",
text: isOwner.value
? 'You have the latest version of RenewCMS.'
: 'Only the owner can update or restart the site.',
get title() {
return t("You're up to date")
},
get text() {
return isOwner.value
? t('You have the latest version of RenewCMS.')
: t('Only the owner can update or restart the site.')
},
meta: isOwner.value ? lastChecked.value : undefined,
}
})
@@ -342,7 +440,9 @@ const showServerGuidance = computed(
)
const plainRestartButton: ActionButton = {
label: 'Restart',
get label() {
return t('Restart')
},
icon: 'i-lucide-power',
primary: false,
disabled: false,
@@ -357,7 +457,9 @@ const actionButtons = computed<ActionButton[]>(() => {
if (props.phase === 'checking') {
return [
{
label: 'Checking…',
get label() {
return t('Checking…')
},
icon: 'i-lucide-loader-circle',
primary: false,
disabled: true,
@@ -367,7 +469,9 @@ const actionButtons = computed<ActionButton[]>(() => {
}
if (props.phase === 'failed') {
return withPlainRestart({
label: 'Try again',
get label() {
return t('Try again')
},
icon: 'i-lucide-rotate-cw',
primary: true,
disabled: false,
@@ -384,7 +488,9 @@ const actionButtons = computed<ActionButton[]>(() => {
return []
}
return withPlainRestart({
label: 'Check for updates',
get label() {
return t('Check for updates')
},
icon: 'i-lucide-refresh-cw',
primary: false,
disabled: false,
@@ -401,14 +507,18 @@ function withPlainRestart(button: ActionButton) {
function limitedButton(action: ButtonAction): ActionButton {
return props.retryActive
? {
label: `Try again in ${props.retryLabel}`,
get label() {
return t('Try again in {0}', [props.retryLabel])
},
icon: 'i-lucide-clock',
primary: false,
disabled: true,
action: null,
}
: {
label: 'Try again',
get label() {
return t('Try again')
},
icon: 'i-lucide-rotate-cw',
primary: false,
disabled: false,
@@ -458,7 +568,7 @@ const notes = computed(() => {
tabindex="-1"
class="self-start text-lg font-semibold text-highlighted focus:outline-2 focus:outline-offset-4 focus:outline-primary"
>
Updates
{{ t('Updates') }}
</h2>
<UpdateStatusBlock
@@ -516,7 +626,7 @@ const notes = computed(() => {
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 self-start text-sm font-medium text-highlighted underline underline-offset-3"
>
How to update
{{ t('How to update') }}
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
</a>
@@ -527,7 +637,7 @@ const notes = computed(() => {
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 self-start text-sm font-medium text-highlighted underline underline-offset-3"
>
Read the release notes
{{ t('Read the release notes') }}
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
</a>
</section>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import RoleBadge from '@/components/RoleBadge.vue'
import UserRowAction from '@/components/UserRowAction.vue'
@@ -62,7 +64,7 @@ function rowKey(item: UserListItem) {
<div class="flex items-center gap-2 text-xs text-muted">
<RoleBadge :role="userRole(item)" />
<InviteStatusBadge :status="item.status" size="sm" />
<span>Created {{ formatCreatedDate(item.created_at) }}</span>
<span>{{ t('Created') }} {{ formatCreatedDate(item.created_at) }}</span>
</div>
<div v-if="canManage && item.kind === 'invite'" class="flex pt-1">
<UserRowAction
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{
email: string
isYou: boolean
@@ -13,8 +15,8 @@ defineProps<{
:class="large ? 'text-[0.9375rem]/[21px]' : ''"
>{{ email }}</span
>
<span v-if="isYou" class="shrink-0 font-mono text-[0.6875rem] font-medium text-muted"
>you</span
>
<span v-if="isYou" class="shrink-0 font-mono text-[0.6875rem] font-medium text-muted">{{
t('you')
}}</span>
</span>
</template>
+39 -5
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useEventListener, useMediaQuery } from '@vueuse/core'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
import LanguagePicker from '@/components/LanguagePicker.vue'
const DESKTOP_QUERY = '(min-width: 64rem)'
const ITEM_CLASS = 'h-8 items-center py-0'
@@ -23,11 +26,35 @@ const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const open = ref(false)
const chip = ref<{ $el: HTMLElement } | null>(null)
const languagePicker = ref<{ $el: HTMLElement } | null>(null)
function focusLanguage(event: KeyboardEvent) {
const target = event.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[role="menu"]') !== languagePicker.value?.$el.closest('[role="menu"]'))
return
if (target.closest('[role="combobox"]')) return
const entries = Array.from(
target
.closest('[role="menu"]')
?.querySelectorAll<HTMLElement>('[role="menuitem"], [role="menuitemcheckbox"]') ?? [],
)
const movesToLanguage =
event.key === 'End' ||
(event.key === 'ArrowDown' && target === entries[entries.length - 1]) ||
(event.key === 'ArrowUp' && target === entries[0])
if (movesToLanguage) {
event.preventDefault()
languagePicker.value?.$el.querySelector<HTMLElement>('[role="combobox"]')?.focus()
}
}
useEventListener(document, 'keydown', focusLanguage, { capture: true })
const chipActive = computed(() => open.value || onProfile.value)
const triggerAriaLabel = computed(() =>
props.collapsed ? `${name.value} — account menu` : undefined,
props.collapsed ? t('{0} — account menu', [name.value]) : undefined,
)
const chipClass = computed(() => [
@@ -59,7 +86,9 @@ const items = computed(() => [
class: 'mb-1 border-b border-default px-2 pt-2 pb-2.5',
},
{
label: 'Profile',
get label() {
return t('Profile')
},
icon: 'i-lucide-circle-user',
class: onProfile.value ? `${ITEM_CLASS} bg-elevated` : ITEM_CLASS,
onSelect: goToProfile,
@@ -79,7 +108,9 @@ const items = computed(() => [
})),
[
{
label: 'Log out',
get label() {
return t('Log out')
},
icon: 'i-lucide-log-out',
class: ITEM_CLASS,
onSelect: () => emit('logout'),
@@ -101,7 +132,7 @@ const items = computed(() => [
color="neutral"
variant="ghost"
block
title="Account"
:title="t('Account')"
:aria-expanded="chipExpanded"
:class="chipClass"
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
@@ -128,5 +159,8 @@ const items = computed(() => [
<span class="truncate text-xs font-normal text-muted">{{ email }}</span>
</span>
</template>
<template #content-bottom>
<LanguagePicker ref="languagePicker" />
</template>
</UDropdownMenu>
</template>
+9 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { UserListItem } from '@/composables/useUsers'
@@ -12,13 +14,17 @@ const emit = defineEmits<{ cancel: []; renew: [] }>()
const actions = {
pending: {
label: 'Cancel invite',
get label() {
return t('Cancel invite')
},
icon: 'i-lucide-user-x',
textClass: 'text-error',
event: 'cancel',
},
expired: {
label: 'Renew invite',
get label() {
return t('Renew invite')
},
icon: 'i-lucide-refresh-cw',
textClass: 'text-highlighted',
event: 'renew',
@@ -29,7 +35,7 @@ const action = computed(() => {
return props.item.kind === 'invite' ? actions[props.item.status] : null
})
const label = computed(() => (props.busy ? 'Renewing…' : (action.value?.label ?? '')))
const label = computed(() => (props.busy ? t('Renewing…') : (action.value?.label ?? '')))
const icon = computed(() => (props.busy ? 'i-lucide-loader-circle' : action.value?.icon))
const sizeClass = computed(() =>
props.large ? 'h-11 gap-2 px-3.5 text-sm' : 'h-7 gap-1.5 px-2.25 text-xs',
+8 -6
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import RoleBadge from '@/components/RoleBadge.vue'
import UserRowAction from '@/components/UserRowAction.vue'
@@ -37,18 +39,18 @@ function rowKey(item: UserListItem) {
<template>
<div class="border border-default bg-default" :aria-busy="loading ? 'true' : undefined">
<div :class="ROW_GRID" class="h-9 bg-elevated font-medium text-muted">
<span>Email</span>
<span>Display name</span>
<span>Role</span>
<span>Invite status</span>
<span>{{ t('Email') }}</span>
<span>{{ t('Display name') }}</span>
<span>{{ t('Role') }}</span>
<span>{{ t('Invite status') }}</span>
<span class="flex items-center gap-1 text-highlighted">
Created
{{ t('Created') }}
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
</span>
<span></span>
</div>
<template v-if="loading">
<span role="status" class="sr-only">Loading users</span>
<span role="status" class="sr-only">{{ t('Loading users') }}</span>
<div
v-for="n in USER_SKELETON_ROWS"
:key="n"