fix: multiple fixes all accross the SPA, missing buttons actions, styling etc.

This commit is contained in:
fsylvain committed 2026-09-27 23:16:53 +02:00
1 parent ad55137883
commit d4fcfb2bba
11 files changed
+262 -43

No files matched your search

+1
View File
@@ -8,6 +8,7 @@
"@nuxt/ui": "^4.11.2", "@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3", "@tiptap/core": "3.31.3",
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@vueuse/core": "14.4.0", "@vueuse/core": "14.4.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"shiki": "^4.4.3", "shiki": "^4.4.3",
+1
View File
@@ -19,6 +19,7 @@
"@nuxt/ui": "^4.11.2", "@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3", "@tiptap/core": "3.31.3",
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@vueuse/core": "14.4.0", "@vueuse/core": "14.4.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"shiki": "^4.4.3", "shiki": "^4.4.3",
+44 -3
View File
@@ -1,10 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useToast } from '@nuxt/ui/composables'
import type { Article } from '@/composables/useArticles' import type { Article } from '@/composables/useArticles'
const props = defineProps<{ article: Article }>() const props = defineProps<{ article: Article }>()
defineEmits<{ 'copy-url': [] }>() const emit = defineEmits<{ 'copy-url': [] }>()
const toast = useToast()
const open = defineModel<boolean>('open', { default: false }) const open = defineModel<boolean>('open', { default: false })
@@ -55,6 +58,28 @@ const apiNote = computed(() =>
? 'Live. Your website can read this article.' ? 'Live. Your website can read this article.'
: 'Returns 404 until the article is online.', : 'Returns 404 until the article is online.',
) )
async function copyUrl() {
const url = `${window.location.origin}${publicUrl.value}`
try {
await navigator.clipboard.writeText(url)
toast.add({
id: `article-link-copied-${props.article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Link copied',
description: `The API URL for "${props.article.title}" is on your clipboard.`,
})
emit('copy-url')
} catch {
toast.add({
id: `article-link-copy-failed-${props.article.id}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't copy the link",
})
}
}
</script> </script>
<template> <template>
@@ -89,7 +114,15 @@ const apiNote = computed(() =>
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs" class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
> >
<span class="flex-1">{{ publicUrl }}</span> <span class="flex-1">{{ publicUrl }}</span>
<UIcon name="i-lucide-copy" class="size-3.5" /> <UButton
icon="i-lucide-copy"
color="neutral"
variant="ghost"
size="sm"
aria-label="Copy the public API URL"
class="size-5 p-0"
@click="copyUrl"
/>
</div> </div>
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p> <p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div> </div>
@@ -127,7 +160,15 @@ const apiNote = computed(() =>
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs" class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
> >
<span class="flex-1">{{ publicUrl }}</span> <span class="flex-1">{{ publicUrl }}</span>
<UIcon name="i-lucide-copy" class="size-3.5" /> <UButton
icon="i-lucide-copy"
color="neutral"
variant="ghost"
size="sm"
aria-label="Copy the public API URL"
class="size-5 p-0"
@click="copyUrl"
/>
</div> </div>
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p> <p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div> </div>
+5 -4
View File
@@ -216,6 +216,7 @@ defineExpose({
@update:model-value="onUpdate" @update:model-value="onUpdate"
> >
<span class="hidden">{{ captureEditor(editor) }}</span> <span class="hidden">{{ captureEditor(editor) }}</span>
<UEditorDragHandle :editor="editor" />
<UEditorToolbar <UEditorToolbar
:editor="editor" :editor="editor"
:items="buildItems(editor)" :items="buildItems(editor)"
@@ -239,16 +240,16 @@ defineExpose({
grid-row-start: 2; grid-row-start: 2;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 2.5rem 3rem;
background: var(--ui-bg); background: var(--ui-bg);
} }
.article-editor :deep(.ProseMirror) { .article-editor :deep(.ProseMirror) {
max-width: 42rem; box-sizing: border-box;
padding: 0; max-width: 48rem;
padding: 2.5rem 3rem;
outline: none; outline: none;
} }
@media (max-width: 47.999rem) { @media (max-width: 47.999rem) {
.article-editor :deep([data-slot='content']) { .article-editor :deep(.ProseMirror) {
padding: 1.5rem 1rem; padding: 1.5rem 1rem;
} }
} }
+63 -11
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue' import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate' import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
@@ -23,14 +24,27 @@ const router = useRouter()
const columnWidths = const columnWidths =
'[&:nth-child(2)]:w-27.5 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-35 [&:nth-child(5)]:w-11' '[&:nth-child(2)]:w-27.5 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-35 [&:nth-child(5)]:w-11'
const sorting = ref([{ id: 'updated_at', desc: true }])
const columns = [ const columns = [
{ accessorKey: 'title', header: 'Title' }, { accessorKey: 'title', header: 'Title', enableSorting: true },
{ accessorKey: 'is_online', header: 'Status', size: 110 }, { accessorKey: 'is_online', header: 'Status', size: 110, enableSorting: true },
{ accessorKey: 'created_at', header: 'Created', size: 130 }, { accessorKey: 'created_at', header: 'Created', size: 130, enableSorting: true },
{ accessorKey: 'updated_at', header: 'Updated', size: 140 }, { accessorKey: 'updated_at', header: 'Updated', size: 140, enableSorting: true },
{ id: 'actions', header: '', size: 44 }, { id: 'actions', header: '', size: 44 },
] ]
function sortIcon(column: { getIsSorted: () => false | 'asc' | 'desc' }) {
const sorted = column.getIsSorted()
if (sorted === 'asc') {
return 'i-lucide-arrow-up'
}
if (sorted === 'desc') {
return 'i-lucide-arrow-down'
}
return undefined
}
function editPath(article: Article) { function editPath(article: Article) {
return `/article/${article.id}/edit` return `/article/${article.id}/edit`
} }
@@ -112,6 +126,7 @@ function onSelect(row: { original: Article }) {
th: `px-3 ${columnWidths}`, th: `px-3 ${columnWidths}`,
td: `h-11 px-3 py-0 ${columnWidths}`, td: `h-11 px-3 py-0 ${columnWidths}`,
}" }"
v-model:sorting="sorting"
@select="onSelect" @select="onSelect"
> >
<template #caption> <template #caption>
@@ -120,6 +135,50 @@ function onSelect(row: { original: Article }) {
</caption> </caption>
</template> </template>
<template #title-header="{ column }">
<button
type="button"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Title
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
<template #is_online-header="{ column }">
<button
type="button"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Status
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
<template #created_at-header="{ column }">
<button
type="button"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Created
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
<template #updated_at-header="{ column }">
<button
type="button"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Updated
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
<template #title-cell="{ row }"> <template #title-cell="{ row }">
<RouterLink <RouterLink
:to="editPath(row.original)" :to="editPath(row.original)"
@@ -140,13 +199,6 @@ function onSelect(row: { original: Article }) {
/> />
</template> </template>
<template #updated_at-header>
<span class="inline-flex items-center gap-1 text-highlighted">
Updated
<UIcon name="i-lucide-arrow-down" class="size-3" />
</span>
</template>
<template #created_at-cell="{ row }"> <template #created_at-cell="{ row }">
<span class="text-muted">{{ <span class="text-muted">{{
formatAbsoluteDateFromIso(row.original.created_at) formatAbsoluteDateFromIso(row.original.created_at)
+10 -5
View File
@@ -8,11 +8,13 @@ const props = withDefaults(
min?: number min?: number
warnAt?: number warnAt?: number
variant?: 'inline' | 'footer' variant?: 'inline' | 'footer'
plainText?: boolean
}>(), }>(),
{ {
min: 0, min: 0,
warnAt: 0.9, warnAt: 0.9,
variant: 'inline', variant: 'inline',
plainText: false,
}, },
) )
@@ -34,11 +36,14 @@ const status = computed<'normal' | 'warn' | 'over' | 'under'>(() => {
return 'normal' return 'normal'
}) })
const overText = computed(() => const overText = computed(() => {
overBy.value === 1 const suffix = props.plainText
? '1 character over. Shorten the text or remove an image. Formatting counts too.' ? 'Shorten the text.'
: `${formatThousands(overBy.value)} characters over. Shorten the text or remove an image. Formatting counts too.`, : 'Shorten the text or remove an image. Formatting counts too.'
) return overBy.value === 1
? `1 character over. ${suffix}`
: `${formatThousands(overBy.value)} characters over. ${suffix}`
})
const warnText = 'Close to the limit. Images and formatting count too.' const warnText = 'Close to the limit. Images and formatting count too.'
+19 -12
View File
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
import AppBoot from '@/components/AppBoot.vue' import AppBoot from '@/components/AppBoot.vue'
import AppLogo from '@/components/AppLogo.vue' import AppLogo from '@/components/AppLogo.vue'
import ErrorView from '@/components/ErrorView.vue' import ErrorView from '@/components/ErrorView.vue'
@@ -44,7 +45,22 @@ const navItems = computed(() =>
const routeTitle = computed(() => (route.meta.title as string) ?? '') const routeTitle = computed(() => (route.meta.title as string) ?? '')
const pageTitle = computed(() => props.mobileTitle ?? routeTitle.value) const pageTitle = computed(() => props.mobileTitle ?? routeTitle.value)
const sidebarCollapsed = ref(false) const sidebarCollapsed = useSidebarCollapsed()
const sidebarUi = computed(() => ({
root: sidebarCollapsed.value ? 'min-w-14 px-1 py-3' : 'min-w-14 p-3',
header: 'mb-3 h-10 gap-2.5 px-1.5',
body: 'gap-0.5 p-0',
footer: 'p-0',
}))
const navUi = computed(() => ({
list: 'flex flex-col gap-0.5',
link: sidebarCollapsed.value
? 'h-8 justify-center gap-0 px-0 text-sm'
: 'h-8 gap-2.5 px-2.5 py-0 text-sm',
linkLeadingIcon: 'size-4',
}))
const loggingOut = ref(false) const loggingOut = ref(false)
function onRetry() { function onRetry() {
@@ -75,12 +91,7 @@ async function onLogout() {
:resizable="false" :resizable="false"
:default-size="14" :default-size="14"
:collapsed-size="3.5" :collapsed-size="3.5"
:ui="{ :ui="sidebarUi"
root: 'min-w-14 p-3',
header: 'mb-3 h-10 gap-2.5 px-1.5',
body: 'gap-0.5 p-0',
footer: 'p-0',
}"
> >
<template #header> <template #header>
<AppLogo :size="26" /> <AppLogo :size="26" />
@@ -97,11 +108,7 @@ async function onLogout() {
:items="navItems" :items="navItems"
:collapsed="sidebarCollapsed" :collapsed="sidebarCollapsed"
aria-label="Main" aria-label="Main"
:ui="{ :ui="navUi"
list: 'flex flex-col gap-0.5',
link: 'h-8 gap-2.5 px-2.5 py-0 text-sm',
linkLeadingIcon: 'size-4',
}"
/> />
<template #footer> <template #footer>
+1
View File
@@ -179,6 +179,7 @@ function onCancel() {
:value="titleLength" :value="titleLength"
:max="TITLE_MAX_LENGTH" :max="TITLE_MAX_LENGTH"
:min="titleCounterMin" :min="titleCounterMin"
plain-text
/> />
</template> </template>
<template v-if="titleError" #error="{ error }"> <template v-if="titleError" #error="{ error }">
+5 -2
View File
@@ -63,8 +63,11 @@ const items = computed(() => [
color="neutral" color="neutral"
variant="ghost" variant="ghost"
block block
class="h-10 justify-start gap-2.5 px-1.5 font-normal text-highlighted" :class="[
trailing-icon="i-lucide-chevrons-up-down" 'h-10 gap-2.5 px-1.5 font-normal text-highlighted',
collapsed ? 'justify-center' : 'justify-start',
]"
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
:ui="{ trailingIcon: 'ms-auto size-3.5 text-default' }" :ui="{ trailingIcon: 'ms-auto size-3.5 text-default' }"
:aria-label="triggerAriaLabel" :aria-label="triggerAriaLabel"
> >
@@ -0,0 +1,5 @@
import { useStorage } from '@vueuse/core'
export function useSidebarCollapsed() {
return useStorage('sidebar-collapsed', false)
}
+108 -6
View File
@@ -34,6 +34,7 @@ const showSkeleton = ref(false)
const article = ref<Article | null>(null) const article = ref<Article | null>(null)
const previewOn = ref(false) const previewOn = ref(false)
const detailsOpen = ref(false) const detailsOpen = ref(false)
const publishPending = ref<string | undefined>(undefined)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null let skeletonTimer: ReturnType<typeof setTimeout> | null = null
function clearSkeletonTimer() { function clearSkeletonTimer() {
@@ -111,6 +112,99 @@ function goBack() {
router.push('/article') router.push('/article')
} }
async function setOnline(makeOnline: boolean) {
if (!article.value) {
return
}
publishPending.value = makeOnline ? 'Publishing…' : 'Unpublishing…'
try {
const updated = await request<Article>(
`/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}`,
{ method: 'PATCH' },
)
article.value = updated
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.`,
})
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
loadState.value = 'notFound'
return
}
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.",
duration: 0,
actions: [{ label: 'Retry', onClick: () => setOnline(makeOnline) }],
})
} finally {
publishPending.value = undefined
}
}
function handlePublish() {
setOnline(true)
}
function handleUnpublish() {
setOnline(false)
}
function deleteDescription() {
const count = article.value?.images.length ?? 0
const imagesPhrase =
count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images`
return `"${article.value?.title ?? ''}"${imagesPhrase} will be deleted. This can't be undone.`
}
async function performDelete() {
try {
await request(`/v1/article/${articleId}`, { method: 'DELETE' })
toast.add({
id: `article-deleted-${articleId}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: `"${article.value?.title ?? ''}" and its images are gone.`,
})
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.")
}
}
async function handleDelete() {
if (!article.value) {
return
}
await overlay
.create(ConfirmDialog, {
props: {
title: 'Delete this article?',
description: deleteDescription(),
confirmLabel: 'Delete article',
cancelLabel: 'Cancel',
pendingLabel: 'Deleting…',
tone: 'danger',
action: performDelete,
},
})
.open()
}
function onKeydown(event: KeyboardEvent) { function onKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault() event.preventDefault()
@@ -254,7 +348,7 @@ onBeforeRouteLeave(async () => {
root: 'items-center gap-2', root: 'items-center gap-2',
container: 'h-auto', container: 'h-auto',
base: 'h-4.5 w-8 p-0.5', base: 'h-4.5 w-8 p-0.5',
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-3.5', thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-2.5',
wrapper: 'ms-0', wrapper: 'ms-0',
label: 'text-sm font-normal text-toned', label: 'text-sm font-normal text-toned',
}" }"
@@ -274,15 +368,20 @@ onBeforeRouteLeave(async () => {
class="h-8 gap-1.5 px-3" class="h-8 gap-1.5 px-3"
:online="article.is_online" :online="article.is_online"
:dirty="draft.state.value === 'dirty'" :dirty="draft.state.value === 'dirty'"
:pending="publishPending"
:disabled="publishDisabled" :disabled="publishDisabled"
@publish="handlePublish"
@unpublish="handleUnpublish"
/> />
<UButton <UButton
icon="i-lucide-ellipsis" icon="i-lucide-trash-2"
color="neutral" color="error"
variant="ghost" variant="ghost"
class="size-8 justify-center text-toned" class="h-8 gap-1.5 px-3"
aria-label="More actions" @click="handleDelete"
/> >
Delete
</UButton>
</div> </div>
</div> </div>
@@ -357,7 +456,10 @@ onBeforeRouteLeave(async () => {
hide-icon hide-icon
:online="article.is_online" :online="article.is_online"
:dirty="draft.state.value === 'dirty'" :dirty="draft.state.value === 'dirty'"
:pending="publishPending"
:disabled="publishDisabled" :disabled="publishDisabled"
@publish="handlePublish"
@unpublish="handleUnpublish"
/> />
</div> </div>
</div> </div>