Portfolio/app/pages/dash/index.vue
2026-09-08 19:54:06 +02:00

284 lines
7.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import type { DashPost, DashSession } from '#shared/types/dash'
import { EMPTY_SESSION, errorText } from '~/composables/useDash'
useSeoMeta({ title: 'dash', robots: 'noindex, nofollow' })
const requestFetch = useRequestFetch()
const { site } = useSiteData()
const { data, refresh } = await useAsyncData(
'dash:overview',
async () => {
const session = await requestFetch<DashSession>('/api/dash/session')
if (!session.authenticated) return { session, posts: [] as DashPost[] }
const { posts } = await requestFetch<{ posts: DashPost[] }>('/api/dash/posts')
return { session, posts }
},
{ default: () => ({ session: { ...EMPTY_SESSION }, posts: [] as DashPost[] }) },
)
const session = computed(() => data.value.session)
const posts = computed(() => data.value.posts)
const published = computed(() => posts.value.filter((p) => !p.draft).length)
const drafts = computed(() => posts.value.length - published.value)
const busy = ref(false)
const flash = ref<{ text: string; kind: 'ok' | 'error' } | null>(null)
const pendingDelete = ref<string | null>(null)
function say(text: string, kind: 'ok' | 'error' = 'ok') {
flash.value = { text, kind }
}
async function refreshCache() {
busy.value = true
flash.value = null
try {
const { cleared } = await $fetch<{ cleared: number }>('/api/dash/refresh', { method: 'POST' })
await refresh()
say(`re-read the repo, dropped ${cleared} cached ${cleared === 1 ? 'entry' : 'entries'}`)
} catch (e) {
say(errorText(e, 'could not refresh'), 'error')
} finally {
busy.value = false
}
}
async function confirmDelete(slug: string) {
busy.value = true
flash.value = null
try {
await $fetch(`/api/dash/posts/${slug}`, { method: 'DELETE' })
pendingDelete.value = null
await refresh()
say(`deleted ${slug}`)
} catch (e) {
say(errorText(e, 'could not delete'), 'error')
} finally {
busy.value = false
}
}
const stamp = (iso: string) =>
new Intl.DateTimeFormat('en-CA', { dateStyle: 'short', timeZone: 'UTC' }).format(new Date(iso))
</script>
<template>
<div>
<DashHeader :session="session" @signed-out="refresh" />
<DashLogin
v-if="!session.authenticated"
:enabled="session.enabled"
:handle="site.handle"
@done="refresh"
/>
<template v-else>
<section class="grid-2 top">
<FrameBox tag="source">
<dl class="def-list">
<dt>provider</dt>
<dd>{{ session.provider }}</dd>
<dt>repo</dt>
<dd>{{ session.repo }} <span class="faint">@{{ session.branch }}</span></dd>
<dt>posts in</dt>
<dd>{{ session.dir || '<repo root>' }}/</dd>
<dt>portfolio</dt>
<dd>{{ session.siteFile }}</dd>
<dt>writes</dt>
<dd :class="session.writable ? 'accent' : ''">
{{ session.writable ? 'enabled' : 'disabled — set CONTENT_TOKEN' }}
</dd>
<dt>webhook</dt>
<dd>{{ session.webhook ? 'configured' : 'not configured' }}</dd>
<dt>cache ttl</dt>
<dd>{{ session.ttl }}s</dd>
</dl>
</FrameBox>
<FrameBox tag="state">
<div class="counts">
<div>
<span class="count accent">{{ published }}</span>
<span class="muted small">published</span>
</div>
<div>
<span class="count">{{ drafts }}</span>
<span class="muted small">draft{{ drafts === 1 ? '' : 's' }}</span>
</div>
</div>
<div class="row actions">
<NuxtLink class="btn btn-primary" to="/dash/posts/new">+ new post</NuxtLink>
<button class="btn" type="button" :disabled="busy" @click="refreshCache">
re-read repo
</button>
</div>
<p v-if="flash" class="flash" :class="`flash-${flash.kind}`">{{ flash.text }}</p>
</FrameBox>
</section>
<AsciiRule label="posts" />
<FrameBox tag="posts">
<p v-if="!posts.length" class="muted small empty">
no markdown found in <code>{{ session.repo }}/{{ session.dir }}</code>. create the first
one — it will be committed for you.
</p>
<table v-else class="posts">
<tbody>
<tr v-for="post in posts" :key="post.slug">
<td class="stamp muted small">{{ stamp(post.date) }}</td>
<td class="title">
<NuxtLink :to="`/dash/posts/${post.slug}`">{{ post.title }}</NuxtLink>
<span v-if="post.draft" class="badge accent tiny">draft</span>
</td>
<td class="tags faint tiny">
<span v-for="tag in post.tags" :key="tag">#{{ tag }}</span>
</td>
<td class="acts">
<template v-if="pendingDelete === post.slug">
<span class="small muted">delete?</span>
<button
class="btn btn-ghost btn-danger"
type="button"
:disabled="busy"
@click="confirmDelete(post.slug)"
>yes</button>
<button class="btn btn-ghost" type="button" @click="pendingDelete = null">no</button>
</template>
<template v-else>
<NuxtLink class="btn btn-ghost" :to="`/blog/${post.slug}`" target="_blank">view</NuxtLink>
<button
class="btn btn-ghost"
type="button"
:disabled="!session.writable"
:title="session.writable ? 'delete' : 'read-only'"
@click="pendingDelete = post.slug"
>×</button>
</template>
</td>
</tr>
</tbody>
</table>
</FrameBox>
</template>
</div>
</template>
<style scoped>
.top {
align-items: stretch;
}
.counts {
display: flex;
gap: 2rem;
margin-bottom: 1.25rem;
}
.counts > div {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.count {
font-size: 2rem;
font-weight: 700;
line-height: 1;
}
.actions {
gap: 0.5rem;
}
.flash {
margin-top: 1rem;
}
.empty {
padding: 1rem 0;
}
.posts {
width: 100%;
border-collapse: collapse;
}
.posts td {
padding: 0.5rem 0.4rem;
border-bottom: 1px dashed var(--ink-faint);
vertical-align: baseline;
}
.posts tr:last-child td {
border-bottom: 0;
}
.stamp {
white-space: nowrap;
font-variant-numeric: tabular-nums;
width: 1%;
}
.title a {
text-decoration: none;
font-weight: 600;
}
.title a:hover {
color: var(--accent);
background: none;
}
.badge {
border: 1px solid var(--accent);
padding: 0 0.3rem;
margin-left: 0.5rem;
}
.tags span + span {
margin-left: 0.4rem;
}
.acts {
text-align: right;
white-space: nowrap;
width: 1%;
}
.acts .btn {
text-decoration: none;
}
@media (max-width: 640px) {
.posts,
.posts tbody,
.posts tr,
.posts td {
display: block;
width: auto;
}
.posts tr {
padding: 0.5rem 0;
border-bottom: 1px dashed var(--ink-faint);
}
.posts td {
border: 0;
padding: 0.1rem 0;
}
.acts {
text-align: left;
margin-top: 0.35rem;
}
}
</style>