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
@@ -0,0 +1,79 @@
|
||||
Feature: English and French throughout the SPA
|
||||
|
||||
Background:
|
||||
Given all existing SPA interface text supports English and French
|
||||
And English preserves existing copy and French provides equivalent meaning
|
||||
And user content, API data and transactional emails remain unchanged
|
||||
And visual checks are waived by the user for this slice
|
||||
And the language control uses existing design tokens
|
||||
|
||||
Scenario Outline: Resolve the language before rendering
|
||||
Given the browser-local stored language is <stored>
|
||||
When the SPA starts
|
||||
Then the interface language and document lang are <locale>
|
||||
And browser language does not override the choice
|
||||
|
||||
Examples:
|
||||
| stored | locale |
|
||||
| absent | en |
|
||||
| en | en |
|
||||
| fr | fr |
|
||||
| unsupported | en |
|
||||
| unreadable | en |
|
||||
|
||||
Scenario Outline: Open the language picker in the profile submenu
|
||||
Given the user opens the <menu>
|
||||
When they activate the localized Language control
|
||||
Then a compact scrollable picker offers English and Français
|
||||
And the selected language is visibly and accessibly identified
|
||||
And the picker uses Nuxt UI and existing design tokens
|
||||
|
||||
Examples:
|
||||
| menu |
|
||||
| desktop UserMenu |
|
||||
| mobile AccountMenuSheet |
|
||||
|
||||
Scenario: Switch language without losing work
|
||||
Given the user is editing an unsaved display name on /profile
|
||||
When they select Français
|
||||
Then all SPA interface text immediately uses French
|
||||
And the document lang is fr
|
||||
And the route, session, theme and unsaved work remain unchanged
|
||||
And no reload, navigation or API request is caused by switching
|
||||
When they select English
|
||||
Then the interface immediately restores English with the same preserved state
|
||||
|
||||
Scenario: Remember language on this browser
|
||||
Given the user selected Français
|
||||
When they navigate, reload, log out or return on this browser
|
||||
Then French remains selected including on public authentication screens
|
||||
And no language preference is sent to the server
|
||||
|
||||
Scenario: Storage failure does not prevent switching
|
||||
Given preference storage cannot be written
|
||||
When the user selects Français
|
||||
Then French is used for the current session without an error
|
||||
And a fresh load without a readable preference defaults to English
|
||||
|
||||
Scenario: Translate the entire interface
|
||||
Given French is selected
|
||||
When an existing SPA screen or shared component renders
|
||||
Then navigation, forms, validation, notifications, dialogs and accessibility labels use French
|
||||
And counts, human-readable dates and numbers use the selected locale
|
||||
And Nuxt UI controls use the selected locale
|
||||
And English and French catalogs have matching keys and interpolation parameters
|
||||
And an unavailable French message falls back to English
|
||||
And user content, account details, filenames, URLs, API data and emails remain unchanged
|
||||
|
||||
Scenario Outline: Operate the picker accessibly
|
||||
Given the picker is open in the <menu>
|
||||
When the user navigates its options with the keyboard and confirms a choice
|
||||
Then the language changes and the picker closes
|
||||
And focus returns to its language control
|
||||
When they reopen it and press Escape or click outside
|
||||
Then the picker closes without changing language
|
||||
|
||||
Examples:
|
||||
| menu |
|
||||
| desktop UserMenu |
|
||||
| mobile AccountMenuSheet |
|
||||
@@ -0,0 +1,21 @@
|
||||
# SPA internationalization QA
|
||||
|
||||
Accepted by the user on 2026-10-03 together with the slice specification.
|
||||
|
||||
## Automated
|
||||
|
||||
1. Verify absent, English, French, unsupported and unreadable stored preferences; English defaults independently of browser language and document lang follows selection.
|
||||
2. Verify desktop UserMenu and mobile AccountMenuSheet offer a localized language entry and a compact bounded scrollable picker with English and Français, selected-state semantics, selection, dismissal and focus return. Preserve profile, theme and logout behavior.
|
||||
3. Switch both ways while editing an unsaved profile display name. Verify translated UI and unchanged draft, route, session and theme without additional API requests.
|
||||
4. Verify browser-local persistence across reload initialization, navigation and logout/public authentication. Verify storage write failure allows switching for the current session.
|
||||
5. Preserve existing English tests and add French checks for login, validation, profile save failure, integration copy feedback, not-found, representative dialogs and notifications.
|
||||
6. Audit every existing view, shared component, composable and library producing interface text, including editor, media, users, invitations, settings and updates. Verify Nuxt UI controls use the active locale.
|
||||
7. Assert catalog key/interpolation parity, nonempty translations, pluralization, English fallback and locale-sensitive formatting. Use fixed input dates without freezing system time. Verify displayed alerts and dialogs react to language changes.
|
||||
8. Verify user content, account details, filenames, URLs, API fields, integration protocol examples, server release notes and transactional emails remain unchanged.
|
||||
9. Run task test:web, frontend type checks, applicable lint and final build checks. Inspect introduced comments and ensure application changes are SPA-only.
|
||||
|
||||
## Visual and manual
|
||||
|
||||
Visual checks are SKIPPED by explicit user waiver for this slice because the designer is unavailable. The user authorized a coherent language control using existing design tokens. Do not edit design sources or claim pixel parity.
|
||||
|
||||
Manual checklist: desktop/mobile and light/dark coherence, viewport containment, constrained scrolling, French text readability, touch and mouse selection, keyboard movement and confirmation, Escape/outside dismissal, focus return and visible focus. Confirm language retention after reload and logout. Do not freeze dates or investigate clock-sensitive states.
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { en, fr } from '@nuxt/ui/locale'
|
||||
import { locale } from '@/i18n'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
|
||||
|
||||
@@ -11,7 +13,7 @@ const toaster = computed(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp class="isolate" :toaster="toaster">
|
||||
<UApp class="isolate" :toaster="toaster" :locale="locale === 'fr' ? fr : en">
|
||||
<RouterView />
|
||||
</UApp>
|
||||
</template>
|
||||
@@ -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"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '@/i18n'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useColorMode, type ColorModePreference } from '@/composables/useColorMode'
|
||||
@@ -6,9 +7,24 @@ import { accountInitials, accountName } from '@/lib/accountName'
|
||||
const PROFILE_PATH = '/profile'
|
||||
|
||||
export const colorModeOptions: { label: string; value: ColorModePreference }[] = [
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Light', value: 'light' },
|
||||
{ label: 'Dark', value: 'dark' },
|
||||
{
|
||||
get label() {
|
||||
return t('System')
|
||||
},
|
||||
value: 'system',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Light')
|
||||
},
|
||||
value: 'light',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Dark')
|
||||
},
|
||||
value: 'dark',
|
||||
},
|
||||
]
|
||||
|
||||
export function useAccountMenu(account: () => { username: string; displayName?: string }) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { initializeLocale, locale, setLocale, t, formatNumber } from './index'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('Resolve the language before rendering', () => {
|
||||
it('keeps English default with a French browser', () => {
|
||||
vi.spyOn(navigator, 'language', 'get').mockReturnValue('fr-FR')
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
expect([locale.value, document.documentElement.lang]).toEqual(['en', 'en'])
|
||||
})
|
||||
it.each([null, 'en', 'fr', 'unsupported'])(
|
||||
'resolves stored %s independently of browser language',
|
||||
(stored) => {
|
||||
if (stored) localStorage.setItem('renewcms-language', stored)
|
||||
initializeLocale()
|
||||
expect([locale.value, document.documentElement.lang]).toEqual(
|
||||
stored === 'fr' ? ['fr', 'fr'] : ['en', 'en'],
|
||||
)
|
||||
},
|
||||
)
|
||||
it('defaults to English when preference storage is unreadable', () => {
|
||||
setLocale('fr')
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('denied')
|
||||
})
|
||||
initializeLocale()
|
||||
expect([locale.value, document.documentElement.lang]).toEqual(['en', 'en'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remember language on this browser', () => {
|
||||
it('restores the selected French language on initialization', () => {
|
||||
setLocale('fr')
|
||||
initializeLocale()
|
||||
expect(locale.value).toBe('fr')
|
||||
})
|
||||
it('switches when storage cannot be written', () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('denied')
|
||||
})
|
||||
setLocale('fr')
|
||||
expect([t('Log in'), document.documentElement.lang]).toEqual(['Se connecter', 'fr'])
|
||||
})
|
||||
it('formats numbers according to the selected locale', () => {
|
||||
setLocale('fr')
|
||||
expect(formatNumber(1234.5)).toBe(new Intl.NumberFormat('fr').format(1234.5))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
import { messages, type MessageKey } from './messages'
|
||||
|
||||
export type Locale = 'en' | 'fr'
|
||||
const STORAGE_KEY = 'renewcms-language'
|
||||
const currentLocale = ref<Locale>('en')
|
||||
export const locale = readonly(currentLocale)
|
||||
|
||||
export function initializeLocale() {
|
||||
let stored: string | null = null
|
||||
try {
|
||||
stored = localStorage.getItem(STORAGE_KEY)
|
||||
} catch {
|
||||
stored = null
|
||||
}
|
||||
currentLocale.value = stored === 'fr' ? 'fr' : 'en'
|
||||
document.documentElement.lang = currentLocale.value
|
||||
}
|
||||
|
||||
export function setLocale(value: Locale) {
|
||||
currentLocale.value = value
|
||||
document.documentElement.lang = value
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, value)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function t(key: MessageKey, values: readonly (string | number | undefined)[] = []) {
|
||||
const message = currentLocale.value === 'fr' ? (messages[key as MessageKey] ?? key) : key
|
||||
return message.replace(/\{(\d+)\}/g, (placeholder, index: string) => {
|
||||
const value = values[Number(index)]
|
||||
return value === undefined
|
||||
? placeholder
|
||||
: typeof value === 'number' && currentLocale.value === 'fr'
|
||||
? formatNumber(value)
|
||||
: String(value)
|
||||
})
|
||||
}
|
||||
|
||||
export function translate(key: string) {
|
||||
const source = Object.entries(messages).find(([, french]) => french === key)?.[0] ?? key
|
||||
return t(source as MessageKey)
|
||||
}
|
||||
|
||||
export function formatNumber(value: number, options?: Intl.NumberFormatOptions) {
|
||||
return new Intl.NumberFormat(currentLocale.value, options).format(value)
|
||||
}
|
||||
|
||||
initializeLocale()
|
||||
@@ -0,0 +1,827 @@
|
||||
export const messages = {
|
||||
'Not found': 'Introuvable',
|
||||
'Edit article': 'Modifier l’article',
|
||||
unselected: 'désélectionnée',
|
||||
'Log in': 'Se connecter',
|
||||
Language: 'Langue',
|
||||
Profile: 'Profil',
|
||||
'Log out': 'Se déconnecter',
|
||||
Account: 'Compte',
|
||||
'Your account menu': 'Menu de votre compte',
|
||||
Home: 'Accueil',
|
||||
Articles: 'Articles',
|
||||
Media: 'Médias',
|
||||
Users: 'Utilisateurs',
|
||||
Integration: 'Intégration',
|
||||
Settings: 'Paramètres',
|
||||
System: 'Système',
|
||||
Light: 'Clair',
|
||||
Dark: 'Sombre',
|
||||
Main: 'Principale',
|
||||
Breadcrumb: 'Fil d’Ariane',
|
||||
'Skip to content': 'Aller au contenu',
|
||||
Save: 'Enregistrer',
|
||||
'Saving…': 'Enregistrement…',
|
||||
Saved: 'Enregistré',
|
||||
'Save failed': 'Échec de l’enregistrement',
|
||||
'Unsaved changes': 'Modifications non enregistrées',
|
||||
'Display name': 'Nom d’affichage',
|
||||
Username: 'Nom d’utilisateur',
|
||||
Email: 'E-mail',
|
||||
Password: 'Mot de passe',
|
||||
'Your username': 'Votre nom d’utilisateur',
|
||||
'First time here?': 'Première visite ?',
|
||||
'Forgot password?': 'Mot de passe oublié ?',
|
||||
'Create the owner account': 'Créer le compte propriétaire',
|
||||
'Welcome back. Log in to manage your articles.':
|
||||
'Bienvenue. Connectez-vous pour gérer vos articles.',
|
||||
"That username and password don't match":
|
||||
'Le nom d’utilisateur et le mot de passe ne correspondent pas',
|
||||
'Check both and try again. Passwords are case-sensitive.':
|
||||
'Vérifiez les deux et réessayez. Les mots de passe sont sensibles à la casse.',
|
||||
'Too many attempts': 'Trop de tentatives',
|
||||
'For your security, wait a moment before trying again.':
|
||||
'Pour votre sécurité, patientez avant de réessayer.',
|
||||
"Couldn't reach RenewCMS": 'Impossible de joindre RenewCMS',
|
||||
'Check your connection and try again.': 'Vérifiez votre connexion et réessayez.',
|
||||
'Your session expired': 'Votre session a expiré',
|
||||
'Log in again to pick up where you left off. Unsaved changes are kept on this device.':
|
||||
'Reconnectez-vous pour reprendre votre travail. Les modifications non enregistrées sont conservées sur cet appareil.',
|
||||
'Password changed': 'Mot de passe modifié',
|
||||
'Log in with your new password.': 'Connectez-vous avec votre nouveau mot de passe.',
|
||||
'Logging in…': 'Connexion…',
|
||||
'Try again in {0}': 'Réessayez dans {0}',
|
||||
'Use at least 3 characters.': 'Utilisez au moins 3 caractères.',
|
||||
'Enter your password.': 'Saisissez votre mot de passe.',
|
||||
'Hide password': 'Masquer le mot de passe',
|
||||
'Show password': 'Afficher le mot de passe',
|
||||
'Your account on this RenewCMS workspace.': 'Votre compte dans cet espace RenewCMS.',
|
||||
'Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username.':
|
||||
'Remplace votre nom d’utilisateur dans l’administration et les e-mails envoyés par RenewCMS. Laissez ce champ vide pour utiliser votre nom d’utilisateur.',
|
||||
'(optional)': '(facultatif)',
|
||||
'e.g. Marta Alves': 'p. ex. Marta Alves',
|
||||
'Use 60 characters or fewer.': 'Utilisez 60 caractères maximum.',
|
||||
"Couldn't save your display name. Your text is still here. Try again in a moment.":
|
||||
'Impossible d’enregistrer votre nom d’affichage. Votre texte est conservé. Réessayez dans un instant.',
|
||||
'You appear as “{0}”.': 'Vous apparaissez sous le nom « {0} ».',
|
||||
'You appear as “{0}”, your username.':
|
||||
'Vous apparaissez sous le nom « {0} », votre nom d’utilisateur.',
|
||||
'Set when your account was created. You log in with your username, even after adding a display name.':
|
||||
'Défini à la création de votre compte. Vous vous connectez avec votre nom d’utilisateur, même après l’ajout d’un nom d’affichage.',
|
||||
"To change it, we'll email a reset link to {0}. The link works once, within 1 hour.":
|
||||
'Pour le modifier, nous enverrons un lien de réinitialisation à {0}. Ce lien est utilisable une fois, pendant 1 heure.',
|
||||
'Reset link sent': 'Lien de réinitialisation envoyé',
|
||||
'Too many requests': 'Trop de demandes',
|
||||
"You've asked for several links recently. Wait a few minutes, then try again.":
|
||||
'Vous avez demandé plusieurs liens récemment. Patientez quelques minutes, puis réessayez.',
|
||||
"Couldn't send the link": 'Impossible d’envoyer le lien',
|
||||
'Check {0}. The link works once, within 1 hour.':
|
||||
'Consultez {0}. Ce lien est utilisable une fois, pendant 1 heure.',
|
||||
'Sending…': 'Envoi…',
|
||||
'Send again': 'Renvoyer',
|
||||
'Send reset link': 'Envoyer le lien de réinitialisation',
|
||||
'Display name saved': 'Nom d’affichage enregistré',
|
||||
'Display name removed': 'Nom d’affichage supprimé',
|
||||
'You now appear as {0} across RenewCMS and in emails.':
|
||||
'Vous apparaissez désormais sous le nom {0} dans RenewCMS et les e-mails.',
|
||||
'You now appear as {0}, your username.':
|
||||
'Vous apparaissez désormais sous le nom {0}, votre nom d’utilisateur.',
|
||||
'Leave without saving?': 'Quitter sans enregistrer ?',
|
||||
"Your display name change hasn't been saved.":
|
||||
'Votre nouveau nom d’affichage n’a pas été enregistré.',
|
||||
'Keep editing': 'Continuer à modifier',
|
||||
'Discard changes': 'Abandonner les modifications',
|
||||
Cancel: 'Annuler',
|
||||
Delete: 'Supprimer',
|
||||
'Deleting…': 'Suppression…',
|
||||
Retry: 'Réessayer',
|
||||
'Try again': 'Réessayer',
|
||||
'Try again in a moment.': 'Réessayez dans un instant.',
|
||||
'Welcome to RenewCMS': 'Bienvenue dans RenewCMS',
|
||||
'Welcome to RenewCMS.': 'Bienvenue dans RenewCMS.',
|
||||
"This creates the single admin account for this site. Once it's verified, registration closes.":
|
||||
'Crée le compte administrateur unique de ce site. Une fois vérifié, les inscriptions sont fermées.',
|
||||
"We'll send a verification link here.": 'Nous enverrons un lien de vérification ici.',
|
||||
"Letters and numbers, 3 to 20 characters. You'll use it to log in.":
|
||||
'Lettres et chiffres, de 3 à 20 caractères. Vous l’utiliserez pour vous connecter.',
|
||||
'At least 8 characters': 'Au moins 8 caractères',
|
||||
'Passwords match': 'Les mots de passe correspondent',
|
||||
'Use 3 to 20 characters.': 'Utilisez de 3 à 20 caractères.',
|
||||
'Use 3 to 200 characters.': 'Utilisez de 3 à 200 caractères.',
|
||||
'Enter a full email address, like name@example.com.':
|
||||
'Saisissez une adresse e-mail complète, comme nom@exemple.fr.',
|
||||
"The passwords don't match.": 'Les mots de passe ne correspondent pas.',
|
||||
'That username is taken. Try another.':
|
||||
'Ce nom d’utilisateur est déjà pris. Essayez-en un autre.',
|
||||
"Something in the form isn't valid. Check the highlighted fields.":
|
||||
'Un élément du formulaire n’est pas valide. Vérifiez les champs signalés.',
|
||||
"Couldn't create the account": 'Impossible de créer le compte',
|
||||
'Already have an account?': 'Vous avez déjà un compte ?',
|
||||
'Already set up?': 'Déjà configuré ?',
|
||||
'e.g. marta': 'p. ex. marta',
|
||||
'e.g. lea': 'p. ex. lea',
|
||||
'Check your email': 'Consultez vos e-mails',
|
||||
'We sent a verification link to {0}. It expires in 2 hours.':
|
||||
'Nous avons envoyé un lien de vérification à {0}. Il expire dans 2 heures.',
|
||||
'We sent a verification link to the email you registered with. It expires in 2 hours.':
|
||||
'Nous avons envoyé un lien de vérification à votre adresse d’inscription. Il expire dans 2 heures.',
|
||||
"Opened it on another device? That works too; you'll log in there first. Can't find it? Check your spam folder. If the link has expired, cancel your registration and start again.":
|
||||
'Ouvert sur un autre appareil ? Cela fonctionne aussi ; connectez-vous d’abord sur cet appareil. Vous ne le trouvez pas ? Vérifiez vos courriers indésirables. Si le lien a expiré, annulez votre inscription et recommencez.',
|
||||
'Cancel registration and start over': 'Annuler l’inscription et recommencer',
|
||||
'Cancel your registration?': 'Annuler votre inscription ?',
|
||||
'Your account will be deleted. You can register again with the same or a different email.':
|
||||
'Votre compte sera supprimé. Vous pourrez vous réinscrire avec la même adresse e-mail ou une autre.',
|
||||
'Cancel registration': 'Annuler l’inscription',
|
||||
'Keep my account': 'Conserver mon compte',
|
||||
'Cancelling…': 'Annulation…',
|
||||
"Couldn't cancel registration": 'Impossible d’annuler l’inscription',
|
||||
"Couldn't cancel registration, so it's still pending. Try again in a moment.":
|
||||
'Impossible d’annuler l’inscription, qui reste en attente. Réessayez dans un instant.',
|
||||
"There's nothing to cancel.": 'Il n’y a rien à annuler.',
|
||||
'Registration cancelled': 'Inscription annulée',
|
||||
'You can create the owner account again.': 'Vous pouvez recréer le compte propriétaire.',
|
||||
'Verifying your account…': 'Vérification de votre compte…',
|
||||
'This only takes a moment.': 'Cela ne prend qu’un instant.',
|
||||
"You're verified": 'Votre compte est vérifié',
|
||||
'Your account is verified': 'Votre compte est vérifié',
|
||||
'Your account is already verified': 'Votre compte est déjà vérifié',
|
||||
'Taking you to your dashboard…': 'Redirection vers votre tableau de bord…',
|
||||
'Go to Home': 'Aller à l’accueil',
|
||||
"This verification link doesn't work": 'Ce lien de vérification ne fonctionne pas',
|
||||
'It may have expired (links last 2 hours) or already been used.':
|
||||
'Il a peut-être expiré (les liens durent 2 heures) ou a déjà été utilisé.',
|
||||
'Cancel your registration and start over to get a new link.':
|
||||
'Annulez votre inscription et recommencez pour obtenir un nouveau lien.',
|
||||
'Log in to finish verifying': 'Connectez-vous pour terminer la vérification',
|
||||
"You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after.":
|
||||
'Vous avez ouvert ce lien sans être connecté. Connectez-vous ici et nous vérifierons votre compte aussitôt.',
|
||||
'Log in and verify': 'Se connecter et vérifier',
|
||||
'The verification code is missing. Open the link from your email again, or copy the full address into your browser.':
|
||||
'Le code de vérification manque. Rouvrez le lien de votre e-mail ou copiez l’adresse complète dans votre navigateur.',
|
||||
'This site already has an owner': 'Ce site a déjà un propriétaire',
|
||||
'Log in instead.': 'Connectez-vous à la place.',
|
||||
'Go to log in': 'Aller à la connexion',
|
||||
'Back to log in': 'Retour à la connexion',
|
||||
'Reset your password': 'Réinitialiser votre mot de passe',
|
||||
"Enter the email for your account and we'll send you a link to choose a new password.":
|
||||
'Saisissez l’adresse e-mail de votre compte et nous vous enverrons un lien pour choisir un nouveau mot de passe.',
|
||||
'If an account exists for that email, a password reset link has been sent.':
|
||||
'Si un compte existe pour cette adresse, un lien de réinitialisation a été envoyé.',
|
||||
'The link expires in 1 hour.': 'Le lien expire dans 1 heure.',
|
||||
'Wait a moment before asking for another link.': 'Patientez avant de demander un autre lien.',
|
||||
'Wait a moment before trying again.': 'Patientez avant de réessayer.',
|
||||
'New password': 'Nouveau mot de passe',
|
||||
'Confirm new password': 'Confirmer le nouveau mot de passe',
|
||||
"Pick something you haven't used here before. You'll log in with it next.":
|
||||
'Choisissez un mot de passe que vous n’avez pas encore utilisé ici. Vous vous connecterez ensuite avec celui-ci.',
|
||||
'This reset link has expired': 'Ce lien de réinitialisation a expiré',
|
||||
'Reset links work once, for 1 hour. Request a new one and use it within the hour.':
|
||||
'Les liens de réinitialisation fonctionnent une fois, pendant 1 heure. Demandez-en un nouveau et utilisez-le dans l’heure.',
|
||||
'Request a new link': 'Demander un nouveau lien',
|
||||
'This link is incomplete': 'Ce lien est incomplet',
|
||||
'Part of the reset link is missing. Open it from your email again, or request a new one.':
|
||||
'Une partie du lien de réinitialisation manque. Rouvrez-le depuis votre e-mail ou demandez-en un nouveau.',
|
||||
'Open the link from your email again, or copy the full address into your browser.':
|
||||
'Rouvrez le lien depuis votre e-mail ou copiez l’adresse complète dans votre navigateur.',
|
||||
'Accept your invite': 'Accepter votre invitation',
|
||||
'Checking your invite…': 'Vérification de votre invitation…',
|
||||
'Choose a username and password to finish setting up your account.':
|
||||
'Choisissez un nom d’utilisateur et un mot de passe pour terminer la configuration de votre compte.',
|
||||
'This invite has expired': 'Cette invitation a expiré',
|
||||
'Invites work once, for 3 days. Ask the person who invited you to send a new one.':
|
||||
'Les invitations fonctionnent une fois, pendant 3 jours. Demandez à la personne qui vous a invité d’en envoyer une nouvelle.',
|
||||
"This invite link doesn't work": 'Ce lien d’invitation ne fonctionne pas',
|
||||
'It may be incomplete, or it may already have been used. If you already set up your account, log in instead.':
|
||||
'Il est peut-être incomplet ou déjà utilisé. Si vous avez déjà configuré votre compte, connectez-vous.',
|
||||
"Couldn't check your invite": 'Impossible de vérifier votre invitation',
|
||||
'Your account is ready': 'Votre compte est prêt',
|
||||
'Good morning': 'Bonjour',
|
||||
'Good afternoon': 'Bon après-midi',
|
||||
'Good evening': 'Bonsoir',
|
||||
"Here's where things stand on your site.": 'Voici la situation de votre site.',
|
||||
'Recently updated': 'Modifiés récemment',
|
||||
'Write your first article': 'Écrire votre premier article',
|
||||
'Write your first one. It stays a draft until you publish it.':
|
||||
'Écrivez votre premier article. Il reste un brouillon jusqu’à sa publication.',
|
||||
"Everything you've written, online and in draft.":
|
||||
'Tous vos articles, publiés et en brouillon.',
|
||||
"Everything you've written is online.": 'Tous vos articles sont publiés.',
|
||||
'Nothing is online yet': 'Aucun article publié',
|
||||
'Publish a draft and it will appear on your website.':
|
||||
'Publiez un brouillon pour l’afficher sur votre site.',
|
||||
'No drafts': 'Aucun brouillon',
|
||||
Online: 'Publié',
|
||||
Draft: 'Brouillon',
|
||||
'Drafts {0}': 'Brouillons {0}',
|
||||
'Online {0}': 'Publiés {0}',
|
||||
'All {0}': 'Tous {0}',
|
||||
All: 'Tous',
|
||||
'New article': 'Nouvel article',
|
||||
'All articles': 'Tous les articles',
|
||||
'No articles yet': 'Aucun article',
|
||||
'No articles match "{0}"': 'Aucun article ne correspond à « {0} »',
|
||||
'Check the spelling, or search for a shorter part of the title.':
|
||||
'Vérifiez l’orthographe ou recherchez une partie plus courte du titre.',
|
||||
'Search by title': 'Rechercher par titre',
|
||||
'Clear search': 'Effacer la recherche',
|
||||
Title: 'Titre',
|
||||
'First paragraph': 'Premier paragraphe',
|
||||
'3 to 200 characters.': 'De 3 à 200 caractères.',
|
||||
"The title can't be changed after creation.":
|
||||
'Le titre ne peut pas être modifié après la création.',
|
||||
"Start writing. You'll continue in the editor.":
|
||||
'Commencez à écrire. Vous poursuivrez dans l’éditeur.',
|
||||
'You can format it and add images in the editor.':
|
||||
'Vous pouvez le mettre en forme et ajouter des images dans l’éditeur.',
|
||||
'It starts as a draft, so nothing appears on your site until you publish it.':
|
||||
'Il commence comme brouillon : rien n’apparaît sur votre site avant sa publication.',
|
||||
"It's saved as a draft. Titles can't be changed later, so check it now.":
|
||||
'Il est enregistré comme brouillon. Les titres ne peuvent pas être modifiés ensuite : vérifiez-le maintenant.',
|
||||
'e.g. Choosing tiles for a small bathroom':
|
||||
'p. ex. Choisir du carrelage pour une petite salle de bains',
|
||||
'Add a first paragraph. It can be a single sentence.':
|
||||
'Ajoutez un premier paragraphe. Une seule phrase suffit.',
|
||||
"Couldn't create the article. Your text is still here. Try again in a moment.":
|
||||
'Impossible de créer l’article. Votre texte est conservé. Réessayez dans un instant.',
|
||||
'Draft created': 'Brouillon créé',
|
||||
'"{0}" is saved as a draft.': '« {0} » est enregistré comme brouillon.',
|
||||
Edit: 'Modifier',
|
||||
Preview: 'Aperçu',
|
||||
Editor: 'Éditeur',
|
||||
Publish: 'Publier',
|
||||
Unpublish: 'Dépublier',
|
||||
'Save and publish': 'Enregistrer et publier',
|
||||
'Publishing…': 'Publication…',
|
||||
'Unpublishing…': 'Dépublication…',
|
||||
'Article published': 'Article publié',
|
||||
'Article unpublished': 'Article dépublié',
|
||||
'"{0}" is now online.': '« {0} » est maintenant publié.',
|
||||
'"{0}" is now offline.': '« {0} » est maintenant dépublié.',
|
||||
"Couldn't publish": 'Impossible de publier',
|
||||
"Couldn't unpublish": 'Impossible de dépublier',
|
||||
'That article no longer exists': 'Cet article n’existe plus',
|
||||
'That article no longer exists.': 'Cet article n’existe plus.',
|
||||
'It was deleted somewhere else, so we removed it from the list.':
|
||||
'Il a été supprimé ailleurs ; nous l’avons donc retiré de la liste.',
|
||||
'Delete article': 'Supprimer l’article',
|
||||
'Delete this article?': 'Supprimer cet article ?',
|
||||
'"{0}" will be deleted.{1} This can\'t be undone.':
|
||||
'« {0} » sera supprimé.{1} Cette action est irréversible.',
|
||||
' Its images stay in your library.': ' Ses images restent dans votre bibliothèque.',
|
||||
'Article deleted': 'Article supprimé',
|
||||
'"{0}" is gone.': '« {0} » a été supprimé.',
|
||||
"Couldn't delete the article, so it's still there. Try again in a moment.":
|
||||
'Impossible de supprimer l’article, qui est toujours présent. Réessayez dans un instant.',
|
||||
'Copy API URL': 'Copier l’URL de l’API',
|
||||
'Copy the public API URL': 'Copier l’URL de l’API publique',
|
||||
Copy: 'Copier',
|
||||
Copied: 'Copié',
|
||||
'Link copied': 'Lien copié',
|
||||
'The API URL for "{0}" is on your clipboard.':
|
||||
'L’URL de l’API de « {0} » est dans votre presse-papiers.',
|
||||
"Couldn't copy the link": 'Impossible de copier le lien',
|
||||
'Articles, sorted by last updated, newest first':
|
||||
'Articles triés par dernière modification, du plus récent au plus ancien',
|
||||
'Loading articles': 'Chargement des articles',
|
||||
Loading: 'Chargement',
|
||||
"Couldn't load your articles": 'Impossible de charger vos articles',
|
||||
'Something went wrong on the server. Try again in a moment.':
|
||||
'Une erreur est survenue sur le serveur. Réessayez dans un instant.',
|
||||
'Live. Your website can read this article.': 'Publié. Votre site peut lire cet article.',
|
||||
"Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.":
|
||||
'Les titres ne peuvent pas être modifiés après la création d’un article. Pour changer de titre, créez un nouvel article et supprimez celui-ci.',
|
||||
'Back to Articles': 'Retour aux articles',
|
||||
'Your changes to "{0}" haven\'t been saved.':
|
||||
'Vos modifications de « {0} » n’ont pas été enregistrées.',
|
||||
'Shorten the text or remove an image. Formatting counts too.':
|
||||
'Raccourcissez le texte ou retirez une image. La mise en forme compte aussi.',
|
||||
'Shorten the text.': 'Raccourcissez le texte.',
|
||||
'Almost at the length limit.': 'La limite de longueur est proche.',
|
||||
'Close to the limit. Images and formatting count too.':
|
||||
'La limite est proche. Les images et la mise en forme comptent aussi.',
|
||||
'1 character over. {0}': '1 caractère en trop. {0}',
|
||||
'{0} characters over. {1}': '{0} caractères en trop. {1}',
|
||||
'Saved at {0}': 'Enregistré à {0}',
|
||||
'Saved {0} ago': 'Enregistré il y a {0}',
|
||||
Undo: 'Annuler',
|
||||
Redo: 'Rétablir',
|
||||
Paragraph: 'Paragraphe',
|
||||
'Paragraph style': 'Style de paragraphe',
|
||||
'Heading 1': 'Titre 1',
|
||||
'Heading 2': 'Titre 2',
|
||||
'Heading 3': 'Titre 3',
|
||||
'Heading 4': 'Titre 4',
|
||||
'Heading {0}': 'Titre {0}',
|
||||
Bold: 'Gras',
|
||||
Italic: 'Italique',
|
||||
Underline: 'Souligné',
|
||||
Strikethrough: 'Barré',
|
||||
'Align left': 'Aligner à gauche',
|
||||
'Align center': 'Centrer',
|
||||
'Align right': 'Aligner à droite',
|
||||
Justify: 'Justifier',
|
||||
'Bullet list': 'Liste à puces',
|
||||
'Numbered list': 'Liste numérotée',
|
||||
Indent: 'Augmenter le retrait',
|
||||
Outdent: 'Réduire le retrait',
|
||||
'Clear formatting': 'Effacer la mise en forme',
|
||||
Image: 'Image',
|
||||
'Add an image': 'Ajouter une image',
|
||||
'Remove image': 'Retirer l’image',
|
||||
'Remove this image from the article?': 'Retirer cette image de l’article ?',
|
||||
"It's removed from this article only. The file stays in your library and in any other article that uses it.":
|
||||
'Elle est retirée de cet article uniquement. Le fichier reste dans votre bibliothèque et dans les autres articles qui l’utilisent.',
|
||||
'Removing…': 'Retrait…',
|
||||
'Image removed': 'Image retirée',
|
||||
'Save to keep it in the article.': 'Enregistrez pour la conserver dans l’article.',
|
||||
'This image is no longer available': 'Cette image n’est plus disponible',
|
||||
'It was deleted from the library. Remove it from the article.':
|
||||
'Elle a été supprimée de la bibliothèque. Retirez-la de l’article.',
|
||||
'Upload an image': 'Importer une image',
|
||||
'Upload image': 'Importer une image',
|
||||
'Insert image': 'Insérer l’image',
|
||||
'Alt text (optional)': 'Texte alternatif (facultatif)',
|
||||
'e.g. North wall of the kitchen before plastering':
|
||||
'p. ex. Mur nord de la cuisine avant l’enduit',
|
||||
Library: 'Bibliothèque',
|
||||
'In this article': 'Dans cet article',
|
||||
Images: 'Images',
|
||||
"Every image you've uploaded, ready to use in any article.":
|
||||
'Toutes vos images importées, prêtes à être utilisées dans vos articles.',
|
||||
'Drop images to upload': 'Déposez des images pour les importer',
|
||||
'PNG, JPEG, WEBP, SVG · max 10 MB each': 'PNG, JPEG, WEBP, SVG · 10 Mo maximum par image',
|
||||
'PNG, JPEG, WEBP, SVG · max 10 MB': 'PNG, JPEG, WEBP, SVG · 10 Mo maximum',
|
||||
Upload: 'Importer',
|
||||
'Upload anyway': 'Importer quand même',
|
||||
'Use existing': 'Utiliser l’image existante',
|
||||
'Choose another file': 'Choisir un autre fichier',
|
||||
'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.':
|
||||
'Les images peuvent atteindre 10 Mo. Utilisez une image PNG, JPEG, WEBP ou SVG.',
|
||||
'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.':
|
||||
'Utilisez une image PNG, JPEG, WEBP ou SVG de 10 Mo maximum.',
|
||||
'Already in your library': 'Déjà dans votre bibliothèque',
|
||||
'{0} already in your library': '{0} déjà dans votre bibliothèque',
|
||||
'Same as "{0}", uploaded {1}{2}.': 'Identique à « {0} », importée {1}{2}.',
|
||||
'Uploaded {0}{1}': 'Importée {0}{1}',
|
||||
' by {0}': ' par {0}',
|
||||
'{0} matches an image uploaded on {1}{2}.': '{0} correspond à une image importée le {1}{2}.',
|
||||
'Upload failed': 'Échec de l’importation',
|
||||
"{0} couldn't be read": 'Impossible de lire {0}',
|
||||
"{0} isn't an image": '{0} n’est pas une image',
|
||||
'{0} is {1} MB': '{0} fait {1} Mo',
|
||||
'{0} is {1} MB. Images can be up to 10 MB.':
|
||||
'{0} fait {1} Mo. Les images peuvent atteindre 10 Mo.',
|
||||
'"{0}" can\'t be used': 'Impossible d’utiliser « {0} »',
|
||||
"The server couldn't read this image. Save it again as a PNG or JPEG, then try again.":
|
||||
'Le serveur n’a pas pu lire cette image. Enregistrez-la de nouveau au format PNG ou JPEG, puis réessayez.',
|
||||
"The server didn't accept the file. Nothing was added to your library.":
|
||||
'Le serveur a refusé le fichier. Rien n’a été ajouté à votre bibliothèque.',
|
||||
"The server didn't accept the file. Your article hasn't changed.":
|
||||
'Le serveur a refusé le fichier. Votre article n’a pas changé.',
|
||||
'Too many uploads': 'Trop d’importations',
|
||||
'For your security, wait a moment before uploading again.':
|
||||
'Pour votre sécurité, patientez avant d’importer à nouveau.',
|
||||
Waiting: 'En attente',
|
||||
'Waiting for connection': 'En attente de connexion',
|
||||
'Cancel upload': 'Annuler l’importation',
|
||||
'Uploading {0}': 'Importation de {0}',
|
||||
'Uploading {0} of {1} KB': 'Importation : {0} sur {1} Ko',
|
||||
'Uploading {0} of {1} MB': 'Importation : {0} sur {1} Mo',
|
||||
'Upload finished': 'Importation terminée',
|
||||
'Nothing was uploaded': 'Aucune image importée',
|
||||
'Added to your library': 'Ajoutée à votre bibliothèque',
|
||||
'Image added': 'Image ajoutée',
|
||||
Uploads: 'Importations',
|
||||
'{0} of {1} done': '{0} sur {1} terminées',
|
||||
'{0} uploaded': '{0} importées',
|
||||
'{0} need attention': '{0} nécessitent votre attention',
|
||||
'No images yet': 'Aucune image',
|
||||
'No images match "{0}"': 'Aucune image ne correspond à « {0} »',
|
||||
'No images match this filter': 'Aucune image ne correspond à ce filtre',
|
||||
'Check the spelling, or search for a shorter part of the filename.':
|
||||
'Vérifiez l’orthographe ou recherchez une partie plus courte du nom de fichier.',
|
||||
'Images you upload appear here, ready to use in any article.':
|
||||
'Vos images importées apparaissent ici, prêtes à être utilisées dans vos articles.',
|
||||
'Your library is empty': 'Votre bibliothèque est vide',
|
||||
'Upload your first image. You can use it in any article.':
|
||||
'Importez votre première image. Vous pourrez l’utiliser dans vos articles.',
|
||||
"Couldn't load your images": 'Impossible de charger vos images',
|
||||
"Couldn't load your library": 'Impossible de charger votre bibliothèque',
|
||||
'Loading images': 'Chargement des images',
|
||||
'Search by filename': 'Rechercher par nom de fichier',
|
||||
'Clear filters': 'Effacer les filtres',
|
||||
Sort: 'Trier',
|
||||
Newest: 'Plus récentes',
|
||||
Oldest: 'Plus anciennes',
|
||||
Largest: 'Plus grandes',
|
||||
'Newest first': 'Plus récentes d’abord',
|
||||
'Newest first · {0} images': 'Plus récentes d’abord · {0} images',
|
||||
'Grid view': 'Vue en grille',
|
||||
'List view': 'Vue en liste',
|
||||
Filename: 'Nom de fichier',
|
||||
Size: 'Taille',
|
||||
Dimensions: 'Dimensions',
|
||||
Uploaded: 'Importée',
|
||||
'Uploaded by': 'Importée par',
|
||||
'Used in': 'Utilisée dans',
|
||||
Unused: 'Inutilisées',
|
||||
unused: 'inutilisée',
|
||||
Type: 'Type',
|
||||
Vector: 'Vectoriel',
|
||||
Selection: 'Sélection',
|
||||
'Select all': 'Tout sélectionner',
|
||||
'Selection cleared': 'Sélection effacée',
|
||||
'{0} selected': '{0} sélectionnées',
|
||||
'{0} {1}, {2} selected': '{0} {1}, {2} sélectionnées',
|
||||
Details: 'Détails',
|
||||
'Image details': 'Détails de l’image',
|
||||
'Copy URL': 'Copier l’URL',
|
||||
'URL copied': 'URL copiée',
|
||||
'Paste it wherever you need the image address.':
|
||||
'Collez-la là où vous avez besoin de l’adresse de l’image.',
|
||||
Rename: 'Renommer',
|
||||
'Rename image': 'Renommer l’image',
|
||||
'Renaming…': 'Renommage…',
|
||||
'Only the filename changes. The URL stays the same, so articles that use this image keep working.':
|
||||
'Seul le nom du fichier change. L’URL reste identique : les articles utilisant cette image continuent de fonctionner.',
|
||||
'Letters, numbers, dashes and underscores. The {0} ending stays.':
|
||||
'Lettres, chiffres, tirets et tirets bas. L’extension {0} est conservée.',
|
||||
'Enter a filename.': 'Saisissez un nom de fichier.',
|
||||
'Image renamed': 'Image renommée',
|
||||
'It\'s now "{0}". The URL is the same.':
|
||||
'Elle s’appelle désormais « {0} ». L’URL est identique.',
|
||||
"Couldn't rename the image. Your text is still here. Try again in a moment.":
|
||||
'Impossible de renommer l’image. Votre texte est conservé. Réessayez dans un instant.',
|
||||
'Delete image': 'Supprimer l’image',
|
||||
'Delete…': 'Supprimer…',
|
||||
'Delete this image?': 'Supprimer cette image ?',
|
||||
"Delete an image that's in use?": 'Supprimer une image utilisée ?',
|
||||
'"{0}" will be deleted from your library. No article uses it. This can\'t be undone.':
|
||||
'« {0} » sera supprimée de votre bibliothèque. Aucun article ne l’utilise. Cette action est irréversible.',
|
||||
'"{0}" is used in {1}. If you delete it, they will show a broken image. This can\'t be undone.':
|
||||
'« {0} » est utilisée dans {1}. Si vous la supprimez, ces articles afficheront une image manquante. Cette action est irréversible.',
|
||||
'Image deleted': 'Image supprimée',
|
||||
'"{0}" is gone from your library.': '« {0} » a été supprimée de votre bibliothèque.',
|
||||
'That image no longer exists': 'Cette image n’existe plus',
|
||||
'It was deleted somewhere else, so we removed it from the library.':
|
||||
'Elle a été supprimée ailleurs ; nous l’avons donc retirée de la bibliothèque.',
|
||||
"Couldn't delete the image, so it's still there. Try again in a moment.":
|
||||
'Impossible de supprimer l’image, qui est toujours présente. Réessayez dans un instant.',
|
||||
"Couldn't delete the images, so they're still there. Try again in a moment.":
|
||||
'Impossible de supprimer les images, qui sont toujours présentes. Réessayez dans un instant.',
|
||||
"Couldn't delete {0}": 'Impossible de supprimer {0}',
|
||||
"They're gone from your library.": 'Elles ont été supprimées de votre bibliothèque.',
|
||||
"It's still in your library.": 'Elle est toujours dans votre bibliothèque.',
|
||||
'This image is safe to delete. No article shows it.':
|
||||
'Cette image peut être supprimée. Aucun article ne l’affiche.',
|
||||
"This can't be undone.": 'Cette action est irréversible.',
|
||||
'None of them are used in any article. {0}': 'Aucune n’est utilisée dans un article. {0}',
|
||||
"It isn't used in any article. {0}": 'Elle n’est utilisée dans aucun article. {0}',
|
||||
'which will show a broken image.': 'qui afficheront une image manquante.',
|
||||
'{0} of them are used in {1}, {2} {3} {4}': '{0} sont utilisées dans {1}, {2} {3} {4}',
|
||||
'{0} deleted': '{0} supprimées',
|
||||
'{0} deleted. {1} {2} still in your library. Try again.':
|
||||
'{0}. {1} {2} toujours dans votre bibliothèque. Réessayez.',
|
||||
'1 image was': '1 image a été supprimée',
|
||||
is: 'est',
|
||||
are: 'sont',
|
||||
'{0} images were': '{0} images ont été supprimées',
|
||||
Duplicates: 'Doublons',
|
||||
Duplicate: 'Doublon',
|
||||
'No duplicates': 'Aucun doublon',
|
||||
'Every image in your library is different.':
|
||||
'Toutes les images de votre bibliothèque sont différentes.',
|
||||
'Review duplicates': 'Examiner les doublons',
|
||||
'Back to all images': 'Retour à toutes les images',
|
||||
Group: 'Groupe',
|
||||
'1 copy': '1 copie',
|
||||
'{0} copies': '{0} copies',
|
||||
'Identical to {0} other {1}': 'Identique à {0} autres {1}',
|
||||
'{0} identical images · {1} each · {2}': '{0} images identiques · {1} chacune · {2}',
|
||||
Suggested: 'Suggérée',
|
||||
'Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it.':
|
||||
'Suggérée : la copie la plus utilisée, puis la plus ancienne. Choisir une autre copie redirige les articles vers celle-ci.',
|
||||
'Merge into this one': 'Fusionner dans celle-ci',
|
||||
'Merging…': 'Fusion…',
|
||||
"Couldn't merge the copies. Nothing was changed. Try again in a moment.":
|
||||
'Impossible de fusionner les copies. Rien n’a changé. Réessayez dans un instant.',
|
||||
'One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.':
|
||||
'Une de ces images a changé : aucune fusion n’a eu lieu. La liste est à jour. Vérifiez-la et réessayez.',
|
||||
"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.":
|
||||
'Les articles utilisant les {0} autres copies utiliseront {1}, et les {2} autres copies seront supprimées. {3} seront mis à jour. Cette action est irréversible.',
|
||||
'Copies merged': 'Copies fusionnées',
|
||||
'{0} merged. {1} updated.': '{0} fusionnées. {1} mis à jour.',
|
||||
"The other one isn't used anywhere.": 'L’autre copie n’est utilisée nulle part.',
|
||||
"The other {0} aren't used anywhere.": 'Les {0} autres copies ne sont utilisées nulle part.',
|
||||
'Stays in the library': 'Reste dans la bibliothèque',
|
||||
'Stays in the library · {0}': 'Reste dans la bibliothèque · {0}',
|
||||
'Kept · used in': 'Conservée · utilisée dans',
|
||||
'Crop and resize': 'Recadrer et redimensionner',
|
||||
'Crop area. Arrow keys move it, Shift and arrow keys resize it.':
|
||||
'Zone de recadrage. Déplacez-la avec les flèches ; redimensionnez-la avec Maj et les flèches.',
|
||||
'Aspect ratio': 'Proportions',
|
||||
Original: 'Original',
|
||||
Free: 'Libre',
|
||||
Width: 'Largeur',
|
||||
Rotate: 'Pivoter',
|
||||
Reset: 'Réinitialiser',
|
||||
"The crop is {0} px wide, so it's uploaded at that size. Enlarging it would make it blurry.":
|
||||
'Le recadrage fait {0} px de large : il sera importé à cette taille. L’agrandir le rendrait flou.',
|
||||
'Uploads as {0} × {1} px · about {2}': 'Importée en {0} × {1} px · environ {2}',
|
||||
'Everyone who can sign in and work on your articles.':
|
||||
'Toutes les personnes pouvant se connecter et travailler sur vos articles.',
|
||||
'Invite user': 'Inviter un utilisateur',
|
||||
'Invite a user': 'Inviter un utilisateur',
|
||||
'Invite someone so they can sign in with their own account instead of sharing yours.':
|
||||
'Invitez quelqu’un à se connecter avec son propre compte plutôt que de partager le vôtre.',
|
||||
"We'll email them a link to choose a username and password. The link expires after 3 days.":
|
||||
'Nous lui enverrons un lien pour choisir un nom d’utilisateur et un mot de passe. Ce lien expire après 3 jours.',
|
||||
'Invite sent': 'Invitation envoyée',
|
||||
'{0} has 3 days to accept.': '{0} a 3 jours pour accepter.',
|
||||
'Too many invites. Wait a moment, then try again.':
|
||||
'Trop d’invitations. Patientez, puis réessayez.',
|
||||
"There's already a pending invite for this address.":
|
||||
'Une invitation est déjà en attente pour cette adresse.',
|
||||
'This address already has an account.': 'Cette adresse a déjà un compte.',
|
||||
"Couldn't send the invite. Try again in a moment.":
|
||||
'Impossible d’envoyer l’invitation. Réessayez dans un instant.',
|
||||
'Loading users': 'Chargement des utilisateurs',
|
||||
"Couldn't load users": 'Impossible de charger les utilisateurs',
|
||||
Name: 'Nom',
|
||||
Role: 'Rôle',
|
||||
'Invite status': 'Statut de l’invitation',
|
||||
Pending: 'En attente',
|
||||
Accepted: 'Acceptée',
|
||||
Expired: 'Expirée',
|
||||
Owner: 'Propriétaire',
|
||||
Member: 'Membre',
|
||||
You: 'Vous',
|
||||
'Renew invite': 'Renouveler l’invitation',
|
||||
'Renewing…': 'Renouvellement…',
|
||||
'Invite renewed': 'Invitation renouvelée',
|
||||
'{0} has 3 days to accept the new link.': '{0} a 3 jours pour accepter le nouveau lien.',
|
||||
"Couldn't renew the invite": 'Impossible de renouveler l’invitation',
|
||||
'Cancel invite': 'Annuler l’invitation',
|
||||
'Cancel this invite?': 'Annuler cette invitation ?',
|
||||
'The link sent to {0} will stop working. You can invite the address again later.':
|
||||
'Le lien envoyé à {0} ne fonctionnera plus. Vous pourrez inviter cette adresse plus tard.',
|
||||
'Keep invite': 'Conserver l’invitation',
|
||||
'Invite cancelled': 'Invitation annulée',
|
||||
'{0} can no longer use the link.': '{0} ne peut plus utiliser le lien.',
|
||||
"Couldn't cancel the invite. Try again in a moment.":
|
||||
'Impossible d’annuler l’invitation. Réessayez dans un instant.',
|
||||
'Workspace information.': 'Informations de l’espace de travail.',
|
||||
Version: 'Version',
|
||||
'Connect your website': 'Connecter votre site web',
|
||||
'Your site reads published articles from RenewCMS. Share this guide with whoever builds it.':
|
||||
'Votre site lit les articles publiés dans RenewCMS. Partagez ce guide avec la personne qui le développe.',
|
||||
'Open the guide': 'Ouvrir le guide',
|
||||
'A guide for the developer who builds your public site.':
|
||||
'Un guide pour la personne qui développe votre site public.',
|
||||
"RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors. Your website fetches published articles from the two addresses below and displays them in its own design.":
|
||||
'RenewCMS stocke et publie vos articles sans les afficher aux visiteurs. Votre site récupère les articles publiés aux deux adresses ci-dessous et les affiche selon son propre design.',
|
||||
'Base URL': 'URL de base',
|
||||
'Public API URL': 'URL de l’API publique',
|
||||
'Lists every online article.': 'Liste tous les articles publiés.',
|
||||
'Returns one online article. Drafts and unknown ids both return 404.':
|
||||
'Renvoie un article publié. Les brouillons et les identifiants inconnus renvoient 404.',
|
||||
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.':
|
||||
'Les requêtes anonymes n’obtiennent que les articles publiés. Les brouillons restent privés jusqu’à leur publication.',
|
||||
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.":
|
||||
'Les navigateurs ne peuvent appeler l’API que depuis les origines autorisées par le paramètre CORS du serveur. Demandez à la personne qui gère le serveur d’ajouter l’adresse de votre site.',
|
||||
Response: 'Réponse',
|
||||
'Returns 404 until the article is online.': 'Renvoie 404 tant que l’article n’est pas publié.',
|
||||
Updates: 'Mises à jour',
|
||||
Update: 'Mettre à jour',
|
||||
'Update available': 'Mise à jour disponible',
|
||||
'Version {0} is available': 'La version {0} est disponible',
|
||||
'Version {0} is ready to install': 'La version {0} est prête à être installée',
|
||||
"You're on version {0}": 'Vous utilisez la version {0}',
|
||||
'You have {0}. Update downloads the new version and checks it. Nothing changes until you restart.':
|
||||
'Vous utilisez {0}. La mise à jour télécharge et vérifie la nouvelle version. Rien ne change avant le redémarrage.',
|
||||
'You have {0}. Only the owner can update RenewCMS. Ask the owner of this site.':
|
||||
'Vous utilisez {0}. Seul le propriétaire peut mettre à jour RenewCMS. Contactez le propriétaire de ce site.',
|
||||
'You have the latest version of RenewCMS.': 'Vous avez la dernière version de RenewCMS.',
|
||||
"You're up to date": 'Vous êtes à jour',
|
||||
'You already have the latest version.': 'Vous avez déjà la dernière version.',
|
||||
'Nothing to update': 'Aucune mise à jour',
|
||||
'Check for updates': 'Rechercher des mises à jour',
|
||||
'Checking for updates…': 'Recherche de mises à jour…',
|
||||
"Couldn't check for updates": 'Impossible de rechercher des mises à jour',
|
||||
"RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected.":
|
||||
'RenewCMS n’a pas pu joindre le serveur de mises à jour. Vérifiez votre connexion et réessayez. Votre site n’est pas affecté.',
|
||||
"Couldn't reach RenewCMS. Check your connection and try again.":
|
||||
'Impossible de joindre RenewCMS. Vérifiez votre connexion et réessayez.',
|
||||
'Only the owner can update or restart the site.':
|
||||
'Seul le propriétaire peut mettre à jour ou redémarrer le site.',
|
||||
'Only the owner can do this': 'Seul le propriétaire peut effectuer cette action',
|
||||
'Ask the owner of this site to update RenewCMS.':
|
||||
'Demandez au propriétaire de ce site de mettre à jour RenewCMS.',
|
||||
'Only the owner can update RenewCMS.': 'Seul le propriétaire peut mettre à jour RenewCMS.',
|
||||
'Go to Settings': 'Aller aux paramètres',
|
||||
'Update it from Settings. It takes about 2 minutes and the site stays online.':
|
||||
'Mettez à jour depuis les paramètres. Cela prend environ 2 minutes et le site reste disponible.',
|
||||
'Takes about 2 minutes. Your site stays online.':
|
||||
'Cela prend environ 2 minutes. Votre site reste disponible.',
|
||||
"This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.":
|
||||
'Ce site ne peut pas encore se mettre à jour depuis le navigateur. La personne qui gère votre serveur peut l’activer ou mettre à jour RenewCMS pour vous.',
|
||||
'How to update': 'Comment mettre à jour',
|
||||
'For whoever manages the server': 'Pour la personne qui gère le serveur',
|
||||
'They run this once on the server. After that, Update and Restart appear here.':
|
||||
'Cette commande s’exécute une fois sur le serveur. Les actions de mise à jour et de redémarrage apparaissent ensuite ici.',
|
||||
'Read the release notes': 'Lire les notes de version',
|
||||
"What's new in": 'Nouveautés de',
|
||||
'Downloading version {0}': 'Téléchargement de la version {0}',
|
||||
'Downloading version {0}…': 'Téléchargement de la version {0}…',
|
||||
'{0}% · {1} of {2} MB': '{0} % · {1} sur {2} Mo',
|
||||
'{0}, {1} percent': '{0}, {1} pour cent',
|
||||
'You can leave this page. The download continues and your site stays online.':
|
||||
'Vous pouvez quitter cette page. Le téléchargement continue et votre site reste disponible.',
|
||||
'Checking the download': 'Vérification du téléchargement',
|
||||
'Checking the download…': 'Vérification du téléchargement…',
|
||||
'Making sure the file is complete and safe to install. This takes a few seconds.':
|
||||
'Vérification de l’intégrité et de la sécurité du fichier. Cela prend quelques secondes.',
|
||||
"The download didn't finish": 'Le téléchargement n’a pas abouti',
|
||||
'The connection dropped before the file was complete. Nothing was changed and your site is running as before.':
|
||||
'La connexion a été interrompue avant la fin du téléchargement. Rien n’a changé et votre site fonctionne comme avant.',
|
||||
"We didn't use this update": 'Cette mise à jour n’a pas été utilisée',
|
||||
"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.":
|
||||
'Le fichier a échoué au contrôle de sécurité et n’a pas été installé. Rien n’a changé. Réessayez le téléchargement. Si le problème persiste, contactez l’assistance RenewCMS.',
|
||||
'An update is already running': 'Une mise à jour est déjà en cours',
|
||||
'Another owner started it. Progress shows here.':
|
||||
'Un autre propriétaire l’a lancée. La progression s’affiche ici.',
|
||||
'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.':
|
||||
'Le téléchargement est vérifié. Redémarrez pour l’installer. Votre site sera indisponible environ 10 secondes.',
|
||||
'Not now? Leave this page. The update waits here, and the banner on Home reminds you.':
|
||||
'Pas maintenant ? Quittez cette page. La mise à jour vous attend ici et la bannière d’accueil vous le rappellera.',
|
||||
'Restart later': 'Redémarrer plus tard',
|
||||
'Restart now': 'Redémarrer maintenant',
|
||||
'Restart site': 'Redémarrer le site',
|
||||
Restart: 'Redémarrer',
|
||||
Restarting: 'Redémarrage',
|
||||
'Restarting…': 'Redémarrage…',
|
||||
'Restarting your site': 'Redémarrage de votre site',
|
||||
'Restart to finish the update?': 'Redémarrer pour terminer la mise à jour ?',
|
||||
'Restart the site?': 'Redémarrer le site ?',
|
||||
'RenewCMS will install version {0} and restart. {1}':
|
||||
'RenewCMS installera la version {0} puis redémarrera. {1}',
|
||||
"Your site will be unavailable for about 10 seconds. This page reloads on its own when the site is back. Save anything you're editing first.":
|
||||
'Votre site sera indisponible environ 10 secondes. Cette page se recharge automatiquement à son retour. Enregistrez d’abord votre travail.',
|
||||
'Restart takes about 10 seconds. Your site is unavailable while it restarts.':
|
||||
'Le redémarrage prend environ 10 secondes. Votre site est indisponible pendant ce temps.',
|
||||
'Restart from Settings to finish. The site is unavailable for about 10 seconds.':
|
||||
'Redémarrez depuis les paramètres pour terminer. Le site sera indisponible environ 10 secondes.',
|
||||
'Installing version {0}': 'Installation de la version {0}',
|
||||
'Your site is restarting with the new version. This takes about 10 seconds. This page reloads on its own when the site is back.':
|
||||
'Votre site redémarre avec la nouvelle version. Cela prend environ 10 secondes. Cette page se recharge automatiquement à son retour.',
|
||||
'This takes about 10 seconds. This page reloads on its own when the site is back.':
|
||||
'Cela prend environ 10 secondes. Cette page se recharge automatiquement au retour du site.',
|
||||
'Still restarting': 'Redémarrage toujours en cours',
|
||||
'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.':
|
||||
'Cela prend plus longtemps que prévu. Gardez cette page ouverte. Elle se recharge automatiquement au retour du site, ou vous pouvez la recharger maintenant.',
|
||||
'Reload page': 'Recharger la page',
|
||||
'Site restarted': 'Site redémarré',
|
||||
'Your site is running again.': 'Votre site fonctionne à nouveau.',
|
||||
'Update installed': 'Mise à jour installée',
|
||||
'RenewCMS is now on version {0}.': 'RenewCMS utilise maintenant la version {0}.',
|
||||
'The update is installed and your site is running again.':
|
||||
'La mise à jour est installée et votre site fonctionne à nouveau.',
|
||||
"The update didn't install": 'La mise à jour n’a pas été installée',
|
||||
'RenewCMS put the previous version back, so your site is running as before. Nothing was lost.':
|
||||
'RenewCMS a rétabli la version précédente. Votre site fonctionne comme avant. Rien n’a été perdu.',
|
||||
'Your site is back on {0}.': 'Votre site utilise à nouveau {0}.',
|
||||
'What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update.':
|
||||
'Réessayez dans quelques minutes. Si l’échec se reproduit, demandez à la personne qui gère votre serveur de vérifier la mise à jour.',
|
||||
"Couldn't start the restart. Nothing was changed and your site is still running. Try again in a moment.":
|
||||
'Impossible de lancer le redémarrage. Rien n’a changé et votre site fonctionne toujours. Réessayez dans un instant.',
|
||||
'Nothing has changed yet.': 'Rien n’a encore changé.',
|
||||
'Released {0}': 'Publiée le {0}',
|
||||
'Last checked today at {0}': 'Dernière vérification aujourd’hui à {0}',
|
||||
'Last checked yesterday at {0}': 'Dernière vérification hier à {0}',
|
||||
'Last checked {0} at {1}': 'Dernière vérification le {0} à {1}',
|
||||
'Updated today at {0}': 'Mis à jour aujourd’hui à {0}',
|
||||
'Updated yesterday at {0}': 'Mis à jour hier à {0}',
|
||||
'Updated {0} at {1}': 'Mis à jour le {0} à {1}',
|
||||
'Started at {0}': 'Démarré à {0}',
|
||||
Updated: 'Modifié',
|
||||
'Updating…': 'Mise à jour…',
|
||||
Created: 'Créé',
|
||||
Today: 'Aujourd’hui',
|
||||
Yesterday: 'Hier',
|
||||
'Today, {0}:{1}': 'Aujourd’hui, {0}:{1}',
|
||||
'1 minute ago': 'Il y a 1 minute',
|
||||
'{0} minutes ago': 'Il y a {0} minutes',
|
||||
'1 hour ago': 'Il y a 1 heure',
|
||||
'{0} hours ago': 'Il y a {0} heures',
|
||||
'{0} days ago': 'Il y a {0} jours',
|
||||
'{0} d': '{0} j',
|
||||
'{0} h': '{0} h',
|
||||
'{0} min': '{0} min',
|
||||
'{0} KB': '{0} Ko',
|
||||
'{0} MB': '{0} Mo',
|
||||
'1 article': '1 article',
|
||||
'{0} articles': '{0} articles',
|
||||
'used in {0}': 'utilisée dans {0}',
|
||||
'{0} and {1}': '{0} et {1}',
|
||||
'Page not found': 'Page introuvable',
|
||||
'The link may be mistyped, or the page may have moved.':
|
||||
'Le lien est peut-être incorrect ou la page a été déplacée.',
|
||||
'Something went wrong': 'Une erreur est survenue',
|
||||
'Something went wrong. Try again.': 'Une erreur est survenue. Réessayez.',
|
||||
"This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.":
|
||||
'Cette page n’a pas pu se charger. Votre travail enregistré est conservé. Réessayez et, si le problème persiste, redémarrez le serveur RenewCMS.',
|
||||
"This article doesn't exist.": 'Cet article n’existe pas.',
|
||||
'It may have been deleted, or the link is wrong.':
|
||||
'Il a peut-être été supprimé ou le lien est incorrect.',
|
||||
Dismiss: 'Fermer',
|
||||
"You're offline. You can keep writing, but saving and publishing will wait until you're back online.":
|
||||
'Vous êtes hors ligne. Vous pouvez continuer à écrire, mais l’enregistrement et la publication attendront le retour de la connexion.',
|
||||
"You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection.":
|
||||
'Vous êtes hors ligne. Vous pouvez consulter les images chargées, mais l’importation, le renommage et la suppression nécessitent une connexion.',
|
||||
"You're offline": 'Vous êtes hors ligne',
|
||||
"You're back online": 'Vous êtes de nouveau en ligne',
|
||||
'{0} — account menu': '{0} — menu du compte',
|
||||
"You're logged in as {0}.": 'Vous êtes connecté en tant que {0}.',
|
||||
'Counted from the text of your articles each time you open this page.':
|
||||
'Calculé à partir du texte de vos articles à chaque ouverture de cette page.',
|
||||
Status: 'Statut',
|
||||
Files: 'Fichiers',
|
||||
Clear: 'Effacer',
|
||||
View: 'Afficher',
|
||||
'Almost there': 'Presque terminé',
|
||||
'Too many tries': 'Trop de tentatives',
|
||||
'Checking…': 'Vérification…',
|
||||
'Check this title and try again.': 'Vérifiez ce titre et réessayez.',
|
||||
'Nothing matches in {0}. Check the spelling, or clear the filters.':
|
||||
'Aucun résultat dans {0}. Vérifiez l’orthographe ou effacez les filtres.',
|
||||
'Show drafts': 'Afficher les brouillons',
|
||||
"The server didn't respond. Nothing changed.": 'Le serveur n’a pas répondu. Rien n’a changé.',
|
||||
'This usually takes a few seconds.': 'Cela prend généralement quelques secondes.',
|
||||
"It's": 'Elle est',
|
||||
'All {0} are': 'Les {0} sont',
|
||||
'{0} used in {1}, {2} {3}': '{0} utilisées dans {1}, {2} {3}',
|
||||
'{0} {1} attention': '{0} {1} votre attention',
|
||||
'{0} {1} rejected': '{0} {1} refusées',
|
||||
'{0} {1} the same content.': '{0} {1} le même contenu.',
|
||||
'Check your email · RenewCMS': 'Consultez vos e-mails · RenewCMS',
|
||||
'This site already has an owner · RenewCMS': 'Ce site a déjà un propriétaire · RenewCMS',
|
||||
'{0} · RenewCMS': '{0} · RenewCMS',
|
||||
Enter: 'Entrée',
|
||||
Escape: 'Échap',
|
||||
'Actions for {0}': 'Actions pour {0}',
|
||||
'Delete {0}?': 'Supprimer {0} ?',
|
||||
'Delete {0}': 'Supprimer {0}',
|
||||
'Used in {0}': 'Utilisée dans {0}',
|
||||
'Copy {0} example': 'Copier l’exemple {0}',
|
||||
'Drop to upload': 'Déposez pour importer',
|
||||
'Drop an image here, or choose a file': 'Déposez une image ici ou choisissez un fichier',
|
||||
'Send invite': 'Envoyer l’invitation',
|
||||
'Select {0}': 'Sélectionner {0}',
|
||||
'Not used in any article': 'Utilisée dans aucun article',
|
||||
'Loading…': 'Chargement…',
|
||||
'Load more': 'Charger davantage',
|
||||
'Cancel all': 'Tout annuler',
|
||||
'Cancel upload of {0}': 'Annuler l’importation de {0}',
|
||||
'Merge into {0}?': 'Fusionner dans {0} ?',
|
||||
'Merge {0}': 'Fusionner {0}',
|
||||
'Creating…': 'Création…',
|
||||
'Create draft': 'Créer un brouillon',
|
||||
'Creating account…': 'Création du compte…',
|
||||
'Create account': 'Créer le compte',
|
||||
'Choose a new password': 'Choisir un nouveau mot de passe',
|
||||
'Changing password…': 'Modification du mot de passe…',
|
||||
'Change password': 'Modifier le mot de passe',
|
||||
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.':
|
||||
'Les images du contenu utilisent des chemins comme /static/uploadedImages/<uuid>.png. Ajoutez l’URL de base avant de les afficher.',
|
||||
selected: 'sélectionnées',
|
||||
you: 'vous',
|
||||
now: 'maintenant',
|
||||
'Accept invite': 'Accepter l’invitation',
|
||||
articles: 'articles',
|
||||
online: 'publiés',
|
||||
drafts: 'brouillons',
|
||||
owner: 'propriétaire',
|
||||
admin: 'administrateur',
|
||||
met: 'respecté',
|
||||
'not met': 'non respecté',
|
||||
copy: 'copie',
|
||||
image: 'image',
|
||||
images: 'images',
|
||||
'{0} image': '{0} image',
|
||||
'{0} images': '{0} images',
|
||||
'{0} file': '{0} fichier',
|
||||
'{0} files': '{0} fichiers',
|
||||
'{0} file was rejected': '{0} fichier a été refusé',
|
||||
'{0} files were rejected': '{0} fichiers ont été refusés',
|
||||
'{0} file needs attention': '{0} fichier nécessite votre attention',
|
||||
'{0} files need attention': '{0} fichiers nécessitent votre attention',
|
||||
'{0} has the same content.': '{0} a le même contenu.',
|
||||
'{0} have the same content.': '{0} ont le même contenu.',
|
||||
'{0} images · {1} selected': '{0} images · {1} sélectionnées',
|
||||
'image, {0} selected': 'image, {0} sélectionnée',
|
||||
'images, {0} selected': 'images, {0} sélectionnées',
|
||||
'Used in 1 article': 'Utilisée dans 1 article',
|
||||
'Used in {0} articles': 'Utilisée dans {0} articles',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,67 @@
|
||||
import { shallowReactive, watch, watchEffect } from 'vue'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import { locale } from './index'
|
||||
|
||||
type ToastSource = Parameters<ReturnType<typeof useToast>['add']>[0]
|
||||
type ToastProvider = ToastSource | (() => ToastSource)
|
||||
const notifications = shallowReactive(
|
||||
new Map<
|
||||
string | number,
|
||||
{ source: () => ToastSource; toast: ReturnType<typeof useToast>; visible: boolean }
|
||||
>(),
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
for (const [id, entry] of notifications) {
|
||||
const active = entry.toast.toasts.value.find((item) => item.id === id && item.open)
|
||||
if (active) entry.visible = true
|
||||
else if (entry.visible) notifications.delete(id)
|
||||
}
|
||||
})
|
||||
|
||||
watch(locale, () => {
|
||||
for (const [id, { source, toast }] of notifications) {
|
||||
const active = toast.toasts.value.find((item) => item.id === id)
|
||||
if (!active) {
|
||||
notifications.delete(id)
|
||||
continue
|
||||
}
|
||||
const content = source()
|
||||
active.title = content.title
|
||||
active.description = content.description
|
||||
active.actions = content.actions
|
||||
}
|
||||
})
|
||||
|
||||
export function useLocalizedToast() {
|
||||
const toast = useToast()
|
||||
function add(content: ToastProvider) {
|
||||
const source = typeof content === 'function' ? content : () => content
|
||||
const result = toast.add(source())
|
||||
if (result) notifications.set(result.id, { source, toast, visible: false })
|
||||
return result
|
||||
}
|
||||
function update(id: string | number, content: ToastProvider) {
|
||||
const source = typeof content === 'function' ? content : () => content
|
||||
toast.update(id, source())
|
||||
notifications.set(id, { source, toast, visible: false })
|
||||
}
|
||||
return { ...toast, add, update }
|
||||
}
|
||||
|
||||
export function useLocalizedOverlay() {
|
||||
const overlay = useOverlay()
|
||||
const create: typeof overlay.create = (component, options) => {
|
||||
const instance = overlay.create(component, options)
|
||||
const open: typeof instance.open = (props) => {
|
||||
const stop = watch(locale, () => {
|
||||
if (overlay.isOpen(instance.id)) instance.patch(options?.props ?? {})
|
||||
})
|
||||
const result = instance.open(props)
|
||||
void result.finally(stop)
|
||||
return result
|
||||
}
|
||||
return { ...instance, open }
|
||||
}
|
||||
return { ...overlay, create }
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import ui from '@nuxt/ui/vue-plugin'
|
||||
import App from '@/App.vue'
|
||||
import LanguagePicker from '@/components/LanguagePicker.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import UserMenu from '@/components/UserMenu.vue'
|
||||
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
|
||||
import { useApi, ApiError } from '@/composables/useApi'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useColorMode } from '@/composables/useColorMode'
|
||||
import router from '@/router'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import { initializeLocale, locale, setLocale, t } from './index'
|
||||
import { messages } from './messages'
|
||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
import { formatSize, pluralArticles } from '@/lib/mediaFormat'
|
||||
|
||||
vi.mock('@/composables/useApi', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useApi: vi.fn(),
|
||||
authRedirectPending: ref(false),
|
||||
ApiError: class extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public retryAfter?: number,
|
||||
public body?: string,
|
||||
) {
|
||||
super('API failure')
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
const request = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
setActivePinia(createPinia())
|
||||
useSessionStore().status = 'ready'
|
||||
request.mockReset().mockResolvedValue([])
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
request,
|
||||
getMe: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
async function mountRoute(path: string) {
|
||||
const session = useSessionStore()
|
||||
session.session =
|
||||
path === '/login'
|
||||
? null
|
||||
: {
|
||||
id: 1,
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
displayName: 'Marta',
|
||||
email: 'marta@example.com',
|
||||
role: 'owner',
|
||||
}
|
||||
session.status = 'ready'
|
||||
await router.push(path)
|
||||
wrapper = mount(App, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function trigger() {
|
||||
return document.body.querySelector<HTMLButtonElement>(
|
||||
'[role="combobox"][aria-label="Language"], [role="combobox"][aria-label="Langue"]',
|
||||
)!
|
||||
}
|
||||
|
||||
function key(element: Element, value: string) {
|
||||
element.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: value,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('Translate the entire interface', () => {
|
||||
it('provides nonempty equivalent interpolation parameters for every English and French key', () => {
|
||||
const placeholders = (text: string) =>
|
||||
[...text.matchAll(/\{\d+\}/g)].map((match) => match[0]).sort()
|
||||
expect(
|
||||
Object.entries(messages).filter(
|
||||
([english, french]) =>
|
||||
!french.trim() ||
|
||||
JSON.stringify(placeholders(english)) !== JSON.stringify(placeholders(french)),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
it('formats French dates, decimal sizes and plural counts', () => {
|
||||
setLocale('fr')
|
||||
expect([
|
||||
formatAbsoluteDateFromIso('2025-02-14T12:00:00Z'),
|
||||
formatSize(1572864),
|
||||
pluralArticles(1234),
|
||||
]).toEqual(['14 févr. 2025', '1,5 Mo', '1 234 articles'])
|
||||
})
|
||||
it('falls back to the English key for an unavailable French message', () => {
|
||||
setLocale('fr')
|
||||
expect(t('Unavailable message' as keyof typeof messages)).toBe('Unavailable message')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Switch language without losing work', () => {
|
||||
it('retranslates added and retried bulk failures while preserving filenames and retry targets', async () => {
|
||||
let items = ['north-wall.jpg', 'kitchen-after.webp', 'kitchen-before.webp'].map(
|
||||
(filename, index) => ({
|
||||
id: index + 1,
|
||||
filename,
|
||||
url: `/media/${filename}`,
|
||||
size: 1024,
|
||||
content_type: 'image/jpeg',
|
||||
width: 100,
|
||||
height: 100,
|
||||
sha256: filename,
|
||||
uploaded_by: 'Marta',
|
||||
created_at: '2025-02-14T12:00:00Z',
|
||||
used_in: 0,
|
||||
duplicate: false,
|
||||
}),
|
||||
)
|
||||
let retry = false
|
||||
request.mockImplementation(async (path: string, options?: RequestInit) => {
|
||||
if (options?.method === 'DELETE') {
|
||||
const id = Number(path.substring(path.lastIndexOf('/') + 1))
|
||||
if (id === 3 || (id === 2 && !retry)) throw new ApiError(500)
|
||||
items = items.filter((item) => item.id !== id)
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
items,
|
||||
next_cursor: null,
|
||||
counts: { all: items.length, unused: items.length, duplicates: 0 },
|
||||
}
|
||||
})
|
||||
await mountRoute('/media')
|
||||
for (const item of items) {
|
||||
document.body
|
||||
.querySelector<HTMLElement>(
|
||||
`[role="checkbox"][aria-label="Select ${item.filename}"]`,
|
||||
)!
|
||||
.click()
|
||||
}
|
||||
await flushPromises()
|
||||
document.body.querySelector<HTMLButtonElement>('[data-testid="bulk-delete"]')!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
|
||||
.find((button) => button.textContent?.trim() === 'Delete 3 images')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
const snapshots: boolean[] = []
|
||||
async function captureBothWays(english: string, french: string) {
|
||||
for (const [language, description, label] of [
|
||||
['en', english, 'Retry'],
|
||||
['fr', french, 'Réessayer'],
|
||||
['en', english, 'Retry'],
|
||||
] as const) {
|
||||
setLocale(language)
|
||||
await flushPromises()
|
||||
snapshots.push(
|
||||
document.body.textContent!.includes(description) &&
|
||||
Array.from(document.body.querySelectorAll('button')).some(
|
||||
(button) => button.textContent?.trim() === label,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
await captureBothWays(
|
||||
'1 image was deleted. kitchen-after.webp and kitchen-before.webp are still in your library. Try again.',
|
||||
'1 image a été supprimée. kitchen-after.webp et kitchen-before.webp sont toujours dans votre bibliothèque. Réessayez.',
|
||||
)
|
||||
retry = true
|
||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
|
||||
.find((button) => button.textContent?.trim() === 'Retry')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
await captureBothWays(
|
||||
'2 images were deleted. kitchen-before.webp is still in your library. Try again.',
|
||||
'2 images ont été supprimées. kitchen-before.webp est toujours dans votre bibliothèque. Réessayez.',
|
||||
)
|
||||
expect({
|
||||
snapshots,
|
||||
deleted: request.mock.calls
|
||||
.filter(([, options]) => options?.method === 'DELETE')
|
||||
.map(([path]) => path),
|
||||
}).toEqual({
|
||||
snapshots: [true, true, true, true, true, true],
|
||||
deleted: ['/v1/media/1', '/v1/media/2', '/v1/media/3', '/v1/media/2', '/v1/media/3'],
|
||||
})
|
||||
})
|
||||
it.each(['fallback', 'French catalog error'])(
|
||||
'updates an active confirmation %s when switching both ways',
|
||||
async (failure) => {
|
||||
wrapper = mount(ConfirmDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
title: 'Delete',
|
||||
description: 'Confirm deletion',
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
pendingLabel: 'Deleting',
|
||||
tone: 'danger',
|
||||
action: async () => {
|
||||
if (failure === 'French catalog error') {
|
||||
setLocale('fr')
|
||||
throw new Error(t('Something went wrong. Try again.'))
|
||||
}
|
||||
throw null
|
||||
},
|
||||
},
|
||||
global: { plugins: [ui] },
|
||||
})
|
||||
await flushPromises()
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'),
|
||||
)
|
||||
.find((button) => button.textContent?.trim() === 'Delete')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
setLocale('en')
|
||||
await flushPromises()
|
||||
const english = document.body.querySelector('[role="alert"]')?.textContent?.trim()
|
||||
setLocale('fr')
|
||||
await flushPromises()
|
||||
const french = document.body.querySelector('[role="alert"]')?.textContent?.trim()
|
||||
setLocale('en')
|
||||
await flushPromises()
|
||||
expect([
|
||||
english,
|
||||
french,
|
||||
document.body.querySelector('[role="alert"]')?.textContent?.trim(),
|
||||
]).toEqual([
|
||||
'Something went wrong. Try again.',
|
||||
'Une erreur est survenue. Réessayez.',
|
||||
'Something went wrong. Try again.',
|
||||
])
|
||||
},
|
||||
)
|
||||
it('switches both ways while preserving profile draft, route, session, theme and request count', async () => {
|
||||
await mountRoute('/profile')
|
||||
const input = wrapper!.find('input[autocomplete="off"]')
|
||||
await input.setValue('Unsaved name')
|
||||
const session = JSON.stringify(useSessionStore().session)
|
||||
const theme = useColorMode().preference
|
||||
const count = request.mock.calls.length
|
||||
setLocale('fr')
|
||||
await flushPromises()
|
||||
const french = document.body.textContent!.includes('Nom d’affichage')
|
||||
setLocale('en')
|
||||
await flushPromises()
|
||||
expect({
|
||||
french,
|
||||
draft: (input.element as HTMLInputElement).value,
|
||||
path: router.currentRoute.value.path,
|
||||
session: JSON.stringify(useSessionStore().session),
|
||||
theme: useColorMode().preference,
|
||||
requests: request.mock.calls.length,
|
||||
english: document.body.textContent!.includes('Display name'),
|
||||
}).toEqual({
|
||||
french: true,
|
||||
draft: 'Unsaved name',
|
||||
path: '/profile',
|
||||
session,
|
||||
theme,
|
||||
requests: count,
|
||||
english: true,
|
||||
})
|
||||
})
|
||||
it('localizes profile save failure and retains the user draft', async () => {
|
||||
setLocale('fr')
|
||||
await mountRoute('/profile')
|
||||
await wrapper!.find('input[autocomplete="off"]').setValue('Name stays')
|
||||
request.mockRejectedValueOnce(new ApiError(500, undefined, 'private backend error'))
|
||||
await wrapper!.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect({
|
||||
alert: document.body.querySelector('[role="alert"]')?.textContent?.trim(),
|
||||
draft: (wrapper!.find('input[autocomplete="off"]').element as HTMLInputElement).value,
|
||||
}).toEqual({
|
||||
alert: 'Impossible d’enregistrer votre nom d’affichage. Votre texte est conservé. Réessayez dans un instant.',
|
||||
draft: 'Name stays',
|
||||
})
|
||||
})
|
||||
it('updates an already-visible authentication alert and its validation', async () => {
|
||||
await mountRoute('/login?reason=expired')
|
||||
await wrapper!.find('form').trigger('submit')
|
||||
setLocale('fr')
|
||||
await flushPromises()
|
||||
expect([
|
||||
document.body.textContent!.includes('Votre session a expiré'),
|
||||
document.body.textContent!.includes('Utilisez au moins 3 caractères.'),
|
||||
document.body.textContent!.includes('Saisissez votre mot de passe.'),
|
||||
]).toEqual([true, true, true])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Operate the picker accessibly', () => {
|
||||
it.each([false, true])(
|
||||
'supports keyboard selection and focus return with mobile=%s',
|
||||
async (mobile) => {
|
||||
wrapper = mount(LanguagePicker, {
|
||||
attachTo: document.body,
|
||||
props: { mobile },
|
||||
global: { plugins: [ui] },
|
||||
})
|
||||
trigger().focus()
|
||||
key(trigger(), 'ArrowDown')
|
||||
await flushPromises()
|
||||
const options = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
)
|
||||
const selected = options
|
||||
.find((option) => option.textContent!.includes('English'))
|
||||
?.getAttribute('aria-selected')
|
||||
key(document.activeElement!, 'End')
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
key(document.activeElement!, 'Enter')
|
||||
await flushPromises()
|
||||
expect({
|
||||
options: options.map((option) => option.textContent!.trim()),
|
||||
selected,
|
||||
locale: locale.value,
|
||||
closed: !document.body.querySelector('[role="listbox"]'),
|
||||
focus: document.activeElement === trigger(),
|
||||
}).toEqual({
|
||||
options: ['English', 'Français'],
|
||||
selected: 'true',
|
||||
locale: 'fr',
|
||||
closed: true,
|
||||
focus: true,
|
||||
})
|
||||
},
|
||||
)
|
||||
it('dismisses with Escape and preserves the language', async () => {
|
||||
wrapper = mount(LanguagePicker, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ui] },
|
||||
})
|
||||
trigger().focus()
|
||||
key(trigger(), 'ArrowDown')
|
||||
await flushPromises()
|
||||
key(document.activeElement!, 'Escape')
|
||||
await flushPromises()
|
||||
expect([
|
||||
locale.value,
|
||||
!document.body.querySelector('[role="listbox"]'),
|
||||
document.activeElement === trigger(),
|
||||
]).toEqual(['en', true, true])
|
||||
})
|
||||
it.each(['desktop', 'mobile'])('offers the picker in the %s account menu', async (menu) => {
|
||||
useSessionStore().session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
displayName: '',
|
||||
}
|
||||
useSessionStore().status = 'ready'
|
||||
await router.push('/profile')
|
||||
wrapper =
|
||||
menu === 'desktop'
|
||||
? mount(UserMenu, {
|
||||
attachTo: document.body,
|
||||
props: { username: 'marta', collapsed: false },
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
: mount(AccountMenuSheet, {
|
||||
attachTo: document.body,
|
||||
props: { username: 'marta', open: true },
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
if (menu === 'desktop')
|
||||
document.body.querySelector<HTMLButtonElement>('button[title="Account"]')!.click()
|
||||
await flushPromises()
|
||||
const profileEntry = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((item) => item.textContent?.trim() === 'Profile')!
|
||||
profileEntry.focus()
|
||||
key(profileEntry, menu === 'desktop' ? 'End' : 'ArrowDown')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const reachedControl = document.activeElement === trigger()
|
||||
key(trigger(), 'ArrowDown')
|
||||
await flushPromises()
|
||||
key(document.activeElement!, 'End')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
key(document.activeElement!, 'Enter')
|
||||
await flushPromises()
|
||||
expect([
|
||||
reachedControl,
|
||||
locale.value,
|
||||
document.activeElement === trigger(),
|
||||
trigger().textContent?.includes('Langue'),
|
||||
]).toEqual([true, 'fr', true, true])
|
||||
})
|
||||
it('retains French after logout and renders the public authentication screen in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountRoute('/profile')
|
||||
document.body.querySelector<HTMLButtonElement>('button[title="Compte"]')!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find((item) => item.textContent?.trim() === 'Se déconnecter')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
expect([
|
||||
router.currentRoute.value.path,
|
||||
locale.value,
|
||||
localStorage.getItem('renewcms-language'),
|
||||
document.body.textContent!.includes('Se connecter'),
|
||||
]).toEqual(['/login', 'fr', 'fr', true])
|
||||
})
|
||||
it('updates an existing success notification without translating the saved account name', async () => {
|
||||
await mountRoute('/profile')
|
||||
await wrapper!.find('input[autocomplete="off"]').setValue('Log in')
|
||||
request.mockResolvedValueOnce({ display_name: 'Log in' })
|
||||
await wrapper!.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
setLocale('fr')
|
||||
await flushPromises()
|
||||
expect([
|
||||
document.body.textContent!.includes('Nom d’affichage enregistré'),
|
||||
document.body.textContent!.includes(
|
||||
'Vous apparaissez désormais sous le nom Log in dans RenewCMS et les e-mails.',
|
||||
),
|
||||
]).toEqual([true, true])
|
||||
})
|
||||
it('updates an already-open confirmation dialog', async () => {
|
||||
await mountRoute('/profile')
|
||||
await wrapper!.find('input[autocomplete="off"]').setValue('Unsaved')
|
||||
const navigation = router.push('/home')
|
||||
await flushPromises()
|
||||
setLocale('fr')
|
||||
await flushPromises()
|
||||
const translated = document.body
|
||||
.querySelector('[role="alertdialog"]')
|
||||
?.textContent?.includes('Quitter sans enregistrer ?')
|
||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
|
||||
.find((button) => button.textContent?.trim() === 'Abandonner les modifications')!
|
||||
.click()
|
||||
await navigation
|
||||
expect(translated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hardener localization outcomes', () => {
|
||||
it.each([false, true])(
|
||||
'updates not-found breadcrumbs round-trip with authenticated=%s',
|
||||
async (authenticated) => {
|
||||
const { default: NotFoundView } = await import('@/views/NotFoundView.vue')
|
||||
useSessionStore().session = authenticated
|
||||
? { verified: true, username: 'marta', displayName: '' }
|
||||
: null
|
||||
wrapper = mount(NotFoundView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
await flushPromises()
|
||||
const snapshots = []
|
||||
for (const language of ['en', 'fr', 'en'] as const) {
|
||||
setLocale(language)
|
||||
await flushPromises()
|
||||
snapshots.push(
|
||||
document.body.querySelector('[role="alert"] nav')?.textContent?.trim(),
|
||||
)
|
||||
}
|
||||
expect(snapshots).toEqual(['Not found', 'Introuvable', 'Not found'])
|
||||
},
|
||||
)
|
||||
it('updates the default error breadcrumb round-trip', async () => {
|
||||
const { default: ErrorView } = await import('@/components/ErrorView.vue')
|
||||
wrapper = mount(ErrorView, {
|
||||
attachTo: document.body,
|
||||
props: { kind: 'error' },
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
const snapshots = []
|
||||
for (const language of ['en', 'fr', 'en'] as const) {
|
||||
setLocale(language)
|
||||
await flushPromises()
|
||||
snapshots.push(wrapper.find('nav').text())
|
||||
}
|
||||
expect(snapshots).toEqual(['Home', 'Accueil', 'Home'])
|
||||
})
|
||||
it('focuses the invalid username in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountRoute('/login')
|
||||
await wrapper!.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(document.activeElement).toBe(wrapper!.find('input[autocomplete="username"]').element)
|
||||
})
|
||||
it('focuses article search after clearing French no-results', async () => {
|
||||
request.mockResolvedValue([
|
||||
{
|
||||
id: 12,
|
||||
title: 'Data title',
|
||||
body: '',
|
||||
is_online: false,
|
||||
created_at: '2025-02-14T12:00:00Z',
|
||||
updated_at: '2025-02-14T12:00:00Z',
|
||||
},
|
||||
])
|
||||
setLocale('fr')
|
||||
await mountRoute('/article?q=absent')
|
||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
|
||||
.find((b) => b.textContent?.trim() === 'Effacer la recherche')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
expect([
|
||||
document.activeElement === document.body.querySelector('input[type="search"]'),
|
||||
router.currentRoute.value.query.q,
|
||||
]).toEqual([true, undefined])
|
||||
})
|
||||
it('copies the unchanged integration URL with French feedback and protocol examples', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
setLocale('fr')
|
||||
useSessionStore().session = { verified: true, username: 'marta', displayName: '' }
|
||||
const { default: IntegrationView } = await import('@/views/IntegrationView.vue')
|
||||
wrapper = mount(IntegrationView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
|
||||
.find((b) => b.textContent?.trim() === 'Copier')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
expect([
|
||||
writeText.mock.calls[0]?.[0],
|
||||
document.body.textContent?.includes('Copié'),
|
||||
document.body.textContent?.includes('/v1/article/'),
|
||||
document.body.textContent?.includes('GET'),
|
||||
]).toEqual([window.location.origin, true, true, true])
|
||||
await vi.waitFor(() =>
|
||||
expect(document.body.textContent).toContain(
|
||||
`const res = await fetch('${window.location.origin}/v1/article/')`,
|
||||
),
|
||||
)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
it.each(['desktop', 'mobile'] as const)(
|
||||
'dismisses nested picker with Escape and outside press in %s menu',
|
||||
async (menu) => {
|
||||
useSessionStore().session = { verified: true, username: 'marta', displayName: '' }
|
||||
useSessionStore().status = 'ready'
|
||||
await router.push('/profile')
|
||||
wrapper =
|
||||
menu === 'desktop'
|
||||
? mount(UserMenu, {
|
||||
attachTo: document.body,
|
||||
props: { username: 'marta', collapsed: false },
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
: mount(AccountMenuSheet, {
|
||||
attachTo: document.body,
|
||||
props: { username: 'marta', open: true },
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
if (menu === 'desktop')
|
||||
document.body.querySelector<HTMLButtonElement>('button[title="Account"]')!.click()
|
||||
await flushPromises()
|
||||
trigger().focus()
|
||||
key(trigger(), 'ArrowDown')
|
||||
await flushPromises()
|
||||
key(document.activeElement!, 'Escape')
|
||||
await flushPromises()
|
||||
const escape = [
|
||||
locale.value,
|
||||
!document.body.querySelector('[role="listbox"]'),
|
||||
document.activeElement === trigger(),
|
||||
]
|
||||
trigger().focus()
|
||||
key(trigger(), 'ArrowDown')
|
||||
await flushPromises()
|
||||
const outside = document.createElement('button')
|
||||
outside.textContent = 'Outside'
|
||||
document.body.append(outside)
|
||||
outside.dispatchEvent(
|
||||
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
|
||||
pointerType: 'mouse',
|
||||
}),
|
||||
)
|
||||
outside.click()
|
||||
await flushPromises()
|
||||
expect([
|
||||
escape,
|
||||
locale.value,
|
||||
!document.body.querySelector('[role="listbox"]'),
|
||||
document.activeElement === trigger(),
|
||||
]).toEqual([['en', true, true], 'en', true, true])
|
||||
},
|
||||
)
|
||||
it.each(['/article', '/article/12/edit'])(
|
||||
'updates already-open delete descriptions round-trip from %s preserving title and image sentence',
|
||||
async (path) => {
|
||||
const article = {
|
||||
id: 12,
|
||||
title: 'Log in',
|
||||
body: '<p><img src="/static/uploadedImages/photo.png"></p>',
|
||||
is_online: false,
|
||||
images: [],
|
||||
created_at: '2025-02-14T12:00:00Z',
|
||||
updated_at: '2025-02-14T12:00:00Z',
|
||||
}
|
||||
request.mockImplementation(async (url: string) =>
|
||||
url === '/v1/article/' ? [article] : article,
|
||||
)
|
||||
await mountRoute(path)
|
||||
if (path === '/article') {
|
||||
document.body
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Actions for Log in"]')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find((b) => b.textContent?.trim() === 'Delete…')!
|
||||
.click()
|
||||
} else
|
||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
|
||||
.find((b) => b.textContent?.trim() === 'Delete')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
const snapshots = []
|
||||
for (const language of ['en', 'fr', 'en'] as const) {
|
||||
setLocale(language)
|
||||
await flushPromises()
|
||||
snapshots.push(document.body.querySelector('[role="alertdialog"]')?.textContent)
|
||||
}
|
||||
expect(
|
||||
snapshots.map((text) => [
|
||||
text?.includes('Log in'),
|
||||
text?.includes('will be deleted'),
|
||||
text?.includes('sera supprimé'),
|
||||
]),
|
||||
).toEqual([
|
||||
[true, true, false],
|
||||
[true, false, true],
|
||||
[true, true, false],
|
||||
])
|
||||
expect(snapshots[0]).toContain('Its images stay in your library.')
|
||||
expect(snapshots[1]).toContain('Ses images restent dans votre bibliothèque.')
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'),
|
||||
)
|
||||
.find((b) => b.textContent?.trim() === 'Cancel')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
},
|
||||
)
|
||||
it('translates the editor mobile heading', async () => {
|
||||
request.mockResolvedValue({
|
||||
id: 12,
|
||||
title: 'Data title',
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: false,
|
||||
created_at: '2025-02-14T12:00:00Z',
|
||||
updated_at: '2025-02-14T12:00:00Z',
|
||||
})
|
||||
setViewportWidth(390)
|
||||
setLocale('fr')
|
||||
await mountRoute('/article/12/edit')
|
||||
expect(document.body.textContent).toContain('Modifier l’article')
|
||||
})
|
||||
it('updates a visible 429 upload alert round-trip', async () => {
|
||||
let upload: LimitedUpload | undefined
|
||||
const captureUpload = (instance: LimitedUpload) => {
|
||||
upload = instance
|
||||
}
|
||||
class LimitedUpload extends EventTarget {
|
||||
upload = new EventTarget()
|
||||
status = 0
|
||||
responseText = ''
|
||||
withCredentials = false
|
||||
open() {}
|
||||
send() {
|
||||
captureUpload(this)
|
||||
}
|
||||
abort() {}
|
||||
getResponseHeader() {
|
||||
return '42'
|
||||
}
|
||||
respond() {
|
||||
this.status = 429
|
||||
this.dispatchEvent(new Event('load'))
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('XMLHttpRequest', LimitedUpload)
|
||||
vi.stubGlobal('crypto', { subtle: { digest: async () => new Uint8Array(32).buffer } })
|
||||
request.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'existing.jpg',
|
||||
url: '/media/existing.jpg',
|
||||
content_type: 'image/jpeg',
|
||||
size: 1024,
|
||||
width: 100,
|
||||
height: 100,
|
||||
created_at: '2025-02-14T12:00:00Z',
|
||||
used_in: 0,
|
||||
sha256: 'abcdef',
|
||||
uploaded_by: 'Marta',
|
||||
duplicate: false,
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
counts: { all: 1, unused: 1, duplicates: 0 },
|
||||
})
|
||||
const listing = await request()
|
||||
request.mockImplementation(async (path: string) =>
|
||||
path.includes('?hash=')
|
||||
? { items: [], next_cursor: null, counts: { all: 0, unused: 0, duplicates: 0 } }
|
||||
: listing,
|
||||
)
|
||||
await mountRoute('/media')
|
||||
const input = document.body.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||
Object.defineProperty(input, 'files', {
|
||||
value: [new File(['photo'], 'photo.jpg', { type: 'image/jpeg' })],
|
||||
configurable: true,
|
||||
})
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await vi.waitFor(() => expect(upload).toBeDefined())
|
||||
upload!.respond()
|
||||
await flushPromises()
|
||||
const snapshots = []
|
||||
for (const language of ['en', 'fr', 'en'] as const) {
|
||||
setLocale(language)
|
||||
await flushPromises()
|
||||
snapshots.push(
|
||||
Array.from(document.body.querySelectorAll('[role="status"]')).some(
|
||||
(e) =>
|
||||
e.textContent?.includes(
|
||||
language === 'fr' ? 'Trop d’importations' : 'Too many uploads',
|
||||
) &&
|
||||
e.textContent.includes(
|
||||
language === 'fr'
|
||||
? 'Pour votre sécurité, patientez avant d’importer à nouveau.'
|
||||
: 'For your security, wait a moment before uploading again.',
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
expect(snapshots).toEqual([true, true, true])
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t, locale, formatNumber } from '@/i18n'
|
||||
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
export const ALLOWED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
|
||||
@@ -39,7 +40,13 @@ export function extensionMatchesType(file: File) {
|
||||
}
|
||||
|
||||
export function formatMegabytes(bytes: number) {
|
||||
return (bytes / (1024 * 1024)).toFixed(1)
|
||||
const value = bytes / (1024 * 1024)
|
||||
return locale.value === 'fr'
|
||||
? formatNumber(value, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
: value.toFixed(1)
|
||||
}
|
||||
|
||||
export function isSizeRejectionBody(body: string) {
|
||||
@@ -47,16 +54,21 @@ export function isSizeRejectionBody(body: string) {
|
||||
}
|
||||
|
||||
export function rejectionTitle(filename: string) {
|
||||
return `"${filename}" can't be used`
|
||||
return t("\u0022{0}\u0022 can't be used", [filename])
|
||||
}
|
||||
|
||||
export function sizeRejectionText(filename: string, bytes: number) {
|
||||
return `${filename} is ${formatMegabytes(bytes)} MB. Images can be up to 10 MB.`
|
||||
return t('{0} is {1} MB. Images can be up to 10 MB.', [filename, formatMegabytes(bytes)])
|
||||
}
|
||||
|
||||
export function formatWholeKB(bytes: number): string {
|
||||
const kb = bytes / 1024
|
||||
const rounded = Math.round(kb * 10) / 10
|
||||
if (locale.value === 'fr')
|
||||
return formatNumber(rounded, {
|
||||
minimumFractionDigits: rounded === 0 ? 1 : 0,
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
if (rounded === 0) {
|
||||
return '0.0'
|
||||
}
|
||||
@@ -65,7 +77,7 @@ export function formatWholeKB(bytes: number): string {
|
||||
|
||||
export function formatUploadProgress(loadedBytes: number, totalBytes: number): string {
|
||||
if (totalBytes < 1024 * 1024) {
|
||||
return `Uploading ${formatWholeKB(loadedBytes)} of ${formatWholeKB(totalBytes)} KB`
|
||||
return t('Uploading {0} of {1} KB', [formatWholeKB(loadedBytes), formatWholeKB(totalBytes)])
|
||||
}
|
||||
return `Uploading ${formatMegabytes(loadedBytes)} of ${formatMegabytes(totalBytes)} MB`
|
||||
return t('Uploading {0} of {1} MB', [formatMegabytes(loadedBytes), formatMegabytes(totalBytes)])
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { formatDimensions, formatSize } from '@/lib/mediaFormat'
|
||||
|
||||
@@ -18,7 +19,11 @@ function byPreference(first: MediaItem, second: MediaItem) {
|
||||
|
||||
export function groupHeader(copies: MediaItem[]) {
|
||||
const [first] = copies
|
||||
return `${copies.length} identical images · ${formatSize(first!.size)} each · ${formatDimensions(first!.width, first!.height)}`
|
||||
return t('{0} identical images · {1} each · {2}', [
|
||||
copies.length,
|
||||
formatSize(first!.size),
|
||||
formatDimensions(first!.width, first!.height),
|
||||
])
|
||||
}
|
||||
|
||||
export function groupDuplicates(items: MediaItem[]): DuplicateGroup[] {
|
||||
@@ -30,7 +35,14 @@ export function groupDuplicates(items: MediaItem[]): DuplicateGroup[] {
|
||||
.filter(([, copies]) => copies.length > 1)
|
||||
.map(([hash, copies]) => {
|
||||
const sorted = [...copies].sort(byPreference)
|
||||
return { hash, copies: sorted, suggested: sorted[0]!, header: groupHeader(sorted) }
|
||||
return {
|
||||
hash,
|
||||
copies: sorted,
|
||||
suggested: sorted[0]!,
|
||||
get header() {
|
||||
return groupHeader(sorted)
|
||||
},
|
||||
}
|
||||
})
|
||||
.sort((first, second) => byAge(first.suggested, second.suggested))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t, locale, formatNumber } from '@/i18n'
|
||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
|
||||
const BYTES_PER_KB = 1024
|
||||
@@ -22,14 +23,22 @@ export interface MediaNameSource {
|
||||
|
||||
export function formatSize(bytes: number) {
|
||||
if (bytes < BYTES_PER_MB) {
|
||||
return `${Math.round(bytes / BYTES_PER_KB)} KB`
|
||||
return t('{0} KB', [Math.round(bytes / BYTES_PER_KB)])
|
||||
}
|
||||
return `${(bytes / BYTES_PER_MB).toFixed(1)} MB`
|
||||
const amount = bytes / BYTES_PER_MB
|
||||
return t('{0} MB', [
|
||||
locale.value === 'fr'
|
||||
? formatNumber(amount, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
: amount.toFixed(1),
|
||||
])
|
||||
}
|
||||
|
||||
export function formatDimensions(width: number | null, height: number | null) {
|
||||
if (width === null || height === null) {
|
||||
return VECTOR_DIMENSIONS
|
||||
return t(VECTOR_DIMENSIONS)
|
||||
}
|
||||
return `${width} × ${height}`
|
||||
}
|
||||
@@ -41,11 +50,11 @@ export function formatType(contentType: string) {
|
||||
export const formatMediaDate = formatAbsoluteDateFromIso
|
||||
|
||||
export function pluralArticles(count: number) {
|
||||
return `${count} ${count === 1 ? 'article' : 'articles'}`
|
||||
return t(count === 1 ? '1 article' : '{0} articles', [count])
|
||||
}
|
||||
|
||||
export function usedInLabel(count: number) {
|
||||
return count > 0 ? `used in ${pluralArticles(count)}` : 'unused'
|
||||
return count > 0 ? t('used in {0}', [pluralArticles(count)]) : t('unused')
|
||||
}
|
||||
|
||||
export function accessibleName(item: MediaNameSource) {
|
||||
@@ -74,13 +83,17 @@ export function joinNames(names: string[]) {
|
||||
if (names.length <= 1) {
|
||||
return names.join('')
|
||||
}
|
||||
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
|
||||
return t('{0} and {1}', [names.slice(0, -1).join(', '), names[names.length - 1]])
|
||||
}
|
||||
|
||||
export function libraryMatchText(
|
||||
filename: string,
|
||||
existing: { created_at: string; uploaded_by: string },
|
||||
) {
|
||||
const by = existing.uploaded_by ? ` by ${existing.uploaded_by}` : ''
|
||||
return `${filename} matches an image uploaded on ${formatMediaDate(existing.created_at)}${by}.`
|
||||
const by = existing.uploaded_by ? t(' by {0}', [existing.uploaded_by]) : ''
|
||||
return t('{0} matches an image uploaded on {1}{2}.', [
|
||||
filename,
|
||||
formatMediaDate(existing.created_at),
|
||||
by,
|
||||
])
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '@/i18n'
|
||||
import { formatMegabytes } from '@/lib/imageUpload'
|
||||
|
||||
export type UploadRowStatus =
|
||||
@@ -42,40 +43,40 @@ export function isSettled(status: UploadRowStatus) {
|
||||
}
|
||||
|
||||
function imageCount(total: number) {
|
||||
return `${total} ${total === 1 ? 'image' : 'images'}`
|
||||
}
|
||||
|
||||
function fileCount(total: number) {
|
||||
return `${total} ${total === 1 ? 'file' : 'files'}`
|
||||
return t(total === 1 ? '{0} image' : '{0} images', [total])
|
||||
}
|
||||
|
||||
export function rejectionTitle(kind: UploadRejection, filename: string, bytes: number) {
|
||||
if (kind === 'size') {
|
||||
return `${filename} is ${formatMegabytes(bytes)} MB`
|
||||
return t('{0} is {1} MB', [filename, formatMegabytes(bytes)])
|
||||
}
|
||||
if (kind === 'unreadable') {
|
||||
return `${filename} couldn't be read`
|
||||
return t("{0} couldn't be read", [filename])
|
||||
}
|
||||
return `${filename} isn't an image`
|
||||
return t("{0} isn't an image", [filename])
|
||||
}
|
||||
|
||||
export function rejectionText(kind: UploadRejection) {
|
||||
return kind === 'unreadable' ? UNREADABLE_TEXT : REJECTION_TEXT
|
||||
return kind === 'unreadable' ? t(UNREADABLE_TEXT) : t(REJECTION_TEXT)
|
||||
}
|
||||
|
||||
export function duplicateText(
|
||||
existing: { filename: string; created_at: string; uploaded_by: string },
|
||||
formatDate: (iso: string) => string,
|
||||
) {
|
||||
const by = existing.uploaded_by ? ` by ${existing.uploaded_by}` : ''
|
||||
return `Same as "${existing.filename}", uploaded ${formatDate(existing.created_at)}${by}.`
|
||||
const by = existing.uploaded_by ? t(' by {0}', [existing.uploaded_by]) : ''
|
||||
return t('Same as \u0022{0}\u0022, uploaded {1}{2}.', [
|
||||
existing.filename,
|
||||
formatDate(existing.created_at),
|
||||
by,
|
||||
])
|
||||
}
|
||||
|
||||
export function summaryLine(counts: { added: number; duplicate: number; attention: number }) {
|
||||
const parts = [
|
||||
...(counts.added > 0 ? [`${counts.added} uploaded`] : []),
|
||||
...(counts.duplicate > 0 ? [`${counts.duplicate} already in your library`] : []),
|
||||
...(counts.attention > 0 ? [`${counts.attention} need attention`] : []),
|
||||
...(counts.added > 0 ? [t('{0} uploaded', [counts.added])] : []),
|
||||
...(counts.duplicate > 0 ? [t('{0} already in your library', [counts.duplicate])] : []),
|
||||
...(counts.attention > 0 ? [t('{0} need attention', [counts.attention])] : []),
|
||||
]
|
||||
return parts.join(' · ')
|
||||
}
|
||||
@@ -87,8 +88,10 @@ export function describeUpload(rows: UploadRowLike[]): UploadHeader {
|
||||
if (settled < total) {
|
||||
return {
|
||||
phase: 'working',
|
||||
title: `Uploading ${imageCount(total)}`,
|
||||
sub: `${settled} of ${total} done`,
|
||||
get title() {
|
||||
return t('Uploading {0}', [imageCount(total)])
|
||||
},
|
||||
sub: t('{0} of {1} done', [settled, total]),
|
||||
summary: null,
|
||||
}
|
||||
}
|
||||
@@ -98,13 +101,22 @@ export function describeUpload(rows: UploadRowLike[]): UploadHeader {
|
||||
if (added === 0 && duplicate === 0) {
|
||||
const sub =
|
||||
count('rejected') === total
|
||||
? `${fileCount(total)} ${total === 1 ? 'was' : 'were'} rejected`
|
||||
: `${fileCount(total)} ${total === 1 ? 'needs' : 'need'} attention`
|
||||
return { phase: 'settled', title: 'Nothing was uploaded', sub, summary: null }
|
||||
? t(total === 1 ? '{0} file was rejected' : '{0} files were rejected', [total])
|
||||
: t(total === 1 ? '{0} file needs attention' : '{0} files need attention', [total])
|
||||
return {
|
||||
phase: 'settled',
|
||||
get title() {
|
||||
return t('Nothing was uploaded')
|
||||
},
|
||||
sub,
|
||||
summary: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
phase: 'settled',
|
||||
title: 'Upload finished',
|
||||
get title() {
|
||||
return t('Upload finished')
|
||||
},
|
||||
sub: imageCount(total),
|
||||
summary: summaryLine({ added, duplicate, attention }),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { t } from '@/i18n'
|
||||
export const registrationCancelledToast = {
|
||||
color: 'success' as const,
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Registration cancelled',
|
||||
description: 'You can create the owner account again.',
|
||||
get title() {
|
||||
return t('Registration cancelled')
|
||||
},
|
||||
get description() {
|
||||
return t('You can create the owner account again.')
|
||||
},
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t, locale } from '@/i18n'
|
||||
const MINUTE_MS = 60 * 1000
|
||||
const HOUR_MS = 60 * MINUTE_MS
|
||||
const DAY_MS = 24 * HOUR_MS
|
||||
@@ -19,6 +20,13 @@ const MONTH_NAMES = [
|
||||
]
|
||||
|
||||
function formatAbsoluteDate(date: Date) {
|
||||
if (locale.value === 'fr') {
|
||||
return new Intl.DateTimeFormat('fr', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`
|
||||
}
|
||||
|
||||
@@ -32,18 +40,18 @@ export function formatRelativeDate(iso: string, now: Date = new Date()) {
|
||||
|
||||
if (diffMs < HOUR_MS) {
|
||||
const minutes = Math.max(1, Math.floor(diffMs / MINUTE_MS))
|
||||
return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`
|
||||
return minutes === 1 ? t('1 minute ago') : t('{0} minutes ago', [minutes])
|
||||
}
|
||||
if (diffMs < DAY_MS) {
|
||||
const hours = Math.floor(diffMs / HOUR_MS)
|
||||
return hours === 1 ? '1 hour ago' : `${hours} hours ago`
|
||||
return hours === 1 ? t('1 hour ago') : t('{0} hours ago', [hours])
|
||||
}
|
||||
const days = Math.floor(diffMs / DAY_MS)
|
||||
if (days === 1) {
|
||||
return 'Yesterday'
|
||||
return t('Yesterday')
|
||||
}
|
||||
if (days < RELATIVE_CUTOFF_DAYS) {
|
||||
return `${days} days ago`
|
||||
return t('{0} days ago', [days])
|
||||
}
|
||||
return formatAbsoluteDate(date)
|
||||
}
|
||||
@@ -58,5 +66,5 @@ function isSameLocalDay(a: Date, b: Date) {
|
||||
|
||||
export function formatCreatedDate(iso: string, now: Date = new Date()) {
|
||||
const date = new Date(iso)
|
||||
return isSameLocalDay(date, now) ? 'Today' : formatAbsoluteDate(date)
|
||||
return isSameLocalDay(date, now) ? t('Today') : formatAbsoluteDate(date)
|
||||
}
|
||||
+12
-11
@@ -1,3 +1,4 @@
|
||||
import { t } from '@/i18n'
|
||||
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
|
||||
|
||||
export const ENABLE_BROWSER_UPDATES_COMMAND = 'renewcms enable-browser-updates'
|
||||
@@ -26,12 +27,12 @@ export function formatLastChecked(iso: string, now: Date = new Date()) {
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(now.getDate() - 1)
|
||||
if (isSameLocalDay(date, now)) {
|
||||
return `Last checked today at ${formatClock(date)}`
|
||||
return t('Last checked today at {0}', [formatClock(date)])
|
||||
}
|
||||
if (isSameLocalDay(date, yesterday)) {
|
||||
return `Last checked yesterday at ${formatClock(date)}`
|
||||
return t('Last checked yesterday at {0}', [formatClock(date)])
|
||||
}
|
||||
return `Last checked ${formatAbsoluteDateFromIso(iso)} at ${formatClock(date)}`
|
||||
return t('Last checked {0} at {1}', [formatAbsoluteDateFromIso(iso), formatClock(date)])
|
||||
}
|
||||
|
||||
export function isCheckDue(checkedAt: string | null, now: number = Date.now()) {
|
||||
@@ -42,7 +43,7 @@ export function isCheckDue(checkedAt: string | null, now: number = Date.now()) {
|
||||
}
|
||||
|
||||
export function formatReleased(iso: string) {
|
||||
return `Released ${formatAbsoluteDateFromIso(iso)}`
|
||||
return t('Released {0}', [formatAbsoluteDateFromIso(iso)])
|
||||
}
|
||||
|
||||
const BYTES_PER_MB = 1024 * 1024
|
||||
@@ -56,7 +57,7 @@ export function formatDownloadProgress(progress: {
|
||||
}) {
|
||||
const done = Math.round(progress.downloaded_bytes / BYTES_PER_MB)
|
||||
const total = Math.round(progress.total_bytes / BYTES_PER_MB)
|
||||
return `${progress.percent}% · ${done} of ${total} MB`
|
||||
return t('{0}% · {1} of {2} MB', [progress.percent, done, total])
|
||||
}
|
||||
|
||||
export function downloadAnnouncement(version: string, percent: number) {
|
||||
@@ -64,8 +65,8 @@ export function downloadAnnouncement(version: string, percent: number) {
|
||||
Math.floor(percent / ANNOUNCEMENT_STEP_PERCENT) * ANNOUNCEMENT_STEP_PERCENT,
|
||||
LAST_ANNOUNCED_PERCENT,
|
||||
)
|
||||
const base = `Downloading version ${version}`
|
||||
return step > 0 ? `${base}, ${step} percent` : base
|
||||
const base = t('Downloading version {0}', [version])
|
||||
return step > 0 ? t('{0}, {1} percent', [base, step]) : base
|
||||
}
|
||||
|
||||
export const VERIFYING_ANNOUNCEMENT = 'Checking the download'
|
||||
@@ -84,7 +85,7 @@ export function browserUpdatesDocsUrl(releaseUrl: string) {
|
||||
}
|
||||
|
||||
export function formatStartedAt(iso: string) {
|
||||
return `Started at ${formatClock(new Date(iso))}`
|
||||
return t('Started at {0}', [formatClock(new Date(iso))])
|
||||
}
|
||||
|
||||
export function formatStartedWithAge(iso: string, now: Date = new Date()) {
|
||||
@@ -96,10 +97,10 @@ export function formatUpdatedAt(iso: string, now: Date = new Date()) {
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(now.getDate() - 1)
|
||||
if (isSameLocalDay(date, now)) {
|
||||
return `Updated today at ${formatClock(date)}`
|
||||
return t('Updated today at {0}', [formatClock(date)])
|
||||
}
|
||||
if (isSameLocalDay(date, yesterday)) {
|
||||
return `Updated yesterday at ${formatClock(date)}`
|
||||
return t('Updated yesterday at {0}', [formatClock(date)])
|
||||
}
|
||||
return `Updated ${formatAbsoluteDateFromIso(iso)} at ${formatClock(date)}`
|
||||
return t('Updated {0} at {1}', [formatAbsoluteDateFromIso(iso), formatClock(date)])
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue'
|
||||
import ResultPanel from '@/components/ResultPanel.vue'
|
||||
@@ -19,7 +21,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const api = useApi()
|
||||
const session = useSessionStore()
|
||||
const toast = useToast()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const code = typeof route.query.c === 'string' ? route.query.c : ''
|
||||
|
||||
@@ -40,22 +42,29 @@ const passwordValid = computed(() => password.value.length >= 8)
|
||||
|
||||
const usernameError = computed(() => {
|
||||
if (usernameTaken.value) {
|
||||
return USERNAME_TAKEN_MESSAGE
|
||||
return t(USERNAME_TAKEN_MESSAGE)
|
||||
}
|
||||
return usernameTouched.value && !usernameValid.value ? USERNAME_RULE_MESSAGE : ''
|
||||
return usernameTouched.value && !usernameValid.value ? t(USERNAME_RULE_MESSAGE) : ''
|
||||
})
|
||||
|
||||
const passwordRules = computed(() => [{ label: 'At least 8 characters', met: passwordValid.value }])
|
||||
const passwordRules = computed(() => [
|
||||
{
|
||||
get label() {
|
||||
return t('At least 8 characters')
|
||||
},
|
||||
met: passwordValid.value,
|
||||
},
|
||||
])
|
||||
|
||||
const formValid = computed(() => usernameValid.value && passwordValid.value)
|
||||
|
||||
const showForm = computed(() => state.value === 'form')
|
||||
|
||||
const pageTitle = computed(() => (showForm.value ? 'Accept your invite' : ''))
|
||||
const pageTitle = computed(() => (showForm.value ? t('Accept your invite') : ''))
|
||||
|
||||
const pageDescription = computed(() =>
|
||||
showForm.value
|
||||
? 'Choose a username and password to finish setting up your account.'
|
||||
? t('Choose a username and password to finish setting up your account.')
|
||||
: undefined,
|
||||
)
|
||||
|
||||
@@ -63,11 +72,20 @@ function goToLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const invalidActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
const invalidActions = [
|
||||
{
|
||||
get label() {
|
||||
return t('Go to log in')
|
||||
},
|
||||
onClick: goToLogin,
|
||||
},
|
||||
]
|
||||
|
||||
const expiredActions = [
|
||||
{
|
||||
label: 'Go to log in',
|
||||
get label() {
|
||||
return t('Go to log in')
|
||||
},
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
onClick: goToLogin,
|
||||
@@ -117,12 +135,16 @@ async function loadInvite() {
|
||||
|
||||
async function handleSuccess() {
|
||||
state.value = 'success'
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Welcome to RenewCMS',
|
||||
description: `You're logged in as ${username.value}.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Welcome to RenewCMS')
|
||||
},
|
||||
get description() {
|
||||
return t("You're logged in as {0}.", [username.value])
|
||||
},
|
||||
}))
|
||||
await Promise.all([session.retry(), wait(VERIFIED_REDIRECT_DELAY_MS)])
|
||||
await router.push('/home')
|
||||
}
|
||||
@@ -187,8 +209,8 @@ onMounted(() => {
|
||||
<UAlert
|
||||
color="error"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't create the account"
|
||||
description="Something went wrong on the server. Try again in a moment."
|
||||
:title="t('Couldn\'t create the account')"
|
||||
:description="t('Something went wrong on the server. Try again in a moment.')"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
role="alert"
|
||||
@@ -202,16 +224,20 @@ onMounted(() => {
|
||||
class="flex flex-col items-center gap-4 py-10 text-center"
|
||||
>
|
||||
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
|
||||
<p class="text-xl/7 font-semibold text-highlighted">Checking your invite…</p>
|
||||
<p class="text-base text-muted">This only takes a moment.</p>
|
||||
<p class="text-xl/7 font-semibold text-highlighted">
|
||||
{{ t('Checking your invite…') }}
|
||||
</p>
|
||||
<p class="text-base text-muted">
|
||||
{{ t('This only takes a moment.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="state === 'failed'" class="flex flex-col gap-4.5">
|
||||
<UAlert
|
||||
color="error"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't check your invite"
|
||||
description="Something went wrong on the server. Try again in a moment."
|
||||
:title="t('Couldn\'t check your invite')"
|
||||
:description="t('Something went wrong on the server. Try again in a moment.')"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
role="alert"
|
||||
@@ -224,7 +250,7 @@ onMounted(() => {
|
||||
:ui="{ base: controlClass }"
|
||||
@click="loadInvite"
|
||||
>
|
||||
Try again
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
@@ -232,12 +258,20 @@ onMounted(() => {
|
||||
v-else-if="state === 'invalid'"
|
||||
tone="error"
|
||||
icon="i-lucide-link-2-off"
|
||||
title="This invite link doesn't work"
|
||||
description="It may be incomplete, or it may already have been used. If you already set up your account, log in instead."
|
||||
:title="t('This invite link doesn\'t work')"
|
||||
:description="
|
||||
t(
|
||||
'It may be incomplete, or it may already have been used. If you already set up your account, log in instead.',
|
||||
)
|
||||
"
|
||||
:actions="invalidActions"
|
||||
>
|
||||
<p class="text-sm text-pretty text-muted">
|
||||
Open the link from your email again, or copy the full address into your browser.
|
||||
{{
|
||||
t(
|
||||
'Open the link from your email again, or copy the full address into your browser.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</ResultPanel>
|
||||
|
||||
@@ -245,8 +279,12 @@ onMounted(() => {
|
||||
v-else-if="state === 'expired'"
|
||||
tone="error"
|
||||
icon="i-lucide-clock"
|
||||
title="This invite has expired"
|
||||
description="Invites work once, for 3 days. Ask the person who invited you to send a new one."
|
||||
:title="t('This invite has expired')"
|
||||
:description="
|
||||
t(
|
||||
'Invites work once, for 3 days. Ask the person who invited you to send a new one.',
|
||||
)
|
||||
"
|
||||
:actions="expiredActions"
|
||||
/>
|
||||
|
||||
@@ -254,8 +292,8 @@ onMounted(() => {
|
||||
v-else-if="state === 'success'"
|
||||
tone="success"
|
||||
icon="i-lucide-circle-check"
|
||||
title="Your account is ready"
|
||||
description="Taking you to your dashboard…"
|
||||
:title="t('Your account is ready')"
|
||||
:description="t('Taking you to your dashboard…')"
|
||||
:actions="[]"
|
||||
/>
|
||||
|
||||
@@ -266,7 +304,7 @@ onMounted(() => {
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UFormField
|
||||
label="Email"
|
||||
:label="t('Email')"
|
||||
name="email"
|
||||
help="From your invitation."
|
||||
:size="controlSize"
|
||||
@@ -288,9 +326,9 @@ onMounted(() => {
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Username"
|
||||
:label="t('Username')"
|
||||
name="username"
|
||||
:error="usernameError || undefined"
|
||||
:error="translate(usernameError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="usernameError" #error="{ error }">
|
||||
@@ -299,11 +337,11 @@ onMounted(() => {
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else #help>
|
||||
Letters and numbers, 3 to 20 characters. You'll use it to log in.
|
||||
{{ t("Letters and numbers, 3 to 20 characters. You'll use it to log in.") }}
|
||||
</template>
|
||||
<template #hint>
|
||||
<span
|
||||
@@ -317,7 +355,7 @@ onMounted(() => {
|
||||
</template>
|
||||
<UInput
|
||||
v-model="username"
|
||||
placeholder="e.g. lea"
|
||||
:placeholder="t('e.g. lea')"
|
||||
autocomplete="username"
|
||||
:disabled="submitting"
|
||||
variant="outline"
|
||||
@@ -331,7 +369,7 @@ onMounted(() => {
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Password" name="password" :size="controlSize">
|
||||
<UFormField :label="t('Password')" name="password" :size="controlSize">
|
||||
<template #help>
|
||||
<PasswordStrengthHint
|
||||
:rules="passwordRules"
|
||||
@@ -361,7 +399,9 @@ onMounted(() => {
|
||||
size="sm"
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
||||
:aria-label="
|
||||
passwordShown ? t('Hide password') : t('Show password')
|
||||
"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
/>
|
||||
@@ -377,19 +417,19 @@ onMounted(() => {
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
>
|
||||
{{ submitting ? 'Creating account…' : 'Accept invite' }}
|
||||
{{ submitting ? t('Creating account…') : t('Accept invite') }}
|
||||
</UButton>
|
||||
</UForm>
|
||||
</template>
|
||||
|
||||
<template v-if="showForm" #footer>
|
||||
<span class="flex flex-wrap items-center gap-1.5">
|
||||
Already have an account?
|
||||
{{ t('Already have an account?') }}
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
Log in
|
||||
{{ t('Log in') }}
|
||||
</RouterLink>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
+112
-48
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ArticleTable from '@/components/ArticleTable.vue'
|
||||
import ArticleCardList from '@/components/ArticleCardList.vue'
|
||||
@@ -19,8 +21,8 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { request } = useApi()
|
||||
const { listArticles } = useArticles()
|
||||
const overlay = useOverlay()
|
||||
const toast = useToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const articles = ref<Article[]>([])
|
||||
const loading = ref(true)
|
||||
@@ -54,7 +56,9 @@ const activeTab = computed<'' | 'online' | 'draft'>({
|
||||
get: () =>
|
||||
route.query.status === 'online' || route.query.status === 'draft' ? route.query.status : '',
|
||||
set: (value) => {
|
||||
router.replace({ query: { ...route.query, status: value || undefined } })
|
||||
router.replace({
|
||||
query: { ...route.query, status: value || undefined },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -68,9 +72,24 @@ const counts = computed(() => {
|
||||
})
|
||||
|
||||
const tabItems = computed(() => [
|
||||
{ label: `All ${counts.value.all}`, value: '' },
|
||||
{ label: `Online ${counts.value.online}`, value: 'online' },
|
||||
{ label: `Drafts ${counts.value.drafts}`, value: 'draft' },
|
||||
{
|
||||
get label() {
|
||||
return t('All {0}', [counts.value.all])
|
||||
},
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Online {0}', [counts.value.online])
|
||||
},
|
||||
value: 'online',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Drafts {0}', [counts.value.drafts])
|
||||
},
|
||||
value: 'draft',
|
||||
},
|
||||
])
|
||||
|
||||
const statusFilteredArticles = computed(() => {
|
||||
@@ -156,8 +175,12 @@ function notFoundToast(article: Article) {
|
||||
id: `article-not-found-${article.id}`,
|
||||
color: 'info' as const,
|
||||
icon: 'i-lucide-info',
|
||||
title: 'That article no longer exists',
|
||||
description: 'It was deleted somewhere else, so we removed it from the list.',
|
||||
get title() {
|
||||
return t('That article no longer exists')
|
||||
},
|
||||
get description() {
|
||||
return t('It was deleted somewhere else, so we removed it from the list.')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,30 +194,45 @@ async function setOnline(article: Article, makeOnline: boolean) {
|
||||
articles.value = articles.value.map((existing) =>
|
||||
existing.id === updated.id ? updated : existing,
|
||||
)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: makeOnline ? 'Article published' : 'Article unpublished',
|
||||
description: makeOnline
|
||||
? `"${article.title}" is now online.`
|
||||
: `"${article.title}" is now offline.`,
|
||||
})
|
||||
get title() {
|
||||
return makeOnline ? t('Article published') : t('Article unpublished')
|
||||
},
|
||||
get description() {
|
||||
return makeOnline
|
||||
? t('\u0022{0}\u0022 is now online.', [article.title])
|
||||
: t('\u0022{0}\u0022 is now offline.', [article.title])
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
removeArticle(article.id)
|
||||
toast.add(notFoundToast(article))
|
||||
toast.add(() => notFoundToast(article))
|
||||
return
|
||||
}
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${article.id}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: makeOnline ? "Couldn't publish" : "Couldn't unpublish",
|
||||
description: "The server didn't respond. Nothing changed.",
|
||||
get title() {
|
||||
return makeOnline ? t("Couldn't publish") : t("Couldn't unpublish")
|
||||
},
|
||||
get description() {
|
||||
return t("The server didn't respond. Nothing changed.")
|
||||
},
|
||||
duration: 0,
|
||||
actions: [{ label: 'Retry', onClick: () => setOnline(article, makeOnline) }],
|
||||
})
|
||||
actions: [
|
||||
{
|
||||
get label() {
|
||||
return t('Retry')
|
||||
},
|
||||
onClick: () => setOnline(article, makeOnline),
|
||||
},
|
||||
],
|
||||
}))
|
||||
} finally {
|
||||
setBusy(article.id, false)
|
||||
}
|
||||
@@ -222,48 +260,62 @@ async function handleCopyUrl(id: number) {
|
||||
const url = `${window.location.origin}/v1/article/${id}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-link-copied-${id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Link copied',
|
||||
description: `The API URL for "${article.title}" is on your clipboard.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Link copied')
|
||||
},
|
||||
get description() {
|
||||
return t('The API URL for \u0022{0}\u0022 is on your clipboard.', [article.title])
|
||||
},
|
||||
}))
|
||||
} catch {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-link-copy-failed-${id}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't copy the link",
|
||||
})
|
||||
get title() {
|
||||
return t("Couldn't copy the link")
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function deleteDescription(article: Article) {
|
||||
const count = bodyImageCount(article.body)
|
||||
const imagesPhrase =
|
||||
count === 0 ? '' : ' Its images stay in your library.'
|
||||
return `"${article.title}" will be deleted.${imagesPhrase} This can't be undone.`
|
||||
const imagesPhrase = count === 0 ? '' : t(' Its images stay in your library.')
|
||||
return t("\u0022{0}\u0022 will be deleted.{1} This can't be undone.", [
|
||||
article.title,
|
||||
imagesPhrase,
|
||||
])
|
||||
}
|
||||
|
||||
async function performDelete(article: Article) {
|
||||
try {
|
||||
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
|
||||
removeArticle(article.id)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-deleted-${article.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Article deleted',
|
||||
description: `"${article.title}" is gone.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Article deleted')
|
||||
},
|
||||
get description() {
|
||||
return t('\u0022{0}\u0022 is gone.', [article.title])
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
removeArticle(article.id)
|
||||
toast.add(notFoundToast(article))
|
||||
toast.add(() => notFoundToast(article))
|
||||
return
|
||||
}
|
||||
throw new Error("Couldn't delete the article, so it's still there. Try again in a moment.")
|
||||
throw new Error(
|
||||
t("Couldn't delete the article, so it's still there. Try again in a moment."),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,11 +335,21 @@ async function handleDelete(article: Article) {
|
||||
const confirmed = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Delete this article?',
|
||||
description: deleteDescription(article),
|
||||
confirmLabel: 'Delete article',
|
||||
cancelLabel: 'Cancel',
|
||||
pendingLabel: 'Deleting…',
|
||||
get title() {
|
||||
return t('Delete this article?')
|
||||
},
|
||||
get description() {
|
||||
return deleteDescription(article)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Delete article')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Cancel')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Deleting…')
|
||||
},
|
||||
tone: 'danger',
|
||||
action: () => performDelete(article),
|
||||
},
|
||||
@@ -311,7 +373,7 @@ function clearSearch() {
|
||||
debouncedSearch.value = ''
|
||||
router.replace({ query: { ...route.query, q: undefined } })
|
||||
nextTick(() => {
|
||||
document.querySelector<HTMLInputElement>('input[placeholder="Search by title"]')?.focus()
|
||||
document.querySelector<HTMLInputElement>('input[type="search"]')?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -340,16 +402,18 @@ onMounted(loadArticles)
|
||||
tabindex="-1"
|
||||
class="text-xl font-semibold text-highlighted"
|
||||
>
|
||||
Articles
|
||||
{{ t('Articles') }}
|
||||
</h1>
|
||||
<p class="text-muted">Everything you've written, online and in draft.</p>
|
||||
<p class="text-muted">
|
||||
{{ t("Everything you've written, online and in draft.") }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
class="hidden h-8 px-3 sm:flex"
|
||||
@click="openNewArticleModal"
|
||||
>
|
||||
New article
|
||||
{{ t('New article') }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
@@ -366,7 +430,7 @@ onMounted(loadArticles)
|
||||
v-model="searchInput"
|
||||
type="search"
|
||||
icon="i-lucide-search"
|
||||
placeholder="Search by title"
|
||||
:placeholder="t('Search by title')"
|
||||
class="w-full sm:w-65"
|
||||
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
|
||||
/>
|
||||
@@ -407,7 +471,7 @@ onMounted(loadArticles)
|
||||
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover) sm:hidden"
|
||||
@click="openNewArticleModal"
|
||||
>
|
||||
New article
|
||||
{{ t('New article') }}
|
||||
</UButton>
|
||||
|
||||
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
|
||||
|
||||
+136
-60
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ErrorView from '@/components/ErrorView.vue'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
@@ -28,8 +30,8 @@ type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { request } = useApi()
|
||||
const overlay = useOverlay()
|
||||
const toast = useToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const articleId = Number(route.params.id)
|
||||
const draft = useArticleDraft(articleId)
|
||||
@@ -91,11 +93,13 @@ const publishDisabled = computed(() => overLimit.value || draft.state.value ===
|
||||
async function runSave() {
|
||||
const outcome = await draft.save()
|
||||
if (outcome === 'not-found') {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: 'That article no longer exists.',
|
||||
})
|
||||
get title() {
|
||||
return t('That article no longer exists.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,36 +125,51 @@ async function setOnline(makeOnline: boolean) {
|
||||
if (!article.value) {
|
||||
return
|
||||
}
|
||||
publishPending.value = makeOnline ? 'Publishing…' : 'Unpublishing…'
|
||||
publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…')
|
||||
try {
|
||||
const updated = await request<Article>(
|
||||
`/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}`,
|
||||
{ method: 'PATCH' },
|
||||
)
|
||||
article.value = updated
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-${makeOnline ? 'published' : 'unpublished'}-${articleId}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: makeOnline ? 'Article published' : 'Article unpublished',
|
||||
description: makeOnline
|
||||
? `"${updated.title}" is now online.`
|
||||
: `"${updated.title}" is now offline.`,
|
||||
})
|
||||
get title() {
|
||||
return makeOnline ? t('Article published') : t('Article unpublished')
|
||||
},
|
||||
get description() {
|
||||
return makeOnline
|
||||
? t('\u0022{0}\u0022 is now online.', [updated.title])
|
||||
: t('\u0022{0}\u0022 is now offline.', [updated.title])
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
loadState.value = 'notFound'
|
||||
return
|
||||
}
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${articleId}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: makeOnline ? "Couldn't publish" : "Couldn't unpublish",
|
||||
description: "The server didn't respond. Nothing changed.",
|
||||
get title() {
|
||||
return makeOnline ? t("Couldn't publish") : t("Couldn't unpublish")
|
||||
},
|
||||
get description() {
|
||||
return t("The server didn't respond. Nothing changed.")
|
||||
},
|
||||
duration: 0,
|
||||
actions: [{ label: 'Retry', onClick: () => setOnline(makeOnline) }],
|
||||
})
|
||||
actions: [
|
||||
{
|
||||
get label() {
|
||||
return t('Retry')
|
||||
},
|
||||
onClick: () => setOnline(makeOnline),
|
||||
},
|
||||
],
|
||||
}))
|
||||
} finally {
|
||||
publishPending.value = undefined
|
||||
}
|
||||
@@ -166,28 +185,36 @@ function handleUnpublish() {
|
||||
|
||||
function deleteDescription() {
|
||||
const count = bodyImageCount(article.value?.body ?? '')
|
||||
const imagesPhrase =
|
||||
count === 0 ? '' : ' Its images stay in your library.'
|
||||
return `"${article.value?.title ?? ''}" will be deleted.${imagesPhrase} This can't be undone.`
|
||||
const imagesPhrase = count === 0 ? '' : t(' Its images stay in your library.')
|
||||
return t("\u0022{0}\u0022 will be deleted.{1} This can't be undone.", [
|
||||
article.value?.title ?? '',
|
||||
imagesPhrase,
|
||||
])
|
||||
}
|
||||
|
||||
async function performDelete() {
|
||||
try {
|
||||
await request(`/v1/article/${articleId}`, { method: 'DELETE' })
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-deleted-${articleId}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Article deleted',
|
||||
description: `"${article.value?.title ?? ''}" is gone.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Article deleted')
|
||||
},
|
||||
get description() {
|
||||
return t('\u0022{0}\u0022 is gone.', [article.value?.title ?? ''])
|
||||
},
|
||||
}))
|
||||
router.push('/article')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
loadState.value = 'notFound'
|
||||
return
|
||||
}
|
||||
throw new Error("Couldn't delete the article, so it's still there. Try again in a moment.")
|
||||
throw new Error(
|
||||
t("Couldn't delete the article, so it's still there. Try again in a moment."),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,11 +225,21 @@ async function handleDelete() {
|
||||
await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Delete this article?',
|
||||
description: deleteDescription(),
|
||||
confirmLabel: 'Delete article',
|
||||
cancelLabel: 'Cancel',
|
||||
pendingLabel: 'Deleting…',
|
||||
get title() {
|
||||
return t('Delete this article?')
|
||||
},
|
||||
get description() {
|
||||
return deleteDescription()
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Delete article')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Cancel')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Deleting…')
|
||||
},
|
||||
tone: 'danger',
|
||||
action: performDelete,
|
||||
},
|
||||
@@ -229,12 +266,16 @@ function onEditorDrop(event: DragEvent) {
|
||||
}
|
||||
|
||||
function announceImageAdded() {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Image added',
|
||||
description: 'Save to keep it in the article.',
|
||||
})
|
||||
get title() {
|
||||
return t('Image added')
|
||||
},
|
||||
get description() {
|
||||
return t('Save to keep it in the article.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function onImageUploaded(item: MediaItem) {
|
||||
@@ -251,12 +292,16 @@ function onImageInserted(item: MediaItem, alt: string) {
|
||||
|
||||
async function performRemoveImage(imageId: number) {
|
||||
editorRef.value?.removeImage(imageId)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Image removed',
|
||||
description: "It's still in your library.",
|
||||
})
|
||||
get title() {
|
||||
return t('Image removed')
|
||||
},
|
||||
get description() {
|
||||
return t("It's still in your library.")
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function removalPreview(imageId: number) {
|
||||
@@ -272,12 +317,23 @@ async function onRequestRemoveImage(imageId: number) {
|
||||
await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Remove this image from the article?',
|
||||
description:
|
||||
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
|
||||
confirmLabel: 'Remove image',
|
||||
cancelLabel: 'Cancel',
|
||||
pendingLabel: 'Removing…',
|
||||
get title() {
|
||||
return t('Remove this image from the article?')
|
||||
},
|
||||
get description() {
|
||||
return t(
|
||||
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
|
||||
)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Remove image')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Cancel')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Removing…')
|
||||
},
|
||||
tone: 'neutral',
|
||||
preview: removalPreview(imageId),
|
||||
action: () => performRemoveImage(imageId),
|
||||
@@ -314,11 +370,23 @@ onBeforeRouteLeave(async () => {
|
||||
const confirmed = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Leave without saving?',
|
||||
description: `Your changes to "${article.value?.title ?? ''}" haven't been saved.`,
|
||||
confirmLabel: 'Keep editing',
|
||||
cancelLabel: 'Discard changes',
|
||||
pendingLabel: 'Keep editing',
|
||||
get title() {
|
||||
return t('Leave without saving?')
|
||||
},
|
||||
get description() {
|
||||
return t("Your changes to \u0022{0}\u0022 haven't been saved.", [
|
||||
article.value?.title ?? '',
|
||||
])
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Keep editing')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Discard changes')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Keep editing')
|
||||
},
|
||||
tone: 'neutral',
|
||||
action: async () => {},
|
||||
dismissResolvesTo: 'confirm',
|
||||
@@ -330,10 +398,18 @@ onBeforeRouteLeave(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell mobile-title="Edit article" full-bleed>
|
||||
<DashboardShell :mobile-title="t('Edit article')" full-bleed>
|
||||
<template v-if="loadState === 'loaded'" #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="[{ label: 'Articles', to: '/article' }, { label: article?.title ?? '' }]"
|
||||
:items="[
|
||||
{
|
||||
get label() {
|
||||
return t('Articles')
|
||||
},
|
||||
to: '/article',
|
||||
},
|
||||
{ label: article?.title ?? '' },
|
||||
]"
|
||||
color="neutral"
|
||||
class="hidden lg:block"
|
||||
/>
|
||||
@@ -356,7 +432,7 @@ onBeforeRouteLeave(async () => {
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="size-8 justify-center text-toned"
|
||||
aria-label="Back to Articles"
|
||||
:aria-label="t('Back to Articles')"
|
||||
@click="goBack"
|
||||
/>
|
||||
<USkeleton class="h-5 w-80 rounded-none" data-testid="skeleton-title" />
|
||||
@@ -393,13 +469,13 @@ onBeforeRouteLeave(async () => {
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="size-8 justify-center text-toned"
|
||||
aria-label="Back to Articles"
|
||||
:aria-label="t('Back to Articles')"
|
||||
@click="goBack"
|
||||
/>
|
||||
<div class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5">
|
||||
<h1
|
||||
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
|
||||
title="The title can't be changed after creation."
|
||||
:title="t('The title can\'t be changed after creation.')"
|
||||
>
|
||||
{{ article.title }}
|
||||
</h1>
|
||||
@@ -413,7 +489,7 @@ onBeforeRouteLeave(async () => {
|
||||
<div class="hidden items-center gap-3 lg:flex">
|
||||
<USwitch
|
||||
v-model="previewOn"
|
||||
label="Preview"
|
||||
:label="t('Preview')"
|
||||
:ui="{
|
||||
root: 'items-center gap-2',
|
||||
container: 'h-auto',
|
||||
@@ -432,7 +508,7 @@ onBeforeRouteLeave(async () => {
|
||||
:disabled="!saveEnabled"
|
||||
@click="performSave"
|
||||
>
|
||||
Save
|
||||
{{ t('Save') }}
|
||||
</UButton>
|
||||
<PublishControl
|
||||
class="h-8 gap-1.5 px-3"
|
||||
@@ -450,7 +526,7 @@ onBeforeRouteLeave(async () => {
|
||||
class="h-8 gap-1.5 px-3"
|
||||
@click="handleDelete"
|
||||
>
|
||||
Delete
|
||||
{{ t('Delete') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -513,7 +589,7 @@ onBeforeRouteLeave(async () => {
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="size-11 justify-center"
|
||||
aria-label="Details"
|
||||
:aria-label="t('Details')"
|
||||
@click="detailsOpen = true"
|
||||
/>
|
||||
<UButton
|
||||
@@ -524,7 +600,7 @@ onBeforeRouteLeave(async () => {
|
||||
:disabled="!saveEnabled"
|
||||
@click="performSave"
|
||||
>
|
||||
Save
|
||||
{{ t('Save') }}
|
||||
</UButton>
|
||||
<PublishControl
|
||||
class="h-11 flex-1 justify-center"
|
||||
|
||||
+59
-29
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t, formatNumber } from '@/i18n'
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import NewArticleModal from '@/components/NewArticleModal.vue'
|
||||
@@ -19,7 +21,7 @@ const RECENT_LIST_SIZE = 5
|
||||
type LoadState = 'loading' | 'default' | 'empty' | 'error'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const toast = useLocalizedToast()
|
||||
const session = useSessionStore()
|
||||
const update = useUpdateStore()
|
||||
const { listArticles } = useArticles()
|
||||
@@ -32,12 +34,12 @@ let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function greetingForHour(hour: number) {
|
||||
if (hour < 12) {
|
||||
return 'Good morning'
|
||||
return t('Good morning')
|
||||
}
|
||||
if (hour < 18) {
|
||||
return 'Good afternoon'
|
||||
return t('Good afternoon')
|
||||
}
|
||||
return 'Good evening'
|
||||
return t('Good evening')
|
||||
}
|
||||
|
||||
const greeting = computed(() => {
|
||||
@@ -48,8 +50,8 @@ const greeting = computed(() => {
|
||||
|
||||
const subtitle = computed(() =>
|
||||
loadState.value === 'empty'
|
||||
? 'Welcome to RenewCMS.'
|
||||
: "Here's where things stand on your site.",
|
||||
? t('Welcome to RenewCMS.')
|
||||
: t("Here's where things stand on your site."),
|
||||
)
|
||||
|
||||
const stats = computed(() => {
|
||||
@@ -62,9 +64,24 @@ const stats = computed(() => {
|
||||
})
|
||||
|
||||
const statTiles = computed(() => [
|
||||
{ label: 'articles', value: stats.value.total },
|
||||
{ label: 'online', value: stats.value.online },
|
||||
{ label: 'drafts', value: stats.value.drafts },
|
||||
{
|
||||
get label() {
|
||||
return t('articles')
|
||||
},
|
||||
value: stats.value.total,
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('online')
|
||||
},
|
||||
value: stats.value.online,
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('drafts')
|
||||
},
|
||||
value: stats.value.drafts,
|
||||
},
|
||||
])
|
||||
|
||||
const recentArticles = computed(() =>
|
||||
@@ -106,13 +123,17 @@ function openNewArticleModal() {
|
||||
|
||||
function onArticleCreated(article: Article) {
|
||||
router.push(`/article/${article.id}/edit`)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-created-${article.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Draft created',
|
||||
description: `"${article.title}" is saved as a draft.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Draft created')
|
||||
},
|
||||
get description() {
|
||||
return t('\u0022{0}\u0022 is saved as a draft.', [article.title])
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const readyBannerDismissed = ref(false)
|
||||
@@ -154,7 +175,7 @@ onMounted(loadArticles)
|
||||
variant="ghost"
|
||||
class="size-11 justify-center text-highlighted sm:hidden"
|
||||
:ui="{ leadingIcon: 'size-5' }"
|
||||
aria-label="New article"
|
||||
:aria-label="t('New article')"
|
||||
@click="openNewArticleModal"
|
||||
/>
|
||||
</template>
|
||||
@@ -172,7 +193,7 @@ onMounted(loadArticles)
|
||||
class="hidden px-3 sm:flex"
|
||||
@click="openNewArticleModal"
|
||||
>
|
||||
New article
|
||||
{{ t('New article') }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
@@ -198,7 +219,7 @@ onMounted(loadArticles)
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-2xl font-semibold text-highlighted">{{
|
||||
tile.value
|
||||
formatNumber(tile.value)
|
||||
}}</span>
|
||||
<span class="text-muted">{{ tile.label }}</span>
|
||||
</template>
|
||||
@@ -207,9 +228,11 @@ onMounted(loadArticles)
|
||||
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h2 class="text-lg font-semibold text-highlighted">Recently updated</h2>
|
||||
<h2 class="text-lg font-semibold text-highlighted">
|
||||
{{ t('Recently updated') }}
|
||||
</h2>
|
||||
<RouterLink to="/article" class="text-toned underline underline-offset-3">
|
||||
All articles
|
||||
{{ t('All articles') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div
|
||||
@@ -249,8 +272,10 @@ onMounted(loadArticles)
|
||||
v-else-if="loadState === 'empty'"
|
||||
variant="outline"
|
||||
icon="i-lucide-file-text"
|
||||
title="Write your first article"
|
||||
description="It starts as a draft, so nothing appears on your site until you publish it."
|
||||
:title="t('Write your first article')"
|
||||
:description="
|
||||
t('It starts as a draft, so nothing appears on your site until you publish it.')
|
||||
"
|
||||
:ui="{
|
||||
root: 'gap-3 rounded-none border border-default px-6 py-12 ring-0 sm:px-6 sm:py-12 lg:px-6 lg:py-12',
|
||||
header: 'max-w-none gap-3',
|
||||
@@ -266,7 +291,7 @@ onMounted(loadArticles)
|
||||
class="h-11 px-4 lg:h-8"
|
||||
@click="openNewArticleModal"
|
||||
>
|
||||
New article
|
||||
{{ t('New article') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
@@ -279,8 +304,8 @@ onMounted(loadArticles)
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't load your articles"
|
||||
description="Check your connection and try again."
|
||||
:title="t('Couldn\'t load your articles')"
|
||||
:description="t('Check your connection and try again.')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
@@ -292,7 +317,7 @@ onMounted(loadArticles)
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="loadArticles"
|
||||
>
|
||||
Try again
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
@@ -304,16 +329,21 @@ onMounted(loadArticles)
|
||||
<UIcon name="i-lucide-plug" class="size-4.5" />
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<h2 class="text-base/5 font-semibold text-highlighted">Connect your website</h2>
|
||||
<h2 class="text-base/5 font-semibold text-highlighted">
|
||||
{{ t('Connect your website') }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">
|
||||
Your site reads published articles from RenewCMS. Share this guide with
|
||||
whoever builds it.
|
||||
{{
|
||||
t(
|
||||
'Your site reads published articles from RenewCMS. Share this guide with whoever builds it.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<RouterLink
|
||||
to="/integration"
|
||||
class="mt-1 self-start font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
Open the guide
|
||||
{{ t('Open the guide') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import EndpointCard from '@/components/EndpointCard.vue'
|
||||
@@ -8,8 +10,10 @@ const origin = computed(() => window.location.origin)
|
||||
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
|
||||
const copyButtonLabel = computed(() => (copied.value ? 'Copied' : 'Copy'))
|
||||
const copyButtonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
|
||||
const copyButtonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
|
||||
const copyButtonIcon = computed(() =>
|
||||
copied.value ? 'i-lucide-check' : 'i-lucide-copy',
|
||||
)
|
||||
|
||||
function copyBaseUrl() {
|
||||
copy(origin.value)
|
||||
@@ -39,7 +43,9 @@ const endpoints = computed(() => [
|
||||
{
|
||||
method: 'GET' as const,
|
||||
path: '/v1/article/',
|
||||
description: 'Lists every online article.',
|
||||
get description() {
|
||||
return t('Lists every online article.')
|
||||
},
|
||||
examples: {
|
||||
fetch: `const res = await fetch('${origin.value}/v1/article/')\nconst articles = await res.json()`,
|
||||
curl: `curl ${origin.value}/v1/article/`,
|
||||
@@ -49,7 +55,11 @@ const endpoints = computed(() => [
|
||||
{
|
||||
method: 'GET' as const,
|
||||
path: '/v1/article/{id}',
|
||||
description: 'Returns one online article. Drafts and unknown ids both return 404.',
|
||||
get description() {
|
||||
return t(
|
||||
'Returns one online article. Drafts and unknown ids both return 404.',
|
||||
)
|
||||
},
|
||||
examples: {
|
||||
fetch: `const res = await fetch('${origin.value}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json()`,
|
||||
curl: `curl ${origin.value}/v1/article/12`,
|
||||
@@ -58,29 +68,47 @@ const endpoints = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const notes = [
|
||||
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
|
||||
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
|
||||
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
|
||||
]
|
||||
const notes = computed(() => [
|
||||
t(
|
||||
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
|
||||
),
|
||||
t(
|
||||
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
|
||||
),
|
||||
t(
|
||||
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
|
||||
),
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell mobile-title="Integration">
|
||||
<DashboardShell :mobile-title="t('Integration')">
|
||||
<div class="flex max-w-199 flex-col gap-6">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="text-xl font-semibold text-highlighted">Connect your website</h1>
|
||||
<p class="text-muted">A guide for the developer who builds your public site.</p>
|
||||
<h1 class="text-xl font-semibold text-highlighted">
|
||||
{{ t('Connect your website') }}
|
||||
</h1>
|
||||
<p class="text-muted">
|
||||
{{
|
||||
t(
|
||||
'A guide for the developer who builds your public site.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-base text-pretty text-toned">
|
||||
RenewCMS is headless: it stores and publishes your articles but doesn't show them to
|
||||
visitors. Your website fetches published articles from the two addresses below and
|
||||
displays them in its own design.
|
||||
{{
|
||||
t(
|
||||
"RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors. Your website fetches published articles from the two addresses below and displays them in its own design.",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-medium text-highlighted">Base URL</span>
|
||||
<span class="font-medium text-highlighted">{{
|
||||
t('Base URL')
|
||||
}}</span>
|
||||
<div class="flex border border-accented bg-default">
|
||||
<span
|
||||
class="h-10 min-w-0 flex-1 truncate px-3 font-mono text-sm leading-10 text-default"
|
||||
@@ -112,8 +140,13 @@ const notes = [
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div v-for="note in notes" :key="note" class="flex gap-2.5">
|
||||
<UIcon name="i-lucide-info" class="mt-0.75 size-4 shrink-0 text-info" />
|
||||
<span class="text-base text-pretty text-toned">{{ note }}</span>
|
||||
<UIcon
|
||||
name="i-lucide-info"
|
||||
class="mt-0.75 size-4 shrink-0 text-info"
|
||||
/>
|
||||
<span class="text-base text-pretty text-toned">{{
|
||||
note
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+60
-57
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
@@ -16,37 +18,58 @@ interface AlertState {
|
||||
const wrongCredentialsAlert: AlertState = {
|
||||
tone: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "That username and password don't match",
|
||||
description: 'Check both and try again. Passwords are case-sensitive.',
|
||||
get title() {
|
||||
return t("That username and password don't match")
|
||||
},
|
||||
get description() {
|
||||
return t('Check both and try again. Passwords are case-sensitive.')
|
||||
},
|
||||
}
|
||||
|
||||
const tooManyAttemptsAlert: AlertState = {
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-timer',
|
||||
title: 'Too many attempts',
|
||||
description: 'For your security, wait a moment before trying again.',
|
||||
get title() {
|
||||
return t('Too many attempts')
|
||||
},
|
||||
get description() {
|
||||
return t('For your security, wait a moment before trying again.')
|
||||
},
|
||||
}
|
||||
|
||||
const serverErrorAlert: AlertState = {
|
||||
tone: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't reach RenewCMS",
|
||||
description: 'Check your connection and try again.',
|
||||
get title() {
|
||||
return t("Couldn't reach RenewCMS")
|
||||
},
|
||||
get description() {
|
||||
return t('Check your connection and try again.')
|
||||
},
|
||||
}
|
||||
|
||||
const sessionExpiredAlert: AlertState = {
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-clock',
|
||||
title: 'Your session expired',
|
||||
description:
|
||||
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
|
||||
get title() {
|
||||
return t('Your session expired')
|
||||
},
|
||||
get description() {
|
||||
return t(
|
||||
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const passwordChangedAlert: AlertState = {
|
||||
tone: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Password changed',
|
||||
description: 'Log in with your new password.',
|
||||
get title() {
|
||||
return t('Password changed')
|
||||
},
|
||||
get description() {
|
||||
return t('Log in with your new password.')
|
||||
},
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
@@ -63,8 +86,7 @@ const passwordError = ref('')
|
||||
const submitting = ref(false)
|
||||
const passwordShown = ref(false)
|
||||
|
||||
const initialReason =
|
||||
typeof route.query.reason === 'string' ? route.query.reason : null
|
||||
const initialReason = typeof route.query.reason === 'string' ? route.query.reason : null
|
||||
|
||||
const alert = ref<AlertState | null>(
|
||||
initialReason === 'expired'
|
||||
@@ -74,23 +96,19 @@ const alert = ref<AlertState | null>(
|
||||
: null,
|
||||
)
|
||||
|
||||
const alertRole = computed(() =>
|
||||
alert.value?.tone === 'error' ? 'alert' : 'status',
|
||||
)
|
||||
const alertRole = computed(() => (alert.value?.tone === 'error' ? 'alert' : 'status'))
|
||||
|
||||
const submitLabel = computed(() => {
|
||||
if (submitting.value) {
|
||||
return 'Logging in…'
|
||||
return t('Logging in…')
|
||||
}
|
||||
if (retryAfter.active.value) {
|
||||
return `Try again in ${retryAfter.formatted.value}`
|
||||
return t('Try again in {0}', [retryAfter.formatted.value])
|
||||
}
|
||||
return 'Log in'
|
||||
return t('Log in')
|
||||
})
|
||||
|
||||
const submitDisabled = computed(
|
||||
() => submitting.value || retryAfter.active.value,
|
||||
)
|
||||
const submitDisabled = computed(() => submitting.value || retryAfter.active.value)
|
||||
|
||||
async function focusField(selector: string) {
|
||||
await nextTick()
|
||||
@@ -103,7 +121,7 @@ function focusPassword() {
|
||||
}
|
||||
|
||||
function focusUsername() {
|
||||
focusField('input[placeholder="Your username"]')
|
||||
focusField('input[autocomplete="username"]')
|
||||
}
|
||||
|
||||
function focusFirstInvalid() {
|
||||
@@ -117,18 +135,15 @@ function focusFirstInvalid() {
|
||||
}
|
||||
|
||||
function validate() {
|
||||
usernameError.value =
|
||||
username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
|
||||
passwordError.value =
|
||||
password.value.length === 0 ? 'Enter your password.' : ''
|
||||
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
|
||||
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
|
||||
return !usernameError.value && !passwordError.value
|
||||
}
|
||||
|
||||
async function afterLoginSuccess() {
|
||||
await session.retry()
|
||||
|
||||
const redirect =
|
||||
typeof route.query.redirect === 'string' ? route.query.redirect : null
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : null
|
||||
if (redirect) {
|
||||
await router.push(redirect)
|
||||
return
|
||||
@@ -193,9 +208,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await api.request<{ open: boolean }>(
|
||||
'/v1/auth/registration',
|
||||
)
|
||||
const status = await api.request<{ open: boolean }>('/v1/auth/registration')
|
||||
registrationOpen.value = status.open
|
||||
} catch {
|
||||
registrationOpen.value = false
|
||||
@@ -205,8 +218,8 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<AuthShell
|
||||
title="Log in"
|
||||
description="Welcome back. Log in to manage your articles."
|
||||
:title="t('Log in')"
|
||||
:description="t('Welcome back. Log in to manage your articles.')"
|
||||
>
|
||||
<template v-if="alert" #notice>
|
||||
<UAlert
|
||||
@@ -221,15 +234,11 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template #default="{ controlSize, controlClass, inputClass }">
|
||||
<UForm
|
||||
:state="{ username, password }"
|
||||
class="flex flex-col gap-4.5"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UForm :state="{ username, password }" class="flex flex-col gap-4.5" @submit="onSubmit">
|
||||
<UFormField
|
||||
label="Username"
|
||||
:label="t('Username')"
|
||||
name="username"
|
||||
:error="usernameError || undefined"
|
||||
:error="translate(usernameError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="usernameError" #error="{ error }">
|
||||
@@ -238,12 +247,12 @@ onMounted(async () => {
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<UInput
|
||||
v-model="username"
|
||||
placeholder="Your username"
|
||||
:placeholder="t('Your username')"
|
||||
autocomplete="username"
|
||||
:disabled="submitDisabled"
|
||||
variant="outline"
|
||||
@@ -256,9 +265,9 @@ onMounted(async () => {
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Password"
|
||||
:label="t('Password')"
|
||||
name="password"
|
||||
:error="passwordError || undefined"
|
||||
:error="translate(passwordError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template #hint>
|
||||
@@ -266,7 +275,7 @@ onMounted(async () => {
|
||||
to="/register/reset/request"
|
||||
class="text-toned text-sm underline underline-offset-[3px]"
|
||||
>
|
||||
Forgot password?
|
||||
{{ t('Forgot password?') }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template v-if="passwordError" #error="{ error }">
|
||||
@@ -275,7 +284,7 @@ onMounted(async () => {
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<UInput
|
||||
@@ -295,20 +304,14 @@ onMounted(async () => {
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
:icon="
|
||||
passwordShown
|
||||
? 'i-lucide-eye-off'
|
||||
: 'i-lucide-eye'
|
||||
"
|
||||
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="
|
||||
passwordShown
|
||||
? 'Hide password'
|
||||
: 'Show password'
|
||||
passwordShown ? t('Hide password') : t('Show password')
|
||||
"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
@@ -332,12 +335,12 @@ onMounted(async () => {
|
||||
|
||||
<template v-if="registrationOpen" #footer>
|
||||
<span class="flex flex-wrap items-center gap-1.5">
|
||||
First time here?
|
||||
{{ t('First time here?') }}
|
||||
<RouterLink
|
||||
to="/register"
|
||||
class="font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
Create the owner account
|
||||
{{ t('Create the owner account') }}
|
||||
</RouterLink>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
+122
-67
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
|
||||
import { useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import BulkDeleteMediaDialog from '@/components/BulkDeleteMediaDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import DeleteMediaDialog from '@/components/DeleteMediaDialog.vue'
|
||||
@@ -68,7 +70,7 @@ const OFFLINE_COPY =
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const toast = useLocalizedToast()
|
||||
const media = useMedia()
|
||||
const selection = useMediaSelection(media.items)
|
||||
const upload = useMediaUpload({ onAdded: refreshInBackground })
|
||||
@@ -90,7 +92,8 @@ const bulkDeleteOpen = ref(false)
|
||||
const bulkTargets = ref<MediaItem[]>([])
|
||||
const mergeTarget = ref<{ group: DuplicateGroup; keep: MediaItem } | null>(null)
|
||||
const dragDepth = ref(0)
|
||||
const announcement = ref('')
|
||||
const announcementSource = ref<() => string>(() => '')
|
||||
const announcement = computed(() => announcementSource.value())
|
||||
const usage = ref<MediaUsageItem[]>([])
|
||||
const copies = ref<MediaItem[]>([])
|
||||
let opener: HTMLElement | null = null
|
||||
@@ -127,13 +130,16 @@ const emptyVariant = computed(() =>
|
||||
isDuplicates.value && controls.value.q === '' ? 'no-duplicates' : 'no-results',
|
||||
)
|
||||
const filterLabel = computed(
|
||||
() => ({ all: 'All', unused: 'Unused', duplicates: 'Duplicates' })[controls.value.filter],
|
||||
() =>
|
||||
({ all: t('All'), unused: t('Unused'), duplicates: t('Duplicates') })[
|
||||
controls.value.filter
|
||||
],
|
||||
)
|
||||
const showList = computed(() => ready.value && !nothingToShow.value && !emptyLibrary.value)
|
||||
const dragging = computed(() => dragDepth.value > 0)
|
||||
const uploadBlocked = computed(() => offline.value || upload.limited.value)
|
||||
const uploadLabel = computed(() =>
|
||||
upload.limited.value ? `Try again in ${upload.countdown.value}` : 'Upload',
|
||||
upload.limited.value ? t('Try again in {0}', [upload.countdown.value]) : t('Upload'),
|
||||
)
|
||||
const uploadIcon = computed(() => (upload.limited.value ? 'i-lucide-timer' : 'i-lucide-upload'))
|
||||
const dialogOpen = computed(
|
||||
@@ -158,7 +164,9 @@ const gridSelected = computed(() => selection.selectedIds.value)
|
||||
|
||||
const showBulkBar = computed(() => hasSelection.value && !emptyLibrary.value)
|
||||
|
||||
watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), { immediate: true })
|
||||
watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), {
|
||||
immediate: true,
|
||||
})
|
||||
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
|
||||
|
||||
function canonicalizeUrl() {
|
||||
@@ -182,8 +190,8 @@ function clearFilters() {
|
||||
updateControls({ q: '', filter: 'all' })
|
||||
}
|
||||
|
||||
function announce(text: string) {
|
||||
announcement.value = text
|
||||
function announce(source: () => string) {
|
||||
announcementSource.value = source
|
||||
}
|
||||
|
||||
canonicalizeUrl()
|
||||
@@ -210,7 +218,11 @@ watch(
|
||||
(next, previous) => {
|
||||
const settledNow = next.phase === 'settled' && previous?.phase === 'working'
|
||||
if (settledNow && upload.rows.value.length > 0) {
|
||||
announce(next.summary ?? `${next.title}. ${next.sub}`)
|
||||
announce(
|
||||
() =>
|
||||
upload.header.value.summary ??
|
||||
`${upload.header.value.title}. ${upload.header.value.sub}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -339,13 +351,17 @@ function removeAndRefocus(item: MediaItem) {
|
||||
}
|
||||
|
||||
function notFoundToast(id: number) {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `media-not-found-${id}`,
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'That image no longer exists',
|
||||
description: 'It was deleted somewhere else, so we removed it from the library.',
|
||||
})
|
||||
get title() {
|
||||
return t('That image no longer exists')
|
||||
},
|
||||
get description() {
|
||||
return t('It was deleted somewhere else, so we removed it from the library.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function handleGone(item: MediaItem) {
|
||||
@@ -363,14 +379,18 @@ async function copyUrl() {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `media-url-copied-${item.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'URL copied',
|
||||
description: 'Paste it wherever you need the image address.',
|
||||
get title() {
|
||||
return t('URL copied')
|
||||
},
|
||||
get description() {
|
||||
return t('Paste it wherever you need the image address.')
|
||||
},
|
||||
duration: TOAST_DURATION_MS,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
async function renameAction(stem: string) {
|
||||
@@ -380,14 +400,18 @@ async function renameAction(stem: string) {
|
||||
}
|
||||
try {
|
||||
const renamed = await media.renameItem(item.id, stem)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `media-renamed-${item.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Image renamed',
|
||||
description: `It's now "${renamed.filename}". The URL is the same.`,
|
||||
get title() {
|
||||
return t('Image renamed')
|
||||
},
|
||||
get description() {
|
||||
return t("It's now \u0022{0}\u0022. The URL is the same.", [renamed.filename])
|
||||
},
|
||||
duration: TOAST_DURATION_MS,
|
||||
})
|
||||
}))
|
||||
} catch (error) {
|
||||
const failure = classifyMediaError(error)
|
||||
if (failure === 'handled') {
|
||||
@@ -397,7 +421,7 @@ async function renameAction(stem: string) {
|
||||
handleGone(item)
|
||||
return
|
||||
}
|
||||
throw new Error(RENAME_FAILED_COPY)
|
||||
throw new Error(t(RENAME_FAILED_COPY))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,32 +442,40 @@ async function deleteAction() {
|
||||
handleGone(item)
|
||||
return
|
||||
}
|
||||
throw new Error(DELETE_FAILED_COPY)
|
||||
throw new Error(t(DELETE_FAILED_COPY))
|
||||
}
|
||||
closeEverything()
|
||||
focusAfterRemoval(index)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `media-deleted-${item.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Image deleted',
|
||||
description: `"${item.filename}" is gone from your library.`,
|
||||
get title() {
|
||||
return t('Image deleted')
|
||||
},
|
||||
get description() {
|
||||
return t('\u0022{0}\u0022 is gone from your library.', [item.filename])
|
||||
},
|
||||
duration: TOAST_DURATION_MS,
|
||||
})
|
||||
}))
|
||||
refreshInBackground()
|
||||
}
|
||||
|
||||
function imageCount(total: number) {
|
||||
return `${total} ${total === 1 ? 'image' : 'images'}`
|
||||
return t(total === 1 ? '{0} image' : '{0} images', [total])
|
||||
}
|
||||
|
||||
function announceSelection(filename: string, selected: boolean) {
|
||||
if (!selected && selection.count.value === 0) {
|
||||
announce(SELECTION_CLEARED)
|
||||
announce(() => t(SELECTION_CLEARED))
|
||||
return
|
||||
}
|
||||
announce(
|
||||
`${filename} ${selected ? 'selected' : 'unselected'}, ${selection.count.value} selected`,
|
||||
announce(() =>
|
||||
t('{0} {1}, {2} selected', [
|
||||
filename,
|
||||
selected ? t('selected') : t('unselected'),
|
||||
selection.count.value,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -459,7 +491,9 @@ function toggleSelected(id: number) {
|
||||
|
||||
function toggleAllSelected() {
|
||||
selection.toggleAll()
|
||||
announce(hasSelection.value ? `${selection.count.value} selected` : SELECTION_CLEARED)
|
||||
announce(() =>
|
||||
hasSelection.value ? t('{0} selected', [selection.count.value]) : t(SELECTION_CLEARED),
|
||||
)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
@@ -467,7 +501,7 @@ function clearSelection() {
|
||||
return
|
||||
}
|
||||
selection.clear()
|
||||
announce(SELECTION_CLEARED)
|
||||
announce(() => t(SELECTION_CLEARED))
|
||||
}
|
||||
|
||||
function selectExisting(item: MediaItem) {
|
||||
@@ -531,7 +565,7 @@ function onFilesChosen(event: Event) {
|
||||
}
|
||||
|
||||
function carriesFiles(event: DragEvent) {
|
||||
return Array.from(event.dataTransfer?.types ?? []).includes('Files')
|
||||
return Array.from(event.dataTransfer?.types ?? []).includes(t('Files'))
|
||||
}
|
||||
|
||||
useEventListener(window, 'dragenter', (event: DragEvent) => {
|
||||
@@ -576,20 +610,31 @@ function openBulkDelete() {
|
||||
|
||||
function bulkFailedDescription(failed: MediaItem[]) {
|
||||
const names = failed.map((item) => item.filename)
|
||||
const deleted = bulkDeletedTotal === 1 ? '1 image was' : `${bulkDeletedTotal} images were`
|
||||
return `${deleted} deleted. ${joinNames(names)} ${names.length === 1 ? 'is' : 'are'} still in your library. Try again.`
|
||||
const deleted =
|
||||
bulkDeletedTotal === 1 ? t('1 image was') : t('{0} images were', [bulkDeletedTotal])
|
||||
return t('{0} deleted. {1} {2} still in your library. Try again.', [
|
||||
deleted,
|
||||
joinNames(names),
|
||||
t(names.length === 1 ? 'is' : 'are'),
|
||||
])
|
||||
}
|
||||
|
||||
function bulkFailedToast(failed: MediaItem[]) {
|
||||
const content = {
|
||||
color: 'error' as const,
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: `Couldn't delete ${imageCount(failed.length)}`,
|
||||
description: bulkFailedDescription(failed),
|
||||
get title() {
|
||||
return t("Couldn't delete {0}", [imageCount(failed.length)])
|
||||
},
|
||||
get description() {
|
||||
return bulkFailedDescription(failed)
|
||||
},
|
||||
duration: PERSISTENT_TOAST,
|
||||
actions: [
|
||||
{
|
||||
label: 'Retry',
|
||||
get label() {
|
||||
return t('Retry')
|
||||
},
|
||||
icon: 'i-lucide-rotate-cw',
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
@@ -600,11 +645,11 @@ function bulkFailedToast(failed: MediaItem[]) {
|
||||
],
|
||||
}
|
||||
if (failureToastShown) {
|
||||
toast.update(BULK_FAILED_TOAST_ID, content)
|
||||
toast.update(BULK_FAILED_TOAST_ID, () => content)
|
||||
return
|
||||
}
|
||||
failureToastShown = true
|
||||
toast.add({ id: BULK_FAILED_TOAST_ID, ...content })
|
||||
toast.add(() => ({ id: BULK_FAILED_TOAST_ID, ...content }))
|
||||
}
|
||||
|
||||
function dismissBulkFailedToast() {
|
||||
@@ -623,15 +668,19 @@ function finishBulkDelete(result: BulkDeleteResult, firstIndex: number) {
|
||||
} else {
|
||||
dismissBulkFailedToast()
|
||||
selection.clear()
|
||||
announce(`${imageCount(bulkDeletedTotal)} deleted`)
|
||||
toast.add({
|
||||
announce(() => t('{0} deleted', [imageCount(bulkDeletedTotal)]))
|
||||
toast.add(() => ({
|
||||
id: 'media-bulk-deleted',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: `${imageCount(bulkDeletedTotal)} deleted`,
|
||||
description: "They're gone from your library.",
|
||||
get title() {
|
||||
return t('{0} deleted', [imageCount(bulkDeletedTotal)])
|
||||
},
|
||||
get description() {
|
||||
return t("They're gone from your library.")
|
||||
},
|
||||
duration: TOAST_DURATION_MS,
|
||||
})
|
||||
}))
|
||||
focusAfterRemoval(firstIndex)
|
||||
}
|
||||
refreshInBackground()
|
||||
@@ -652,7 +701,7 @@ async function bulkDeleteAction() {
|
||||
return false
|
||||
}
|
||||
if (result.deleted.length === 0) {
|
||||
throw new Error(BULK_DELETE_FAILED_COPY)
|
||||
throw new Error(t(BULK_DELETE_FAILED_COPY))
|
||||
}
|
||||
bulkDeleteOpen.value = false
|
||||
finishBulkDelete(result, firstIndex)
|
||||
@@ -672,9 +721,10 @@ function openMerge(group: DuplicateGroup, keep: MediaItem) {
|
||||
}
|
||||
|
||||
function mergeText(merged: number, articlesUpdated: number) {
|
||||
const copiesText = merged === 1 ? '1 copy' : `${merged} copies`
|
||||
const articlesText = articlesUpdated === 1 ? '1 article' : `${articlesUpdated} articles`
|
||||
return `${copiesText} merged. ${articlesText} updated.`
|
||||
const copiesText = merged === 1 ? t('1 copy') : t('{0} copies', [merged])
|
||||
const articlesText =
|
||||
articlesUpdated === 1 ? t('1 article') : t('{0} articles', [articlesUpdated])
|
||||
return t('{0} merged. {1} updated.', [copiesText, articlesText])
|
||||
}
|
||||
|
||||
async function mergeAction() {
|
||||
@@ -688,16 +738,19 @@ async function mergeAction() {
|
||||
const result = await media.mergeCopies(keep.id, mergeIds)
|
||||
mergeTarget.value = null
|
||||
media.removeLocalMany(group.copies.map((copy) => copy.id))
|
||||
const text = mergeText(result.merged, result.articles_updated)
|
||||
announce(text)
|
||||
toast.add({
|
||||
announce(() => mergeText(result.merged, result.articles_updated))
|
||||
toast.add(() => ({
|
||||
id: `media-merged-${keep.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Copies merged',
|
||||
description: text,
|
||||
get title() {
|
||||
return t('Copies merged')
|
||||
},
|
||||
get description() {
|
||||
return mergeText(result.merged, result.articles_updated)
|
||||
},
|
||||
duration: TOAST_DURATION_MS,
|
||||
})
|
||||
}))
|
||||
focusElement('[data-testid="media-heading"]')
|
||||
refreshInBackground()
|
||||
} catch (error) {
|
||||
@@ -713,9 +766,9 @@ async function mergeAction() {
|
||||
}
|
||||
if (error instanceof ApiError && error.status === HTTP_CONFLICT) {
|
||||
refreshInBackground()
|
||||
throw new Error(MERGE_CONFLICT_COPY)
|
||||
throw new Error(t(MERGE_CONFLICT_COPY))
|
||||
}
|
||||
throw new Error(MERGE_FAILED_COPY)
|
||||
throw new Error(t(MERGE_FAILED_COPY))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -723,7 +776,7 @@ async function mergeAction() {
|
||||
<template>
|
||||
<DashboardShell
|
||||
compact-padding
|
||||
:offline-message="OFFLINE_COPY"
|
||||
:offline-message="t(OFFLINE_COPY)"
|
||||
offline-icon="i-lucide-wifi-off"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -735,10 +788,10 @@ async function mergeAction() {
|
||||
tabindex="-1"
|
||||
class="text-xl font-semibold text-highlighted"
|
||||
>
|
||||
Media
|
||||
{{ t('Media') }}
|
||||
</h1>
|
||||
<p class="text-muted">
|
||||
Every image you've uploaded, ready to use in any article.
|
||||
{{ t("Every image you've uploaded, ready to use in any article.") }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
@@ -770,7 +823,9 @@ async function mergeAction() {
|
||||
:limited="upload.limited.value"
|
||||
@action="openChooser"
|
||||
/>
|
||||
<p class="self-center text-xs text-muted">PNG, JPEG, WEBP, SVG · max 10 MB each</p>
|
||||
<p class="self-center text-xs text-muted">
|
||||
{{ t('PNG, JPEG, WEBP, SVG · max 10 MB each') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -781,8 +836,8 @@ async function mergeAction() {
|
||||
variant="soft"
|
||||
:close="false"
|
||||
icon="i-lucide-timer"
|
||||
:title="RATE_LIMITED_TITLE"
|
||||
:description="RATE_LIMITED_TEXT"
|
||||
:title="t(RATE_LIMITED_TITLE)"
|
||||
:description="t(RATE_LIMITED_TEXT)"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
@@ -793,8 +848,8 @@ async function mergeAction() {
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't load your images"
|
||||
description="Check your connection and try again."
|
||||
:title="t('Couldn\'t load your images')"
|
||||
:description="t('Check your connection and try again.')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
@@ -806,7 +861,7 @@ async function mergeAction() {
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="media.load(controls)"
|
||||
>
|
||||
Try again
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
@@ -9,9 +11,9 @@ const session = useSessionStore()
|
||||
|
||||
<template>
|
||||
<DashboardShell v-if="session.session">
|
||||
<ErrorView kind="not-found" breadcrumb="Not found" />
|
||||
<ErrorView kind="not-found" :breadcrumb="t('Not found')" />
|
||||
</DashboardShell>
|
||||
<AuthShell v-else title="Page not found">
|
||||
<ErrorView kind="not-found" breadcrumb="Not found" />
|
||||
<AuthShell v-else :title="t('Page not found')">
|
||||
<ErrorView kind="not-found" :breadcrumb="t('Not found')" />
|
||||
</AuthShell>
|
||||
</template>
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, onMounted, ref, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import ResultPanel from '@/components/ResultPanel.vue'
|
||||
@@ -13,8 +15,8 @@ import { useSessionStore } from '@/stores/session'
|
||||
const router = useRouter()
|
||||
const api = useApi()
|
||||
const session = useSessionStore()
|
||||
const overlay = useOverlay()
|
||||
const toast = useToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const pendingEmail = ref<string | null>(null)
|
||||
const loggingOut = ref(false)
|
||||
@@ -23,13 +25,16 @@ const verified = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
pendingEmail.value = sessionStorage.getItem('renewcms:pendingEmail')
|
||||
document.title = 'Check your email · RenewCMS'
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
document.title = t('Check your email · RenewCMS')
|
||||
})
|
||||
|
||||
const description = computed(() =>
|
||||
pendingEmail.value
|
||||
? `We sent a verification link to ${pendingEmail.value}. It expires in 2 hours.`
|
||||
: 'We sent a verification link to the email you registered with. It expires in 2 hours.',
|
||||
? t('We sent a verification link to {0}. It expires in 2 hours.', [pendingEmail.value])
|
||||
: t('We sent a verification link to the email you registered with. It expires in 2 hours.'),
|
||||
)
|
||||
|
||||
async function logOut() {
|
||||
@@ -57,7 +62,7 @@ async function deleteRegistration() {
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
"Couldn't cancel registration, so it's still pending. Try again in a moment.",
|
||||
t("Couldn't cancel registration, so it's still pending. Try again in a moment."),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -67,12 +72,23 @@ async function cancelRegistration() {
|
||||
const confirmed = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Cancel your registration?',
|
||||
description:
|
||||
'Your account will be deleted. You can register again with the same or a different email.',
|
||||
confirmLabel: 'Cancel registration',
|
||||
cancelLabel: 'Keep my account',
|
||||
pendingLabel: 'Cancelling…',
|
||||
get title() {
|
||||
return t('Cancel your registration?')
|
||||
},
|
||||
get description() {
|
||||
return t(
|
||||
'Your account will be deleted. You can register again with the same or a different email.',
|
||||
)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Cancel registration')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Keep my account')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Cancelling…')
|
||||
},
|
||||
tone: 'danger',
|
||||
action: deleteRegistration,
|
||||
},
|
||||
@@ -85,18 +101,22 @@ async function cancelRegistration() {
|
||||
|
||||
if (cancelOutcome.value === 'cancelled') {
|
||||
await router.push('/register')
|
||||
toast.add(registrationCancelledToast)
|
||||
toast.add(() => registrationCancelledToast)
|
||||
return
|
||||
}
|
||||
|
||||
if (cancelOutcome.value === 'alreadyVerified') {
|
||||
verified.value = true
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'Your account is already verified',
|
||||
description: "There's nothing to cancel.",
|
||||
})
|
||||
get title() {
|
||||
return t('Your account is already verified')
|
||||
},
|
||||
get description() {
|
||||
return t("There's nothing to cancel.")
|
||||
},
|
||||
}))
|
||||
await wait(VERIFIED_REDIRECT_DELAY_MS)
|
||||
await router.push('/home')
|
||||
}
|
||||
@@ -104,14 +124,18 @@ async function cancelRegistration() {
|
||||
|
||||
const actions = computed(() => [
|
||||
{
|
||||
label: 'Log out',
|
||||
get label() {
|
||||
return t('Log out')
|
||||
},
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
disabled: loggingOut.value,
|
||||
onClick: logOut,
|
||||
},
|
||||
{
|
||||
label: 'Cancel registration',
|
||||
get label() {
|
||||
return t('Cancel registration')
|
||||
},
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
disabled: loggingOut.value,
|
||||
@@ -126,22 +150,24 @@ const actions = computed(() => [
|
||||
v-if="verified"
|
||||
tone="success"
|
||||
icon="i-lucide-shield-check"
|
||||
title="You're verified"
|
||||
description="Taking you to your dashboard…"
|
||||
:title="t('You\'re verified')"
|
||||
:description="t('Taking you to your dashboard…')"
|
||||
:actions="[]"
|
||||
/>
|
||||
<ResultPanel
|
||||
v-else
|
||||
tone="success"
|
||||
icon="i-lucide-mail-check"
|
||||
title="Check your email"
|
||||
:title="t('Check your email')"
|
||||
:description="description"
|
||||
:actions="actions"
|
||||
>
|
||||
<p class="text-sm text-pretty text-muted">
|
||||
Opened it on another device? That works too; you'll log in there
|
||||
first. Can't find it? Check your spam folder. If the link has
|
||||
expired, cancel your registration and start again.
|
||||
{{
|
||||
t(
|
||||
"Opened it on another device? That works too; you'll log in there first. Can't find it? Check your spam folder. If the link has expired, cancel your registration and start again.",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</ResultPanel>
|
||||
</AuthShell>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
@@ -26,8 +28,8 @@ const SKELETON_CARDS = [
|
||||
type ResetState = 'idle' | 'sending' | 'sent' | 'limited' | 'error'
|
||||
|
||||
const session = useSessionStore()
|
||||
const toast = useToast()
|
||||
const overlay = useOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const { request } = useApi()
|
||||
const retryAfter = useRetryAfter()
|
||||
|
||||
@@ -81,34 +83,46 @@ const showUnsaved = computed(() => dirty.value && !saving.value)
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (showUnsaved.value) {
|
||||
return 'Unsaved changes'
|
||||
return t('Unsaved changes')
|
||||
}
|
||||
return savedName.value
|
||||
? `You appear as “${savedName.value}”.`
|
||||
: `You appear as “${username.value}”, your username.`
|
||||
? t('You appear as “{0}”.', [savedName.value])
|
||||
: t('You appear as “{0}”, your username.', [username.value])
|
||||
})
|
||||
|
||||
const inputUi = { base: 'h-11 text-lg lg:h-10 lg:text-base' }
|
||||
const readOnlyUi = { base: 'h-11 text-lg disabled:text-toned lg:h-10 lg:text-base' }
|
||||
const readOnlyUi = {
|
||||
base: 'h-11 text-lg disabled:text-toned lg:h-10 lg:text-base',
|
||||
}
|
||||
const readOnlyLockUi = { trailingIcon: 'size-3.5 text-dimmed' }
|
||||
|
||||
const resetNotes = {
|
||||
sent: {
|
||||
color: 'success',
|
||||
icon: 'i-lucide-mail-check',
|
||||
title: 'Reset link sent',
|
||||
get title() {
|
||||
return t('Reset link sent')
|
||||
},
|
||||
},
|
||||
limited: {
|
||||
color: 'warning',
|
||||
icon: 'i-lucide-clock-alert',
|
||||
title: 'Too many requests',
|
||||
description: "You've asked for several links recently. Wait a few minutes, then try again.",
|
||||
get title() {
|
||||
return t('Too many requests')
|
||||
},
|
||||
get description() {
|
||||
return t("You've asked for several links recently. Wait a few minutes, then try again.")
|
||||
},
|
||||
},
|
||||
error: {
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't send the link",
|
||||
description: 'Check your connection and try again.',
|
||||
get title() {
|
||||
return t("Couldn't send the link")
|
||||
},
|
||||
get description() {
|
||||
return t('Check your connection and try again.')
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -116,7 +130,9 @@ const resetNote = computed(() => {
|
||||
if (resetState.value === 'sent') {
|
||||
return {
|
||||
...resetNotes.sent,
|
||||
description: `Check ${email.value}. The link works once, within 1 hour.`,
|
||||
get description() {
|
||||
return t('Check {0}. The link works once, within 1 hour.', [email.value])
|
||||
},
|
||||
}
|
||||
}
|
||||
if (resetState.value === 'limited' || resetState.value === 'error') {
|
||||
@@ -127,9 +143,9 @@ const resetNote = computed(() => {
|
||||
|
||||
const resetButtonLabel = computed(() => {
|
||||
if (resetState.value === 'sending') {
|
||||
return 'Sending…'
|
||||
return t('Sending…')
|
||||
}
|
||||
return resetState.value === 'sent' ? 'Send again' : 'Send reset link'
|
||||
return resetState.value === 'sent' ? t('Send again') : t('Send reset link')
|
||||
})
|
||||
|
||||
const resetBusy = computed(() => resetState.value === 'sending')
|
||||
@@ -140,15 +156,19 @@ function focusField() {
|
||||
}
|
||||
|
||||
function announceSaved(displayName: string) {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: 'display-name-saved',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: displayName ? 'Display name saved' : 'Display name removed',
|
||||
description: displayName
|
||||
? `You now appear as ${displayName} across RenewCMS and in emails.`
|
||||
: `You now appear as ${username.value}, your username.`,
|
||||
})
|
||||
get title() {
|
||||
return displayName ? t('Display name saved') : t('Display name removed')
|
||||
},
|
||||
get description() {
|
||||
return displayName
|
||||
? t('You now appear as {0} across RenewCMS and in emails.', [displayName])
|
||||
: t('You now appear as {0}, your username.', [username.value])
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function isHandledGlobally(error: unknown) {
|
||||
@@ -220,11 +240,21 @@ onBeforeRouteLeave(async () => {
|
||||
const keepEditing = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Leave without saving?',
|
||||
description: "Your display name change hasn't been saved.",
|
||||
confirmLabel: 'Keep editing',
|
||||
cancelLabel: 'Discard changes',
|
||||
pendingLabel: 'Keep editing',
|
||||
get title() {
|
||||
return t('Leave without saving?')
|
||||
},
|
||||
get description() {
|
||||
return t("Your display name change hasn't been saved.")
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Keep editing')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Discard changes')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Keep editing')
|
||||
},
|
||||
tone: 'neutral',
|
||||
action: async () => {},
|
||||
dismissResolvesTo: 'confirm',
|
||||
@@ -239,8 +269,12 @@ onBeforeRouteLeave(async () => {
|
||||
<DashboardShell render-while-checking>
|
||||
<div class="flex max-w-164 flex-col gap-5">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted">Profile</h1>
|
||||
<p class="text-muted">Your account on this RenewCMS workspace.</p>
|
||||
<h1 class="text-xl font-semibold text-highlighted">
|
||||
{{ t('Profile') }}
|
||||
</h1>
|
||||
<p class="text-muted">
|
||||
{{ t('Your account on this RenewCMS workspace.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!account" aria-busy="true" class="flex flex-col gap-4">
|
||||
@@ -262,8 +296,12 @@ onBeforeRouteLeave(async () => {
|
||||
<template v-else>
|
||||
<UForm :state="{ displayName: field }" @submit="onSave">
|
||||
<ProfileCard
|
||||
title="Display name"
|
||||
description="Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username."
|
||||
:title="t('Display name')"
|
||||
:description="
|
||||
t(
|
||||
'Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username.',
|
||||
)
|
||||
"
|
||||
>
|
||||
<UAlert
|
||||
v-if="saveFailed"
|
||||
@@ -271,19 +309,20 @@ onBeforeRouteLeave(async () => {
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:description="SAVE_FAILED_COPY"
|
||||
:description="t(SAVE_FAILED_COPY)"
|
||||
:close="false"
|
||||
:ui="{ root: 'gap-2 px-3 py-2.5' }"
|
||||
/>
|
||||
|
||||
<UFormField name="displayName" :error="showFieldError">
|
||||
<template #label>
|
||||
Display name <span class="font-normal text-muted">(optional)</span>
|
||||
{{ t('Display name') }}
|
||||
<span class="font-normal text-muted">{{ t('(optional)') }}</span>
|
||||
</template>
|
||||
<UInput
|
||||
ref="inputRef"
|
||||
v-model="field"
|
||||
placeholder="e.g. Marta Alves"
|
||||
:placeholder="t('e.g. Marta Alves')"
|
||||
autocomplete="off"
|
||||
:disabled="saving"
|
||||
class="w-full"
|
||||
@@ -297,7 +336,7 @@ onBeforeRouteLeave(async () => {
|
||||
class="flex flex-1 items-center gap-1.5 text-xs/5 text-error"
|
||||
>
|
||||
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
|
||||
{{ DISPLAY_NAME_LIMIT_COPY }}
|
||||
{{ t(DISPLAY_NAME_LIMIT_COPY) }}
|
||||
</span>
|
||||
<span v-else class="flex-1" />
|
||||
<CharacterCounter
|
||||
@@ -322,18 +361,22 @@ onBeforeRouteLeave(async () => {
|
||||
:disabled="!saveEnabled && !saving"
|
||||
class="h-11 shrink-0 px-3.5 lg:h-8"
|
||||
>
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
{{ saving ? t('Saving…') : t('Save') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</ProfileCard>
|
||||
</UForm>
|
||||
|
||||
<ProfileCard
|
||||
title="Account"
|
||||
description="Set when your account was created. You log in with your username, even after adding a display name."
|
||||
:title="t('Account')"
|
||||
:description="
|
||||
t(
|
||||
'Set when your account was created. You log in with your username, even after adding a display name.',
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="grid gap-3.5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||
<UFormField label="Username">
|
||||
<UFormField :label="t('Username')">
|
||||
<UInput
|
||||
:model-value="username"
|
||||
disabled
|
||||
@@ -342,7 +385,7 @@ onBeforeRouteLeave(async () => {
|
||||
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Email">
|
||||
<UFormField :label="t('Email')">
|
||||
<UInput
|
||||
:model-value="email"
|
||||
disabled
|
||||
@@ -355,11 +398,16 @@ onBeforeRouteLeave(async () => {
|
||||
</ProfileCard>
|
||||
|
||||
<ProfileCard
|
||||
title="Password"
|
||||
:description="`To change it, we'll email a reset link to ${email}. The link works once, within 1 hour.`"
|
||||
:title="t('Password')"
|
||||
:description="
|
||||
t(
|
||||
'To change it, we\'ll email a reset link to {0}. The link works once, within 1 hour.',
|
||||
[email],
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<UFormField label="Password" class="min-w-0 flex-1">
|
||||
<UFormField :label="t('Password')" class="min-w-0 flex-1">
|
||||
<UInput
|
||||
:model-value="PASSWORD_MASK"
|
||||
disabled
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue'
|
||||
@@ -22,33 +24,35 @@ const passwordTouched = ref(false)
|
||||
const submitting = ref(false)
|
||||
const passwordShown = ref(false)
|
||||
const registrationClosed = ref(false)
|
||||
watchEffect(() => {
|
||||
if (registrationClosed.value) document.title = t('This site already has an owner · RenewCMS')
|
||||
})
|
||||
const serverErrorAlert = ref(false)
|
||||
|
||||
const usernameValid = computed(
|
||||
() => username.value.length >= 3 && username.value.length <= 20,
|
||||
)
|
||||
const usernameValid = computed(() => username.value.length >= 3 && username.value.length <= 20)
|
||||
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
|
||||
const passwordValid = computed(() => password.value.length >= 8)
|
||||
|
||||
const usernameError = computed(() =>
|
||||
usernameTouched.value && !usernameValid.value
|
||||
? 'Use 3 to 20 characters.'
|
||||
: '',
|
||||
usernameTouched.value && !usernameValid.value ? t('Use 3 to 20 characters.') : '',
|
||||
)
|
||||
|
||||
const emailError = computed(() =>
|
||||
emailTouched.value && !emailValid.value
|
||||
? 'Enter a full email address, like name@example.com.'
|
||||
? t('Enter a full email address, like name@example.com.')
|
||||
: '',
|
||||
)
|
||||
|
||||
const passwordRules = computed(() => [
|
||||
{ label: 'At least 8 characters', met: passwordValid.value },
|
||||
{
|
||||
get label() {
|
||||
return t('At least 8 characters')
|
||||
},
|
||||
met: passwordValid.value,
|
||||
},
|
||||
])
|
||||
|
||||
const formValid = computed(
|
||||
() => usernameValid.value && emailValid.value && passwordValid.value,
|
||||
)
|
||||
const formValid = computed(() => usernameValid.value && emailValid.value && passwordValid.value)
|
||||
|
||||
function touchAll() {
|
||||
usernameTouched.value = true
|
||||
@@ -84,7 +88,7 @@ async function onSubmit() {
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 403) {
|
||||
registrationClosed.value = true
|
||||
document.title = 'This site already has an owner · RenewCMS'
|
||||
document.title = t('This site already has an owner · RenewCMS')
|
||||
return
|
||||
}
|
||||
serverErrorAlert.value = true
|
||||
@@ -93,17 +97,24 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
const pageTitle = computed(() =>
|
||||
registrationClosed.value ? '' : 'Create the owner account',
|
||||
)
|
||||
const pageTitle = computed(() => (registrationClosed.value ? '' : t('Create the owner account')))
|
||||
|
||||
const pageDescription = computed(() =>
|
||||
registrationClosed.value
|
||||
? undefined
|
||||
: "This creates the single admin account for this site. Once it's verified, registration closes.",
|
||||
: t(
|
||||
"This creates the single admin account for this site. Once it's verified, registration closes.",
|
||||
),
|
||||
)
|
||||
|
||||
const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
const closedPanelActions = [
|
||||
{
|
||||
get label() {
|
||||
return t('Go to log in')
|
||||
},
|
||||
onClick: goToLogin,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -112,8 +123,8 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
<UAlert
|
||||
color="error"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't create the account"
|
||||
description="Something went wrong on the server. Try again in a moment."
|
||||
:title="t('Couldn\'t create the account')"
|
||||
:description="t('Something went wrong on the server. Try again in a moment.')"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
role="alert"
|
||||
@@ -124,8 +135,8 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
<ResultPanel
|
||||
tone="info"
|
||||
icon="i-lucide-user-x"
|
||||
title="This site already has an owner"
|
||||
description="Log in instead."
|
||||
:title="t('This site already has an owner')"
|
||||
:description="t('Log in instead.')"
|
||||
:actions="closedPanelActions"
|
||||
/>
|
||||
</template>
|
||||
@@ -137,9 +148,9 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UFormField
|
||||
label="Username"
|
||||
:label="t('Username')"
|
||||
name="username"
|
||||
:error="usernameError || undefined"
|
||||
:error="translate(usernameError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="usernameError" #error="{ error }">
|
||||
@@ -148,12 +159,11 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else #help>
|
||||
Letters and numbers, 3 to 20 characters. You'll use it
|
||||
to log in.
|
||||
{{ t("Letters and numbers, 3 to 20 characters. You'll use it to log in.") }}
|
||||
</template>
|
||||
<template #hint>
|
||||
<span
|
||||
@@ -167,7 +177,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
</template>
|
||||
<UInput
|
||||
v-model="username"
|
||||
placeholder="e.g. marta"
|
||||
:placeholder="t('e.g. marta')"
|
||||
autocomplete="username"
|
||||
:disabled="submitting"
|
||||
variant="outline"
|
||||
@@ -181,9 +191,9 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Email"
|
||||
:label="t('Email')"
|
||||
name="email"
|
||||
:error="emailError || undefined"
|
||||
:error="translate(emailError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="emailError" #error="{ error }">
|
||||
@@ -192,11 +202,11 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else #help>
|
||||
We'll send a verification link here.
|
||||
{{ t("We'll send a verification link here.") }}
|
||||
</template>
|
||||
<UInput
|
||||
v-model="email"
|
||||
@@ -214,11 +224,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Password"
|
||||
name="password"
|
||||
:size="controlSize"
|
||||
>
|
||||
<UFormField :label="t('Password')" name="password" :size="controlSize">
|
||||
<template #help>
|
||||
<PasswordStrengthHint
|
||||
:rules="passwordRules"
|
||||
@@ -242,20 +248,14 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
:icon="
|
||||
passwordShown
|
||||
? 'i-lucide-eye-off'
|
||||
: 'i-lucide-eye'
|
||||
"
|
||||
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="
|
||||
passwordShown
|
||||
? 'Hide password'
|
||||
: 'Show password'
|
||||
passwordShown ? t('Hide password') : t('Show password')
|
||||
"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
@@ -272,19 +272,19 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
>
|
||||
{{ submitting ? 'Creating account…' : 'Create account' }}
|
||||
{{ submitting ? t('Creating account…') : t('Create account') }}
|
||||
</UButton>
|
||||
</UForm>
|
||||
</template>
|
||||
|
||||
<template v-if="!registrationClosed" #footer>
|
||||
<span class="flex flex-wrap items-center gap-1.5">
|
||||
Already set up?
|
||||
{{ t('Already set up?') }}
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
Log in
|
||||
{{ t('Log in') }}
|
||||
</RouterLink>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
@@ -32,13 +34,25 @@ const passwordsMatch = computed(() => confirmPassword.value === password.value)
|
||||
const showLengthError = computed(() => submitFailed.value && !passwordValid.value)
|
||||
const showMatchError = computed(() => mismatchTouched.value && !passwordsMatch.value)
|
||||
|
||||
const passwordRules = computed(() => [{ label: 'At least 8 characters', met: passwordValid.value }])
|
||||
|
||||
const confirmPasswordRules = computed(() => [
|
||||
{ label: 'Passwords match', met: passwordsMatch.value },
|
||||
const passwordRules = computed(() => [
|
||||
{
|
||||
get label() {
|
||||
return t('At least 8 characters')
|
||||
},
|
||||
met: passwordValid.value,
|
||||
},
|
||||
])
|
||||
|
||||
const confirmError = computed(() => (showMatchError.value ? "The passwords don't match." : ''))
|
||||
const confirmPasswordRules = computed(() => [
|
||||
{
|
||||
get label() {
|
||||
return t('Passwords match')
|
||||
},
|
||||
met: passwordsMatch.value,
|
||||
},
|
||||
])
|
||||
|
||||
const confirmError = computed(() => (showMatchError.value ? t("The passwords don't match.") : ''))
|
||||
|
||||
function goToRequest() {
|
||||
router.push('/register/reset/request')
|
||||
@@ -49,16 +63,30 @@ function goToLogin() {
|
||||
}
|
||||
|
||||
const expiredActions = [
|
||||
{ label: 'Request a new link', onClick: goToRequest },
|
||||
{
|
||||
label: 'Back to log in',
|
||||
get label() {
|
||||
return t('Request a new link')
|
||||
},
|
||||
onClick: goToRequest,
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Back to log in')
|
||||
},
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
onClick: goToLogin,
|
||||
},
|
||||
]
|
||||
|
||||
const missingParametersActions = [{ label: 'Request a new link', onClick: goToRequest }]
|
||||
const missingParametersActions = [
|
||||
{
|
||||
get label() {
|
||||
return t('Request a new link')
|
||||
},
|
||||
onClick: goToRequest,
|
||||
},
|
||||
]
|
||||
|
||||
const confirmDescription =
|
||||
"Pick something you haven't used here before. You'll log in with it next."
|
||||
@@ -102,15 +130,15 @@ async function onSubmit() {
|
||||
|
||||
<template>
|
||||
<AuthShell
|
||||
:title="expired || missingParameters ? '' : 'Choose a new password'"
|
||||
:description="expired || missingParameters ? undefined : confirmDescription"
|
||||
:title="expired || missingParameters ? '' : t('Choose a new password')"
|
||||
:description="expired || missingParameters ? undefined : t(confirmDescription)"
|
||||
>
|
||||
<template v-if="serverErrorAlert" #notice>
|
||||
<UAlert
|
||||
color="error"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't reach RenewCMS"
|
||||
description="Check your connection and try again."
|
||||
:title="t('Couldn\'t reach RenewCMS')"
|
||||
:description="t('Check your connection and try again.')"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
role="alert"
|
||||
@@ -121,8 +149,12 @@ async function onSubmit() {
|
||||
<ResultPanel
|
||||
tone="error"
|
||||
icon="i-lucide-link-2-off"
|
||||
title="This link is incomplete"
|
||||
description="Part of the reset link is missing. Open it from your email again, or request a new one."
|
||||
:title="t('This link is incomplete')"
|
||||
:description="
|
||||
t(
|
||||
'Part of the reset link is missing. Open it from your email again, or request a new one.',
|
||||
)
|
||||
"
|
||||
:actions="missingParametersActions"
|
||||
/>
|
||||
</template>
|
||||
@@ -131,8 +163,12 @@ async function onSubmit() {
|
||||
<ResultPanel
|
||||
tone="error"
|
||||
icon="i-lucide-link-2-off"
|
||||
title="This reset link has expired"
|
||||
description="Reset links work once, for 1 hour. Request a new one and use it within the hour."
|
||||
:title="t('This reset link has expired')"
|
||||
:description="
|
||||
t(
|
||||
'Reset links work once, for 1 hour. Request a new one and use it within the hour.',
|
||||
)
|
||||
"
|
||||
:actions="expiredActions"
|
||||
/>
|
||||
</template>
|
||||
@@ -144,7 +180,7 @@ async function onSubmit() {
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UFormField
|
||||
label="Email"
|
||||
:label="t('Email')"
|
||||
name="email"
|
||||
help="From your reset link."
|
||||
:size="controlSize"
|
||||
@@ -163,7 +199,7 @@ async function onSubmit() {
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="New password" name="password" :size="controlSize">
|
||||
<UFormField :label="t('New password')" name="password" :size="controlSize">
|
||||
<template #help>
|
||||
<PasswordStrengthHint
|
||||
:rules="passwordRules"
|
||||
@@ -193,7 +229,9 @@ async function onSubmit() {
|
||||
size="sm"
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
||||
:aria-label="
|
||||
passwordShown ? t('Hide password') : t('Show password')
|
||||
"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
/>
|
||||
@@ -201,7 +239,11 @@ async function onSubmit() {
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Confirm new password" name="confirmPassword" :size="controlSize">
|
||||
<UFormField
|
||||
:label="t('Confirm new password')"
|
||||
name="confirmPassword"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template #help>
|
||||
<span
|
||||
v-if="confirmError"
|
||||
@@ -212,7 +254,7 @@ async function onSubmit() {
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ confirmError }}
|
||||
{{ translate(confirmError) }}
|
||||
</span>
|
||||
<PasswordStrengthHint
|
||||
:rules="confirmPasswordRules"
|
||||
@@ -245,7 +287,7 @@ async function onSubmit() {
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="
|
||||
confirmPasswordShown ? 'Hide password' : 'Show password'
|
||||
confirmPasswordShown ? t('Hide password') : t('Show password')
|
||||
"
|
||||
:aria-pressed="confirmPasswordShown"
|
||||
@click="confirmPasswordShown = !confirmPasswordShown"
|
||||
@@ -262,7 +304,7 @@ async function onSubmit() {
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
>
|
||||
{{ submitting ? 'Changing password…' : 'Change password' }}
|
||||
{{ submitting ? t('Changing password…') : t('Change password') }}
|
||||
</UButton>
|
||||
</UForm>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
@@ -18,15 +20,23 @@ interface AlertState {
|
||||
const tooManyAttemptsAlert: AlertState = {
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-timer',
|
||||
title: 'Too many requests',
|
||||
description: 'Wait a moment before asking for another link.',
|
||||
get title() {
|
||||
return t('Too many requests')
|
||||
},
|
||||
get description() {
|
||||
return t('Wait a moment before asking for another link.')
|
||||
},
|
||||
}
|
||||
|
||||
const serverErrorAlert: AlertState = {
|
||||
tone: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't send the link",
|
||||
description: 'Check your connection and try again.',
|
||||
get title() {
|
||||
return t("Couldn't send the link")
|
||||
},
|
||||
get description() {
|
||||
return t('Check your connection and try again.')
|
||||
},
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
@@ -45,12 +55,12 @@ const submitDisabled = computed(() => submitting.value || retryAfter.active.valu
|
||||
|
||||
const submitLabel = computed(() => {
|
||||
if (submitting.value) {
|
||||
return 'Sending…'
|
||||
return t('Sending…')
|
||||
}
|
||||
if (retryAfter.active.value) {
|
||||
return `Try again in ${retryAfter.formatted.value}`
|
||||
return t('Try again in {0}', [retryAfter.formatted.value])
|
||||
}
|
||||
return 'Send reset link'
|
||||
return t('Send reset link')
|
||||
})
|
||||
|
||||
function validate() {
|
||||
@@ -66,7 +76,9 @@ function goToLogin() {
|
||||
|
||||
const resultPanelActions = [
|
||||
{
|
||||
label: 'Back to log in',
|
||||
get label() {
|
||||
return t('Back to log in')
|
||||
},
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
onClick: goToLogin,
|
||||
@@ -108,11 +120,11 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => (sent.value ? '' : 'Reset your password'))
|
||||
const pageTitle = computed(() => (sent.value ? '' : t('Reset your password')))
|
||||
const pageDescription = computed(() =>
|
||||
sent.value
|
||||
? undefined
|
||||
: "Enter the email for your account and we'll send you a link to choose a new password.",
|
||||
: t("Enter the email for your account and we'll send you a link to choose a new password."),
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -134,20 +146,24 @@ const pageDescription = computed(() =>
|
||||
<ResultPanel
|
||||
tone="success"
|
||||
icon="i-lucide-mail-check"
|
||||
title="Check your email"
|
||||
description="If an account exists for that email, a password reset link has been sent."
|
||||
:title="t('Check your email')"
|
||||
:description="
|
||||
t('If an account exists for that email, a password reset link has been sent.')
|
||||
"
|
||||
:actions="resultPanelActions"
|
||||
>
|
||||
<p class="text-sm text-muted">The link expires in 1 hour.</p>
|
||||
<p class="text-sm text-muted">
|
||||
{{ t('The link expires in 1 hour.') }}
|
||||
</p>
|
||||
</ResultPanel>
|
||||
</template>
|
||||
|
||||
<template v-else #default="{ controlSize, controlClass, inputClass }">
|
||||
<UForm :state="{ email }" class="flex flex-col gap-4.5" @submit="onSubmit">
|
||||
<UFormField
|
||||
label="Email"
|
||||
:label="t('Email')"
|
||||
name="email"
|
||||
:error="emailError || undefined"
|
||||
:error="translate(emailError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="emailError" #error="{ error }">
|
||||
@@ -156,7 +172,7 @@ const pageDescription = computed(() =>
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<UInput
|
||||
@@ -192,7 +208,7 @@ const pageDescription = computed(() =>
|
||||
to="/login"
|
||||
class="font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
Back to log in
|
||||
{{ t('Back to log in') }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</AuthShell>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import RestartOverlay from '@/components/RestartOverlay.vue'
|
||||
@@ -22,17 +24,25 @@ const RESTART_FAILED_COPY =
|
||||
const RESTART_WARNING =
|
||||
"Your site will be unavailable for about 10 seconds. This page reloads on its own when the site is back. Save anything you're editing first."
|
||||
const PLAIN_RESTART_DIALOG = {
|
||||
title: 'Restart the site?',
|
||||
description: RESTART_WARNING,
|
||||
confirmLabel: 'Restart site',
|
||||
cancelLabel: 'Cancel',
|
||||
get title() {
|
||||
return t('Restart the site?')
|
||||
},
|
||||
get description() {
|
||||
return t(RESTART_WARNING)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Restart site')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Cancel')
|
||||
},
|
||||
}
|
||||
|
||||
type LoadState = 'loading' | 'ready' | 'error'
|
||||
|
||||
const { getSettings } = useSettings()
|
||||
const toast = useToast()
|
||||
const overlay = useOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const session = useSessionStore()
|
||||
const update = useUpdateStore()
|
||||
const retry = useRetryAfter()
|
||||
@@ -108,12 +118,16 @@ async function reloadAfterRoleChange() {
|
||||
}
|
||||
|
||||
function notifyOnlyOwner() {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: 'Only the owner can do this',
|
||||
description: 'Ask the owner of this site to update RenewCMS.',
|
||||
})
|
||||
get title() {
|
||||
return t('Only the owner can do this')
|
||||
},
|
||||
get description() {
|
||||
return t('Ask the owner of this site to update RenewCMS.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async function onUpdate() {
|
||||
@@ -127,24 +141,32 @@ async function onUpdate() {
|
||||
return
|
||||
}
|
||||
if (result === 'nothing') {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'Nothing to update',
|
||||
description: 'You already have the latest version.',
|
||||
})
|
||||
get title() {
|
||||
return t('Nothing to update')
|
||||
},
|
||||
get description() {
|
||||
return t('You already have the latest version.')
|
||||
},
|
||||
}))
|
||||
await update.load()
|
||||
return
|
||||
}
|
||||
if (result === 'conflict') {
|
||||
await update.load()
|
||||
if (update.jobRunning) {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'An update is already running',
|
||||
description: 'Another owner started it. Progress shows here.',
|
||||
})
|
||||
get title() {
|
||||
return t('An update is already running')
|
||||
},
|
||||
get description() {
|
||||
return t('Another owner started it. Progress shows here.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,10 +183,21 @@ async function onCheck() {
|
||||
function installRestartDialog() {
|
||||
const version = displayVersion(update.status?.latest ?? '')
|
||||
return {
|
||||
title: 'Restart to finish the update?',
|
||||
description: `RenewCMS will install version ${version} and restart. ${RESTART_WARNING}`,
|
||||
confirmLabel: 'Restart now',
|
||||
cancelLabel: 'Restart later',
|
||||
get title() {
|
||||
return t('Restart to finish the update?')
|
||||
},
|
||||
get description() {
|
||||
return t('RenewCMS will install version {0} and restart. {1}', [
|
||||
version,
|
||||
t(RESTART_WARNING),
|
||||
])
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Restart now')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Restart later')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,12 +208,14 @@ async function onRestart(kind: RestartKind) {
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
...dialog,
|
||||
pendingLabel: 'Restarting…',
|
||||
get pendingLabel() {
|
||||
return t('Restarting…')
|
||||
},
|
||||
tone: 'neutral',
|
||||
action: async () => {
|
||||
attempt.result = await update.requestRestart(kind)
|
||||
if (attempt.result === 'failed') {
|
||||
throw new Error(RESTART_FAILED_COPY)
|
||||
throw new Error(t(RESTART_FAILED_COPY))
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -209,12 +244,16 @@ async function settleRefusedRestart(result: RestartRequestResult) {
|
||||
}
|
||||
await update.load()
|
||||
if (update.jobRunning || update.restarting) {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'An update is already running',
|
||||
description: 'Another owner started it. Progress shows here.',
|
||||
})
|
||||
get title() {
|
||||
return t('An update is already running')
|
||||
},
|
||||
get description() {
|
||||
return t('Another owner started it. Progress shows here.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
update.resumeRestart()
|
||||
}
|
||||
@@ -223,26 +262,40 @@ const OUTCOME_TOASTS = {
|
||||
updated: () => ({
|
||||
color: 'success' as const,
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Update installed',
|
||||
description: `RenewCMS is now on version ${displayVersion(update.status?.current ?? '')}.`,
|
||||
get title() {
|
||||
return t('Update installed')
|
||||
},
|
||||
get description() {
|
||||
return t('RenewCMS is now on version {0}.', [
|
||||
displayVersion(update.status?.current ?? ''),
|
||||
])
|
||||
},
|
||||
}),
|
||||
'rolled-back': () => ({
|
||||
color: 'error' as const,
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "The update didn't install",
|
||||
description: `Your site is back on ${update.status?.current ?? ''}.`,
|
||||
get title() {
|
||||
return t("The update didn't install")
|
||||
},
|
||||
get description() {
|
||||
return t('Your site is back on {0}.', [update.status?.current ?? ''])
|
||||
},
|
||||
duration: 0,
|
||||
}),
|
||||
restarted: () => ({
|
||||
color: 'success' as const,
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Site restarted',
|
||||
description: 'Your site is running again.',
|
||||
get title() {
|
||||
return t('Site restarted')
|
||||
},
|
||||
get description() {
|
||||
return t('Your site is running again.')
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
async function announceRestartOutcome(outcome: RestartOutcome) {
|
||||
toast.add(OUTCOME_TOASTS[outcome]())
|
||||
toast.add(() => OUTCOME_TOASTS[outcome]())
|
||||
await nextTick()
|
||||
updatesCard.value?.focusHeading()
|
||||
}
|
||||
@@ -330,8 +383,10 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<div class="flex max-w-164 flex-col gap-5" :inert="update.restartRun !== null">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted">Settings</h1>
|
||||
<p class="text-muted">Workspace information.</p>
|
||||
<h1 class="text-xl font-semibold text-highlighted">
|
||||
{{ t('Settings') }}
|
||||
</h1>
|
||||
<p class="text-muted">{{ t('Workspace information.') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -347,7 +402,7 @@ onBeforeUnmount(() => {
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:description="UNREACHABLE_COPY"
|
||||
:description="t(UNREACHABLE_COPY)"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
@@ -359,13 +414,13 @@ onBeforeUnmount(() => {
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="loadSettings"
|
||||
>
|
||||
Try again
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<div v-else class="flex items-center justify-between gap-4">
|
||||
<span class="text-lg font-semibold text-highlighted">Version</span>
|
||||
<span class="text-lg font-semibold text-highlighted">{{ t('Version') }}</span>
|
||||
<USkeleton
|
||||
v-if="loadState === 'loading' && showSkeleton"
|
||||
data-testid="settings-skeleton"
|
||||
|
||||
+72
-33
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import InviteUserModal from '@/components/InviteUserModal.vue'
|
||||
@@ -19,8 +21,8 @@ type LoadState = 'loading' | 'ready' | 'error'
|
||||
const AUTH_HANDLED_STATUSES = [401, 403]
|
||||
const CANCEL_FAILED_COPY = "Couldn't cancel the invite. Try again in a moment."
|
||||
|
||||
const toast = useToast()
|
||||
const overlay = useOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const session = useSessionStore()
|
||||
const { listUsers, cancelInvite, renewInvite } = useUsers()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
@@ -105,13 +107,17 @@ function onInvited(invite: CreatedInvite) {
|
||||
),
|
||||
]
|
||||
highlightedKey.value = userRowKey('invite', invite.id)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `invite-sent-${invite.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Invite sent',
|
||||
description: `${invite.email} has 3 days to accept.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Invite sent')
|
||||
},
|
||||
get description() {
|
||||
return t('{0} has 3 days to accept.', [invite.email])
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function isHandledByApiClient(err: unknown) {
|
||||
@@ -127,11 +133,24 @@ function confirmCancel(invite: UserListItem) {
|
||||
overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Cancel this invite?',
|
||||
description: `The link sent to ${invite.email} will stop working. You can invite the address again later.`,
|
||||
confirmLabel: 'Cancel invite',
|
||||
cancelLabel: 'Keep invite',
|
||||
pendingLabel: 'Cancelling…',
|
||||
get title() {
|
||||
return t('Cancel this invite?')
|
||||
},
|
||||
get description() {
|
||||
return t(
|
||||
'The link sent to {0} will stop working. You can invite the address again later.',
|
||||
[invite.email],
|
||||
)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Cancel invite')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Keep invite')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Cancelling…')
|
||||
},
|
||||
tone: 'danger',
|
||||
action: async () => {
|
||||
try {
|
||||
@@ -140,16 +159,20 @@ function confirmCancel(invite: UserListItem) {
|
||||
if (isHandledByApiClient(err)) {
|
||||
return
|
||||
}
|
||||
throw new Error(CANCEL_FAILED_COPY)
|
||||
throw new Error(t(CANCEL_FAILED_COPY))
|
||||
}
|
||||
removeRow(key)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `invite-cancelled-${invite.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Invite cancelled',
|
||||
description: `${invite.email} can no longer use the link.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Invite cancelled')
|
||||
},
|
||||
get description() {
|
||||
return t('{0} can no longer use the link.', [invite.email])
|
||||
},
|
||||
}))
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -176,22 +199,30 @@ async function renew(invite: UserListItem) {
|
||||
...items.value.filter((item) => userRowKey(item.kind, item.id) !== key),
|
||||
]
|
||||
highlightedKey.value = userRowKey('invite', renewed.id)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `invite-renewed-${renewed.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Invite renewed',
|
||||
description: `${renewed.email} has 3 days to accept the new link.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Invite renewed')
|
||||
},
|
||||
get description() {
|
||||
return t('{0} has 3 days to accept the new link.', [renewed.email])
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
if (!isHandledByApiClient(err)) {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `invite-renew-failed-${invite.id}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't renew the invite",
|
||||
description: 'Try again in a moment.',
|
||||
})
|
||||
get title() {
|
||||
return t("Couldn't renew the invite")
|
||||
},
|
||||
get description() {
|
||||
return t('Try again in a moment.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
} finally {
|
||||
busyKeys.value = busyKeys.value.filter((busyKey) => busyKey !== key)
|
||||
@@ -207,8 +238,12 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted">Users</h1>
|
||||
<p class="text-muted">Everyone who can sign in and work on your articles.</p>
|
||||
<h1 class="text-xl font-semibold text-highlighted">
|
||||
{{ t('Users') }}
|
||||
</h1>
|
||||
<p class="text-muted">
|
||||
{{ t('Everyone who can sign in and work on your articles.') }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="isDesktop && canInvite"
|
||||
@@ -216,7 +251,7 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
class="h-8 px-3"
|
||||
@click="openInviteModal"
|
||||
>
|
||||
Invite user
|
||||
{{ t('Invite user') }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
@@ -228,8 +263,8 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't load users"
|
||||
description="Check your connection and try again."
|
||||
:title="t('Couldn\'t load users')"
|
||||
:description="t('Check your connection and try again.')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
@@ -241,7 +276,7 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="loadUsers"
|
||||
>
|
||||
Try again
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
@@ -272,7 +307,11 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
</template>
|
||||
|
||||
<p v-if="showOnlyYouHint" class="text-pretty text-muted">
|
||||
Invite someone so they can sign in with their own account instead of sharing yours.
|
||||
{{
|
||||
t(
|
||||
'Invite someone so they can sign in with their own account instead of sharing yours.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -284,7 +323,7 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
:ui="{ base: 'gap-2', leadingIcon: 'size-4.5' }"
|
||||
@click="openInviteModal"
|
||||
>
|
||||
Invite user
|
||||
{{ t('Invite user') }}
|
||||
</UButton>
|
||||
|
||||
<InviteUserModal
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
import ResultPanel from '@/components/ResultPanel.vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
@@ -28,36 +30,52 @@ interface AlertState {
|
||||
const wrongCredentialsAlert: AlertState = {
|
||||
tone: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "That username and password don't match",
|
||||
description: 'Check both and try again. Passwords are case-sensitive.',
|
||||
get title() {
|
||||
return t("That username and password don't match")
|
||||
},
|
||||
get description() {
|
||||
return t('Check both and try again. Passwords are case-sensitive.')
|
||||
},
|
||||
}
|
||||
|
||||
const tooManyAttemptsAlert: AlertState = {
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-timer',
|
||||
title: 'Too many attempts',
|
||||
description: 'For your security, wait a moment before trying again.',
|
||||
get title() {
|
||||
return t('Too many attempts')
|
||||
},
|
||||
get description() {
|
||||
return t('For your security, wait a moment before trying again.')
|
||||
},
|
||||
}
|
||||
|
||||
const serverErrorAlert: AlertState = {
|
||||
tone: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't reach RenewCMS",
|
||||
description: 'Check your connection and try again.',
|
||||
get title() {
|
||||
return t("Couldn't reach RenewCMS")
|
||||
},
|
||||
get description() {
|
||||
return t('Check your connection and try again.')
|
||||
},
|
||||
}
|
||||
|
||||
const cancelFailedAlert: AlertState = {
|
||||
tone: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't cancel registration",
|
||||
description: 'Something went wrong on the server. Try again in a moment.',
|
||||
get title() {
|
||||
return t("Couldn't cancel registration")
|
||||
},
|
||||
get description() {
|
||||
return t('Something went wrong on the server. Try again in a moment.')
|
||||
},
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const api = useApi()
|
||||
const session = useSessionStore()
|
||||
const toast = useToast()
|
||||
const toast = useLocalizedToast()
|
||||
const retryAfter = useRetryAfter()
|
||||
|
||||
const state = ref<VerifyState>('missing')
|
||||
@@ -71,39 +89,35 @@ const loginSubmitting = ref(false)
|
||||
const loginAlert = ref<AlertState | null>(null)
|
||||
|
||||
const shellTitle = computed(() =>
|
||||
state.value === 'noSession' ? 'Log in to finish verifying' : '',
|
||||
state.value === 'noSession' ? t('Log in to finish verifying') : '',
|
||||
)
|
||||
const shellDescription = computed(() =>
|
||||
state.value === 'noSession'
|
||||
? "You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after."
|
||||
? t(
|
||||
"You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after.",
|
||||
)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const usernameError = ref('')
|
||||
const passwordError = ref('')
|
||||
|
||||
const loginSubmitDisabled = computed(
|
||||
() => loginSubmitting.value || retryAfter.active.value,
|
||||
)
|
||||
const loginSubmitDisabled = computed(() => loginSubmitting.value || retryAfter.active.value)
|
||||
|
||||
const loginSubmitLabel = computed(() => {
|
||||
if (loginSubmitting.value) {
|
||||
return 'Logging in…'
|
||||
return t('Logging in…')
|
||||
}
|
||||
if (retryAfter.active.value) {
|
||||
return `Try again in ${retryAfter.formatted.value}`
|
||||
return t('Try again in {0}', [retryAfter.formatted.value])
|
||||
}
|
||||
return 'Log in and verify'
|
||||
return t('Log in and verify')
|
||||
})
|
||||
|
||||
async function focusPassword() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
document
|
||||
.querySelector<HTMLInputElement>(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
?.focus()
|
||||
document.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')?.focus()
|
||||
}
|
||||
|
||||
async function performVerify(code: string) {
|
||||
@@ -116,12 +130,16 @@ async function performVerify(code: string) {
|
||||
onForbidden: 'passthrough',
|
||||
})
|
||||
state.value = 'success'
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Your account is verified',
|
||||
description: 'Welcome to RenewCMS.',
|
||||
})
|
||||
get title() {
|
||||
return t('Your account is verified')
|
||||
},
|
||||
get description() {
|
||||
return t('Welcome to RenewCMS.')
|
||||
},
|
||||
}))
|
||||
await Promise.all([session.retry(), wait(VERIFIED_REDIRECT_DELAY_MS)])
|
||||
await router.push('/home')
|
||||
return
|
||||
@@ -160,10 +178,8 @@ function handleLoginError(error: unknown) {
|
||||
}
|
||||
|
||||
function validateLogin() {
|
||||
usernameError.value =
|
||||
username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
|
||||
passwordError.value =
|
||||
password.value.length === 0 ? 'Enter your password.' : ''
|
||||
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
|
||||
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
|
||||
return !usernameError.value && !passwordError.value
|
||||
}
|
||||
|
||||
@@ -203,7 +219,7 @@ async function cancelAndStartOver() {
|
||||
onForbidden: 'passthrough',
|
||||
})
|
||||
await router.push('/register')
|
||||
toast.add(registrationCancelledToast)
|
||||
toast.add(() => registrationCancelledToast)
|
||||
} catch {
|
||||
cancelAlert.value = cancelFailedAlert
|
||||
} finally {
|
||||
@@ -224,14 +240,18 @@ async function logOutFromInvalid() {
|
||||
|
||||
const invalidActions = computed(() => [
|
||||
{
|
||||
label: 'Cancel registration and start over',
|
||||
get label() {
|
||||
return t('Cancel registration and start over')
|
||||
},
|
||||
color: 'primary' as const,
|
||||
variant: 'solid' as const,
|
||||
disabled: invalidActionsDisabled.value,
|
||||
onClick: cancelAndStartOver,
|
||||
},
|
||||
{
|
||||
label: 'Log out',
|
||||
get label() {
|
||||
return t('Log out')
|
||||
},
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
disabled: invalidActionsDisabled.value,
|
||||
@@ -245,7 +265,9 @@ function goToLogin() {
|
||||
|
||||
const missingCodeActions = [
|
||||
{
|
||||
label: 'Go to log in',
|
||||
get label() {
|
||||
return t('Go to log in')
|
||||
},
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
onClick: goToLogin,
|
||||
@@ -294,22 +316,21 @@ onMounted(() => {
|
||||
role="status"
|
||||
class="flex flex-col items-center gap-4 py-10 text-center"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-loader-2"
|
||||
class="size-8 animate-spin text-highlighted"
|
||||
/>
|
||||
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
|
||||
<p class="text-xl/7 font-semibold text-highlighted">
|
||||
Verifying your account…
|
||||
{{ t('Verifying your account…') }}
|
||||
</p>
|
||||
<p class="text-base text-muted">
|
||||
{{ t('This only takes a moment.') }}
|
||||
</p>
|
||||
<p class="text-base text-muted">This only takes a moment.</p>
|
||||
</div>
|
||||
|
||||
<ResultPanel
|
||||
v-else-if="state === 'success'"
|
||||
tone="success"
|
||||
icon="i-lucide-shield-check"
|
||||
title="Your account is verified"
|
||||
description="Taking you to your dashboard…"
|
||||
:title="t('Your account is verified')"
|
||||
:description="t('Taking you to your dashboard…')"
|
||||
:actions="[]"
|
||||
/>
|
||||
|
||||
@@ -317,12 +338,12 @@ onMounted(() => {
|
||||
v-else-if="state === 'invalid'"
|
||||
tone="error"
|
||||
icon="i-lucide-link-2-off"
|
||||
title="This verification link doesn't work"
|
||||
description="It may have expired (links last 2 hours) or already been used."
|
||||
:title="t('This verification link doesn\'t work')"
|
||||
:description="t('It may have expired (links last 2 hours) or already been used.')"
|
||||
:actions="invalidActions"
|
||||
>
|
||||
<p class="text-sm text-pretty text-muted">
|
||||
Cancel your registration and start over to get a new link.
|
||||
{{ t('Cancel your registration and start over to get a new link.') }}
|
||||
</p>
|
||||
</ResultPanel>
|
||||
|
||||
@@ -330,8 +351,12 @@ onMounted(() => {
|
||||
v-else-if="state === 'missing'"
|
||||
tone="error"
|
||||
icon="i-lucide-link-2-off"
|
||||
title="This link is incomplete"
|
||||
description="The verification code is missing. Open the link from your email again, or copy the full address into your browser."
|
||||
:title="t('This link is incomplete')"
|
||||
:description="
|
||||
t(
|
||||
'The verification code is missing. Open the link from your email again, or copy the full address into your browser.',
|
||||
)
|
||||
"
|
||||
:actions="missingCodeActions"
|
||||
/>
|
||||
|
||||
@@ -342,9 +367,9 @@ onMounted(() => {
|
||||
@submit="onLoginSubmit"
|
||||
>
|
||||
<UFormField
|
||||
label="Username"
|
||||
:label="t('Username')"
|
||||
name="username"
|
||||
:error="usernameError || undefined"
|
||||
:error="translate(usernameError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="usernameError" #error="{ error }">
|
||||
@@ -353,13 +378,13 @@ onMounted(() => {
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<UInput
|
||||
v-model="username"
|
||||
:highlight="!!usernameError"
|
||||
placeholder="Your username"
|
||||
:placeholder="t('Your username')"
|
||||
autocomplete="username"
|
||||
:disabled="loginSubmitDisabled"
|
||||
variant="outline"
|
||||
@@ -371,9 +396,9 @@ onMounted(() => {
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Password"
|
||||
:label="t('Password')"
|
||||
name="password"
|
||||
:error="passwordError || undefined"
|
||||
:error="translate(passwordError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="passwordError" #error="{ error }">
|
||||
@@ -382,7 +407,7 @@ onMounted(() => {
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<UInput
|
||||
@@ -402,20 +427,14 @@ onMounted(() => {
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
:icon="
|
||||
passwordShown
|
||||
? 'i-lucide-eye-off'
|
||||
: 'i-lucide-eye'
|
||||
"
|
||||
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="
|
||||
passwordShown
|
||||
? 'Hide password'
|
||||
: 'Show password'
|
||||
passwordShown ? t('Hide password') : t('Show password')
|
||||
"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
|
||||
Reference in new issue
Block a user