feat(SPA): i18n internationzalization, english and french

This commit is contained in:
fsylvain committed 2026-10-03 01:53:49 +02:00
1 parent 7f1e8e572d
commit 721311a643
88 files changed
+4438 -1148

No files matched your search

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