diff --git a/web/bun.lock b/web/bun.lock
index 5dca00d..acddd03 100644
--- a/web/bun.lock
+++ b/web/bun.lock
@@ -8,6 +8,7 @@
"@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3",
+ "@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@vueuse/core": "14.4.0",
"pinia": "^3.0.4",
"shiki": "^4.4.3",
diff --git a/web/package.json b/web/package.json
index 264d667..d6d4143 100644
--- a/web/package.json
+++ b/web/package.json
@@ -19,6 +19,7 @@
"@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3",
+ "@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@vueuse/core": "14.4.0",
"pinia": "^3.0.4",
"shiki": "^4.4.3",
diff --git a/web/src/components/ArticleDetails.vue b/web/src/components/ArticleDetails.vue
index 64a6b55..480a452 100644
--- a/web/src/components/ArticleDetails.vue
+++ b/web/src/components/ArticleDetails.vue
@@ -1,10 +1,13 @@
@@ -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"
>
{{ publicUrl }}
-
+
{{ apiNote }}
@@ -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"
>
{{ publicUrl }}
-
+
{{ apiNote }}
diff --git a/web/src/components/ArticleEditor.vue b/web/src/components/ArticleEditor.vue
index 58b8843..9b3d063 100644
--- a/web/src/components/ArticleEditor.vue
+++ b/web/src/components/ArticleEditor.vue
@@ -216,6 +216,7 @@ defineExpose({
@update:model-value="onUpdate"
>
{{ captureEditor(editor) }}
+
+import { ref } from 'vue'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
@@ -23,14 +24,27 @@ const router = useRouter()
const columnWidths =
'[&: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 = [
- { accessorKey: 'title', header: 'Title' },
- { accessorKey: 'is_online', header: 'Status', size: 110 },
- { accessorKey: 'created_at', header: 'Created', size: 130 },
- { accessorKey: 'updated_at', header: 'Updated', size: 140 },
+ { accessorKey: 'title', header: 'Title', enableSorting: true },
+ { accessorKey: 'is_online', header: 'Status', size: 110, enableSorting: true },
+ { accessorKey: 'created_at', header: 'Created', size: 130, enableSorting: true },
+ { accessorKey: 'updated_at', header: 'Updated', size: 140, enableSorting: true },
{ 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) {
return `/article/${article.id}/edit`
}
@@ -112,6 +126,7 @@ function onSelect(row: { original: Article }) {
th: `px-3 ${columnWidths}`,
td: `h-11 px-3 py-0 ${columnWidths}`,
}"
+ v-model:sorting="sorting"
@select="onSelect"
>
@@ -120,6 +135,50 @@ function onSelect(row: { original: Article }) {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
- Updated
-
-
-
-
{{
formatAbsoluteDateFromIso(row.original.created_at)
diff --git a/web/src/components/CharacterCounter.vue b/web/src/components/CharacterCounter.vue
index fefdfcc..433659b 100644
--- a/web/src/components/CharacterCounter.vue
+++ b/web/src/components/CharacterCounter.vue
@@ -8,11 +8,13 @@ const props = withDefaults(
min?: number
warnAt?: number
variant?: 'inline' | 'footer'
+ plainText?: boolean
}>(),
{
min: 0,
warnAt: 0.9,
variant: 'inline',
+ plainText: false,
},
)
@@ -34,11 +36,14 @@ const status = computed<'normal' | 'warn' | 'over' | 'under'>(() => {
return 'normal'
})
-const overText = computed(() =>
- overBy.value === 1
- ? '1 character over. Shorten the text or remove an image. Formatting counts too.'
- : `${formatThousands(overBy.value)} characters over. Shorten the text or remove an image. Formatting counts too.`,
-)
+const overText = computed(() => {
+ const suffix = props.plainText
+ ? 'Shorten the text.'
+ : '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.'
diff --git a/web/src/components/DashboardShell.vue b/web/src/components/DashboardShell.vue
index 5ef90d8..8008d8a 100644
--- a/web/src/components/DashboardShell.vue
+++ b/web/src/components/DashboardShell.vue
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { useApi } from '@/composables/useApi'
+import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
import AppBoot from '@/components/AppBoot.vue'
import AppLogo from '@/components/AppLogo.vue'
import ErrorView from '@/components/ErrorView.vue'
@@ -44,7 +45,22 @@ const navItems = computed(() =>
const routeTitle = computed(() => (route.meta.title as string) ?? '')
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)
function onRetry() {
@@ -75,12 +91,7 @@ async function onLogout() {
:resizable="false"
:default-size="14"
:collapsed-size="3.5"
- :ui="{
- 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',
- }"
+ :ui="sidebarUi"
>
@@ -97,11 +108,7 @@ async function onLogout() {
:items="navItems"
:collapsed="sidebarCollapsed"
aria-label="Main"
- :ui="{
- list: 'flex flex-col gap-0.5',
- link: 'h-8 gap-2.5 px-2.5 py-0 text-sm',
- linkLeadingIcon: 'size-4',
- }"
+ :ui="navUi"
/>
diff --git a/web/src/components/NewArticleModal.vue b/web/src/components/NewArticleModal.vue
index 7cc6fe1..b175886 100644
--- a/web/src/components/NewArticleModal.vue
+++ b/web/src/components/NewArticleModal.vue
@@ -179,6 +179,7 @@ function onCancel() {
:value="titleLength"
:max="TITLE_MAX_LENGTH"
:min="titleCounterMin"
+ plain-text
/>
diff --git a/web/src/components/UserMenu.vue b/web/src/components/UserMenu.vue
index 9ba933c..4063785 100644
--- a/web/src/components/UserMenu.vue
+++ b/web/src/components/UserMenu.vue
@@ -63,8 +63,11 @@ const items = computed(() => [
color="neutral"
variant="ghost"
block
- class="h-10 justify-start gap-2.5 px-1.5 font-normal text-highlighted"
- trailing-icon="i-lucide-chevrons-up-down"
+ :class="[
+ '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' }"
:aria-label="triggerAriaLabel"
>
diff --git a/web/src/composables/useSidebarCollapsed.ts b/web/src/composables/useSidebarCollapsed.ts
new file mode 100644
index 0000000..459aa75
--- /dev/null
+++ b/web/src/composables/useSidebarCollapsed.ts
@@ -0,0 +1,5 @@
+import { useStorage } from '@vueuse/core'
+
+export function useSidebarCollapsed() {
+ return useStorage('sidebar-collapsed', false)
+}
diff --git a/web/src/views/EditorView.vue b/web/src/views/EditorView.vue
index b0490f8..f502756 100644
--- a/web/src/views/EditorView.vue
+++ b/web/src/views/EditorView.vue
@@ -34,6 +34,7 @@ const showSkeleton = ref(false)
const article = ref(null)
const previewOn = ref(false)
const detailsOpen = ref(false)
+const publishPending = ref(undefined)
let skeletonTimer: ReturnType | null = null
function clearSkeletonTimer() {
@@ -111,6 +112,99 @@ function goBack() {
router.push('/article')
}
+async function setOnline(makeOnline: boolean) {
+ if (!article.value) {
+ return
+ }
+ publishPending.value = makeOnline ? 'Publishing…' : 'Unpublishing…'
+ try {
+ const updated = await request(
+ `/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) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
@@ -254,7 +348,7 @@ onBeforeRouteLeave(async () => {
root: 'items-center gap-2',
container: 'h-auto',
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',
label: 'text-sm font-normal text-toned',
}"
@@ -274,15 +368,20 @@ onBeforeRouteLeave(async () => {
class="h-8 gap-1.5 px-3"
:online="article.is_online"
:dirty="draft.state.value === 'dirty'"
+ :pending="publishPending"
:disabled="publishDisabled"
+ @publish="handlePublish"
+ @unpublish="handleUnpublish"
/>
+ class="h-8 gap-1.5 px-3"
+ @click="handleDelete"
+ >
+ Delete
+
@@ -357,7 +456,10 @@ onBeforeRouteLeave(async () => {
hide-icon
:online="article.is_online"
:dirty="draft.state.value === 'dirty'"
+ :pending="publishPending"
:disabled="publishDisabled"
+ @publish="handlePublish"
+ @unpublish="handleUnpublish"
/>