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