feat(SPA): i18n internationzalization, english and french
This commit is contained in:
1 parent
7f1e8e572d
commit
721311a643
88 files changed
+4438
-1148
No files matched your search
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue'
|
||||
import ResultPanel from '@/components/ResultPanel.vue'
|
||||
@@ -19,7 +21,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const api = useApi()
|
||||
const session = useSessionStore()
|
||||
const toast = useToast()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const code = typeof route.query.c === 'string' ? route.query.c : ''
|
||||
|
||||
@@ -40,22 +42,29 @@ const passwordValid = computed(() => password.value.length >= 8)
|
||||
|
||||
const usernameError = computed(() => {
|
||||
if (usernameTaken.value) {
|
||||
return USERNAME_TAKEN_MESSAGE
|
||||
return t(USERNAME_TAKEN_MESSAGE)
|
||||
}
|
||||
return usernameTouched.value && !usernameValid.value ? USERNAME_RULE_MESSAGE : ''
|
||||
return usernameTouched.value && !usernameValid.value ? t(USERNAME_RULE_MESSAGE) : ''
|
||||
})
|
||||
|
||||
const passwordRules = computed(() => [{ label: 'At least 8 characters', met: passwordValid.value }])
|
||||
const passwordRules = computed(() => [
|
||||
{
|
||||
get label() {
|
||||
return t('At least 8 characters')
|
||||
},
|
||||
met: passwordValid.value,
|
||||
},
|
||||
])
|
||||
|
||||
const formValid = computed(() => usernameValid.value && passwordValid.value)
|
||||
|
||||
const showForm = computed(() => state.value === 'form')
|
||||
|
||||
const pageTitle = computed(() => (showForm.value ? 'Accept your invite' : ''))
|
||||
const pageTitle = computed(() => (showForm.value ? t('Accept your invite') : ''))
|
||||
|
||||
const pageDescription = computed(() =>
|
||||
showForm.value
|
||||
? 'Choose a username and password to finish setting up your account.'
|
||||
? t('Choose a username and password to finish setting up your account.')
|
||||
: undefined,
|
||||
)
|
||||
|
||||
@@ -63,11 +72,20 @@ function goToLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const invalidActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
const invalidActions = [
|
||||
{
|
||||
get label() {
|
||||
return t('Go to log in')
|
||||
},
|
||||
onClick: goToLogin,
|
||||
},
|
||||
]
|
||||
|
||||
const expiredActions = [
|
||||
{
|
||||
label: 'Go to log in',
|
||||
get label() {
|
||||
return t('Go to log in')
|
||||
},
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
onClick: goToLogin,
|
||||
@@ -117,12 +135,16 @@ async function loadInvite() {
|
||||
|
||||
async function handleSuccess() {
|
||||
state.value = 'success'
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Welcome to RenewCMS',
|
||||
description: `You're logged in as ${username.value}.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Welcome to RenewCMS')
|
||||
},
|
||||
get description() {
|
||||
return t("You're logged in as {0}.", [username.value])
|
||||
},
|
||||
}))
|
||||
await Promise.all([session.retry(), wait(VERIFIED_REDIRECT_DELAY_MS)])
|
||||
await router.push('/home')
|
||||
}
|
||||
@@ -187,8 +209,8 @@ onMounted(() => {
|
||||
<UAlert
|
||||
color="error"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't create the account"
|
||||
description="Something went wrong on the server. Try again in a moment."
|
||||
:title="t('Couldn\'t create the account')"
|
||||
:description="t('Something went wrong on the server. Try again in a moment.')"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
role="alert"
|
||||
@@ -202,16 +224,20 @@ onMounted(() => {
|
||||
class="flex flex-col items-center gap-4 py-10 text-center"
|
||||
>
|
||||
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
|
||||
<p class="text-xl/7 font-semibold text-highlighted">Checking your invite…</p>
|
||||
<p class="text-base text-muted">This only takes a moment.</p>
|
||||
<p class="text-xl/7 font-semibold text-highlighted">
|
||||
{{ t('Checking your invite…') }}
|
||||
</p>
|
||||
<p class="text-base text-muted">
|
||||
{{ t('This only takes a moment.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="state === 'failed'" class="flex flex-col gap-4.5">
|
||||
<UAlert
|
||||
color="error"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't check your invite"
|
||||
description="Something went wrong on the server. Try again in a moment."
|
||||
:title="t('Couldn\'t check your invite')"
|
||||
:description="t('Something went wrong on the server. Try again in a moment.')"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
role="alert"
|
||||
@@ -224,7 +250,7 @@ onMounted(() => {
|
||||
:ui="{ base: controlClass }"
|
||||
@click="loadInvite"
|
||||
>
|
||||
Try again
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
@@ -232,12 +258,20 @@ onMounted(() => {
|
||||
v-else-if="state === 'invalid'"
|
||||
tone="error"
|
||||
icon="i-lucide-link-2-off"
|
||||
title="This invite link doesn't work"
|
||||
description="It may be incomplete, or it may already have been used. If you already set up your account, log in instead."
|
||||
:title="t('This invite link doesn\'t work')"
|
||||
:description="
|
||||
t(
|
||||
'It may be incomplete, or it may already have been used. If you already set up your account, log in instead.',
|
||||
)
|
||||
"
|
||||
:actions="invalidActions"
|
||||
>
|
||||
<p class="text-sm text-pretty text-muted">
|
||||
Open the link from your email again, or copy the full address into your browser.
|
||||
{{
|
||||
t(
|
||||
'Open the link from your email again, or copy the full address into your browser.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</ResultPanel>
|
||||
|
||||
@@ -245,8 +279,12 @@ onMounted(() => {
|
||||
v-else-if="state === 'expired'"
|
||||
tone="error"
|
||||
icon="i-lucide-clock"
|
||||
title="This invite has expired"
|
||||
description="Invites work once, for 3 days. Ask the person who invited you to send a new one."
|
||||
:title="t('This invite has expired')"
|
||||
:description="
|
||||
t(
|
||||
'Invites work once, for 3 days. Ask the person who invited you to send a new one.',
|
||||
)
|
||||
"
|
||||
:actions="expiredActions"
|
||||
/>
|
||||
|
||||
@@ -254,8 +292,8 @@ onMounted(() => {
|
||||
v-else-if="state === 'success'"
|
||||
tone="success"
|
||||
icon="i-lucide-circle-check"
|
||||
title="Your account is ready"
|
||||
description="Taking you to your dashboard…"
|
||||
:title="t('Your account is ready')"
|
||||
:description="t('Taking you to your dashboard…')"
|
||||
:actions="[]"
|
||||
/>
|
||||
|
||||
@@ -266,7 +304,7 @@ onMounted(() => {
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UFormField
|
||||
label="Email"
|
||||
:label="t('Email')"
|
||||
name="email"
|
||||
help="From your invitation."
|
||||
:size="controlSize"
|
||||
@@ -288,9 +326,9 @@ onMounted(() => {
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Username"
|
||||
:label="t('Username')"
|
||||
name="username"
|
||||
:error="usernameError || undefined"
|
||||
:error="translate(usernameError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="usernameError" #error="{ error }">
|
||||
@@ -299,11 +337,11 @@ onMounted(() => {
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else #help>
|
||||
Letters and numbers, 3 to 20 characters. You'll use it to log in.
|
||||
{{ t("Letters and numbers, 3 to 20 characters. You'll use it to log in.") }}
|
||||
</template>
|
||||
<template #hint>
|
||||
<span
|
||||
@@ -317,7 +355,7 @@ onMounted(() => {
|
||||
</template>
|
||||
<UInput
|
||||
v-model="username"
|
||||
placeholder="e.g. lea"
|
||||
:placeholder="t('e.g. lea')"
|
||||
autocomplete="username"
|
||||
:disabled="submitting"
|
||||
variant="outline"
|
||||
@@ -331,7 +369,7 @@ onMounted(() => {
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Password" name="password" :size="controlSize">
|
||||
<UFormField :label="t('Password')" name="password" :size="controlSize">
|
||||
<template #help>
|
||||
<PasswordStrengthHint
|
||||
:rules="passwordRules"
|
||||
@@ -361,7 +399,9 @@ onMounted(() => {
|
||||
size="sm"
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
||||
:aria-label="
|
||||
passwordShown ? t('Hide password') : t('Show password')
|
||||
"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
/>
|
||||
@@ -377,19 +417,19 @@ onMounted(() => {
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
>
|
||||
{{ submitting ? 'Creating account…' : 'Accept invite' }}
|
||||
{{ submitting ? t('Creating account…') : t('Accept invite') }}
|
||||
</UButton>
|
||||
</UForm>
|
||||
</template>
|
||||
|
||||
<template v-if="showForm" #footer>
|
||||
<span class="flex flex-wrap items-center gap-1.5">
|
||||
Already have an account?
|
||||
{{ t('Already have an account?') }}
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
Log in
|
||||
{{ t('Log in') }}
|
||||
</RouterLink>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
+112
-48
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ArticleTable from '@/components/ArticleTable.vue'
|
||||
import ArticleCardList from '@/components/ArticleCardList.vue'
|
||||
@@ -19,8 +21,8 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { request } = useApi()
|
||||
const { listArticles } = useArticles()
|
||||
const overlay = useOverlay()
|
||||
const toast = useToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const articles = ref<Article[]>([])
|
||||
const loading = ref(true)
|
||||
@@ -54,7 +56,9 @@ const activeTab = computed<'' | 'online' | 'draft'>({
|
||||
get: () =>
|
||||
route.query.status === 'online' || route.query.status === 'draft' ? route.query.status : '',
|
||||
set: (value) => {
|
||||
router.replace({ query: { ...route.query, status: value || undefined } })
|
||||
router.replace({
|
||||
query: { ...route.query, status: value || undefined },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -68,9 +72,24 @@ const counts = computed(() => {
|
||||
})
|
||||
|
||||
const tabItems = computed(() => [
|
||||
{ label: `All ${counts.value.all}`, value: '' },
|
||||
{ label: `Online ${counts.value.online}`, value: 'online' },
|
||||
{ label: `Drafts ${counts.value.drafts}`, value: 'draft' },
|
||||
{
|
||||
get label() {
|
||||
return t('All {0}', [counts.value.all])
|
||||
},
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Online {0}', [counts.value.online])
|
||||
},
|
||||
value: 'online',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Drafts {0}', [counts.value.drafts])
|
||||
},
|
||||
value: 'draft',
|
||||
},
|
||||
])
|
||||
|
||||
const statusFilteredArticles = computed(() => {
|
||||
@@ -156,8 +175,12 @@ function notFoundToast(article: Article) {
|
||||
id: `article-not-found-${article.id}`,
|
||||
color: 'info' as const,
|
||||
icon: 'i-lucide-info',
|
||||
title: 'That article no longer exists',
|
||||
description: 'It was deleted somewhere else, so we removed it from the list.',
|
||||
get title() {
|
||||
return t('That article no longer exists')
|
||||
},
|
||||
get description() {
|
||||
return t('It was deleted somewhere else, so we removed it from the list.')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,30 +194,45 @@ async function setOnline(article: Article, makeOnline: boolean) {
|
||||
articles.value = articles.value.map((existing) =>
|
||||
existing.id === updated.id ? updated : existing,
|
||||
)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: makeOnline ? 'Article published' : 'Article unpublished',
|
||||
description: makeOnline
|
||||
? `"${article.title}" is now online.`
|
||||
: `"${article.title}" is now offline.`,
|
||||
})
|
||||
get title() {
|
||||
return makeOnline ? t('Article published') : t('Article unpublished')
|
||||
},
|
||||
get description() {
|
||||
return makeOnline
|
||||
? t('\u0022{0}\u0022 is now online.', [article.title])
|
||||
: t('\u0022{0}\u0022 is now offline.', [article.title])
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
removeArticle(article.id)
|
||||
toast.add(notFoundToast(article))
|
||||
toast.add(() => notFoundToast(article))
|
||||
return
|
||||
}
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${article.id}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: makeOnline ? "Couldn't publish" : "Couldn't unpublish",
|
||||
description: "The server didn't respond. Nothing changed.",
|
||||
get title() {
|
||||
return makeOnline ? t("Couldn't publish") : t("Couldn't unpublish")
|
||||
},
|
||||
get description() {
|
||||
return t("The server didn't respond. Nothing changed.")
|
||||
},
|
||||
duration: 0,
|
||||
actions: [{ label: 'Retry', onClick: () => setOnline(article, makeOnline) }],
|
||||
})
|
||||
actions: [
|
||||
{
|
||||
get label() {
|
||||
return t('Retry')
|
||||
},
|
||||
onClick: () => setOnline(article, makeOnline),
|
||||
},
|
||||
],
|
||||
}))
|
||||
} finally {
|
||||
setBusy(article.id, false)
|
||||
}
|
||||
@@ -222,48 +260,62 @@ async function handleCopyUrl(id: number) {
|
||||
const url = `${window.location.origin}/v1/article/${id}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-link-copied-${id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Link copied',
|
||||
description: `The API URL for "${article.title}" is on your clipboard.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Link copied')
|
||||
},
|
||||
get description() {
|
||||
return t('The API URL for \u0022{0}\u0022 is on your clipboard.', [article.title])
|
||||
},
|
||||
}))
|
||||
} catch {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-link-copy-failed-${id}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't copy the link",
|
||||
})
|
||||
get title() {
|
||||
return t("Couldn't copy the link")
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function deleteDescription(article: Article) {
|
||||
const count = bodyImageCount(article.body)
|
||||
const imagesPhrase =
|
||||
count === 0 ? '' : ' Its images stay in your library.'
|
||||
return `"${article.title}" will be deleted.${imagesPhrase} This can't be undone.`
|
||||
const imagesPhrase = count === 0 ? '' : t(' Its images stay in your library.')
|
||||
return t("\u0022{0}\u0022 will be deleted.{1} This can't be undone.", [
|
||||
article.title,
|
||||
imagesPhrase,
|
||||
])
|
||||
}
|
||||
|
||||
async function performDelete(article: Article) {
|
||||
try {
|
||||
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
|
||||
removeArticle(article.id)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-deleted-${article.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Article deleted',
|
||||
description: `"${article.title}" is gone.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Article deleted')
|
||||
},
|
||||
get description() {
|
||||
return t('\u0022{0}\u0022 is gone.', [article.title])
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
removeArticle(article.id)
|
||||
toast.add(notFoundToast(article))
|
||||
toast.add(() => notFoundToast(article))
|
||||
return
|
||||
}
|
||||
throw new Error("Couldn't delete the article, so it's still there. Try again in a moment.")
|
||||
throw new Error(
|
||||
t("Couldn't delete the article, so it's still there. Try again in a moment."),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,11 +335,21 @@ async function handleDelete(article: Article) {
|
||||
const confirmed = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Delete this article?',
|
||||
description: deleteDescription(article),
|
||||
confirmLabel: 'Delete article',
|
||||
cancelLabel: 'Cancel',
|
||||
pendingLabel: 'Deleting…',
|
||||
get title() {
|
||||
return t('Delete this article?')
|
||||
},
|
||||
get description() {
|
||||
return deleteDescription(article)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Delete article')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Cancel')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Deleting…')
|
||||
},
|
||||
tone: 'danger',
|
||||
action: () => performDelete(article),
|
||||
},
|
||||
@@ -311,7 +373,7 @@ function clearSearch() {
|
||||
debouncedSearch.value = ''
|
||||
router.replace({ query: { ...route.query, q: undefined } })
|
||||
nextTick(() => {
|
||||
document.querySelector<HTMLInputElement>('input[placeholder="Search by title"]')?.focus()
|
||||
document.querySelector<HTMLInputElement>('input[type="search"]')?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -340,16 +402,18 @@ onMounted(loadArticles)
|
||||
tabindex="-1"
|
||||
class="text-xl font-semibold text-highlighted"
|
||||
>
|
||||
Articles
|
||||
{{ t('Articles') }}
|
||||
</h1>
|
||||
<p class="text-muted">Everything you've written, online and in draft.</p>
|
||||
<p class="text-muted">
|
||||
{{ t("Everything you've written, online and in draft.") }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
class="hidden h-8 px-3 sm:flex"
|
||||
@click="openNewArticleModal"
|
||||
>
|
||||
New article
|
||||
{{ t('New article') }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
@@ -366,7 +430,7 @@ onMounted(loadArticles)
|
||||
v-model="searchInput"
|
||||
type="search"
|
||||
icon="i-lucide-search"
|
||||
placeholder="Search by title"
|
||||
:placeholder="t('Search by title')"
|
||||
class="w-full sm:w-65"
|
||||
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
|
||||
/>
|
||||
@@ -407,7 +471,7 @@ onMounted(loadArticles)
|
||||
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover) sm:hidden"
|
||||
@click="openNewArticleModal"
|
||||
>
|
||||
New article
|
||||
{{ t('New article') }}
|
||||
</UButton>
|
||||
|
||||
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
|
||||
|
||||
+136
-60
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ErrorView from '@/components/ErrorView.vue'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
@@ -28,8 +30,8 @@ type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { request } = useApi()
|
||||
const overlay = useOverlay()
|
||||
const toast = useToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const articleId = Number(route.params.id)
|
||||
const draft = useArticleDraft(articleId)
|
||||
@@ -91,11 +93,13 @@ const publishDisabled = computed(() => overLimit.value || draft.state.value ===
|
||||
async function runSave() {
|
||||
const outcome = await draft.save()
|
||||
if (outcome === 'not-found') {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: 'That article no longer exists.',
|
||||
})
|
||||
get title() {
|
||||
return t('That article no longer exists.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,36 +125,51 @@ async function setOnline(makeOnline: boolean) {
|
||||
if (!article.value) {
|
||||
return
|
||||
}
|
||||
publishPending.value = makeOnline ? 'Publishing…' : 'Unpublishing…'
|
||||
publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…')
|
||||
try {
|
||||
const updated = await request<Article>(
|
||||
`/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}`,
|
||||
{ method: 'PATCH' },
|
||||
)
|
||||
article.value = updated
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-${makeOnline ? 'published' : 'unpublished'}-${articleId}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: makeOnline ? 'Article published' : 'Article unpublished',
|
||||
description: makeOnline
|
||||
? `"${updated.title}" is now online.`
|
||||
: `"${updated.title}" is now offline.`,
|
||||
})
|
||||
get title() {
|
||||
return makeOnline ? t('Article published') : t('Article unpublished')
|
||||
},
|
||||
get description() {
|
||||
return makeOnline
|
||||
? t('\u0022{0}\u0022 is now online.', [updated.title])
|
||||
: t('\u0022{0}\u0022 is now offline.', [updated.title])
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
loadState.value = 'notFound'
|
||||
return
|
||||
}
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${articleId}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: makeOnline ? "Couldn't publish" : "Couldn't unpublish",
|
||||
description: "The server didn't respond. Nothing changed.",
|
||||
get title() {
|
||||
return makeOnline ? t("Couldn't publish") : t("Couldn't unpublish")
|
||||
},
|
||||
get description() {
|
||||
return t("The server didn't respond. Nothing changed.")
|
||||
},
|
||||
duration: 0,
|
||||
actions: [{ label: 'Retry', onClick: () => setOnline(makeOnline) }],
|
||||
})
|
||||
actions: [
|
||||
{
|
||||
get label() {
|
||||
return t('Retry')
|
||||
},
|
||||
onClick: () => setOnline(makeOnline),
|
||||
},
|
||||
],
|
||||
}))
|
||||
} finally {
|
||||
publishPending.value = undefined
|
||||
}
|
||||
@@ -166,28 +185,36 @@ function handleUnpublish() {
|
||||
|
||||
function deleteDescription() {
|
||||
const count = bodyImageCount(article.value?.body ?? '')
|
||||
const imagesPhrase =
|
||||
count === 0 ? '' : ' Its images stay in your library.'
|
||||
return `"${article.value?.title ?? ''}" will be deleted.${imagesPhrase} This can't be undone.`
|
||||
const imagesPhrase = count === 0 ? '' : t(' Its images stay in your library.')
|
||||
return t("\u0022{0}\u0022 will be deleted.{1} This can't be undone.", [
|
||||
article.value?.title ?? '',
|
||||
imagesPhrase,
|
||||
])
|
||||
}
|
||||
|
||||
async function performDelete() {
|
||||
try {
|
||||
await request(`/v1/article/${articleId}`, { method: 'DELETE' })
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-deleted-${articleId}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Article deleted',
|
||||
description: `"${article.value?.title ?? ''}" is gone.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Article deleted')
|
||||
},
|
||||
get description() {
|
||||
return t('\u0022{0}\u0022 is gone.', [article.value?.title ?? ''])
|
||||
},
|
||||
}))
|
||||
router.push('/article')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
loadState.value = 'notFound'
|
||||
return
|
||||
}
|
||||
throw new Error("Couldn't delete the article, so it's still there. Try again in a moment.")
|
||||
throw new Error(
|
||||
t("Couldn't delete the article, so it's still there. Try again in a moment."),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,11 +225,21 @@ async function handleDelete() {
|
||||
await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Delete this article?',
|
||||
description: deleteDescription(),
|
||||
confirmLabel: 'Delete article',
|
||||
cancelLabel: 'Cancel',
|
||||
pendingLabel: 'Deleting…',
|
||||
get title() {
|
||||
return t('Delete this article?')
|
||||
},
|
||||
get description() {
|
||||
return deleteDescription()
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Delete article')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Cancel')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Deleting…')
|
||||
},
|
||||
tone: 'danger',
|
||||
action: performDelete,
|
||||
},
|
||||
@@ -229,12 +266,16 @@ function onEditorDrop(event: DragEvent) {
|
||||
}
|
||||
|
||||
function announceImageAdded() {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Image added',
|
||||
description: 'Save to keep it in the article.',
|
||||
})
|
||||
get title() {
|
||||
return t('Image added')
|
||||
},
|
||||
get description() {
|
||||
return t('Save to keep it in the article.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function onImageUploaded(item: MediaItem) {
|
||||
@@ -251,12 +292,16 @@ function onImageInserted(item: MediaItem, alt: string) {
|
||||
|
||||
async function performRemoveImage(imageId: number) {
|
||||
editorRef.value?.removeImage(imageId)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Image removed',
|
||||
description: "It's still in your library.",
|
||||
})
|
||||
get title() {
|
||||
return t('Image removed')
|
||||
},
|
||||
get description() {
|
||||
return t("It's still in your library.")
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function removalPreview(imageId: number) {
|
||||
@@ -272,12 +317,23 @@ async function onRequestRemoveImage(imageId: number) {
|
||||
await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Remove this image from the article?',
|
||||
description:
|
||||
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
|
||||
confirmLabel: 'Remove image',
|
||||
cancelLabel: 'Cancel',
|
||||
pendingLabel: 'Removing…',
|
||||
get title() {
|
||||
return t('Remove this image from the article?')
|
||||
},
|
||||
get description() {
|
||||
return t(
|
||||
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
|
||||
)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Remove image')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Cancel')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Removing…')
|
||||
},
|
||||
tone: 'neutral',
|
||||
preview: removalPreview(imageId),
|
||||
action: () => performRemoveImage(imageId),
|
||||
@@ -314,11 +370,23 @@ onBeforeRouteLeave(async () => {
|
||||
const confirmed = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Leave without saving?',
|
||||
description: `Your changes to "${article.value?.title ?? ''}" haven't been saved.`,
|
||||
confirmLabel: 'Keep editing',
|
||||
cancelLabel: 'Discard changes',
|
||||
pendingLabel: 'Keep editing',
|
||||
get title() {
|
||||
return t('Leave without saving?')
|
||||
},
|
||||
get description() {
|
||||
return t("Your changes to \u0022{0}\u0022 haven't been saved.", [
|
||||
article.value?.title ?? '',
|
||||
])
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Keep editing')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Discard changes')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Keep editing')
|
||||
},
|
||||
tone: 'neutral',
|
||||
action: async () => {},
|
||||
dismissResolvesTo: 'confirm',
|
||||
@@ -330,10 +398,18 @@ onBeforeRouteLeave(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell mobile-title="Edit article" full-bleed>
|
||||
<DashboardShell :mobile-title="t('Edit article')" full-bleed>
|
||||
<template v-if="loadState === 'loaded'" #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="[{ label: 'Articles', to: '/article' }, { label: article?.title ?? '' }]"
|
||||
:items="[
|
||||
{
|
||||
get label() {
|
||||
return t('Articles')
|
||||
},
|
||||
to: '/article',
|
||||
},
|
||||
{ label: article?.title ?? '' },
|
||||
]"
|
||||
color="neutral"
|
||||
class="hidden lg:block"
|
||||
/>
|
||||
@@ -356,7 +432,7 @@ onBeforeRouteLeave(async () => {
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="size-8 justify-center text-toned"
|
||||
aria-label="Back to Articles"
|
||||
:aria-label="t('Back to Articles')"
|
||||
@click="goBack"
|
||||
/>
|
||||
<USkeleton class="h-5 w-80 rounded-none" data-testid="skeleton-title" />
|
||||
@@ -393,13 +469,13 @@ onBeforeRouteLeave(async () => {
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="size-8 justify-center text-toned"
|
||||
aria-label="Back to Articles"
|
||||
:aria-label="t('Back to Articles')"
|
||||
@click="goBack"
|
||||
/>
|
||||
<div class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5">
|
||||
<h1
|
||||
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
|
||||
title="The title can't be changed after creation."
|
||||
:title="t('The title can\'t be changed after creation.')"
|
||||
>
|
||||
{{ article.title }}
|
||||
</h1>
|
||||
@@ -413,7 +489,7 @@ onBeforeRouteLeave(async () => {
|
||||
<div class="hidden items-center gap-3 lg:flex">
|
||||
<USwitch
|
||||
v-model="previewOn"
|
||||
label="Preview"
|
||||
:label="t('Preview')"
|
||||
:ui="{
|
||||
root: 'items-center gap-2',
|
||||
container: 'h-auto',
|
||||
@@ -432,7 +508,7 @@ onBeforeRouteLeave(async () => {
|
||||
:disabled="!saveEnabled"
|
||||
@click="performSave"
|
||||
>
|
||||
Save
|
||||
{{ t('Save') }}
|
||||
</UButton>
|
||||
<PublishControl
|
||||
class="h-8 gap-1.5 px-3"
|
||||
@@ -450,7 +526,7 @@ onBeforeRouteLeave(async () => {
|
||||
class="h-8 gap-1.5 px-3"
|
||||
@click="handleDelete"
|
||||
>
|
||||
Delete
|
||||
{{ t('Delete') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -513,7 +589,7 @@ onBeforeRouteLeave(async () => {
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="size-11 justify-center"
|
||||
aria-label="Details"
|
||||
:aria-label="t('Details')"
|
||||
@click="detailsOpen = true"
|
||||
/>
|
||||
<UButton
|
||||
@@ -524,7 +600,7 @@ onBeforeRouteLeave(async () => {
|
||||
:disabled="!saveEnabled"
|
||||
@click="performSave"
|
||||
>
|
||||
Save
|
||||
{{ t('Save') }}
|
||||
</UButton>
|
||||
<PublishControl
|
||||
class="h-11 flex-1 justify-center"
|
||||
|
||||
+59
-29
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t, formatNumber } from '@/i18n'
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import NewArticleModal from '@/components/NewArticleModal.vue'
|
||||
@@ -19,7 +21,7 @@ const RECENT_LIST_SIZE = 5
|
||||
type LoadState = 'loading' | 'default' | 'empty' | 'error'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const toast = useLocalizedToast()
|
||||
const session = useSessionStore()
|
||||
const update = useUpdateStore()
|
||||
const { listArticles } = useArticles()
|
||||
@@ -32,12 +34,12 @@ let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function greetingForHour(hour: number) {
|
||||
if (hour < 12) {
|
||||
return 'Good morning'
|
||||
return t('Good morning')
|
||||
}
|
||||
if (hour < 18) {
|
||||
return 'Good afternoon'
|
||||
return t('Good afternoon')
|
||||
}
|
||||
return 'Good evening'
|
||||
return t('Good evening')
|
||||
}
|
||||
|
||||
const greeting = computed(() => {
|
||||
@@ -48,8 +50,8 @@ const greeting = computed(() => {
|
||||
|
||||
const subtitle = computed(() =>
|
||||
loadState.value === 'empty'
|
||||
? 'Welcome to RenewCMS.'
|
||||
: "Here's where things stand on your site.",
|
||||
? t('Welcome to RenewCMS.')
|
||||
: t("Here's where things stand on your site."),
|
||||
)
|
||||
|
||||
const stats = computed(() => {
|
||||
@@ -62,9 +64,24 @@ const stats = computed(() => {
|
||||
})
|
||||
|
||||
const statTiles = computed(() => [
|
||||
{ label: 'articles', value: stats.value.total },
|
||||
{ label: 'online', value: stats.value.online },
|
||||
{ label: 'drafts', value: stats.value.drafts },
|
||||
{
|
||||
get label() {
|
||||
return t('articles')
|
||||
},
|
||||
value: stats.value.total,
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('online')
|
||||
},
|
||||
value: stats.value.online,
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('drafts')
|
||||
},
|
||||
value: stats.value.drafts,
|
||||
},
|
||||
])
|
||||
|
||||
const recentArticles = computed(() =>
|
||||
@@ -106,13 +123,17 @@ function openNewArticleModal() {
|
||||
|
||||
function onArticleCreated(article: Article) {
|
||||
router.push(`/article/${article.id}/edit`)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `article-created-${article.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Draft created',
|
||||
description: `"${article.title}" is saved as a draft.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Draft created')
|
||||
},
|
||||
get description() {
|
||||
return t('\u0022{0}\u0022 is saved as a draft.', [article.title])
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const readyBannerDismissed = ref(false)
|
||||
@@ -154,7 +175,7 @@ onMounted(loadArticles)
|
||||
variant="ghost"
|
||||
class="size-11 justify-center text-highlighted sm:hidden"
|
||||
:ui="{ leadingIcon: 'size-5' }"
|
||||
aria-label="New article"
|
||||
:aria-label="t('New article')"
|
||||
@click="openNewArticleModal"
|
||||
/>
|
||||
</template>
|
||||
@@ -172,7 +193,7 @@ onMounted(loadArticles)
|
||||
class="hidden px-3 sm:flex"
|
||||
@click="openNewArticleModal"
|
||||
>
|
||||
New article
|
||||
{{ t('New article') }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
@@ -198,7 +219,7 @@ onMounted(loadArticles)
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-2xl font-semibold text-highlighted">{{
|
||||
tile.value
|
||||
formatNumber(tile.value)
|
||||
}}</span>
|
||||
<span class="text-muted">{{ tile.label }}</span>
|
||||
</template>
|
||||
@@ -207,9 +228,11 @@ onMounted(loadArticles)
|
||||
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h2 class="text-lg font-semibold text-highlighted">Recently updated</h2>
|
||||
<h2 class="text-lg font-semibold text-highlighted">
|
||||
{{ t('Recently updated') }}
|
||||
</h2>
|
||||
<RouterLink to="/article" class="text-toned underline underline-offset-3">
|
||||
All articles
|
||||
{{ t('All articles') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div
|
||||
@@ -249,8 +272,10 @@ onMounted(loadArticles)
|
||||
v-else-if="loadState === 'empty'"
|
||||
variant="outline"
|
||||
icon="i-lucide-file-text"
|
||||
title="Write your first article"
|
||||
description="It starts as a draft, so nothing appears on your site until you publish it."
|
||||
:title="t('Write your first article')"
|
||||
:description="
|
||||
t('It starts as a draft, so nothing appears on your site until you publish it.')
|
||||
"
|
||||
:ui="{
|
||||
root: 'gap-3 rounded-none border border-default px-6 py-12 ring-0 sm:px-6 sm:py-12 lg:px-6 lg:py-12',
|
||||
header: 'max-w-none gap-3',
|
||||
@@ -266,7 +291,7 @@ onMounted(loadArticles)
|
||||
class="h-11 px-4 lg:h-8"
|
||||
@click="openNewArticleModal"
|
||||
>
|
||||
New article
|
||||
{{ t('New article') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
@@ -279,8 +304,8 @@ onMounted(loadArticles)
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't load your articles"
|
||||
description="Check your connection and try again."
|
||||
:title="t('Couldn\'t load your articles')"
|
||||
:description="t('Check your connection and try again.')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
@@ -292,7 +317,7 @@ onMounted(loadArticles)
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="loadArticles"
|
||||
>
|
||||
Try again
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
@@ -304,16 +329,21 @@ onMounted(loadArticles)
|
||||
<UIcon name="i-lucide-plug" class="size-4.5" />
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<h2 class="text-base/5 font-semibold text-highlighted">Connect your website</h2>
|
||||
<h2 class="text-base/5 font-semibold text-highlighted">
|
||||
{{ t('Connect your website') }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">
|
||||
Your site reads published articles from RenewCMS. Share this guide with
|
||||
whoever builds it.
|
||||
{{
|
||||
t(
|
||||
'Your site reads published articles from RenewCMS. Share this guide with whoever builds it.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<RouterLink
|
||||
to="/integration"
|
||||
class="mt-1 self-start font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
Open the guide
|
||||
{{ t('Open the guide') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import EndpointCard from '@/components/EndpointCard.vue'
|
||||
@@ -8,8 +10,10 @@ const origin = computed(() => window.location.origin)
|
||||
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
|
||||
const copyButtonLabel = computed(() => (copied.value ? 'Copied' : 'Copy'))
|
||||
const copyButtonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
|
||||
const copyButtonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
|
||||
const copyButtonIcon = computed(() =>
|
||||
copied.value ? 'i-lucide-check' : 'i-lucide-copy',
|
||||
)
|
||||
|
||||
function copyBaseUrl() {
|
||||
copy(origin.value)
|
||||
@@ -39,7 +43,9 @@ const endpoints = computed(() => [
|
||||
{
|
||||
method: 'GET' as const,
|
||||
path: '/v1/article/',
|
||||
description: 'Lists every online article.',
|
||||
get description() {
|
||||
return t('Lists every online article.')
|
||||
},
|
||||
examples: {
|
||||
fetch: `const res = await fetch('${origin.value}/v1/article/')\nconst articles = await res.json()`,
|
||||
curl: `curl ${origin.value}/v1/article/`,
|
||||
@@ -49,7 +55,11 @@ const endpoints = computed(() => [
|
||||
{
|
||||
method: 'GET' as const,
|
||||
path: '/v1/article/{id}',
|
||||
description: 'Returns one online article. Drafts and unknown ids both return 404.',
|
||||
get description() {
|
||||
return t(
|
||||
'Returns one online article. Drafts and unknown ids both return 404.',
|
||||
)
|
||||
},
|
||||
examples: {
|
||||
fetch: `const res = await fetch('${origin.value}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json()`,
|
||||
curl: `curl ${origin.value}/v1/article/12`,
|
||||
@@ -58,29 +68,47 @@ const endpoints = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const notes = [
|
||||
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
|
||||
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
|
||||
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
|
||||
]
|
||||
const notes = computed(() => [
|
||||
t(
|
||||
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
|
||||
),
|
||||
t(
|
||||
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
|
||||
),
|
||||
t(
|
||||
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
|
||||
),
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell mobile-title="Integration">
|
||||
<DashboardShell :mobile-title="t('Integration')">
|
||||
<div class="flex max-w-199 flex-col gap-6">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="text-xl font-semibold text-highlighted">Connect your website</h1>
|
||||
<p class="text-muted">A guide for the developer who builds your public site.</p>
|
||||
<h1 class="text-xl font-semibold text-highlighted">
|
||||
{{ t('Connect your website') }}
|
||||
</h1>
|
||||
<p class="text-muted">
|
||||
{{
|
||||
t(
|
||||
'A guide for the developer who builds your public site.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-base text-pretty text-toned">
|
||||
RenewCMS is headless: it stores and publishes your articles but doesn't show them to
|
||||
visitors. Your website fetches published articles from the two addresses below and
|
||||
displays them in its own design.
|
||||
{{
|
||||
t(
|
||||
"RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors. Your website fetches published articles from the two addresses below and displays them in its own design.",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-medium text-highlighted">Base URL</span>
|
||||
<span class="font-medium text-highlighted">{{
|
||||
t('Base URL')
|
||||
}}</span>
|
||||
<div class="flex border border-accented bg-default">
|
||||
<span
|
||||
class="h-10 min-w-0 flex-1 truncate px-3 font-mono text-sm leading-10 text-default"
|
||||
@@ -112,8 +140,13 @@ const notes = [
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div v-for="note in notes" :key="note" class="flex gap-2.5">
|
||||
<UIcon name="i-lucide-info" class="mt-0.75 size-4 shrink-0 text-info" />
|
||||
<span class="text-base text-pretty text-toned">{{ note }}</span>
|
||||
<UIcon
|
||||
name="i-lucide-info"
|
||||
class="mt-0.75 size-4 shrink-0 text-info"
|
||||
/>
|
||||
<span class="text-base text-pretty text-toned">{{
|
||||
note
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+60
-57
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
@@ -16,37 +18,58 @@ interface AlertState {
|
||||
const wrongCredentialsAlert: AlertState = {
|
||||
tone: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "That username and password don't match",
|
||||
description: 'Check both and try again. Passwords are case-sensitive.',
|
||||
get title() {
|
||||
return t("That username and password don't match")
|
||||
},
|
||||
get description() {
|
||||
return t('Check both and try again. Passwords are case-sensitive.')
|
||||
},
|
||||
}
|
||||
|
||||
const tooManyAttemptsAlert: AlertState = {
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-timer',
|
||||
title: 'Too many attempts',
|
||||
description: 'For your security, wait a moment before trying again.',
|
||||
get title() {
|
||||
return t('Too many attempts')
|
||||
},
|
||||
get description() {
|
||||
return t('For your security, wait a moment before trying again.')
|
||||
},
|
||||
}
|
||||
|
||||
const serverErrorAlert: AlertState = {
|
||||
tone: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't reach RenewCMS",
|
||||
description: 'Check your connection and try again.',
|
||||
get title() {
|
||||
return t("Couldn't reach RenewCMS")
|
||||
},
|
||||
get description() {
|
||||
return t('Check your connection and try again.')
|
||||
},
|
||||
}
|
||||
|
||||
const sessionExpiredAlert: AlertState = {
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-clock',
|
||||
title: 'Your session expired',
|
||||
description:
|
||||
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
|
||||
get title() {
|
||||
return t('Your session expired')
|
||||
},
|
||||
get description() {
|
||||
return t(
|
||||
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const passwordChangedAlert: AlertState = {
|
||||
tone: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Password changed',
|
||||
description: 'Log in with your new password.',
|
||||
get title() {
|
||||
return t('Password changed')
|
||||
},
|
||||
get description() {
|
||||
return t('Log in with your new password.')
|
||||
},
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
@@ -63,8 +86,7 @@ const passwordError = ref('')
|
||||
const submitting = ref(false)
|
||||
const passwordShown = ref(false)
|
||||
|
||||
const initialReason =
|
||||
typeof route.query.reason === 'string' ? route.query.reason : null
|
||||
const initialReason = typeof route.query.reason === 'string' ? route.query.reason : null
|
||||
|
||||
const alert = ref<AlertState | null>(
|
||||
initialReason === 'expired'
|
||||
@@ -74,23 +96,19 @@ const alert = ref<AlertState | null>(
|
||||
: null,
|
||||
)
|
||||
|
||||
const alertRole = computed(() =>
|
||||
alert.value?.tone === 'error' ? 'alert' : 'status',
|
||||
)
|
||||
const alertRole = computed(() => (alert.value?.tone === 'error' ? 'alert' : 'status'))
|
||||
|
||||
const submitLabel = computed(() => {
|
||||
if (submitting.value) {
|
||||
return 'Logging in…'
|
||||
return t('Logging in…')
|
||||
}
|
||||
if (retryAfter.active.value) {
|
||||
return `Try again in ${retryAfter.formatted.value}`
|
||||
return t('Try again in {0}', [retryAfter.formatted.value])
|
||||
}
|
||||
return 'Log in'
|
||||
return t('Log in')
|
||||
})
|
||||
|
||||
const submitDisabled = computed(
|
||||
() => submitting.value || retryAfter.active.value,
|
||||
)
|
||||
const submitDisabled = computed(() => submitting.value || retryAfter.active.value)
|
||||
|
||||
async function focusField(selector: string) {
|
||||
await nextTick()
|
||||
@@ -103,7 +121,7 @@ function focusPassword() {
|
||||
}
|
||||
|
||||
function focusUsername() {
|
||||
focusField('input[placeholder="Your username"]')
|
||||
focusField('input[autocomplete="username"]')
|
||||
}
|
||||
|
||||
function focusFirstInvalid() {
|
||||
@@ -117,18 +135,15 @@ function focusFirstInvalid() {
|
||||
}
|
||||
|
||||
function validate() {
|
||||
usernameError.value =
|
||||
username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
|
||||
passwordError.value =
|
||||
password.value.length === 0 ? 'Enter your password.' : ''
|
||||
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
|
||||
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
|
||||
return !usernameError.value && !passwordError.value
|
||||
}
|
||||
|
||||
async function afterLoginSuccess() {
|
||||
await session.retry()
|
||||
|
||||
const redirect =
|
||||
typeof route.query.redirect === 'string' ? route.query.redirect : null
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : null
|
||||
if (redirect) {
|
||||
await router.push(redirect)
|
||||
return
|
||||
@@ -193,9 +208,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await api.request<{ open: boolean }>(
|
||||
'/v1/auth/registration',
|
||||
)
|
||||
const status = await api.request<{ open: boolean }>('/v1/auth/registration')
|
||||
registrationOpen.value = status.open
|
||||
} catch {
|
||||
registrationOpen.value = false
|
||||
@@ -205,8 +218,8 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<AuthShell
|
||||
title="Log in"
|
||||
description="Welcome back. Log in to manage your articles."
|
||||
:title="t('Log in')"
|
||||
:description="t('Welcome back. Log in to manage your articles.')"
|
||||
>
|
||||
<template v-if="alert" #notice>
|
||||
<UAlert
|
||||
@@ -221,15 +234,11 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template #default="{ controlSize, controlClass, inputClass }">
|
||||
<UForm
|
||||
:state="{ username, password }"
|
||||
class="flex flex-col gap-4.5"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UForm :state="{ username, password }" class="flex flex-col gap-4.5" @submit="onSubmit">
|
||||
<UFormField
|
||||
label="Username"
|
||||
:label="t('Username')"
|
||||
name="username"
|
||||
:error="usernameError || undefined"
|
||||
:error="translate(usernameError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="usernameError" #error="{ error }">
|
||||
@@ -238,12 +247,12 @@ onMounted(async () => {
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<UInput
|
||||
v-model="username"
|
||||
placeholder="Your username"
|
||||
:placeholder="t('Your username')"
|
||||
autocomplete="username"
|
||||
:disabled="submitDisabled"
|
||||
variant="outline"
|
||||
@@ -256,9 +265,9 @@ onMounted(async () => {
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Password"
|
||||
:label="t('Password')"
|
||||
name="password"
|
||||
:error="passwordError || undefined"
|
||||
:error="translate(passwordError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template #hint>
|
||||
@@ -266,7 +275,7 @@ onMounted(async () => {
|
||||
to="/register/reset/request"
|
||||
class="text-toned text-sm underline underline-offset-[3px]"
|
||||
>
|
||||
Forgot password?
|
||||
{{ t('Forgot password?') }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template v-if="passwordError" #error="{ error }">
|
||||
@@ -275,7 +284,7 @@ onMounted(async () => {
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<UInput
|
||||
@@ -295,20 +304,14 @@ onMounted(async () => {
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
:icon="
|
||||
passwordShown
|
||||
? 'i-lucide-eye-off'
|
||||
: 'i-lucide-eye'
|
||||
"
|
||||
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="
|
||||
passwordShown
|
||||
? 'Hide password'
|
||||
: 'Show password'
|
||||
passwordShown ? t('Hide password') : t('Show password')
|
||||
"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
@@ -332,12 +335,12 @@ onMounted(async () => {
|
||||
|
||||
<template v-if="registrationOpen" #footer>
|
||||
<span class="flex flex-wrap items-center gap-1.5">
|
||||
First time here?
|
||||
{{ t('First time here?') }}
|
||||
<RouterLink
|
||||
to="/register"
|
||||
class="font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
Create the owner account
|
||||
{{ t('Create the owner account') }}
|
||||
</RouterLink>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
+122
-67
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
|
||||
import { useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import BulkDeleteMediaDialog from '@/components/BulkDeleteMediaDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import DeleteMediaDialog from '@/components/DeleteMediaDialog.vue'
|
||||
@@ -68,7 +70,7 @@ const OFFLINE_COPY =
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const toast = useLocalizedToast()
|
||||
const media = useMedia()
|
||||
const selection = useMediaSelection(media.items)
|
||||
const upload = useMediaUpload({ onAdded: refreshInBackground })
|
||||
@@ -90,7 +92,8 @@ const bulkDeleteOpen = ref(false)
|
||||
const bulkTargets = ref<MediaItem[]>([])
|
||||
const mergeTarget = ref<{ group: DuplicateGroup; keep: MediaItem } | null>(null)
|
||||
const dragDepth = ref(0)
|
||||
const announcement = ref('')
|
||||
const announcementSource = ref<() => string>(() => '')
|
||||
const announcement = computed(() => announcementSource.value())
|
||||
const usage = ref<MediaUsageItem[]>([])
|
||||
const copies = ref<MediaItem[]>([])
|
||||
let opener: HTMLElement | null = null
|
||||
@@ -127,13 +130,16 @@ const emptyVariant = computed(() =>
|
||||
isDuplicates.value && controls.value.q === '' ? 'no-duplicates' : 'no-results',
|
||||
)
|
||||
const filterLabel = computed(
|
||||
() => ({ all: 'All', unused: 'Unused', duplicates: 'Duplicates' })[controls.value.filter],
|
||||
() =>
|
||||
({ all: t('All'), unused: t('Unused'), duplicates: t('Duplicates') })[
|
||||
controls.value.filter
|
||||
],
|
||||
)
|
||||
const showList = computed(() => ready.value && !nothingToShow.value && !emptyLibrary.value)
|
||||
const dragging = computed(() => dragDepth.value > 0)
|
||||
const uploadBlocked = computed(() => offline.value || upload.limited.value)
|
||||
const uploadLabel = computed(() =>
|
||||
upload.limited.value ? `Try again in ${upload.countdown.value}` : 'Upload',
|
||||
upload.limited.value ? t('Try again in {0}', [upload.countdown.value]) : t('Upload'),
|
||||
)
|
||||
const uploadIcon = computed(() => (upload.limited.value ? 'i-lucide-timer' : 'i-lucide-upload'))
|
||||
const dialogOpen = computed(
|
||||
@@ -158,7 +164,9 @@ const gridSelected = computed(() => selection.selectedIds.value)
|
||||
|
||||
const showBulkBar = computed(() => hasSelection.value && !emptyLibrary.value)
|
||||
|
||||
watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), { immediate: true })
|
||||
watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), {
|
||||
immediate: true,
|
||||
})
|
||||
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
|
||||
|
||||
function canonicalizeUrl() {
|
||||
@@ -182,8 +190,8 @@ function clearFilters() {
|
||||
updateControls({ q: '', filter: 'all' })
|
||||
}
|
||||
|
||||
function announce(text: string) {
|
||||
announcement.value = text
|
||||
function announce(source: () => string) {
|
||||
announcementSource.value = source
|
||||
}
|
||||
|
||||
canonicalizeUrl()
|
||||
@@ -210,7 +218,11 @@ watch(
|
||||
(next, previous) => {
|
||||
const settledNow = next.phase === 'settled' && previous?.phase === 'working'
|
||||
if (settledNow && upload.rows.value.length > 0) {
|
||||
announce(next.summary ?? `${next.title}. ${next.sub}`)
|
||||
announce(
|
||||
() =>
|
||||
upload.header.value.summary ??
|
||||
`${upload.header.value.title}. ${upload.header.value.sub}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -339,13 +351,17 @@ function removeAndRefocus(item: MediaItem) {
|
||||
}
|
||||
|
||||
function notFoundToast(id: number) {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `media-not-found-${id}`,
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'That image no longer exists',
|
||||
description: 'It was deleted somewhere else, so we removed it from the library.',
|
||||
})
|
||||
get title() {
|
||||
return t('That image no longer exists')
|
||||
},
|
||||
get description() {
|
||||
return t('It was deleted somewhere else, so we removed it from the library.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function handleGone(item: MediaItem) {
|
||||
@@ -363,14 +379,18 @@ async function copyUrl() {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `media-url-copied-${item.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'URL copied',
|
||||
description: 'Paste it wherever you need the image address.',
|
||||
get title() {
|
||||
return t('URL copied')
|
||||
},
|
||||
get description() {
|
||||
return t('Paste it wherever you need the image address.')
|
||||
},
|
||||
duration: TOAST_DURATION_MS,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
async function renameAction(stem: string) {
|
||||
@@ -380,14 +400,18 @@ async function renameAction(stem: string) {
|
||||
}
|
||||
try {
|
||||
const renamed = await media.renameItem(item.id, stem)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `media-renamed-${item.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Image renamed',
|
||||
description: `It's now "${renamed.filename}". The URL is the same.`,
|
||||
get title() {
|
||||
return t('Image renamed')
|
||||
},
|
||||
get description() {
|
||||
return t("It's now \u0022{0}\u0022. The URL is the same.", [renamed.filename])
|
||||
},
|
||||
duration: TOAST_DURATION_MS,
|
||||
})
|
||||
}))
|
||||
} catch (error) {
|
||||
const failure = classifyMediaError(error)
|
||||
if (failure === 'handled') {
|
||||
@@ -397,7 +421,7 @@ async function renameAction(stem: string) {
|
||||
handleGone(item)
|
||||
return
|
||||
}
|
||||
throw new Error(RENAME_FAILED_COPY)
|
||||
throw new Error(t(RENAME_FAILED_COPY))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,32 +442,40 @@ async function deleteAction() {
|
||||
handleGone(item)
|
||||
return
|
||||
}
|
||||
throw new Error(DELETE_FAILED_COPY)
|
||||
throw new Error(t(DELETE_FAILED_COPY))
|
||||
}
|
||||
closeEverything()
|
||||
focusAfterRemoval(index)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `media-deleted-${item.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Image deleted',
|
||||
description: `"${item.filename}" is gone from your library.`,
|
||||
get title() {
|
||||
return t('Image deleted')
|
||||
},
|
||||
get description() {
|
||||
return t('\u0022{0}\u0022 is gone from your library.', [item.filename])
|
||||
},
|
||||
duration: TOAST_DURATION_MS,
|
||||
})
|
||||
}))
|
||||
refreshInBackground()
|
||||
}
|
||||
|
||||
function imageCount(total: number) {
|
||||
return `${total} ${total === 1 ? 'image' : 'images'}`
|
||||
return t(total === 1 ? '{0} image' : '{0} images', [total])
|
||||
}
|
||||
|
||||
function announceSelection(filename: string, selected: boolean) {
|
||||
if (!selected && selection.count.value === 0) {
|
||||
announce(SELECTION_CLEARED)
|
||||
announce(() => t(SELECTION_CLEARED))
|
||||
return
|
||||
}
|
||||
announce(
|
||||
`${filename} ${selected ? 'selected' : 'unselected'}, ${selection.count.value} selected`,
|
||||
announce(() =>
|
||||
t('{0} {1}, {2} selected', [
|
||||
filename,
|
||||
selected ? t('selected') : t('unselected'),
|
||||
selection.count.value,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -459,7 +491,9 @@ function toggleSelected(id: number) {
|
||||
|
||||
function toggleAllSelected() {
|
||||
selection.toggleAll()
|
||||
announce(hasSelection.value ? `${selection.count.value} selected` : SELECTION_CLEARED)
|
||||
announce(() =>
|
||||
hasSelection.value ? t('{0} selected', [selection.count.value]) : t(SELECTION_CLEARED),
|
||||
)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
@@ -467,7 +501,7 @@ function clearSelection() {
|
||||
return
|
||||
}
|
||||
selection.clear()
|
||||
announce(SELECTION_CLEARED)
|
||||
announce(() => t(SELECTION_CLEARED))
|
||||
}
|
||||
|
||||
function selectExisting(item: MediaItem) {
|
||||
@@ -531,7 +565,7 @@ function onFilesChosen(event: Event) {
|
||||
}
|
||||
|
||||
function carriesFiles(event: DragEvent) {
|
||||
return Array.from(event.dataTransfer?.types ?? []).includes('Files')
|
||||
return Array.from(event.dataTransfer?.types ?? []).includes(t('Files'))
|
||||
}
|
||||
|
||||
useEventListener(window, 'dragenter', (event: DragEvent) => {
|
||||
@@ -576,20 +610,31 @@ function openBulkDelete() {
|
||||
|
||||
function bulkFailedDescription(failed: MediaItem[]) {
|
||||
const names = failed.map((item) => item.filename)
|
||||
const deleted = bulkDeletedTotal === 1 ? '1 image was' : `${bulkDeletedTotal} images were`
|
||||
return `${deleted} deleted. ${joinNames(names)} ${names.length === 1 ? 'is' : 'are'} still in your library. Try again.`
|
||||
const deleted =
|
||||
bulkDeletedTotal === 1 ? t('1 image was') : t('{0} images were', [bulkDeletedTotal])
|
||||
return t('{0} deleted. {1} {2} still in your library. Try again.', [
|
||||
deleted,
|
||||
joinNames(names),
|
||||
t(names.length === 1 ? 'is' : 'are'),
|
||||
])
|
||||
}
|
||||
|
||||
function bulkFailedToast(failed: MediaItem[]) {
|
||||
const content = {
|
||||
color: 'error' as const,
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: `Couldn't delete ${imageCount(failed.length)}`,
|
||||
description: bulkFailedDescription(failed),
|
||||
get title() {
|
||||
return t("Couldn't delete {0}", [imageCount(failed.length)])
|
||||
},
|
||||
get description() {
|
||||
return bulkFailedDescription(failed)
|
||||
},
|
||||
duration: PERSISTENT_TOAST,
|
||||
actions: [
|
||||
{
|
||||
label: 'Retry',
|
||||
get label() {
|
||||
return t('Retry')
|
||||
},
|
||||
icon: 'i-lucide-rotate-cw',
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
@@ -600,11 +645,11 @@ function bulkFailedToast(failed: MediaItem[]) {
|
||||
],
|
||||
}
|
||||
if (failureToastShown) {
|
||||
toast.update(BULK_FAILED_TOAST_ID, content)
|
||||
toast.update(BULK_FAILED_TOAST_ID, () => content)
|
||||
return
|
||||
}
|
||||
failureToastShown = true
|
||||
toast.add({ id: BULK_FAILED_TOAST_ID, ...content })
|
||||
toast.add(() => ({ id: BULK_FAILED_TOAST_ID, ...content }))
|
||||
}
|
||||
|
||||
function dismissBulkFailedToast() {
|
||||
@@ -623,15 +668,19 @@ function finishBulkDelete(result: BulkDeleteResult, firstIndex: number) {
|
||||
} else {
|
||||
dismissBulkFailedToast()
|
||||
selection.clear()
|
||||
announce(`${imageCount(bulkDeletedTotal)} deleted`)
|
||||
toast.add({
|
||||
announce(() => t('{0} deleted', [imageCount(bulkDeletedTotal)]))
|
||||
toast.add(() => ({
|
||||
id: 'media-bulk-deleted',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: `${imageCount(bulkDeletedTotal)} deleted`,
|
||||
description: "They're gone from your library.",
|
||||
get title() {
|
||||
return t('{0} deleted', [imageCount(bulkDeletedTotal)])
|
||||
},
|
||||
get description() {
|
||||
return t("They're gone from your library.")
|
||||
},
|
||||
duration: TOAST_DURATION_MS,
|
||||
})
|
||||
}))
|
||||
focusAfterRemoval(firstIndex)
|
||||
}
|
||||
refreshInBackground()
|
||||
@@ -652,7 +701,7 @@ async function bulkDeleteAction() {
|
||||
return false
|
||||
}
|
||||
if (result.deleted.length === 0) {
|
||||
throw new Error(BULK_DELETE_FAILED_COPY)
|
||||
throw new Error(t(BULK_DELETE_FAILED_COPY))
|
||||
}
|
||||
bulkDeleteOpen.value = false
|
||||
finishBulkDelete(result, firstIndex)
|
||||
@@ -672,9 +721,10 @@ function openMerge(group: DuplicateGroup, keep: MediaItem) {
|
||||
}
|
||||
|
||||
function mergeText(merged: number, articlesUpdated: number) {
|
||||
const copiesText = merged === 1 ? '1 copy' : `${merged} copies`
|
||||
const articlesText = articlesUpdated === 1 ? '1 article' : `${articlesUpdated} articles`
|
||||
return `${copiesText} merged. ${articlesText} updated.`
|
||||
const copiesText = merged === 1 ? t('1 copy') : t('{0} copies', [merged])
|
||||
const articlesText =
|
||||
articlesUpdated === 1 ? t('1 article') : t('{0} articles', [articlesUpdated])
|
||||
return t('{0} merged. {1} updated.', [copiesText, articlesText])
|
||||
}
|
||||
|
||||
async function mergeAction() {
|
||||
@@ -688,16 +738,19 @@ async function mergeAction() {
|
||||
const result = await media.mergeCopies(keep.id, mergeIds)
|
||||
mergeTarget.value = null
|
||||
media.removeLocalMany(group.copies.map((copy) => copy.id))
|
||||
const text = mergeText(result.merged, result.articles_updated)
|
||||
announce(text)
|
||||
toast.add({
|
||||
announce(() => mergeText(result.merged, result.articles_updated))
|
||||
toast.add(() => ({
|
||||
id: `media-merged-${keep.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Copies merged',
|
||||
description: text,
|
||||
get title() {
|
||||
return t('Copies merged')
|
||||
},
|
||||
get description() {
|
||||
return mergeText(result.merged, result.articles_updated)
|
||||
},
|
||||
duration: TOAST_DURATION_MS,
|
||||
})
|
||||
}))
|
||||
focusElement('[data-testid="media-heading"]')
|
||||
refreshInBackground()
|
||||
} catch (error) {
|
||||
@@ -713,9 +766,9 @@ async function mergeAction() {
|
||||
}
|
||||
if (error instanceof ApiError && error.status === HTTP_CONFLICT) {
|
||||
refreshInBackground()
|
||||
throw new Error(MERGE_CONFLICT_COPY)
|
||||
throw new Error(t(MERGE_CONFLICT_COPY))
|
||||
}
|
||||
throw new Error(MERGE_FAILED_COPY)
|
||||
throw new Error(t(MERGE_FAILED_COPY))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -723,7 +776,7 @@ async function mergeAction() {
|
||||
<template>
|
||||
<DashboardShell
|
||||
compact-padding
|
||||
:offline-message="OFFLINE_COPY"
|
||||
:offline-message="t(OFFLINE_COPY)"
|
||||
offline-icon="i-lucide-wifi-off"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -735,10 +788,10 @@ async function mergeAction() {
|
||||
tabindex="-1"
|
||||
class="text-xl font-semibold text-highlighted"
|
||||
>
|
||||
Media
|
||||
{{ t('Media') }}
|
||||
</h1>
|
||||
<p class="text-muted">
|
||||
Every image you've uploaded, ready to use in any article.
|
||||
{{ t("Every image you've uploaded, ready to use in any article.") }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
@@ -770,7 +823,9 @@ async function mergeAction() {
|
||||
:limited="upload.limited.value"
|
||||
@action="openChooser"
|
||||
/>
|
||||
<p class="self-center text-xs text-muted">PNG, JPEG, WEBP, SVG · max 10 MB each</p>
|
||||
<p class="self-center text-xs text-muted">
|
||||
{{ t('PNG, JPEG, WEBP, SVG · max 10 MB each') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -781,8 +836,8 @@ async function mergeAction() {
|
||||
variant="soft"
|
||||
:close="false"
|
||||
icon="i-lucide-timer"
|
||||
:title="RATE_LIMITED_TITLE"
|
||||
:description="RATE_LIMITED_TEXT"
|
||||
:title="t(RATE_LIMITED_TITLE)"
|
||||
:description="t(RATE_LIMITED_TEXT)"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
@@ -793,8 +848,8 @@ async function mergeAction() {
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't load your images"
|
||||
description="Check your connection and try again."
|
||||
:title="t('Couldn\'t load your images')"
|
||||
:description="t('Check your connection and try again.')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
@@ -806,7 +861,7 @@ async function mergeAction() {
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="media.load(controls)"
|
||||
>
|
||||
Try again
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
@@ -9,9 +11,9 @@ const session = useSessionStore()
|
||||
|
||||
<template>
|
||||
<DashboardShell v-if="session.session">
|
||||
<ErrorView kind="not-found" breadcrumb="Not found" />
|
||||
<ErrorView kind="not-found" :breadcrumb="t('Not found')" />
|
||||
</DashboardShell>
|
||||
<AuthShell v-else title="Page not found">
|
||||
<ErrorView kind="not-found" breadcrumb="Not found" />
|
||||
<AuthShell v-else :title="t('Page not found')">
|
||||
<ErrorView kind="not-found" :breadcrumb="t('Not found')" />
|
||||
</AuthShell>
|
||||
</template>
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, onMounted, ref, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import ResultPanel from '@/components/ResultPanel.vue'
|
||||
@@ -13,8 +15,8 @@ import { useSessionStore } from '@/stores/session'
|
||||
const router = useRouter()
|
||||
const api = useApi()
|
||||
const session = useSessionStore()
|
||||
const overlay = useOverlay()
|
||||
const toast = useToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const pendingEmail = ref<string | null>(null)
|
||||
const loggingOut = ref(false)
|
||||
@@ -23,13 +25,16 @@ const verified = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
pendingEmail.value = sessionStorage.getItem('renewcms:pendingEmail')
|
||||
document.title = 'Check your email · RenewCMS'
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
document.title = t('Check your email · RenewCMS')
|
||||
})
|
||||
|
||||
const description = computed(() =>
|
||||
pendingEmail.value
|
||||
? `We sent a verification link to ${pendingEmail.value}. It expires in 2 hours.`
|
||||
: 'We sent a verification link to the email you registered with. It expires in 2 hours.',
|
||||
? t('We sent a verification link to {0}. It expires in 2 hours.', [pendingEmail.value])
|
||||
: t('We sent a verification link to the email you registered with. It expires in 2 hours.'),
|
||||
)
|
||||
|
||||
async function logOut() {
|
||||
@@ -57,7 +62,7 @@ async function deleteRegistration() {
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
"Couldn't cancel registration, so it's still pending. Try again in a moment.",
|
||||
t("Couldn't cancel registration, so it's still pending. Try again in a moment."),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -67,12 +72,23 @@ async function cancelRegistration() {
|
||||
const confirmed = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Cancel your registration?',
|
||||
description:
|
||||
'Your account will be deleted. You can register again with the same or a different email.',
|
||||
confirmLabel: 'Cancel registration',
|
||||
cancelLabel: 'Keep my account',
|
||||
pendingLabel: 'Cancelling…',
|
||||
get title() {
|
||||
return t('Cancel your registration?')
|
||||
},
|
||||
get description() {
|
||||
return t(
|
||||
'Your account will be deleted. You can register again with the same or a different email.',
|
||||
)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Cancel registration')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Keep my account')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Cancelling…')
|
||||
},
|
||||
tone: 'danger',
|
||||
action: deleteRegistration,
|
||||
},
|
||||
@@ -85,18 +101,22 @@ async function cancelRegistration() {
|
||||
|
||||
if (cancelOutcome.value === 'cancelled') {
|
||||
await router.push('/register')
|
||||
toast.add(registrationCancelledToast)
|
||||
toast.add(() => registrationCancelledToast)
|
||||
return
|
||||
}
|
||||
|
||||
if (cancelOutcome.value === 'alreadyVerified') {
|
||||
verified.value = true
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'Your account is already verified',
|
||||
description: "There's nothing to cancel.",
|
||||
})
|
||||
get title() {
|
||||
return t('Your account is already verified')
|
||||
},
|
||||
get description() {
|
||||
return t("There's nothing to cancel.")
|
||||
},
|
||||
}))
|
||||
await wait(VERIFIED_REDIRECT_DELAY_MS)
|
||||
await router.push('/home')
|
||||
}
|
||||
@@ -104,14 +124,18 @@ async function cancelRegistration() {
|
||||
|
||||
const actions = computed(() => [
|
||||
{
|
||||
label: 'Log out',
|
||||
get label() {
|
||||
return t('Log out')
|
||||
},
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
disabled: loggingOut.value,
|
||||
onClick: logOut,
|
||||
},
|
||||
{
|
||||
label: 'Cancel registration',
|
||||
get label() {
|
||||
return t('Cancel registration')
|
||||
},
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
disabled: loggingOut.value,
|
||||
@@ -126,22 +150,24 @@ const actions = computed(() => [
|
||||
v-if="verified"
|
||||
tone="success"
|
||||
icon="i-lucide-shield-check"
|
||||
title="You're verified"
|
||||
description="Taking you to your dashboard…"
|
||||
:title="t('You\'re verified')"
|
||||
:description="t('Taking you to your dashboard…')"
|
||||
:actions="[]"
|
||||
/>
|
||||
<ResultPanel
|
||||
v-else
|
||||
tone="success"
|
||||
icon="i-lucide-mail-check"
|
||||
title="Check your email"
|
||||
:title="t('Check your email')"
|
||||
:description="description"
|
||||
:actions="actions"
|
||||
>
|
||||
<p class="text-sm text-pretty text-muted">
|
||||
Opened it on another device? That works too; you'll log in there
|
||||
first. Can't find it? Check your spam folder. If the link has
|
||||
expired, cancel your registration and start again.
|
||||
{{
|
||||
t(
|
||||
"Opened it on another device? That works too; you'll log in there first. Can't find it? Check your spam folder. If the link has expired, cancel your registration and start again.",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</ResultPanel>
|
||||
</AuthShell>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
@@ -26,8 +28,8 @@ const SKELETON_CARDS = [
|
||||
type ResetState = 'idle' | 'sending' | 'sent' | 'limited' | 'error'
|
||||
|
||||
const session = useSessionStore()
|
||||
const toast = useToast()
|
||||
const overlay = useOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const { request } = useApi()
|
||||
const retryAfter = useRetryAfter()
|
||||
|
||||
@@ -81,34 +83,46 @@ const showUnsaved = computed(() => dirty.value && !saving.value)
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (showUnsaved.value) {
|
||||
return 'Unsaved changes'
|
||||
return t('Unsaved changes')
|
||||
}
|
||||
return savedName.value
|
||||
? `You appear as “${savedName.value}”.`
|
||||
: `You appear as “${username.value}”, your username.`
|
||||
? t('You appear as “{0}”.', [savedName.value])
|
||||
: t('You appear as “{0}”, your username.', [username.value])
|
||||
})
|
||||
|
||||
const inputUi = { base: 'h-11 text-lg lg:h-10 lg:text-base' }
|
||||
const readOnlyUi = { base: 'h-11 text-lg disabled:text-toned lg:h-10 lg:text-base' }
|
||||
const readOnlyUi = {
|
||||
base: 'h-11 text-lg disabled:text-toned lg:h-10 lg:text-base',
|
||||
}
|
||||
const readOnlyLockUi = { trailingIcon: 'size-3.5 text-dimmed' }
|
||||
|
||||
const resetNotes = {
|
||||
sent: {
|
||||
color: 'success',
|
||||
icon: 'i-lucide-mail-check',
|
||||
title: 'Reset link sent',
|
||||
get title() {
|
||||
return t('Reset link sent')
|
||||
},
|
||||
},
|
||||
limited: {
|
||||
color: 'warning',
|
||||
icon: 'i-lucide-clock-alert',
|
||||
title: 'Too many requests',
|
||||
description: "You've asked for several links recently. Wait a few minutes, then try again.",
|
||||
get title() {
|
||||
return t('Too many requests')
|
||||
},
|
||||
get description() {
|
||||
return t("You've asked for several links recently. Wait a few minutes, then try again.")
|
||||
},
|
||||
},
|
||||
error: {
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't send the link",
|
||||
description: 'Check your connection and try again.',
|
||||
get title() {
|
||||
return t("Couldn't send the link")
|
||||
},
|
||||
get description() {
|
||||
return t('Check your connection and try again.')
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -116,7 +130,9 @@ const resetNote = computed(() => {
|
||||
if (resetState.value === 'sent') {
|
||||
return {
|
||||
...resetNotes.sent,
|
||||
description: `Check ${email.value}. The link works once, within 1 hour.`,
|
||||
get description() {
|
||||
return t('Check {0}. The link works once, within 1 hour.', [email.value])
|
||||
},
|
||||
}
|
||||
}
|
||||
if (resetState.value === 'limited' || resetState.value === 'error') {
|
||||
@@ -127,9 +143,9 @@ const resetNote = computed(() => {
|
||||
|
||||
const resetButtonLabel = computed(() => {
|
||||
if (resetState.value === 'sending') {
|
||||
return 'Sending…'
|
||||
return t('Sending…')
|
||||
}
|
||||
return resetState.value === 'sent' ? 'Send again' : 'Send reset link'
|
||||
return resetState.value === 'sent' ? t('Send again') : t('Send reset link')
|
||||
})
|
||||
|
||||
const resetBusy = computed(() => resetState.value === 'sending')
|
||||
@@ -140,15 +156,19 @@ function focusField() {
|
||||
}
|
||||
|
||||
function announceSaved(displayName: string) {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: 'display-name-saved',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: displayName ? 'Display name saved' : 'Display name removed',
|
||||
description: displayName
|
||||
? `You now appear as ${displayName} across RenewCMS and in emails.`
|
||||
: `You now appear as ${username.value}, your username.`,
|
||||
})
|
||||
get title() {
|
||||
return displayName ? t('Display name saved') : t('Display name removed')
|
||||
},
|
||||
get description() {
|
||||
return displayName
|
||||
? t('You now appear as {0} across RenewCMS and in emails.', [displayName])
|
||||
: t('You now appear as {0}, your username.', [username.value])
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function isHandledGlobally(error: unknown) {
|
||||
@@ -220,11 +240,21 @@ onBeforeRouteLeave(async () => {
|
||||
const keepEditing = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Leave without saving?',
|
||||
description: "Your display name change hasn't been saved.",
|
||||
confirmLabel: 'Keep editing',
|
||||
cancelLabel: 'Discard changes',
|
||||
pendingLabel: 'Keep editing',
|
||||
get title() {
|
||||
return t('Leave without saving?')
|
||||
},
|
||||
get description() {
|
||||
return t("Your display name change hasn't been saved.")
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Keep editing')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Discard changes')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Keep editing')
|
||||
},
|
||||
tone: 'neutral',
|
||||
action: async () => {},
|
||||
dismissResolvesTo: 'confirm',
|
||||
@@ -239,8 +269,12 @@ onBeforeRouteLeave(async () => {
|
||||
<DashboardShell render-while-checking>
|
||||
<div class="flex max-w-164 flex-col gap-5">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted">Profile</h1>
|
||||
<p class="text-muted">Your account on this RenewCMS workspace.</p>
|
||||
<h1 class="text-xl font-semibold text-highlighted">
|
||||
{{ t('Profile') }}
|
||||
</h1>
|
||||
<p class="text-muted">
|
||||
{{ t('Your account on this RenewCMS workspace.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!account" aria-busy="true" class="flex flex-col gap-4">
|
||||
@@ -262,8 +296,12 @@ onBeforeRouteLeave(async () => {
|
||||
<template v-else>
|
||||
<UForm :state="{ displayName: field }" @submit="onSave">
|
||||
<ProfileCard
|
||||
title="Display name"
|
||||
description="Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username."
|
||||
:title="t('Display name')"
|
||||
:description="
|
||||
t(
|
||||
'Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username.',
|
||||
)
|
||||
"
|
||||
>
|
||||
<UAlert
|
||||
v-if="saveFailed"
|
||||
@@ -271,19 +309,20 @@ onBeforeRouteLeave(async () => {
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:description="SAVE_FAILED_COPY"
|
||||
:description="t(SAVE_FAILED_COPY)"
|
||||
:close="false"
|
||||
:ui="{ root: 'gap-2 px-3 py-2.5' }"
|
||||
/>
|
||||
|
||||
<UFormField name="displayName" :error="showFieldError">
|
||||
<template #label>
|
||||
Display name <span class="font-normal text-muted">(optional)</span>
|
||||
{{ t('Display name') }}
|
||||
<span class="font-normal text-muted">{{ t('(optional)') }}</span>
|
||||
</template>
|
||||
<UInput
|
||||
ref="inputRef"
|
||||
v-model="field"
|
||||
placeholder="e.g. Marta Alves"
|
||||
:placeholder="t('e.g. Marta Alves')"
|
||||
autocomplete="off"
|
||||
:disabled="saving"
|
||||
class="w-full"
|
||||
@@ -297,7 +336,7 @@ onBeforeRouteLeave(async () => {
|
||||
class="flex flex-1 items-center gap-1.5 text-xs/5 text-error"
|
||||
>
|
||||
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
|
||||
{{ DISPLAY_NAME_LIMIT_COPY }}
|
||||
{{ t(DISPLAY_NAME_LIMIT_COPY) }}
|
||||
</span>
|
||||
<span v-else class="flex-1" />
|
||||
<CharacterCounter
|
||||
@@ -322,18 +361,22 @@ onBeforeRouteLeave(async () => {
|
||||
:disabled="!saveEnabled && !saving"
|
||||
class="h-11 shrink-0 px-3.5 lg:h-8"
|
||||
>
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
{{ saving ? t('Saving…') : t('Save') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</ProfileCard>
|
||||
</UForm>
|
||||
|
||||
<ProfileCard
|
||||
title="Account"
|
||||
description="Set when your account was created. You log in with your username, even after adding a display name."
|
||||
:title="t('Account')"
|
||||
:description="
|
||||
t(
|
||||
'Set when your account was created. You log in with your username, even after adding a display name.',
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="grid gap-3.5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||
<UFormField label="Username">
|
||||
<UFormField :label="t('Username')">
|
||||
<UInput
|
||||
:model-value="username"
|
||||
disabled
|
||||
@@ -342,7 +385,7 @@ onBeforeRouteLeave(async () => {
|
||||
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Email">
|
||||
<UFormField :label="t('Email')">
|
||||
<UInput
|
||||
:model-value="email"
|
||||
disabled
|
||||
@@ -355,11 +398,16 @@ onBeforeRouteLeave(async () => {
|
||||
</ProfileCard>
|
||||
|
||||
<ProfileCard
|
||||
title="Password"
|
||||
:description="`To change it, we'll email a reset link to ${email}. The link works once, within 1 hour.`"
|
||||
:title="t('Password')"
|
||||
:description="
|
||||
t(
|
||||
'To change it, we\'ll email a reset link to {0}. The link works once, within 1 hour.',
|
||||
[email],
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<UFormField label="Password" class="min-w-0 flex-1">
|
||||
<UFormField :label="t('Password')" class="min-w-0 flex-1">
|
||||
<UInput
|
||||
:model-value="PASSWORD_MASK"
|
||||
disabled
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue'
|
||||
@@ -22,33 +24,35 @@ const passwordTouched = ref(false)
|
||||
const submitting = ref(false)
|
||||
const passwordShown = ref(false)
|
||||
const registrationClosed = ref(false)
|
||||
watchEffect(() => {
|
||||
if (registrationClosed.value) document.title = t('This site already has an owner · RenewCMS')
|
||||
})
|
||||
const serverErrorAlert = ref(false)
|
||||
|
||||
const usernameValid = computed(
|
||||
() => username.value.length >= 3 && username.value.length <= 20,
|
||||
)
|
||||
const usernameValid = computed(() => username.value.length >= 3 && username.value.length <= 20)
|
||||
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
|
||||
const passwordValid = computed(() => password.value.length >= 8)
|
||||
|
||||
const usernameError = computed(() =>
|
||||
usernameTouched.value && !usernameValid.value
|
||||
? 'Use 3 to 20 characters.'
|
||||
: '',
|
||||
usernameTouched.value && !usernameValid.value ? t('Use 3 to 20 characters.') : '',
|
||||
)
|
||||
|
||||
const emailError = computed(() =>
|
||||
emailTouched.value && !emailValid.value
|
||||
? 'Enter a full email address, like name@example.com.'
|
||||
? t('Enter a full email address, like name@example.com.')
|
||||
: '',
|
||||
)
|
||||
|
||||
const passwordRules = computed(() => [
|
||||
{ label: 'At least 8 characters', met: passwordValid.value },
|
||||
{
|
||||
get label() {
|
||||
return t('At least 8 characters')
|
||||
},
|
||||
met: passwordValid.value,
|
||||
},
|
||||
])
|
||||
|
||||
const formValid = computed(
|
||||
() => usernameValid.value && emailValid.value && passwordValid.value,
|
||||
)
|
||||
const formValid = computed(() => usernameValid.value && emailValid.value && passwordValid.value)
|
||||
|
||||
function touchAll() {
|
||||
usernameTouched.value = true
|
||||
@@ -84,7 +88,7 @@ async function onSubmit() {
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 403) {
|
||||
registrationClosed.value = true
|
||||
document.title = 'This site already has an owner · RenewCMS'
|
||||
document.title = t('This site already has an owner · RenewCMS')
|
||||
return
|
||||
}
|
||||
serverErrorAlert.value = true
|
||||
@@ -93,17 +97,24 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
const pageTitle = computed(() =>
|
||||
registrationClosed.value ? '' : 'Create the owner account',
|
||||
)
|
||||
const pageTitle = computed(() => (registrationClosed.value ? '' : t('Create the owner account')))
|
||||
|
||||
const pageDescription = computed(() =>
|
||||
registrationClosed.value
|
||||
? undefined
|
||||
: "This creates the single admin account for this site. Once it's verified, registration closes.",
|
||||
: t(
|
||||
"This creates the single admin account for this site. Once it's verified, registration closes.",
|
||||
),
|
||||
)
|
||||
|
||||
const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
const closedPanelActions = [
|
||||
{
|
||||
get label() {
|
||||
return t('Go to log in')
|
||||
},
|
||||
onClick: goToLogin,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -112,8 +123,8 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
<UAlert
|
||||
color="error"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't create the account"
|
||||
description="Something went wrong on the server. Try again in a moment."
|
||||
:title="t('Couldn\'t create the account')"
|
||||
:description="t('Something went wrong on the server. Try again in a moment.')"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
role="alert"
|
||||
@@ -124,8 +135,8 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
<ResultPanel
|
||||
tone="info"
|
||||
icon="i-lucide-user-x"
|
||||
title="This site already has an owner"
|
||||
description="Log in instead."
|
||||
:title="t('This site already has an owner')"
|
||||
:description="t('Log in instead.')"
|
||||
:actions="closedPanelActions"
|
||||
/>
|
||||
</template>
|
||||
@@ -137,9 +148,9 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UFormField
|
||||
label="Username"
|
||||
:label="t('Username')"
|
||||
name="username"
|
||||
:error="usernameError || undefined"
|
||||
:error="translate(usernameError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="usernameError" #error="{ error }">
|
||||
@@ -148,12 +159,11 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else #help>
|
||||
Letters and numbers, 3 to 20 characters. You'll use it
|
||||
to log in.
|
||||
{{ t("Letters and numbers, 3 to 20 characters. You'll use it to log in.") }}
|
||||
</template>
|
||||
<template #hint>
|
||||
<span
|
||||
@@ -167,7 +177,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
</template>
|
||||
<UInput
|
||||
v-model="username"
|
||||
placeholder="e.g. marta"
|
||||
:placeholder="t('e.g. marta')"
|
||||
autocomplete="username"
|
||||
:disabled="submitting"
|
||||
variant="outline"
|
||||
@@ -181,9 +191,9 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Email"
|
||||
:label="t('Email')"
|
||||
name="email"
|
||||
:error="emailError || undefined"
|
||||
:error="translate(emailError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="emailError" #error="{ error }">
|
||||
@@ -192,11 +202,11 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else #help>
|
||||
We'll send a verification link here.
|
||||
{{ t("We'll send a verification link here.") }}
|
||||
</template>
|
||||
<UInput
|
||||
v-model="email"
|
||||
@@ -214,11 +224,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Password"
|
||||
name="password"
|
||||
:size="controlSize"
|
||||
>
|
||||
<UFormField :label="t('Password')" name="password" :size="controlSize">
|
||||
<template #help>
|
||||
<PasswordStrengthHint
|
||||
:rules="passwordRules"
|
||||
@@ -242,20 +248,14 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
:icon="
|
||||
passwordShown
|
||||
? 'i-lucide-eye-off'
|
||||
: 'i-lucide-eye'
|
||||
"
|
||||
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="
|
||||
passwordShown
|
||||
? 'Hide password'
|
||||
: 'Show password'
|
||||
passwordShown ? t('Hide password') : t('Show password')
|
||||
"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
@@ -272,19 +272,19 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
>
|
||||
{{ submitting ? 'Creating account…' : 'Create account' }}
|
||||
{{ submitting ? t('Creating account…') : t('Create account') }}
|
||||
</UButton>
|
||||
</UForm>
|
||||
</template>
|
||||
|
||||
<template v-if="!registrationClosed" #footer>
|
||||
<span class="flex flex-wrap items-center gap-1.5">
|
||||
Already set up?
|
||||
{{ t('Already set up?') }}
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
Log in
|
||||
{{ t('Log in') }}
|
||||
</RouterLink>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
@@ -32,13 +34,25 @@ const passwordsMatch = computed(() => confirmPassword.value === password.value)
|
||||
const showLengthError = computed(() => submitFailed.value && !passwordValid.value)
|
||||
const showMatchError = computed(() => mismatchTouched.value && !passwordsMatch.value)
|
||||
|
||||
const passwordRules = computed(() => [{ label: 'At least 8 characters', met: passwordValid.value }])
|
||||
|
||||
const confirmPasswordRules = computed(() => [
|
||||
{ label: 'Passwords match', met: passwordsMatch.value },
|
||||
const passwordRules = computed(() => [
|
||||
{
|
||||
get label() {
|
||||
return t('At least 8 characters')
|
||||
},
|
||||
met: passwordValid.value,
|
||||
},
|
||||
])
|
||||
|
||||
const confirmError = computed(() => (showMatchError.value ? "The passwords don't match." : ''))
|
||||
const confirmPasswordRules = computed(() => [
|
||||
{
|
||||
get label() {
|
||||
return t('Passwords match')
|
||||
},
|
||||
met: passwordsMatch.value,
|
||||
},
|
||||
])
|
||||
|
||||
const confirmError = computed(() => (showMatchError.value ? t("The passwords don't match.") : ''))
|
||||
|
||||
function goToRequest() {
|
||||
router.push('/register/reset/request')
|
||||
@@ -49,16 +63,30 @@ function goToLogin() {
|
||||
}
|
||||
|
||||
const expiredActions = [
|
||||
{ label: 'Request a new link', onClick: goToRequest },
|
||||
{
|
||||
label: 'Back to log in',
|
||||
get label() {
|
||||
return t('Request a new link')
|
||||
},
|
||||
onClick: goToRequest,
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Back to log in')
|
||||
},
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
onClick: goToLogin,
|
||||
},
|
||||
]
|
||||
|
||||
const missingParametersActions = [{ label: 'Request a new link', onClick: goToRequest }]
|
||||
const missingParametersActions = [
|
||||
{
|
||||
get label() {
|
||||
return t('Request a new link')
|
||||
},
|
||||
onClick: goToRequest,
|
||||
},
|
||||
]
|
||||
|
||||
const confirmDescription =
|
||||
"Pick something you haven't used here before. You'll log in with it next."
|
||||
@@ -102,15 +130,15 @@ async function onSubmit() {
|
||||
|
||||
<template>
|
||||
<AuthShell
|
||||
:title="expired || missingParameters ? '' : 'Choose a new password'"
|
||||
:description="expired || missingParameters ? undefined : confirmDescription"
|
||||
:title="expired || missingParameters ? '' : t('Choose a new password')"
|
||||
:description="expired || missingParameters ? undefined : t(confirmDescription)"
|
||||
>
|
||||
<template v-if="serverErrorAlert" #notice>
|
||||
<UAlert
|
||||
color="error"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't reach RenewCMS"
|
||||
description="Check your connection and try again."
|
||||
:title="t('Couldn\'t reach RenewCMS')"
|
||||
:description="t('Check your connection and try again.')"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
role="alert"
|
||||
@@ -121,8 +149,12 @@ async function onSubmit() {
|
||||
<ResultPanel
|
||||
tone="error"
|
||||
icon="i-lucide-link-2-off"
|
||||
title="This link is incomplete"
|
||||
description="Part of the reset link is missing. Open it from your email again, or request a new one."
|
||||
:title="t('This link is incomplete')"
|
||||
:description="
|
||||
t(
|
||||
'Part of the reset link is missing. Open it from your email again, or request a new one.',
|
||||
)
|
||||
"
|
||||
:actions="missingParametersActions"
|
||||
/>
|
||||
</template>
|
||||
@@ -131,8 +163,12 @@ async function onSubmit() {
|
||||
<ResultPanel
|
||||
tone="error"
|
||||
icon="i-lucide-link-2-off"
|
||||
title="This reset link has expired"
|
||||
description="Reset links work once, for 1 hour. Request a new one and use it within the hour."
|
||||
:title="t('This reset link has expired')"
|
||||
:description="
|
||||
t(
|
||||
'Reset links work once, for 1 hour. Request a new one and use it within the hour.',
|
||||
)
|
||||
"
|
||||
:actions="expiredActions"
|
||||
/>
|
||||
</template>
|
||||
@@ -144,7 +180,7 @@ async function onSubmit() {
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UFormField
|
||||
label="Email"
|
||||
:label="t('Email')"
|
||||
name="email"
|
||||
help="From your reset link."
|
||||
:size="controlSize"
|
||||
@@ -163,7 +199,7 @@ async function onSubmit() {
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="New password" name="password" :size="controlSize">
|
||||
<UFormField :label="t('New password')" name="password" :size="controlSize">
|
||||
<template #help>
|
||||
<PasswordStrengthHint
|
||||
:rules="passwordRules"
|
||||
@@ -193,7 +229,9 @@ async function onSubmit() {
|
||||
size="sm"
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
||||
:aria-label="
|
||||
passwordShown ? t('Hide password') : t('Show password')
|
||||
"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
/>
|
||||
@@ -201,7 +239,11 @@ async function onSubmit() {
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Confirm new password" name="confirmPassword" :size="controlSize">
|
||||
<UFormField
|
||||
:label="t('Confirm new password')"
|
||||
name="confirmPassword"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template #help>
|
||||
<span
|
||||
v-if="confirmError"
|
||||
@@ -212,7 +254,7 @@ async function onSubmit() {
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ confirmError }}
|
||||
{{ translate(confirmError) }}
|
||||
</span>
|
||||
<PasswordStrengthHint
|
||||
:rules="confirmPasswordRules"
|
||||
@@ -245,7 +287,7 @@ async function onSubmit() {
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="
|
||||
confirmPasswordShown ? 'Hide password' : 'Show password'
|
||||
confirmPasswordShown ? t('Hide password') : t('Show password')
|
||||
"
|
||||
:aria-pressed="confirmPasswordShown"
|
||||
@click="confirmPasswordShown = !confirmPasswordShown"
|
||||
@@ -262,7 +304,7 @@ async function onSubmit() {
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
>
|
||||
{{ submitting ? 'Changing password…' : 'Change password' }}
|
||||
{{ submitting ? t('Changing password…') : t('Change password') }}
|
||||
</UButton>
|
||||
</UForm>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
@@ -18,15 +20,23 @@ interface AlertState {
|
||||
const tooManyAttemptsAlert: AlertState = {
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-timer',
|
||||
title: 'Too many requests',
|
||||
description: 'Wait a moment before asking for another link.',
|
||||
get title() {
|
||||
return t('Too many requests')
|
||||
},
|
||||
get description() {
|
||||
return t('Wait a moment before asking for another link.')
|
||||
},
|
||||
}
|
||||
|
||||
const serverErrorAlert: AlertState = {
|
||||
tone: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't send the link",
|
||||
description: 'Check your connection and try again.',
|
||||
get title() {
|
||||
return t("Couldn't send the link")
|
||||
},
|
||||
get description() {
|
||||
return t('Check your connection and try again.')
|
||||
},
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
@@ -45,12 +55,12 @@ const submitDisabled = computed(() => submitting.value || retryAfter.active.valu
|
||||
|
||||
const submitLabel = computed(() => {
|
||||
if (submitting.value) {
|
||||
return 'Sending…'
|
||||
return t('Sending…')
|
||||
}
|
||||
if (retryAfter.active.value) {
|
||||
return `Try again in ${retryAfter.formatted.value}`
|
||||
return t('Try again in {0}', [retryAfter.formatted.value])
|
||||
}
|
||||
return 'Send reset link'
|
||||
return t('Send reset link')
|
||||
})
|
||||
|
||||
function validate() {
|
||||
@@ -66,7 +76,9 @@ function goToLogin() {
|
||||
|
||||
const resultPanelActions = [
|
||||
{
|
||||
label: 'Back to log in',
|
||||
get label() {
|
||||
return t('Back to log in')
|
||||
},
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
onClick: goToLogin,
|
||||
@@ -108,11 +120,11 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => (sent.value ? '' : 'Reset your password'))
|
||||
const pageTitle = computed(() => (sent.value ? '' : t('Reset your password')))
|
||||
const pageDescription = computed(() =>
|
||||
sent.value
|
||||
? undefined
|
||||
: "Enter the email for your account and we'll send you a link to choose a new password.",
|
||||
: t("Enter the email for your account and we'll send you a link to choose a new password."),
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -134,20 +146,24 @@ const pageDescription = computed(() =>
|
||||
<ResultPanel
|
||||
tone="success"
|
||||
icon="i-lucide-mail-check"
|
||||
title="Check your email"
|
||||
description="If an account exists for that email, a password reset link has been sent."
|
||||
:title="t('Check your email')"
|
||||
:description="
|
||||
t('If an account exists for that email, a password reset link has been sent.')
|
||||
"
|
||||
:actions="resultPanelActions"
|
||||
>
|
||||
<p class="text-sm text-muted">The link expires in 1 hour.</p>
|
||||
<p class="text-sm text-muted">
|
||||
{{ t('The link expires in 1 hour.') }}
|
||||
</p>
|
||||
</ResultPanel>
|
||||
</template>
|
||||
|
||||
<template v-else #default="{ controlSize, controlClass, inputClass }">
|
||||
<UForm :state="{ email }" class="flex flex-col gap-4.5" @submit="onSubmit">
|
||||
<UFormField
|
||||
label="Email"
|
||||
:label="t('Email')"
|
||||
name="email"
|
||||
:error="emailError || undefined"
|
||||
:error="translate(emailError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="emailError" #error="{ error }">
|
||||
@@ -156,7 +172,7 @@ const pageDescription = computed(() =>
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<UInput
|
||||
@@ -192,7 +208,7 @@ const pageDescription = computed(() =>
|
||||
to="/login"
|
||||
class="font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
Back to log in
|
||||
{{ t('Back to log in') }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</AuthShell>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import RestartOverlay from '@/components/RestartOverlay.vue'
|
||||
@@ -22,17 +24,25 @@ const RESTART_FAILED_COPY =
|
||||
const RESTART_WARNING =
|
||||
"Your site will be unavailable for about 10 seconds. This page reloads on its own when the site is back. Save anything you're editing first."
|
||||
const PLAIN_RESTART_DIALOG = {
|
||||
title: 'Restart the site?',
|
||||
description: RESTART_WARNING,
|
||||
confirmLabel: 'Restart site',
|
||||
cancelLabel: 'Cancel',
|
||||
get title() {
|
||||
return t('Restart the site?')
|
||||
},
|
||||
get description() {
|
||||
return t(RESTART_WARNING)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Restart site')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Cancel')
|
||||
},
|
||||
}
|
||||
|
||||
type LoadState = 'loading' | 'ready' | 'error'
|
||||
|
||||
const { getSettings } = useSettings()
|
||||
const toast = useToast()
|
||||
const overlay = useOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const session = useSessionStore()
|
||||
const update = useUpdateStore()
|
||||
const retry = useRetryAfter()
|
||||
@@ -108,12 +118,16 @@ async function reloadAfterRoleChange() {
|
||||
}
|
||||
|
||||
function notifyOnlyOwner() {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: 'Only the owner can do this',
|
||||
description: 'Ask the owner of this site to update RenewCMS.',
|
||||
})
|
||||
get title() {
|
||||
return t('Only the owner can do this')
|
||||
},
|
||||
get description() {
|
||||
return t('Ask the owner of this site to update RenewCMS.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async function onUpdate() {
|
||||
@@ -127,24 +141,32 @@ async function onUpdate() {
|
||||
return
|
||||
}
|
||||
if (result === 'nothing') {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'Nothing to update',
|
||||
description: 'You already have the latest version.',
|
||||
})
|
||||
get title() {
|
||||
return t('Nothing to update')
|
||||
},
|
||||
get description() {
|
||||
return t('You already have the latest version.')
|
||||
},
|
||||
}))
|
||||
await update.load()
|
||||
return
|
||||
}
|
||||
if (result === 'conflict') {
|
||||
await update.load()
|
||||
if (update.jobRunning) {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'An update is already running',
|
||||
description: 'Another owner started it. Progress shows here.',
|
||||
})
|
||||
get title() {
|
||||
return t('An update is already running')
|
||||
},
|
||||
get description() {
|
||||
return t('Another owner started it. Progress shows here.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,10 +183,21 @@ async function onCheck() {
|
||||
function installRestartDialog() {
|
||||
const version = displayVersion(update.status?.latest ?? '')
|
||||
return {
|
||||
title: 'Restart to finish the update?',
|
||||
description: `RenewCMS will install version ${version} and restart. ${RESTART_WARNING}`,
|
||||
confirmLabel: 'Restart now',
|
||||
cancelLabel: 'Restart later',
|
||||
get title() {
|
||||
return t('Restart to finish the update?')
|
||||
},
|
||||
get description() {
|
||||
return t('RenewCMS will install version {0} and restart. {1}', [
|
||||
version,
|
||||
t(RESTART_WARNING),
|
||||
])
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Restart now')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Restart later')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,12 +208,14 @@ async function onRestart(kind: RestartKind) {
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
...dialog,
|
||||
pendingLabel: 'Restarting…',
|
||||
get pendingLabel() {
|
||||
return t('Restarting…')
|
||||
},
|
||||
tone: 'neutral',
|
||||
action: async () => {
|
||||
attempt.result = await update.requestRestart(kind)
|
||||
if (attempt.result === 'failed') {
|
||||
throw new Error(RESTART_FAILED_COPY)
|
||||
throw new Error(t(RESTART_FAILED_COPY))
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -209,12 +244,16 @@ async function settleRefusedRestart(result: RestartRequestResult) {
|
||||
}
|
||||
await update.load()
|
||||
if (update.jobRunning || update.restarting) {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'An update is already running',
|
||||
description: 'Another owner started it. Progress shows here.',
|
||||
})
|
||||
get title() {
|
||||
return t('An update is already running')
|
||||
},
|
||||
get description() {
|
||||
return t('Another owner started it. Progress shows here.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
update.resumeRestart()
|
||||
}
|
||||
@@ -223,26 +262,40 @@ const OUTCOME_TOASTS = {
|
||||
updated: () => ({
|
||||
color: 'success' as const,
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Update installed',
|
||||
description: `RenewCMS is now on version ${displayVersion(update.status?.current ?? '')}.`,
|
||||
get title() {
|
||||
return t('Update installed')
|
||||
},
|
||||
get description() {
|
||||
return t('RenewCMS is now on version {0}.', [
|
||||
displayVersion(update.status?.current ?? ''),
|
||||
])
|
||||
},
|
||||
}),
|
||||
'rolled-back': () => ({
|
||||
color: 'error' as const,
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "The update didn't install",
|
||||
description: `Your site is back on ${update.status?.current ?? ''}.`,
|
||||
get title() {
|
||||
return t("The update didn't install")
|
||||
},
|
||||
get description() {
|
||||
return t('Your site is back on {0}.', [update.status?.current ?? ''])
|
||||
},
|
||||
duration: 0,
|
||||
}),
|
||||
restarted: () => ({
|
||||
color: 'success' as const,
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Site restarted',
|
||||
description: 'Your site is running again.',
|
||||
get title() {
|
||||
return t('Site restarted')
|
||||
},
|
||||
get description() {
|
||||
return t('Your site is running again.')
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
async function announceRestartOutcome(outcome: RestartOutcome) {
|
||||
toast.add(OUTCOME_TOASTS[outcome]())
|
||||
toast.add(() => OUTCOME_TOASTS[outcome]())
|
||||
await nextTick()
|
||||
updatesCard.value?.focusHeading()
|
||||
}
|
||||
@@ -330,8 +383,10 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<div class="flex max-w-164 flex-col gap-5" :inert="update.restartRun !== null">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted">Settings</h1>
|
||||
<p class="text-muted">Workspace information.</p>
|
||||
<h1 class="text-xl font-semibold text-highlighted">
|
||||
{{ t('Settings') }}
|
||||
</h1>
|
||||
<p class="text-muted">{{ t('Workspace information.') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -347,7 +402,7 @@ onBeforeUnmount(() => {
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:description="UNREACHABLE_COPY"
|
||||
:description="t(UNREACHABLE_COPY)"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
@@ -359,13 +414,13 @@ onBeforeUnmount(() => {
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="loadSettings"
|
||||
>
|
||||
Try again
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<div v-else class="flex items-center justify-between gap-4">
|
||||
<span class="text-lg font-semibold text-highlighted">Version</span>
|
||||
<span class="text-lg font-semibold text-highlighted">{{ t('Version') }}</span>
|
||||
<USkeleton
|
||||
v-if="loadState === 'loading' && showSkeleton"
|
||||
data-testid="settings-skeleton"
|
||||
|
||||
+72
-33
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import InviteUserModal from '@/components/InviteUserModal.vue'
|
||||
@@ -19,8 +21,8 @@ type LoadState = 'loading' | 'ready' | 'error'
|
||||
const AUTH_HANDLED_STATUSES = [401, 403]
|
||||
const CANCEL_FAILED_COPY = "Couldn't cancel the invite. Try again in a moment."
|
||||
|
||||
const toast = useToast()
|
||||
const overlay = useOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const session = useSessionStore()
|
||||
const { listUsers, cancelInvite, renewInvite } = useUsers()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
@@ -105,13 +107,17 @@ function onInvited(invite: CreatedInvite) {
|
||||
),
|
||||
]
|
||||
highlightedKey.value = userRowKey('invite', invite.id)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `invite-sent-${invite.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Invite sent',
|
||||
description: `${invite.email} has 3 days to accept.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Invite sent')
|
||||
},
|
||||
get description() {
|
||||
return t('{0} has 3 days to accept.', [invite.email])
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function isHandledByApiClient(err: unknown) {
|
||||
@@ -127,11 +133,24 @@ function confirmCancel(invite: UserListItem) {
|
||||
overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Cancel this invite?',
|
||||
description: `The link sent to ${invite.email} will stop working. You can invite the address again later.`,
|
||||
confirmLabel: 'Cancel invite',
|
||||
cancelLabel: 'Keep invite',
|
||||
pendingLabel: 'Cancelling…',
|
||||
get title() {
|
||||
return t('Cancel this invite?')
|
||||
},
|
||||
get description() {
|
||||
return t(
|
||||
'The link sent to {0} will stop working. You can invite the address again later.',
|
||||
[invite.email],
|
||||
)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Cancel invite')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Keep invite')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Cancelling…')
|
||||
},
|
||||
tone: 'danger',
|
||||
action: async () => {
|
||||
try {
|
||||
@@ -140,16 +159,20 @@ function confirmCancel(invite: UserListItem) {
|
||||
if (isHandledByApiClient(err)) {
|
||||
return
|
||||
}
|
||||
throw new Error(CANCEL_FAILED_COPY)
|
||||
throw new Error(t(CANCEL_FAILED_COPY))
|
||||
}
|
||||
removeRow(key)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `invite-cancelled-${invite.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Invite cancelled',
|
||||
description: `${invite.email} can no longer use the link.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Invite cancelled')
|
||||
},
|
||||
get description() {
|
||||
return t('{0} can no longer use the link.', [invite.email])
|
||||
},
|
||||
}))
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -176,22 +199,30 @@ async function renew(invite: UserListItem) {
|
||||
...items.value.filter((item) => userRowKey(item.kind, item.id) !== key),
|
||||
]
|
||||
highlightedKey.value = userRowKey('invite', renewed.id)
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `invite-renewed-${renewed.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Invite renewed',
|
||||
description: `${renewed.email} has 3 days to accept the new link.`,
|
||||
})
|
||||
get title() {
|
||||
return t('Invite renewed')
|
||||
},
|
||||
get description() {
|
||||
return t('{0} has 3 days to accept the new link.', [renewed.email])
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
if (!isHandledByApiClient(err)) {
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
id: `invite-renew-failed-${invite.id}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't renew the invite",
|
||||
description: 'Try again in a moment.',
|
||||
})
|
||||
get title() {
|
||||
return t("Couldn't renew the invite")
|
||||
},
|
||||
get description() {
|
||||
return t('Try again in a moment.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
} finally {
|
||||
busyKeys.value = busyKeys.value.filter((busyKey) => busyKey !== key)
|
||||
@@ -207,8 +238,12 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted">Users</h1>
|
||||
<p class="text-muted">Everyone who can sign in and work on your articles.</p>
|
||||
<h1 class="text-xl font-semibold text-highlighted">
|
||||
{{ t('Users') }}
|
||||
</h1>
|
||||
<p class="text-muted">
|
||||
{{ t('Everyone who can sign in and work on your articles.') }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="isDesktop && canInvite"
|
||||
@@ -216,7 +251,7 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
class="h-8 px-3"
|
||||
@click="openInviteModal"
|
||||
>
|
||||
Invite user
|
||||
{{ t('Invite user') }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
@@ -228,8 +263,8 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't load users"
|
||||
description="Check your connection and try again."
|
||||
:title="t('Couldn\'t load users')"
|
||||
:description="t('Check your connection and try again.')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
@@ -241,7 +276,7 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="loadUsers"
|
||||
>
|
||||
Try again
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
@@ -272,7 +307,11 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
</template>
|
||||
|
||||
<p v-if="showOnlyYouHint" class="text-pretty text-muted">
|
||||
Invite someone so they can sign in with their own account instead of sharing yours.
|
||||
{{
|
||||
t(
|
||||
'Invite someone so they can sign in with their own account instead of sharing yours.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -284,7 +323,7 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
:ui="{ base: 'gap-2', leadingIcon: 'size-4.5' }"
|
||||
@click="openInviteModal"
|
||||
>
|
||||
Invite user
|
||||
{{ t('Invite user') }}
|
||||
</UButton>
|
||||
|
||||
<InviteUserModal
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from '@nuxt/ui/composables'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
import ResultPanel from '@/components/ResultPanel.vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
@@ -28,36 +30,52 @@ interface AlertState {
|
||||
const wrongCredentialsAlert: AlertState = {
|
||||
tone: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "That username and password don't match",
|
||||
description: 'Check both and try again. Passwords are case-sensitive.',
|
||||
get title() {
|
||||
return t("That username and password don't match")
|
||||
},
|
||||
get description() {
|
||||
return t('Check both and try again. Passwords are case-sensitive.')
|
||||
},
|
||||
}
|
||||
|
||||
const tooManyAttemptsAlert: AlertState = {
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-timer',
|
||||
title: 'Too many attempts',
|
||||
description: 'For your security, wait a moment before trying again.',
|
||||
get title() {
|
||||
return t('Too many attempts')
|
||||
},
|
||||
get description() {
|
||||
return t('For your security, wait a moment before trying again.')
|
||||
},
|
||||
}
|
||||
|
||||
const serverErrorAlert: AlertState = {
|
||||
tone: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't reach RenewCMS",
|
||||
description: 'Check your connection and try again.',
|
||||
get title() {
|
||||
return t("Couldn't reach RenewCMS")
|
||||
},
|
||||
get description() {
|
||||
return t('Check your connection and try again.')
|
||||
},
|
||||
}
|
||||
|
||||
const cancelFailedAlert: AlertState = {
|
||||
tone: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't cancel registration",
|
||||
description: 'Something went wrong on the server. Try again in a moment.',
|
||||
get title() {
|
||||
return t("Couldn't cancel registration")
|
||||
},
|
||||
get description() {
|
||||
return t('Something went wrong on the server. Try again in a moment.')
|
||||
},
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const api = useApi()
|
||||
const session = useSessionStore()
|
||||
const toast = useToast()
|
||||
const toast = useLocalizedToast()
|
||||
const retryAfter = useRetryAfter()
|
||||
|
||||
const state = ref<VerifyState>('missing')
|
||||
@@ -71,39 +89,35 @@ const loginSubmitting = ref(false)
|
||||
const loginAlert = ref<AlertState | null>(null)
|
||||
|
||||
const shellTitle = computed(() =>
|
||||
state.value === 'noSession' ? 'Log in to finish verifying' : '',
|
||||
state.value === 'noSession' ? t('Log in to finish verifying') : '',
|
||||
)
|
||||
const shellDescription = computed(() =>
|
||||
state.value === 'noSession'
|
||||
? "You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after."
|
||||
? t(
|
||||
"You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after.",
|
||||
)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const usernameError = ref('')
|
||||
const passwordError = ref('')
|
||||
|
||||
const loginSubmitDisabled = computed(
|
||||
() => loginSubmitting.value || retryAfter.active.value,
|
||||
)
|
||||
const loginSubmitDisabled = computed(() => loginSubmitting.value || retryAfter.active.value)
|
||||
|
||||
const loginSubmitLabel = computed(() => {
|
||||
if (loginSubmitting.value) {
|
||||
return 'Logging in…'
|
||||
return t('Logging in…')
|
||||
}
|
||||
if (retryAfter.active.value) {
|
||||
return `Try again in ${retryAfter.formatted.value}`
|
||||
return t('Try again in {0}', [retryAfter.formatted.value])
|
||||
}
|
||||
return 'Log in and verify'
|
||||
return t('Log in and verify')
|
||||
})
|
||||
|
||||
async function focusPassword() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
document
|
||||
.querySelector<HTMLInputElement>(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
?.focus()
|
||||
document.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')?.focus()
|
||||
}
|
||||
|
||||
async function performVerify(code: string) {
|
||||
@@ -116,12 +130,16 @@ async function performVerify(code: string) {
|
||||
onForbidden: 'passthrough',
|
||||
})
|
||||
state.value = 'success'
|
||||
toast.add({
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Your account is verified',
|
||||
description: 'Welcome to RenewCMS.',
|
||||
})
|
||||
get title() {
|
||||
return t('Your account is verified')
|
||||
},
|
||||
get description() {
|
||||
return t('Welcome to RenewCMS.')
|
||||
},
|
||||
}))
|
||||
await Promise.all([session.retry(), wait(VERIFIED_REDIRECT_DELAY_MS)])
|
||||
await router.push('/home')
|
||||
return
|
||||
@@ -160,10 +178,8 @@ function handleLoginError(error: unknown) {
|
||||
}
|
||||
|
||||
function validateLogin() {
|
||||
usernameError.value =
|
||||
username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
|
||||
passwordError.value =
|
||||
password.value.length === 0 ? 'Enter your password.' : ''
|
||||
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
|
||||
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
|
||||
return !usernameError.value && !passwordError.value
|
||||
}
|
||||
|
||||
@@ -203,7 +219,7 @@ async function cancelAndStartOver() {
|
||||
onForbidden: 'passthrough',
|
||||
})
|
||||
await router.push('/register')
|
||||
toast.add(registrationCancelledToast)
|
||||
toast.add(() => registrationCancelledToast)
|
||||
} catch {
|
||||
cancelAlert.value = cancelFailedAlert
|
||||
} finally {
|
||||
@@ -224,14 +240,18 @@ async function logOutFromInvalid() {
|
||||
|
||||
const invalidActions = computed(() => [
|
||||
{
|
||||
label: 'Cancel registration and start over',
|
||||
get label() {
|
||||
return t('Cancel registration and start over')
|
||||
},
|
||||
color: 'primary' as const,
|
||||
variant: 'solid' as const,
|
||||
disabled: invalidActionsDisabled.value,
|
||||
onClick: cancelAndStartOver,
|
||||
},
|
||||
{
|
||||
label: 'Log out',
|
||||
get label() {
|
||||
return t('Log out')
|
||||
},
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
disabled: invalidActionsDisabled.value,
|
||||
@@ -245,7 +265,9 @@ function goToLogin() {
|
||||
|
||||
const missingCodeActions = [
|
||||
{
|
||||
label: 'Go to log in',
|
||||
get label() {
|
||||
return t('Go to log in')
|
||||
},
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
onClick: goToLogin,
|
||||
@@ -294,22 +316,21 @@ onMounted(() => {
|
||||
role="status"
|
||||
class="flex flex-col items-center gap-4 py-10 text-center"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-loader-2"
|
||||
class="size-8 animate-spin text-highlighted"
|
||||
/>
|
||||
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
|
||||
<p class="text-xl/7 font-semibold text-highlighted">
|
||||
Verifying your account…
|
||||
{{ t('Verifying your account…') }}
|
||||
</p>
|
||||
<p class="text-base text-muted">
|
||||
{{ t('This only takes a moment.') }}
|
||||
</p>
|
||||
<p class="text-base text-muted">This only takes a moment.</p>
|
||||
</div>
|
||||
|
||||
<ResultPanel
|
||||
v-else-if="state === 'success'"
|
||||
tone="success"
|
||||
icon="i-lucide-shield-check"
|
||||
title="Your account is verified"
|
||||
description="Taking you to your dashboard…"
|
||||
:title="t('Your account is verified')"
|
||||
:description="t('Taking you to your dashboard…')"
|
||||
:actions="[]"
|
||||
/>
|
||||
|
||||
@@ -317,12 +338,12 @@ onMounted(() => {
|
||||
v-else-if="state === 'invalid'"
|
||||
tone="error"
|
||||
icon="i-lucide-link-2-off"
|
||||
title="This verification link doesn't work"
|
||||
description="It may have expired (links last 2 hours) or already been used."
|
||||
:title="t('This verification link doesn\'t work')"
|
||||
:description="t('It may have expired (links last 2 hours) or already been used.')"
|
||||
:actions="invalidActions"
|
||||
>
|
||||
<p class="text-sm text-pretty text-muted">
|
||||
Cancel your registration and start over to get a new link.
|
||||
{{ t('Cancel your registration and start over to get a new link.') }}
|
||||
</p>
|
||||
</ResultPanel>
|
||||
|
||||
@@ -330,8 +351,12 @@ onMounted(() => {
|
||||
v-else-if="state === 'missing'"
|
||||
tone="error"
|
||||
icon="i-lucide-link-2-off"
|
||||
title="This link is incomplete"
|
||||
description="The verification code is missing. Open the link from your email again, or copy the full address into your browser."
|
||||
:title="t('This link is incomplete')"
|
||||
:description="
|
||||
t(
|
||||
'The verification code is missing. Open the link from your email again, or copy the full address into your browser.',
|
||||
)
|
||||
"
|
||||
:actions="missingCodeActions"
|
||||
/>
|
||||
|
||||
@@ -342,9 +367,9 @@ onMounted(() => {
|
||||
@submit="onLoginSubmit"
|
||||
>
|
||||
<UFormField
|
||||
label="Username"
|
||||
:label="t('Username')"
|
||||
name="username"
|
||||
:error="usernameError || undefined"
|
||||
:error="translate(usernameError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="usernameError" #error="{ error }">
|
||||
@@ -353,13 +378,13 @@ onMounted(() => {
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<UInput
|
||||
v-model="username"
|
||||
:highlight="!!usernameError"
|
||||
placeholder="Your username"
|
||||
:placeholder="t('Your username')"
|
||||
autocomplete="username"
|
||||
:disabled="loginSubmitDisabled"
|
||||
variant="outline"
|
||||
@@ -371,9 +396,9 @@ onMounted(() => {
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Password"
|
||||
:label="t('Password')"
|
||||
name="password"
|
||||
:error="passwordError || undefined"
|
||||
:error="translate(passwordError) || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="passwordError" #error="{ error }">
|
||||
@@ -382,7 +407,7 @@ onMounted(() => {
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
{{ t(error) }}
|
||||
</span>
|
||||
</template>
|
||||
<UInput
|
||||
@@ -402,20 +427,14 @@ onMounted(() => {
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
:icon="
|
||||
passwordShown
|
||||
? 'i-lucide-eye-off'
|
||||
: 'i-lucide-eye'
|
||||
"
|
||||
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="
|
||||
passwordShown
|
||||
? 'Hide password'
|
||||
: 'Show password'
|
||||
passwordShown ? t('Hide password') : t('Show password')
|
||||
"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
|
||||
Reference in new issue
Block a user