feat: new-version
This commit is contained in:
commit
15d5112ded
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@ -0,0 +1,14 @@
|
||||
.git
|
||||
.github
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.data
|
||||
dist
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
README.md
|
||||
docker-compose.yml
|
||||
63
.env.example
Normal file
63
.env.example
Normal file
@ -0,0 +1,63 @@
|
||||
# ---- public ----------------------------------------------------------------
|
||||
# used for canonical urls, rss and og tags. must also be passed at *build* time
|
||||
# (Dockerfile ARG SITE_URL) because it lands in the client bundle.
|
||||
NUXT_PUBLIC_SITE_URL=https://nak0x.dev
|
||||
|
||||
# ---- where the blog lives --------------------------------------------------
|
||||
# gitea | github | local
|
||||
CONTENT_PROVIDER=gitea
|
||||
CONTENT_REPO=Nak0x/Journal
|
||||
CONTENT_BRANCH=main
|
||||
# sub-directory inside the repo holding the .md files ('' = repo root)
|
||||
CONTENT_DIR=posts
|
||||
# only read when CONTENT_PROVIDER=gitea
|
||||
GITEA_URL=https://git.nak0x.dev
|
||||
# personal access token — only needed for a private repo (gitea: scope read:repository)
|
||||
CONTENT_TOKEN=
|
||||
# seconds before a cached post is considered stale (it is still served while
|
||||
# refreshing). the webhook makes this a safety net, not the main path.
|
||||
CONTENT_TTL=600
|
||||
# only read when CONTENT_PROVIDER=local. acts as the repo root: posts live in
|
||||
# <CONTENT_LOCAL_DIR>/<CONTENT_DIR>, site.json at <CONTENT_LOCAL_DIR>/site.json
|
||||
CONTENT_LOCAL_DIR=content
|
||||
# portfolio content (bio, skills, links, projects), relative to the repo root.
|
||||
# absent from the repo => the seed in shared/site.ts is used until /dash saves.
|
||||
CONTENT_SITE_FILE=site.json
|
||||
|
||||
# ---- projects --------------------------------------------------------------
|
||||
# /projects is every public repo of these accounts — there is no hand-written
|
||||
# list. leave one empty to switch that forge off.
|
||||
PROJECTS_GITHUB_USER=nak0x
|
||||
PROJECTS_GITEA_USER=Nak0x
|
||||
# defaults to GITEA_URL above
|
||||
PROJECTS_GITEA_URL=
|
||||
# optional PATs. github allows 60 anonymous requests/hour per IP; a token
|
||||
# raises that to 5000. the gitea one falls back to CONTENT_TOKEN.
|
||||
PROJECTS_GITHUB_TOKEN=
|
||||
PROJECTS_GITEA_TOKEN=
|
||||
# repos to drop, comma-separated; matches "name" or "owner/name". for anything
|
||||
# more than a handful, tick "hide" on the project in /dash instead.
|
||||
PROJECTS_EXCLUDE=
|
||||
# forks belong to somebody else
|
||||
PROJECTS_INCLUDE_FORKS=false
|
||||
# seconds before the repo list is refetched
|
||||
PROJECTS_TTL=900
|
||||
|
||||
# ---- webhook ---------------------------------------------------------------
|
||||
# shared secret between this app and gitea/github. generate with:
|
||||
# openssl rand -hex 32
|
||||
CONTENT_WEBHOOK_SECRET=
|
||||
|
||||
# ---- /dash -----------------------------------------------------------------
|
||||
# unset => the dashboard is disabled entirely (login always 503s).
|
||||
# writing from /dash also needs CONTENT_TOKEN above, with repo write scope.
|
||||
DASH_PASSWORD=
|
||||
# optional; defaults to a key derived from DASH_PASSWORD, so changing the
|
||||
# password already invalidates every open session.
|
||||
DASH_SESSION_SECRET=
|
||||
# how long a login lasts, in hours
|
||||
DASH_SESSION_HOURS=168
|
||||
|
||||
# ---- misc ------------------------------------------------------------------
|
||||
# render posts with `draft: true` in production too
|
||||
SHOW_DRAFTS=false
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.data
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
44
Dockerfile
Normal file
44
Dockerfile
Normal file
@ -0,0 +1,44 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---- build -----------------------------------------------------------------
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=development
|
||||
|
||||
# install deps first so the layer caches on lockfile changes only.
|
||||
# --ignore-scripts skips `nuxt prepare`, which needs the source we have not
|
||||
# copied yet; `nuxt build` runs it anyway.
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts --no-audit --no-fund
|
||||
|
||||
COPY . .
|
||||
|
||||
# baked into the client bundle, so it has to be present at build time.
|
||||
# override with --build-arg SITE_URL=... (coolify: Build Variables)
|
||||
ARG SITE_URL=https://nak0x.dev
|
||||
ENV NUXT_PUBLIC_SITE_URL=$SITE_URL
|
||||
|
||||
RUN NODE_ENV=production npm run build
|
||||
|
||||
# ---- runtime ---------------------------------------------------------------
|
||||
FROM node:24-alpine AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=3000 \
|
||||
NITRO_PORT=3000
|
||||
|
||||
# nitro's output is self-contained: no node_modules, no package.json needed
|
||||
COPY --from=build --chown=node:node /app/.output ./.output
|
||||
# only read when CONTENT_PROVIDER=local; a couple of KB, keeps that path usable
|
||||
COPY --from=build --chown=node:node /app/content ./content
|
||||
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3000/healthz > /dev/null || exit 1
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
234
README.md
Normal file
234
README.md
Normal file
@ -0,0 +1,234 @@
|
||||
# nak0x.dev
|
||||
|
||||
A monospace, terminal-shaped portfolio in Nuxt 4, with a blog whose posts live
|
||||
in a separate git repo as plain markdown. Push a `.md`, a webhook fires, the
|
||||
post is live. No rebuild, no CMS, no database.
|
||||
|
||||
```
|
||||
browser ──► nuxt (nitro, SSR)
|
||||
│
|
||||
├─ / ──► site.json ─┐
|
||||
├─ /blog/* ──► posts/*.md ─┼──► gitea api ──► content repo
|
||||
└─ /dash ──► edits both ─┘ ▲
|
||||
│
|
||||
push ────────────────────────────┘
|
||||
└─ webhook ──► /api/webhook/content (busts cache)
|
||||
```
|
||||
|
||||
Two things live in the content repo: `posts/*.md` (the blog) and `site.json`
|
||||
(the portfolio — bio, skills, links, projects). `/dash` edits both and commits
|
||||
through the git API. Everything else is code.
|
||||
|
||||
## quick start
|
||||
|
||||
```sh
|
||||
npm install
|
||||
cp .env.example .env # then edit it
|
||||
npm run dev # http://localhost:3000
|
||||
```
|
||||
|
||||
Out of the box `.env.example` points at Gitea. To write offline against the
|
||||
`content/posts/` folder in this repo instead:
|
||||
|
||||
```sh
|
||||
CONTENT_PROVIDER=local npm run dev
|
||||
```
|
||||
|
||||
## where your content goes
|
||||
|
||||
Two repos, on purpose: the site is code, the blog is prose, and they have very
|
||||
different commit rhythms.
|
||||
|
||||
```
|
||||
Journal/ # the content repo (git.nak0x.dev/Nak0x/Journal)
|
||||
├── site.json # portfolio content — written by /dash
|
||||
└── posts/
|
||||
├── 2026-09-08-hello.md
|
||||
└── rust-on-old-thinkpads/
|
||||
├── index.md
|
||||
└── bench.png #  resolves against the post's dir
|
||||
```
|
||||
|
||||
`site.json` is optional. Without it the site uses the seed compiled into
|
||||
[`shared/site.ts`](./shared/site.ts); the first save from `/dash` creates the
|
||||
file. A malformed or partial `site.json` degrades field by field back to that
|
||||
seed rather than taking the site down.
|
||||
|
||||
Frontmatter — everything is optional except, in practice, `title`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: how this blog works
|
||||
description: one line for the list view and the og tags
|
||||
date: 2026-09-08 # or just name the file 2026-09-08-*.md
|
||||
updated: 2026-09-09
|
||||
tags: [meta, nuxt]
|
||||
slug: how-this-blog-works # defaults to the filename, date prefix stripped
|
||||
draft: true # hidden in production, visible in dev
|
||||
---
|
||||
```
|
||||
|
||||
`content/posts/2026-09-08-hello.md` in this repo is a working example — copy it
|
||||
into the content repo as your first post.
|
||||
|
||||
## wiring the webhook
|
||||
|
||||
**Gitea** — repo → Settings → Webhooks → *Add Webhook* → *Gitea*:
|
||||
|
||||
| field | value |
|
||||
| ------------- | ------------------------------------------ |
|
||||
| Target URL | `https://nak0x.dev/api/webhook/content` |
|
||||
| HTTP Method | `POST` |
|
||||
| Content Type | `application/json` |
|
||||
| Secret | same value as `CONTENT_WEBHOOK_SECRET` |
|
||||
| Trigger On | Push Events |
|
||||
| Branch filter | `main` |
|
||||
|
||||
**GitHub** — same URL and secret, content type `application/json`, "Just the
|
||||
push event". The handler accepts both `X-Gitea-Signature` and
|
||||
`X-Hub-Signature-256`.
|
||||
|
||||
Generate the secret with `openssl rand -hex 32`. Requests without a valid HMAC
|
||||
get a 401, so the endpoint is safe to leave public.
|
||||
|
||||
The webhook is an optimisation, not a dependency: `CONTENT_TTL` (default 600s)
|
||||
expires the cache anyway, and a stale copy is served while the refresh runs —
|
||||
so a Gitea outage degrades to "slightly old posts", never to a 502.
|
||||
|
||||
## deploying on Coolify
|
||||
|
||||
1. **New Resource → Application → Dockerfile** (or *Public/Private Repository*
|
||||
with Build Pack = `Dockerfile`). Coolify picks up the `Dockerfile` at the
|
||||
repo root.
|
||||
2. **Port**: `3000`. The image already sets `HOST=0.0.0.0`.
|
||||
3. **Health check path**: `/healthz` (the image also has a `HEALTHCHECK`).
|
||||
4. **Environment variables** — paste from `.env.example` and fill in:
|
||||
`CONTENT_PROVIDER`, `CONTENT_REPO`, `CONTENT_BRANCH`, `CONTENT_DIR`,
|
||||
`GITEA_URL`, `CONTENT_WEBHOOK_SECRET`, `NUXT_PUBLIC_SITE_URL`, plus
|
||||
`DASH_PASSWORD` and `CONTENT_TOKEN` if you want to edit from `/dash`.
|
||||
5. **Build variable** — `NUXT_PUBLIC_SITE_URL` is inlined into the client
|
||||
bundle, so it also has to exist at build time. In Coolify, tick *"Build
|
||||
Variable"* on it, or set the build arg `SITE_URL`.
|
||||
6. **Domain**: `https://nak0x.dev`. Coolify terminates TLS in front.
|
||||
|
||||
Redeploy on push to *this* repo is the usual Coolify webhook. Posts do not need
|
||||
a redeploy at all — that is the whole point.
|
||||
|
||||
Locally, `docker compose up --build` runs the same image.
|
||||
|
||||
## environment
|
||||
|
||||
| variable | default | what it does |
|
||||
| ------------------------ | ------------------------ | --------------------------------------------------- |
|
||||
| `NUXT_PUBLIC_SITE_URL` | `https://nak0x.dev` | canonical URL for rss, sitemap, og tags |
|
||||
| `CONTENT_PROVIDER` | `gitea` | `gitea` \| `github` \| `local` |
|
||||
| `CONTENT_REPO` | `Nak0x/Journal` | `owner/repo` of the content repo |
|
||||
| `CONTENT_BRANCH` | `main` | branch to read |
|
||||
| `CONTENT_DIR` | `posts` | sub-directory holding the markdown (`''` = root) |
|
||||
| `GITEA_URL` | `https://git.nak0x.dev` | Gitea base URL (ignored for github) |
|
||||
| `CONTENT_TOKEN` | — | PAT, only for private repos or GitHub rate limits |
|
||||
| `CONTENT_TTL` | `600` | seconds before a cached post goes stale |
|
||||
| `CONTENT_WEBHOOK_SECRET` | — | HMAC secret; without it the webhook returns 503 |
|
||||
| `CONTENT_LOCAL_DIR` | `content` | repo root used when `CONTENT_PROVIDER=local` |
|
||||
| `CONTENT_SITE_FILE` | `site.json` | portfolio data, relative to the repo root |
|
||||
| `PROJECTS_GITHUB_USER` | `nak0x` | github account to list repos from (`''` disables) |
|
||||
| `PROJECTS_GITEA_USER` | `Nak0x` | gitea account to list repos from (`''` disables) |
|
||||
| `PROJECTS_GITEA_URL` | `GITEA_URL` | gitea instance to read the repos from |
|
||||
| `PROJECTS_GITHUB_TOKEN` | — | PAT; lifts github's 60 req/h anonymous limit |
|
||||
| `PROJECTS_GITEA_TOKEN` | `CONTENT_TOKEN` | PAT for the gitea instance |
|
||||
| `PROJECTS_EXCLUDE` | — | repos to drop, comma-separated `name` or `owner/name` |
|
||||
| `PROJECTS_INCLUDE_FORKS` | `false` | `true` lists forks as projects too |
|
||||
| `PROJECTS_TTL` | `900` | seconds before the repo list is refetched |
|
||||
| `DASH_PASSWORD` | — | unset disables `/dash` entirely |
|
||||
| `DASH_SESSION_SECRET` | derived from password | HMAC key for the session cookie |
|
||||
| `DASH_SESSION_HOURS` | `168` | how long a login lasts |
|
||||
| `SHOW_DRAFTS` | `false` | `true` renders `draft: true` posts in production |
|
||||
|
||||
## /dash — editing without a terminal
|
||||
|
||||
`/dash` is a small single-user CMS for this site. It is disabled until
|
||||
`DASH_PASSWORD` is set, and it can only *write* once `CONTENT_TOKEN` holds a
|
||||
token with write access to the content repo (Gitea: scope `write:repository`).
|
||||
Without the token it still works, read-only.
|
||||
|
||||
```sh
|
||||
DASH_PASSWORD=$(openssl rand -hex 24) # login
|
||||
CONTENT_TOKEN=… # gitea/github PAT, write scope
|
||||
```
|
||||
|
||||
| page | what it does |
|
||||
| ------------------- | ------------------------------------------------------------------ |
|
||||
| `/dash` | repo status, post list, cache refresh, create/delete posts |
|
||||
| `/dash/posts/:slug` | markdown editor with live preview; `new` creates a post |
|
||||
| `/dash/site` | bio, skills, links and the whole project list |
|
||||
|
||||
Every save is one commit through the git API — no separate deploy, no rebuild,
|
||||
and the history is the same one you get from pushing by hand. The editor sends
|
||||
the sha it loaded with each save, so if you also edited the file from nvim in
|
||||
the meantime you get a 409 instead of a silent overwrite.
|
||||
|
||||
The preview pane posts the body to `/api/dash/preview` and renders the HTML the
|
||||
published page would show, Shiki highlighting included — the client never gets a
|
||||
markdown parser.
|
||||
|
||||
**Auth.** A password check sets an HttpOnly, SameSite=Lax, HMAC-signed cookie
|
||||
(`Secure` behind an https proxy), valid for `DASH_SESSION_HOURS`. The signing
|
||||
key defaults to a value derived from the password, so changing the password logs
|
||||
every session out. Failed logins are rate-limited per IP (8 per 10 minutes).
|
||||
`/dash` is `Disallow`ed in `robots.txt` and sends `noindex`.
|
||||
|
||||
Every `/api/dash/*` route checks the session server-side; the route middleware
|
||||
only spares you a page that would 401 on load.
|
||||
|
||||
## where the project list comes from
|
||||
|
||||
`/projects` is not a list anybody maintains: it is every **public repository**
|
||||
of `PROJECTS_GITHUB_USER` and `PROJECTS_GITEA_USER`, read from both forges on
|
||||
every cache miss and merged. Forks are skipped unless
|
||||
`PROJECTS_INCLUDE_FORKS=true`, and a repo on both forges is shown once (github
|
||||
wins). Each repo maps to a card on its own:
|
||||
|
||||
| card | comes from |
|
||||
| ------------ | ---------------------------------------------------------- |
|
||||
| tagline | the repo description |
|
||||
| stack | the primary language, then the repo topics |
|
||||
| year | the year the repo was created |
|
||||
| status | `archived` if archived, `active` if pushed in the last 120 days, else `shipped` |
|
||||
|
||||
The `projects` block in `site.json` is an **overlay** on top of that, not the
|
||||
list itself. An entry whose `href` (preferred) or `name` matches a repository
|
||||
replaces that repo's fields wherever it actually says something — a nicer name,
|
||||
hand-written bullets, `featured: true` for the home page, `hidden: true` to drop
|
||||
it from the site. An entry that matches no repository is still shown, so a
|
||||
project with no repo behind it does not disappear. All of it is editable from
|
||||
`/dash`.
|
||||
|
||||
Both forges are independent: if github rate-limits you, the gitea repos still
|
||||
render and `/projects` says one source is missing. If *both* fail, the overlay
|
||||
entries are all that is left — which is exactly the old hand-written list.
|
||||
|
||||
## editing the portfolio in code instead
|
||||
|
||||
[`shared/site.ts`](./shared/site.ts) holds the seed and the types. Edit it when
|
||||
you want a change tracked in *this* repo rather than the content one — but note
|
||||
that a `site.json` in the content repo overrides it.
|
||||
|
||||
## routes
|
||||
|
||||
| route | what |
|
||||
| ------------------------ | ------------------------------------------- |
|
||||
| `/` | hero, about, skills, featured work, latest posts |
|
||||
| `/projects` | every public repo, github + gitea |
|
||||
| `/blog`, `/blog?tag=x` | index, filterable by tag |
|
||||
| `/blog/:slug` | a post, with TOC and prev/next |
|
||||
| `/dash`, `/dash/*` | the editor (password-protected, noindex) |
|
||||
| `/rss.xml` | feed |
|
||||
| `/sitemap.xml` | sitemap |
|
||||
| `/healthz` | liveness probe |
|
||||
| `/api/webhook/content` | push webhook (POST, HMAC-signed) |
|
||||
|
||||
## stack
|
||||
|
||||
Nuxt 4 · Nitro (node-server) · markdown-it + Shiki · gray-matter · hand-written
|
||||
CSS. No UI framework, no client-side markdown, no database, ~zero JavaScript on
|
||||
the blog pages beyond hydration.
|
||||
22
app/app.vue
Normal file
22
app/app.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
const { public: pub } = useRuntimeConfig()
|
||||
const { site } = useSiteData()
|
||||
|
||||
useHead({
|
||||
titleTemplate: (chunk) => (chunk ? `${chunk} — ${site.value.domain}` : site.value.domain),
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
description: () => site.value.description,
|
||||
ogType: 'website',
|
||||
ogSiteName: () => site.value.domain,
|
||||
ogUrl: pub.siteUrl,
|
||||
twitterCard: 'summary',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
496
app/assets/css/main.css
Normal file
496
app/assets/css/main.css
Normal file
@ -0,0 +1,496 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
nak0x.dev — a terminal-shaped sheet of paper.
|
||||
Everything is monospace, everything sits on a 1px ink rule.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
:root,
|
||||
:root[data-theme='light'] {
|
||||
--paper: #f6f3ec;
|
||||
--paper-soft: #efece4;
|
||||
--ink: #0a0a0a;
|
||||
--ink-mute: #4a4a4a;
|
||||
--ink-faint: #8a8a8a;
|
||||
--rule: #0a0a0a;
|
||||
--accent: #D97629;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--paper: #0e0e10;
|
||||
--paper-soft: #16161a;
|
||||
--ink: #ece9e0;
|
||||
--ink-mute: #9a9a9a;
|
||||
--ink-faint: #6a6a6a;
|
||||
--rule: #ece9e0;
|
||||
--accent: #D97629;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) {
|
||||
--paper: #0e0e10;
|
||||
--paper-soft: #16161a;
|
||||
--ink: #ece9e0;
|
||||
--ink-mute: #9a9a9a;
|
||||
--ink-faint: #6a6a6a;
|
||||
--rule: #ece9e0;
|
||||
--accent: #e56fd8;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'IBM Plex Mono', Menlo, Monaco,
|
||||
'Liberation Mono', 'DejaVu Sans Mono', Consolas, monospace;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
h1 { font-size: 1.5rem; }
|
||||
h2 { font-size: 1.05rem; text-transform: lowercase; }
|
||||
h3 { font-size: 0.95rem; text-transform: lowercase; color: var(--ink-mute); }
|
||||
p { margin: 0; }
|
||||
|
||||
a {
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
a:hover {
|
||||
background: var(--accent);
|
||||
color: var(--paper);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--rule);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
pre { margin: 0; font-family: inherit; }
|
||||
|
||||
/* --- layout --------------------------------------------------------------- */
|
||||
|
||||
.paper {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.25rem 6rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stack > * + * { margin-top: 1.25rem; }
|
||||
.stack-sm > * + * { margin-top: 0.5rem; }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.column { display: grid; gap: 1.25rem; }
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* --- the frame: a 1px box with `+` corners, like a TUI widget -------------- */
|
||||
|
||||
.frame {
|
||||
position: relative;
|
||||
border: 1px solid var(--ink);
|
||||
background: var(--paper);
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
.frame::before,
|
||||
.frame::after {
|
||||
content: '+';
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-size: 0.95rem;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.frame::before { top: -6px; left: -6px; }
|
||||
.frame::after { bottom: -6px; right: -6px; }
|
||||
|
||||
.frame-tag {
|
||||
position: absolute;
|
||||
top: -0.6rem;
|
||||
left: 0.75rem;
|
||||
background: var(--paper);
|
||||
padding: 0 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink-mute);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.accent-on-hover:hover {
|
||||
/* the box is often an <a>, so undo the global link-hover fill */
|
||||
background: var(--paper);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.accent-on-hover:hover::before,
|
||||
.accent-on-hover:hover::after {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- text helpers --------------------------------------------------------- */
|
||||
|
||||
.muted { color: var(--ink-mute); }
|
||||
.faint { color: var(--ink-faint); }
|
||||
.small { font-size: 0.85rem; }
|
||||
.tiny { font-size: 0.75rem; letter-spacing: 0.04em; text-transform: uppercase; }
|
||||
.accent { color: var(--accent); }
|
||||
.lower { text-transform: lowercase; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
|
||||
.ascii-rule {
|
||||
color: var(--ink-faint);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* --- lists ---------------------------------------------------------------- */
|
||||
|
||||
.bullets {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--ink-mute);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.bullets li {
|
||||
padding-left: 1.25rem;
|
||||
position: relative;
|
||||
}
|
||||
.bullets li::before {
|
||||
content: '\203A';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.def-list {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: 1rem;
|
||||
row-gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.def-list dt {
|
||||
color: var(--ink-mute);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.def-list dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
.def-list dd::before {
|
||||
content: '\203A';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.def-list { grid-template-columns: 1fr; row-gap: 0.15rem; }
|
||||
.def-list dt { margin-top: 0.4rem; }
|
||||
.def-list dt:first-of-type { margin-top: 0; }
|
||||
}
|
||||
|
||||
/* --- rendered markdown ---------------------------------------------------- */
|
||||
|
||||
.prose { font-size: 0.95rem; }
|
||||
.prose > * + * { margin-top: 1rem; }
|
||||
.prose h2,
|
||||
.prose h3,
|
||||
.prose h4 {
|
||||
margin-top: 2rem;
|
||||
text-transform: none;
|
||||
color: var(--ink);
|
||||
scroll-margin-top: 2rem;
|
||||
}
|
||||
.prose h2 { font-size: 1.15rem; }
|
||||
.prose h2::before,
|
||||
.prose h3::before {
|
||||
content: '# ';
|
||||
color: var(--accent);
|
||||
}
|
||||
.prose h3 { font-size: 1rem; }
|
||||
.prose ul,
|
||||
.prose ol { padding-left: 1.5rem; margin-top: 0.75rem; }
|
||||
.prose li + li { margin-top: 0.25rem; }
|
||||
.prose li::marker { color: var(--accent); }
|
||||
.prose img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--ink);
|
||||
}
|
||||
.prose blockquote {
|
||||
margin: 0;
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: 1rem;
|
||||
color: var(--ink-mute);
|
||||
}
|
||||
.prose code {
|
||||
background: var(--paper-soft);
|
||||
border: 1px solid var(--paper-soft);
|
||||
padding: 0.05em 0.35em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.prose pre {
|
||||
border: 1px solid var(--ink);
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
background: var(--paper-soft) !important;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.prose pre code {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
.prose table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.prose th,
|
||||
.prose td {
|
||||
border: 1px solid var(--ink-faint);
|
||||
padding: 0.35rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
.prose .heading-anchor {
|
||||
text-decoration: none;
|
||||
color: var(--ink-faint);
|
||||
opacity: 0;
|
||||
padding-left: 0.4rem;
|
||||
}
|
||||
.prose :is(h2, h3, h4):hover .heading-anchor { opacity: 1; }
|
||||
|
||||
/* shiki emits both themes; swap them with the css variable trick */
|
||||
html[data-theme='dark'] .shiki,
|
||||
html[data-theme='dark'] .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: transparent !important;
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not([data-theme]) .shiki,
|
||||
html:not([data-theme]) .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- forms & buttons ------------------------------------------------------
|
||||
Used by /dash. Same rules as the rest of the page: 1px ink, no radius,
|
||||
monospace, inversion on interaction.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
.field {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.field > .label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: lowercase;
|
||||
color: var(--ink-mute);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea,
|
||||
.select {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink);
|
||||
background: var(--paper-soft);
|
||||
border: 1px solid var(--ink-faint);
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.textarea:focus,
|
||||
.select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.55;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.select {
|
||||
/* room for the caret we draw with the wrapper */
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
appearance: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 1px solid var(--ink-faint);
|
||||
background: var(--paper-soft);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checkbox input:checked {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.checkbox input:checked::after {
|
||||
content: '×';
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--ink);
|
||||
padding: 0.3rem 0.75rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #c0392b;
|
||||
border-color: #c0392b;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
border-color: transparent;
|
||||
padding-inline: 0.35rem;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
/* inline status line: ok / working / failed */
|
||||
.flash {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--ink-faint);
|
||||
}
|
||||
|
||||
.flash-ok {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.flash-error {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
79
app/components/AsciiBanner.vue
Normal file
79
app/components/AsciiBanner.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
const { site } = useSiteData()
|
||||
|
||||
const banner = String.raw`███╗ ██╗ █████╗ ██╗ ██╗ ██████╗ ██╗ ██╗
|
||||
████╗ ██║██╔══██╗██║ ██╔╝██╔═████╗╚██╗██╔╝
|
||||
██╔██╗ ██║███████║█████╔╝ ██║██╔██║ ╚███╔╝
|
||||
██║╚██╗██║██╔══██║██╔═██╗ ████╔╝██║ ██╔██╗
|
||||
██║ ╚████║██║ ██║██║ ██╗╚██████╔╝██╔╝ ██╗
|
||||
╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="hero">
|
||||
<div class="frame hero-frame">
|
||||
<div class="banner-wrap">
|
||||
<pre class="banner" aria-label="nak0x">{{ banner }}</pre>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="sub-row">
|
||||
<span class="domain">{{ site.domain }}</span>
|
||||
<span class="sep">//</span>
|
||||
<span class="muted">{{ site.role }}</span>
|
||||
<span class="sep">//</span>
|
||||
<span class="muted">{{ site.location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero {
|
||||
margin: 1.5rem 0 2rem;
|
||||
}
|
||||
|
||||
.hero-frame {
|
||||
padding: 1.25rem 1.5rem;
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.banner-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: block;
|
||||
font-size: clamp(5px, 1.85cqi, 15px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
color: var(--ink);
|
||||
white-space: pre;
|
||||
text-align: center;
|
||||
margin: 0.5rem auto;
|
||||
}
|
||||
|
||||
.sub-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.domain {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
</style>
|
||||
32
app/components/AsciiRule.vue
Normal file
32
app/components/AsciiRule.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{ char?: string; label?: string }>(), {
|
||||
char: '*',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rule-row" aria-hidden="true">
|
||||
<span class="ascii-rule fill">{{ props.char.repeat(200) }}</span>
|
||||
<span v-if="props.label" class="label tiny muted">{{ props.label }}</span>
|
||||
<span v-if="props.label" class="ascii-rule fill">{{ props.char.repeat(200) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rule-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
overflow: hidden;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.fill {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
66
app/components/DashHeader.vue
Normal file
66
app/components/DashHeader.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import type { DashSession } from '#shared/types/dash'
|
||||
import { dashLogout } from '~/composables/useDash'
|
||||
|
||||
const props = defineProps<{ session: DashSession }>()
|
||||
const emit = defineEmits<{ 'signed-out': [] }>()
|
||||
|
||||
async function logout() {
|
||||
await dashLogout()
|
||||
emit('signed-out')
|
||||
await navigateTo('/dash')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="dash-head">
|
||||
<div class="row title-row">
|
||||
<h1>/dash</h1>
|
||||
<span v-if="props.session.repo" class="muted small nowrap">
|
||||
{{ props.session.provider }} · {{ props.session.repo }}@{{ props.session.branch }}
|
||||
</span>
|
||||
<span
|
||||
v-if="props.session.authenticated && !props.session.writable"
|
||||
class="flash flash-error tiny"
|
||||
>
|
||||
read-only — no CONTENT_TOKEN
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav v-if="props.session.authenticated" class="row nav small">
|
||||
<NuxtLink to="/dash">posts</NuxtLink>
|
||||
<NuxtLink to="/dash/site">portfolio</NuxtLink>
|
||||
<a href="/" target="_blank" rel="noopener">view site ↗</a>
|
||||
<button class="btn btn-ghost" type="button" @click="logout">logout</button>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dash-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin: 2rem 0 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.nav {
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav a.router-link-exact-active {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
65
app/components/DashLinkList.vue
Normal file
65
app/components/DashLinkList.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProjectLink } from '#shared/site'
|
||||
|
||||
const model = defineModel<ProjectLink[]>({ required: true })
|
||||
|
||||
const props = withDefaults(defineProps<{ addLabel?: string }>(), { addLabel: 'add link' })
|
||||
|
||||
function update(index: number, key: keyof ProjectLink, value: string) {
|
||||
model.value = model.value.map((link, i) => (i === index ? { ...link, [key]: value } : link))
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
model.value = model.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
function add() {
|
||||
model.value = [...model.value, { label: '', href: '' }]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list">
|
||||
<div v-for="(link, index) in model" :key="index" class="pair">
|
||||
<input
|
||||
class="input label-input"
|
||||
type="text"
|
||||
placeholder="label"
|
||||
:value="link.label"
|
||||
@input="update(index, 'label', ($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="https://…"
|
||||
:value="link.href"
|
||||
@input="update(index, 'href', ($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
<button class="btn btn-ghost" type="button" title="remove" @click="remove(index)">×</button>
|
||||
</div>
|
||||
|
||||
<button class="btn" type="button" @click="add">+ {{ props.addLabel }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pair {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pair > .input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label-input {
|
||||
max-width: 9rem;
|
||||
}
|
||||
</style>
|
||||
84
app/components/DashLogin.vue
Normal file
84
app/components/DashLogin.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { dashLogin, errorText } from '~/composables/useDash'
|
||||
|
||||
const props = defineProps<{ enabled: boolean; handle: string }>()
|
||||
const emit = defineEmits<{ done: [] }>()
|
||||
|
||||
const password = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
if (!password.value || busy.value) return
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await dashLogin(password.value)
|
||||
password.value = ''
|
||||
emit('done')
|
||||
} catch (e) {
|
||||
error.value = errorText(e, 'could not sign in')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="frame login">
|
||||
<span class="frame-tag">auth</span>
|
||||
|
||||
<template v-if="props.enabled">
|
||||
<p class="small muted intro">
|
||||
<span class="accent">$</span> sudo -u {{ props.handle }} edit site
|
||||
</p>
|
||||
|
||||
<form class="form" @submit.prevent="submit">
|
||||
<label class="field">
|
||||
<span class="label">password</span>
|
||||
<input
|
||||
v-model="password"
|
||||
class="input"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:disabled="busy"
|
||||
>
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit" :disabled="busy || !password">
|
||||
{{ busy ? 'checking…' : 'unlock' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="flash flash-error err">{{ error }}</p>
|
||||
</template>
|
||||
|
||||
<p v-else class="small muted">
|
||||
the dashboard is off. set <code>DASH_PASSWORD</code> in the environment and restart.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login {
|
||||
max-width: 26rem;
|
||||
margin: 3rem auto;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.form .field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
88
app/components/DashStringList.vue
Normal file
88
app/components/DashStringList.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<string[]>({ required: true })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ placeholder?: string; multiline?: boolean; addLabel?: string }>(),
|
||||
{ placeholder: '', multiline: false, addLabel: 'add' },
|
||||
)
|
||||
|
||||
function update(index: number, value: string) {
|
||||
const next = [...model.value]
|
||||
next[index] = value
|
||||
model.value = next
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
model.value = model.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
function move(index: number, delta: number) {
|
||||
const target = index + delta
|
||||
if (target < 0 || target >= model.value.length) return
|
||||
const next = [...model.value]
|
||||
const [item] = next.splice(index, 1)
|
||||
next.splice(target, 0, item as string)
|
||||
model.value = next
|
||||
}
|
||||
|
||||
function add() {
|
||||
model.value = [...model.value, '']
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list">
|
||||
<div v-for="(item, index) in model" :key="index" class="row-item">
|
||||
<textarea
|
||||
v-if="props.multiline"
|
||||
class="textarea"
|
||||
rows="3"
|
||||
:value="item"
|
||||
:placeholder="props.placeholder"
|
||||
@input="update(index, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
class="input"
|
||||
type="text"
|
||||
:value="item"
|
||||
:placeholder="props.placeholder"
|
||||
@input="update(index, ($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
<div class="controls">
|
||||
<button class="btn btn-ghost" type="button" title="move up" @click="move(index, -1)">↑</button>
|
||||
<button class="btn btn-ghost" type="button" title="move down" @click="move(index, 1)">↓</button>
|
||||
<button class="btn btn-ghost" type="button" title="remove" @click="remove(index)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn" type="button" @click="add">+ {{ props.addLabel }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.row-item {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.row-item > :first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.controls .btn {
|
||||
padding: 0.3rem 0.4rem;
|
||||
}
|
||||
</style>
|
||||
49
app/components/FrameBox.vue
Normal file
49
app/components/FrameBox.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** the little label that sits on the top border */
|
||||
tag?: string
|
||||
/** highlight the border in the accent color on hover — use it when the box is a link */
|
||||
hover?: boolean
|
||||
/** internal route, external url, or nothing */
|
||||
to?: string
|
||||
}>(),
|
||||
{ hover: false },
|
||||
)
|
||||
|
||||
// resolveComponent has to run in setup, not lazily inside a computed
|
||||
const NuxtLinkComponent = resolveComponent('NuxtLink')
|
||||
|
||||
const isExternal = computed(() => !!props.to && /^(https?:|mailto:)/i.test(props.to))
|
||||
const component = computed(() => {
|
||||
if (!props.to) return 'div'
|
||||
return isExternal.value ? 'a' : NuxtLinkComponent
|
||||
})
|
||||
|
||||
const bindings = computed(() => {
|
||||
if (!props.to) return {}
|
||||
return isExternal.value
|
||||
? { href: props.to, target: '_blank', rel: 'noopener noreferrer' }
|
||||
: { to: props.to }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="component"
|
||||
v-bind="bindings"
|
||||
class="frame frame-box"
|
||||
:class="{ 'accent-on-hover': props.hover }"
|
||||
>
|
||||
<span v-if="props.tag" class="frame-tag">{{ props.tag }}</span>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.frame-box {
|
||||
display: block;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
88
app/components/PostRow.vue
Normal file
88
app/components/PostRow.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import type { PostMeta } from '#shared/types/blog'
|
||||
|
||||
const props = defineProps<{ post: PostMeta }>()
|
||||
|
||||
const stamp = computed(() =>
|
||||
new Intl.DateTimeFormat('en-CA', { dateStyle: 'short', timeZone: 'UTC' }).format(
|
||||
new Date(props.post.date),
|
||||
),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink :to="`/blog/${props.post.slug}`" class="post-row">
|
||||
<div class="line">
|
||||
<span class="stamp muted small">{{ stamp }}</span>
|
||||
<span class="title">{{ props.post.title }}</span>
|
||||
<span v-if="props.post.draft" class="badge accent tiny">draft</span>
|
||||
<span class="dots" aria-hidden="true" />
|
||||
<span class="time faint small nowrap">{{ props.post.readingTime }} min</span>
|
||||
</div>
|
||||
<p class="desc muted small">{{ props.post.description }}</p>
|
||||
<div v-if="props.post.tags.length" class="tags faint tiny">
|
||||
<span v-for="tag in props.post.tags" :key="tag">#{{ tag }}</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.post-row {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
padding: 0.85rem 0.5rem;
|
||||
border-bottom: 1px dashed var(--ink-faint);
|
||||
}
|
||||
|
||||
.post-row:hover {
|
||||
background: var(--paper-soft);
|
||||
}
|
||||
|
||||
.post-row:hover .title {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.stamp {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border: 1px solid var(--accent);
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
.dots {
|
||||
flex: 1;
|
||||
min-width: 1rem;
|
||||
border-bottom: 1px dotted var(--ink-faint);
|
||||
transform: translateY(-0.25em);
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.tags {
|
||||
margin-top: 0.35rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.dots { display: none; }
|
||||
.line { flex-wrap: wrap; }
|
||||
}
|
||||
</style>
|
||||
97
app/components/ProjectCard.vue
Normal file
97
app/components/ProjectCard.vue
Normal file
@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '#shared/site'
|
||||
import type { RepoProject } from '#shared/types/projects'
|
||||
|
||||
const props = defineProps<{ project: Project | RepoProject; compact?: boolean }>()
|
||||
|
||||
const statusGlyph: Record<NonNullable<Project['status']>, string> = {
|
||||
active: '●',
|
||||
wip: '◐',
|
||||
shipped: '✓',
|
||||
archived: '□',
|
||||
}
|
||||
|
||||
const sourceGlyph: Record<RepoProject['source'], string> = {
|
||||
github: 'gh',
|
||||
gitea: 'gitea',
|
||||
manual: '',
|
||||
}
|
||||
|
||||
/** repo metadata is absent on hand-written entries */
|
||||
const repo = computed(() => ('source' in props.project ? props.project : null))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FrameBox :to="props.project.href" hover :tag="props.project.year" class="card">
|
||||
<div class="head">
|
||||
<h2>{{ props.project.name }}</h2>
|
||||
<span class="arrow muted">↗</span>
|
||||
</div>
|
||||
|
||||
<p class="muted small tagline">{{ props.project.tagline }}</p>
|
||||
|
||||
<ul v-if="!props.compact && props.project.bullets?.length" class="bullets">
|
||||
<li v-for="bullet in props.project.bullets" :key="bullet">{{ bullet }}</li>
|
||||
</ul>
|
||||
|
||||
<div class="foot row small">
|
||||
<span class="stack-line muted">{{ props.project.stack.join(' · ') }}</span>
|
||||
<span class="meta faint">
|
||||
<span v-if="repo && sourceGlyph[repo.source]" class="origin">
|
||||
{{ sourceGlyph[repo.source] }}
|
||||
</span>
|
||||
<span v-if="repo && repo.stars" class="stars">★ {{ repo.stars }}</span>
|
||||
<span v-if="props.project.status" class="status">
|
||||
<span class="accent">{{ statusGlyph[props.project.status] }}</span>
|
||||
{{ props.project.status }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</FrameBox>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.05rem;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.foot {
|
||||
margin-top: auto;
|
||||
padding-top: 1rem;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stack-line {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
38
app/components/SiteFooter.vue
Normal file
38
app/components/SiteFooter.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const { site } = useSiteData()
|
||||
|
||||
const year = new Date().getFullYear()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="foot">
|
||||
<hr>
|
||||
<div class="row foot-row small">
|
||||
<span class="muted">© {{ year }} {{ site.name }} — no trackers, no cookies.</span>
|
||||
<span class="links">
|
||||
<a v-for="link in site.links" :key="link.href" :href="link.href">{{ link.label }}</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.foot {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.foot-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: inline-flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 2rem 0 1rem;
|
||||
border-top: 1px solid var(--ink);
|
||||
}
|
||||
</style>
|
||||
138
app/components/StatusBar.vue
Normal file
138
app/components/StatusBar.vue
Normal file
@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
const { site } = useSiteData()
|
||||
const route = useRoute()
|
||||
const { theme, mounted, toggle } = useTheme()
|
||||
|
||||
const path = computed(() => {
|
||||
const p = route.path.replace(/\/+$/, '')
|
||||
return p === '' ? '~' : `~${p}`
|
||||
})
|
||||
|
||||
const cmd = computed(() => {
|
||||
const p = route.path
|
||||
if (p === '/') return 'cat about.md'
|
||||
if (p === '/projects') return 'ls -la ./projects'
|
||||
if (p === '/blog') return 'ls ./posts'
|
||||
if (p.startsWith('/blog/')) return `bat posts/${p.slice(6)}.md`
|
||||
return `find . -path "${p}"`
|
||||
})
|
||||
|
||||
const clock = ref('--:--:--')
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
function tick() {
|
||||
clock.value = new Intl.DateTimeFormat('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: site.value.timezone,
|
||||
}).format(new Date())
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tick()
|
||||
timer = setInterval(tick, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="status">
|
||||
<div class="status-left">
|
||||
<span class="prompt">{{ site.handle }}@dev</span>
|
||||
<span class="sep">:</span>
|
||||
<span class="path">{{ path }}</span>
|
||||
<span class="dollar">$</span>
|
||||
<span class="cmd"><slot name="cmd">{{ cmd }}</slot></span>
|
||||
<span class="cursor">_</span>
|
||||
</div>
|
||||
|
||||
<div class="status-right small">
|
||||
<NuxtLink v-for="item in site.nav" :key="item.href" :to="item.href">
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
<span class="sep">|</span>
|
||||
<button class="theme-toggle" type="button" :aria-label="`switch to ${theme === 'dark' ? 'light' : 'dark'} theme`" @click="toggle">
|
||||
<span class="theme-icon">{{ mounted && theme === 'dark' ? '◐' : '◑' }}</span>
|
||||
<span>{{ mounted ? theme : 'theme' }}</span>
|
||||
</button>
|
||||
<span class="sep">|</span>
|
||||
<span class="muted nowrap">{{ clock }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--ink);
|
||||
border-bottom: 1px solid var(--ink);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
background: var(--paper);
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-left,
|
||||
.status-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-right {
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.prompt { color: var(--accent); }
|
||||
.sep { color: var(--ink-mute); }
|
||||
.path { color: var(--ink); }
|
||||
.dollar { color: var(--ink-mute); }
|
||||
.cmd { color: var(--ink); }
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
color: var(--ink);
|
||||
animation: blink 1s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cursor { animation: none; }
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: var(--accent);
|
||||
color: var(--paper);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.theme-icon { color: var(--accent); }
|
||||
.theme-toggle:hover .theme-icon { color: var(--paper); }
|
||||
</style>
|
||||
27
app/composables/useDash.ts
Normal file
27
app/composables/useDash.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { DashSession } from '#shared/types/dash'
|
||||
|
||||
export const EMPTY_SESSION: DashSession = {
|
||||
enabled: true,
|
||||
authenticated: false,
|
||||
writable: false,
|
||||
}
|
||||
|
||||
export async function dashLogin(password: string): Promise<void> {
|
||||
await $fetch('/api/dash/login', { method: 'POST', body: { password } })
|
||||
}
|
||||
|
||||
export async function dashLogout(): Promise<void> {
|
||||
await $fetch('/api/dash/logout', { method: 'POST' })
|
||||
}
|
||||
|
||||
/** turn an h3 error into something worth showing a human */
|
||||
export function errorText(error: unknown, fallback = 'something went wrong'): string {
|
||||
const e = error as {
|
||||
data?: { statusMessage?: string; message?: string }
|
||||
statusMessage?: string
|
||||
message?: string
|
||||
}
|
||||
return (
|
||||
e?.data?.message || e?.data?.statusMessage || e?.message || e?.statusMessage || fallback
|
||||
)
|
||||
}
|
||||
31
app/composables/useProjects.ts
Normal file
31
app/composables/useProjects.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { featuredOf } from '#shared/site'
|
||||
import type { ProjectsPayload, RepoProject } from '#shared/types/projects'
|
||||
|
||||
const EMPTY: ProjectsPayload = { projects: [], sources: [] }
|
||||
|
||||
/** how many projects the home page shows when nothing is flagged featured */
|
||||
const FEATURED_FALLBACK = 4
|
||||
|
||||
/**
|
||||
* Every public repo of the configured github + gitea accounts, enriched by
|
||||
* `site.json`. Keyed, so the home page and /projects share one fetch.
|
||||
*/
|
||||
export function useProjects() {
|
||||
const { data, refresh } = useAsyncData<ProjectsPayload>(
|
||||
'projects',
|
||||
() => $fetch('/api/projects'),
|
||||
{ default: () => EMPTY },
|
||||
)
|
||||
|
||||
const projects = computed<RepoProject[]>(() => data.value?.projects ?? [])
|
||||
|
||||
// nothing flagged in site.json => the top of the sort is the selection
|
||||
const featured = computed<RepoProject[]>(() => {
|
||||
const flagged = featuredOf(projects.value) as RepoProject[]
|
||||
return flagged.length ? flagged : projects.value.slice(0, FEATURED_FALLBACK)
|
||||
})
|
||||
|
||||
const failed = computed(() => (data.value?.sources ?? []).filter((s) => !s.ok))
|
||||
|
||||
return { projects, featured, sources: computed(() => data.value?.sources ?? []), failed, refresh }
|
||||
}
|
||||
16
app/composables/useSiteData.ts
Normal file
16
app/composables/useSiteData.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { defaultSiteData, type SiteData } from '#shared/site'
|
||||
|
||||
/**
|
||||
* Portfolio content lives in the content repo (`site.json`) so /dash can edit
|
||||
* it. The seed compiled into the bundle is the fallback, which means the page
|
||||
* still renders if the repo is unreachable.
|
||||
*
|
||||
* Keyed, so the layout and the page share one fetch.
|
||||
*/
|
||||
export function useSiteData() {
|
||||
const { data, refresh } = useAsyncData<SiteData>('site', () => $fetch('/api/site'), {
|
||||
default: () => defaultSiteData,
|
||||
})
|
||||
|
||||
return { site: data, refresh }
|
||||
}
|
||||
30
app/composables/useTheme.ts
Normal file
30
app/composables/useTheme.ts
Normal file
@ -0,0 +1,30 @@
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
/**
|
||||
* The theme is applied by an inline script in `nuxt.config.ts` before first
|
||||
* paint, so there is no flash. This composable only mirrors and flips it.
|
||||
*/
|
||||
export function useTheme() {
|
||||
const theme = useState<Theme>('theme', () => 'light')
|
||||
const mounted = useState<boolean>('theme:mounted', () => false)
|
||||
|
||||
onMounted(() => {
|
||||
const current = document.documentElement.getAttribute('data-theme')
|
||||
theme.value = current === 'dark' ? 'dark' : 'light'
|
||||
mounted.value = true
|
||||
})
|
||||
|
||||
function set(next: Theme) {
|
||||
theme.value = next
|
||||
document.documentElement.setAttribute('data-theme', next)
|
||||
try {
|
||||
localStorage.setItem('theme', next)
|
||||
} catch {
|
||||
// private mode, blocked storage — the toggle still works for this session
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = () => set(theme.value === 'dark' ? 'light' : 'dark')
|
||||
|
||||
return { theme, mounted, set, toggle }
|
||||
}
|
||||
45
app/error.vue
Normal file
45
app/error.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { NuxtError } from '#app'
|
||||
|
||||
const props = defineProps<{ error: NuxtError }>()
|
||||
|
||||
const code = computed(() => props.error?.statusCode ?? 500)
|
||||
const message = computed(
|
||||
() => props.error?.message || props.error?.statusMessage || 'something went sideways',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="paper">
|
||||
<StatusBar />
|
||||
|
||||
<div class="frame err">
|
||||
<span class="frame-tag">error</span>
|
||||
<pre class="code accent">{{ code }}</pre>
|
||||
<p class="muted">{{ message }}</p>
|
||||
<p class="small muted back">
|
||||
<NuxtLink to="/">cd ~</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.err {
|
||||
margin-top: 3rem;
|
||||
text-align: center;
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-size: clamp(3rem, 12vw, 6rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.back {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
9
app/layouts/default.vue
Normal file
9
app/layouts/default.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="paper">
|
||||
<StatusBar />
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</template>
|
||||
14
app/middleware/dash.ts
Normal file
14
app/middleware/dash.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { DashSession } from '#shared/types/dash'
|
||||
|
||||
/**
|
||||
* Guards the editor pages. The real enforcement is server-side on every
|
||||
* /api/dash route — this only spares you a page that would 401 on load.
|
||||
*/
|
||||
export default defineNuxtRouteMiddleware(async () => {
|
||||
const requestFetch = useRequestFetch()
|
||||
const session = await requestFetch<DashSession>('/api/dash/session').catch(() => null)
|
||||
|
||||
if (!session?.authenticated) {
|
||||
return navigateTo('/dash')
|
||||
}
|
||||
})
|
||||
195
app/pages/blog/[slug].vue
Normal file
195
app/pages/blog/[slug].vue
Normal file
@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import type { Post, PostMeta } from '#shared/types/blog'
|
||||
|
||||
const route = useRoute()
|
||||
const slug = computed(() => String(route.params.slug))
|
||||
|
||||
const { data, error } = await useFetch<{
|
||||
post: Post
|
||||
previous: PostMeta | null
|
||||
next: PostMeta | null
|
||||
}>(() => `/api/blog/${slug.value}`, { key: () => `post-${slug.value}` })
|
||||
|
||||
if (error.value) {
|
||||
throw createError({
|
||||
statusCode: error.value.statusCode ?? 500,
|
||||
message: error.value.statusCode === 404 ? 'no such post' : 'could not load this post',
|
||||
fatal: true,
|
||||
})
|
||||
}
|
||||
|
||||
const post = computed(() => data.value!.post)
|
||||
|
||||
const published = computed(() =>
|
||||
new Intl.DateTimeFormat('en-GB', { dateStyle: 'long', timeZone: 'UTC' }).format(
|
||||
new Date(post.value.date),
|
||||
),
|
||||
)
|
||||
|
||||
const updated = computed(() =>
|
||||
post.value.updated
|
||||
? new Intl.DateTimeFormat('en-GB', { dateStyle: 'long', timeZone: 'UTC' }).format(
|
||||
new Date(post.value.updated),
|
||||
)
|
||||
: null,
|
||||
)
|
||||
|
||||
useSeoMeta({
|
||||
title: () => post.value.title,
|
||||
description: () => post.value.description,
|
||||
ogTitle: () => post.value.title,
|
||||
ogDescription: () => post.value.description,
|
||||
ogType: 'article',
|
||||
articlePublishedTime: () => post.value.date,
|
||||
articleTag: () => [...post.value.tags],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article v-if="post">
|
||||
<header class="head">
|
||||
<p class="small muted crumbs">
|
||||
<NuxtLink to="/blog">../blog</NuxtLink> / {{ post.slug }}.md
|
||||
</p>
|
||||
<h1>{{ post.title }}</h1>
|
||||
<p class="muted small lede">{{ post.description }}</p>
|
||||
|
||||
<div class="row meta small faint">
|
||||
<span>{{ published }}</span>
|
||||
<span v-if="updated">· updated {{ updated }}</span>
|
||||
<span>· {{ post.readingTime }} min</span>
|
||||
<span v-if="post.tags.length">
|
||||
·
|
||||
<NuxtLink
|
||||
v-for="tag in post.tags"
|
||||
:key="tag"
|
||||
:to="{ path: '/blog', query: { tag } }"
|
||||
class="tag"
|
||||
>#{{ tag }}</NuxtLink>
|
||||
</span>
|
||||
<span v-if="post.draft" class="accent">· draft</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AsciiRule />
|
||||
|
||||
<div class="body">
|
||||
<aside v-if="post.toc.length > 2" class="toc">
|
||||
<div class="frame toc-frame">
|
||||
<span class="frame-tag">contents</span>
|
||||
<ul>
|
||||
<li v-for="entry in post.toc" :key="entry.id" :class="`d${entry.depth}`">
|
||||
<a :href="`#${entry.id}`">{{ entry.text }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- rendered server-side from the repo's markdown -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="prose" v-html="post.html" />
|
||||
</div>
|
||||
|
||||
<AsciiRule />
|
||||
|
||||
<nav class="neighbours grid-2">
|
||||
<FrameBox v-if="data?.previous" :to="`/blog/${data.previous.slug}`" hover tag="older">
|
||||
<span class="small">← {{ data.previous.title }}</span>
|
||||
</FrameBox>
|
||||
<span v-else />
|
||||
<FrameBox v-if="data?.next" :to="`/blog/${data.next.slug}`" hover tag="newer" class="right">
|
||||
<span class="small">{{ data.next.title }} →</span>
|
||||
</FrameBox>
|
||||
</nav>
|
||||
|
||||
<p v-if="post.sourceUrl" class="small faint source">
|
||||
<a :href="post.sourceUrl" target="_blank" rel="noopener noreferrer">→ view the raw markdown</a>
|
||||
</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
margin: 2rem 0 0;
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 0.75rem;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 940px) {
|
||||
.body {
|
||||
grid-template-columns: minmax(0, 1fr) 15rem;
|
||||
}
|
||||
|
||||
.toc {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.toc-frame {
|
||||
position: sticky;
|
||||
top: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.toc ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.toc li + li {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.toc .d3 {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.toc a {
|
||||
text-decoration: none;
|
||||
color: var(--ink-mute);
|
||||
}
|
||||
|
||||
.toc a:hover {
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.neighbours {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.source {
|
||||
margin-top: 1.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
108
app/pages/blog/index.vue
Normal file
108
app/pages/blog/index.vue
Normal file
@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import type { PostMeta } from '#shared/types/blog'
|
||||
|
||||
const route = useRoute()
|
||||
const activeTag = computed(() => (route.query.tag as string | undefined) ?? '')
|
||||
|
||||
const { data, error, status } = await useFetch<{
|
||||
posts: PostMeta[]
|
||||
tags: Array<{ tag: string; count: number }>
|
||||
}>('/api/blog', {
|
||||
key: 'blog-index',
|
||||
query: { tag: activeTag },
|
||||
default: () => ({ posts: [], tags: [] }),
|
||||
})
|
||||
|
||||
const posts = computed(() => data.value?.posts ?? [])
|
||||
const tags = computed(() => data.value?.tags ?? [])
|
||||
|
||||
useSeoMeta({
|
||||
title: 'blog',
|
||||
description: 'notes on rust, self-hosting, and making slow hardware feel fast.',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header class="head">
|
||||
<h1>blog</h1>
|
||||
<p class="muted small">
|
||||
written in markdown, pushed to a git repo, rendered here. no CMS was harmed.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<AsciiRule />
|
||||
|
||||
<nav v-if="tags.length" class="tags small">
|
||||
<NuxtLink to="/blog" :class="{ active: !activeTag }">all</NuxtLink>
|
||||
<NuxtLink
|
||||
v-for="t in tags"
|
||||
:key="t.tag"
|
||||
:to="{ path: '/blog', query: { tag: t.tag } }"
|
||||
:class="{ active: activeTag === t.tag }"
|
||||
>
|
||||
#{{ t.tag }} <span class="faint">{{ t.count }}</span>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<FrameBox :tag="activeTag ? `posts · #${activeTag}` : 'posts'" class="list">
|
||||
<p v-if="error" class="muted small state">
|
||||
the content repo did not answer ({{ error.statusCode ?? '???' }}). it will retry on its own.
|
||||
</p>
|
||||
<p v-else-if="status === 'pending' && !posts.length" class="muted small state">
|
||||
reading the repo…
|
||||
</p>
|
||||
<p v-else-if="!posts.length" class="muted small state">
|
||||
nothing here yet. push a <code>.md</code> to the content repo and it shows up.
|
||||
</p>
|
||||
<PostRow v-for="post in posts" :key="post.slug" :post="post" />
|
||||
</FrameBox>
|
||||
|
||||
<p class="small muted feed">
|
||||
<a href="/rss.xml">→ rss</a>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
margin: 2rem 0 0;
|
||||
}
|
||||
|
||||
.head p {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.tags a {
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--ink-faint);
|
||||
padding: 0.1rem 0.5rem;
|
||||
}
|
||||
|
||||
.tags a:hover,
|
||||
.tags a.active {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
.list :deep(.post-row:last-child) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.feed {
|
||||
margin-top: 1.25rem;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
283
app/pages/dash/index.vue
Normal file
283
app/pages/dash/index.vue
Normal file
@ -0,0 +1,283 @@
|
||||
<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>
|
||||
458
app/pages/dash/posts/[slug].vue
Normal file
458
app/pages/dash/posts/[slug].vue
Normal file
@ -0,0 +1,458 @@
|
||||
<script setup lang="ts">
|
||||
import type { DashSession, PostDraft, PostFrontmatterInput, SaveResult } from '#shared/types/dash'
|
||||
import type { TocEntry } from '#shared/types/blog'
|
||||
import { EMPTY_SESSION, errorText } from '~/composables/useDash'
|
||||
|
||||
definePageMeta({ middleware: 'dash' })
|
||||
|
||||
const route = useRoute()
|
||||
const requestFetch = useRequestFetch()
|
||||
|
||||
const slug = computed(() => String(route.params.slug))
|
||||
const isNew = computed(() => slug.value === 'new')
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
function blankDraft(): PostDraft {
|
||||
return {
|
||||
path: '',
|
||||
sha: '',
|
||||
sourceUrl: '',
|
||||
frontmatter: {
|
||||
title: '',
|
||||
description: '',
|
||||
date: today,
|
||||
updated: '',
|
||||
tags: [],
|
||||
slug: '',
|
||||
draft: true,
|
||||
},
|
||||
body: '',
|
||||
}
|
||||
}
|
||||
|
||||
const { data } = await useAsyncData(
|
||||
() => `dash:post:${slug.value}`,
|
||||
async () => {
|
||||
const session = await requestFetch<DashSession>('/api/dash/session')
|
||||
if (isNew.value) return { session, draft: blankDraft() }
|
||||
const draft = await requestFetch<PostDraft>(`/api/dash/posts/${slug.value}`)
|
||||
return { session, draft }
|
||||
},
|
||||
{
|
||||
watch: [slug],
|
||||
default: () => ({ session: { ...EMPTY_SESSION }, draft: blankDraft() }),
|
||||
},
|
||||
)
|
||||
|
||||
const session = computed<DashSession>(() => data.value.session)
|
||||
|
||||
// --- editable state -------------------------------------------------------
|
||||
|
||||
const form = reactive<PostFrontmatterInput>({ ...blankDraft().frontmatter })
|
||||
const body = ref('')
|
||||
const sha = ref('')
|
||||
const path = ref('')
|
||||
const sourceUrl = ref('')
|
||||
const message = ref('')
|
||||
|
||||
const tagsText = computed({
|
||||
get: () => form.tags.join(', '),
|
||||
set: (value: string) => {
|
||||
form.tags = value.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
||||
},
|
||||
})
|
||||
|
||||
/** a stable snapshot, so "dirty" means the file would actually change */
|
||||
const snapshot = ref('')
|
||||
const fingerprint = () => JSON.stringify({ ...form, body: body.value })
|
||||
const dirty = computed(() => fingerprint() !== snapshot.value)
|
||||
|
||||
function hydrate() {
|
||||
const draft = data.value.draft
|
||||
Object.assign(form, draft.frontmatter)
|
||||
body.value = draft.body
|
||||
sha.value = draft.sha
|
||||
path.value = draft.path
|
||||
sourceUrl.value = draft.sourceUrl
|
||||
snapshot.value = fingerprint()
|
||||
}
|
||||
|
||||
watch(data, hydrate, { immediate: true })
|
||||
|
||||
// --- preview --------------------------------------------------------------
|
||||
|
||||
type View = 'split' | 'edit' | 'preview'
|
||||
const view = ref<View>('split')
|
||||
const preview = ref<{ html: string; toc: TocEntry[]; readingTime: number } | null>(null)
|
||||
const previewing = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function renderPreview() {
|
||||
previewing.value = true
|
||||
try {
|
||||
preview.value = await $fetch('/api/dash/preview', {
|
||||
method: 'POST',
|
||||
body: { body: body.value, path: path.value },
|
||||
})
|
||||
} catch {
|
||||
// a failed preview is not worth interrupting the writing for
|
||||
} finally {
|
||||
previewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePreview() {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(renderPreview, 450)
|
||||
}
|
||||
|
||||
watch(body, () => {
|
||||
if (view.value !== 'edit') schedulePreview()
|
||||
})
|
||||
|
||||
watch(view, (v) => {
|
||||
if (v !== 'edit' && !preview.value) renderPreview()
|
||||
})
|
||||
|
||||
onMounted(renderPreview)
|
||||
onBeforeUnmount(() => clearTimeout(timer))
|
||||
|
||||
// --- saving ---------------------------------------------------------------
|
||||
|
||||
const busy = ref(false)
|
||||
const flash = ref<{ text: string; kind: 'ok' | 'error' } | null>(null)
|
||||
const pendingDelete = ref(false)
|
||||
|
||||
const canSave = computed(() => session.value.writable && !busy.value && !!form.title.trim())
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value) return
|
||||
busy.value = true
|
||||
flash.value = null
|
||||
|
||||
const payload = {
|
||||
frontmatter: { ...form },
|
||||
body: body.value,
|
||||
message: message.value,
|
||||
sha: sha.value || undefined,
|
||||
}
|
||||
|
||||
try {
|
||||
const result = isNew.value
|
||||
? await $fetch<SaveResult>('/api/dash/posts', { method: 'POST', body: payload })
|
||||
: await $fetch<SaveResult>(`/api/dash/posts/${slug.value}`, { method: 'PUT', body: payload })
|
||||
|
||||
sha.value = result.sha
|
||||
path.value = result.path
|
||||
message.value = ''
|
||||
snapshot.value = fingerprint()
|
||||
flash.value = {
|
||||
text: `committed ${result.path}${result.commit ? ` (${result.commit.slice(0, 7)})` : ''}`,
|
||||
kind: 'ok',
|
||||
}
|
||||
|
||||
if (result.slug !== slug.value) await navigateTo(`/dash/posts/${result.slug}`)
|
||||
} catch (e) {
|
||||
flash.value = { text: errorText(e, 'could not save'), kind: 'error' }
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function destroy() {
|
||||
busy.value = true
|
||||
flash.value = null
|
||||
try {
|
||||
await $fetch(`/api/dash/posts/${slug.value}`, {
|
||||
method: 'DELETE',
|
||||
body: { message: message.value },
|
||||
})
|
||||
snapshot.value = fingerprint() // stop the leave guard from firing
|
||||
await navigateTo('/dash')
|
||||
} catch (e) {
|
||||
flash.value = { text: errorText(e, 'could not delete'), kind: 'error' }
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
if (!dirty.value) return true
|
||||
return confirm('you have unsaved changes. leave anyway?')
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: () => (isNew.value ? 'new post' : `edit ${slug.value}`),
|
||||
robots: 'noindex, nofollow',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<DashHeader :session="session" />
|
||||
|
||||
<div class="bar">
|
||||
<div class="row crumbs small muted">
|
||||
<NuxtLink to="/dash">../dash</NuxtLink>
|
||||
<span>/</span>
|
||||
<span>{{ isNew ? 'new post' : path || slug }}</span>
|
||||
<span v-if="dirty" class="accent tiny">● unsaved</span>
|
||||
</div>
|
||||
|
||||
<div class="row tools">
|
||||
<div class="views">
|
||||
<button
|
||||
v-for="option in (['edit', 'split', 'preview'] as const)"
|
||||
:key="option"
|
||||
class="btn btn-ghost"
|
||||
:class="{ on: view === option }"
|
||||
type="button"
|
||||
@click="view = option"
|
||||
>{{ option }}</button>
|
||||
</div>
|
||||
<a v-if="sourceUrl && !isNew" class="btn btn-ghost" :href="sourceUrl" target="_blank" rel="noopener">raw ↗</a>
|
||||
<NuxtLink v-if="!isNew" class="btn btn-ghost" :to="`/blog/${form.slug || slug}`" target="_blank">view ↗</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FrameBox tag="frontmatter" class="meta">
|
||||
<div class="meta-grid">
|
||||
<label class="field span-2">
|
||||
<span class="label">title</span>
|
||||
<input v-model="form.title" class="input" type="text" placeholder="how this blog works">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">slug (url)</span>
|
||||
<input v-model="form.slug" class="input" type="text" :placeholder="'auto from title'">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">date</span>
|
||||
<input v-model="form.date" class="input" type="date">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">updated (optional)</span>
|
||||
<input v-model="form.updated" class="input" type="date">
|
||||
</label>
|
||||
|
||||
<label class="field span-2">
|
||||
<span class="label">description</span>
|
||||
<input v-model="form.description" class="input" type="text" placeholder="leave empty to use the first paragraph">
|
||||
</label>
|
||||
|
||||
<label class="field span-2">
|
||||
<span class="label">tags (comma separated)</span>
|
||||
<input v-model="tagsText" class="input" type="text" placeholder="rust, self-hosting">
|
||||
</label>
|
||||
|
||||
<label class="checkbox draft-toggle">
|
||||
<input v-model="form.draft" type="checkbox">
|
||||
<span>draft — hidden in production</span>
|
||||
</label>
|
||||
</div>
|
||||
</FrameBox>
|
||||
|
||||
<div class="panes" :class="`view-${view}`">
|
||||
<FrameBox v-if="view !== 'preview'" tag="markdown" class="pane">
|
||||
<textarea
|
||||
v-model="body"
|
||||
class="textarea editor"
|
||||
spellcheck="false"
|
||||
placeholder="# heading write here."
|
||||
/>
|
||||
</FrameBox>
|
||||
|
||||
<FrameBox v-if="view !== 'edit'" tag="preview" class="pane">
|
||||
<div class="pane-head small faint">
|
||||
<span>{{ preview?.readingTime ?? 1 }} min read</span>
|
||||
<span v-if="previewing" class="accent">rendering…</span>
|
||||
</div>
|
||||
<!-- server-rendered by the same pipeline the published page uses -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="prose preview-body" v-html="preview?.html ?? ''" />
|
||||
</FrameBox>
|
||||
</div>
|
||||
|
||||
<FrameBox tag="commit" class="commit">
|
||||
<div class="commit-row">
|
||||
<label class="field msg">
|
||||
<span class="label">commit message</span>
|
||||
<input
|
||||
v-model="message"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="isNew ? `post: add ${form.slug || 'new-post'}` : `post: update ${form.slug || slug}`"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="row commit-actions">
|
||||
<button class="btn btn-primary" type="button" :disabled="!canSave" @click="save">
|
||||
{{ busy ? 'committing…' : isNew ? 'create' : 'save' }}
|
||||
</button>
|
||||
|
||||
<template v-if="!isNew">
|
||||
<template v-if="pendingDelete">
|
||||
<span class="small muted">delete this post?</span>
|
||||
<button class="btn btn-danger" type="button" :disabled="busy" @click="destroy">yes, delete</button>
|
||||
<button class="btn btn-ghost" type="button" @click="pendingDelete = false">cancel</button>
|
||||
</template>
|
||||
<button
|
||||
v-else
|
||||
class="btn"
|
||||
type="button"
|
||||
:disabled="!session.writable || busy"
|
||||
@click="pendingDelete = true"
|
||||
>delete</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!session.writable" class="flash flash-error note">
|
||||
no write token configured — set <code>CONTENT_TOKEN</code> to commit from here.
|
||||
</p>
|
||||
<p v-else-if="flash" class="flash note" :class="`flash-${flash.kind}`">{{ flash.text }}</p>
|
||||
</FrameBox>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.tools {
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.views {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--ink-faint);
|
||||
}
|
||||
|
||||
.views .btn {
|
||||
border: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.views .btn.on {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.draft-toggle {
|
||||
grid-column: span 3;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.span-2,
|
||||
.draft-toggle {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
.panes {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.panes.view-split {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.panes.view-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.pane {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-height: 26rem;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.editor:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pane-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-bottom: 1px dashed var(--ink-faint);
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
min-height: 24rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.commit {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.commit-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.msg {
|
||||
flex: 1;
|
||||
min-width: 16rem;
|
||||
}
|
||||
|
||||
.commit-actions {
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
452
app/pages/dash/site.vue
Normal file
452
app/pages/dash/site.vue
Normal file
@ -0,0 +1,452 @@
|
||||
<script setup lang="ts">
|
||||
import { PROJECT_STATUSES, defaultSiteData, type Project, type SiteData } from '#shared/site'
|
||||
import type { DashSession } from '#shared/types/dash'
|
||||
import { EMPTY_SESSION, errorText } from '~/composables/useDash'
|
||||
|
||||
definePageMeta({ middleware: 'dash' })
|
||||
|
||||
useSeoMeta({ title: 'dash · portfolio', robots: 'noindex, nofollow' })
|
||||
|
||||
const requestFetch = useRequestFetch()
|
||||
|
||||
const { data } = await useAsyncData(
|
||||
'dash:site',
|
||||
async () => {
|
||||
const [session, doc] = await Promise.all([
|
||||
requestFetch<DashSession>('/api/dash/session'),
|
||||
requestFetch<{ data: SiteData; sha: string | null; fromRepo: boolean }>('/api/dash/site'),
|
||||
])
|
||||
return { session, doc }
|
||||
},
|
||||
{
|
||||
default: () => ({
|
||||
session: { ...EMPTY_SESSION },
|
||||
doc: { data: structuredClone(defaultSiteData), sha: null, fromRepo: false },
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
const session = computed<DashSession>(() => data.value.session)
|
||||
|
||||
const form = ref<SiteData>(structuredClone(defaultSiteData))
|
||||
const sha = ref<string | null>(null)
|
||||
const fromRepo = ref(false)
|
||||
const snapshot = ref('')
|
||||
const message = ref('')
|
||||
|
||||
function hydrate() {
|
||||
form.value = structuredClone(toRaw(data.value.doc.data))
|
||||
sha.value = data.value.doc.sha
|
||||
fromRepo.value = data.value.doc.fromRepo
|
||||
snapshot.value = JSON.stringify(form.value)
|
||||
}
|
||||
|
||||
watch(data, hydrate, { immediate: true })
|
||||
|
||||
const dirty = computed(() => JSON.stringify(form.value) !== snapshot.value)
|
||||
|
||||
// word lists (skills, stack) are edited as one comma-separated field
|
||||
const joinList = (items: string[]) => items.join(', ')
|
||||
const parseList = (value: string) => value.split(',').map((v) => v.trim()).filter(Boolean)
|
||||
const inputValue = (event: Event) => (event.target as HTMLInputElement).value
|
||||
|
||||
// --- projects -------------------------------------------------------------
|
||||
|
||||
const openProject = ref<number | null>(null)
|
||||
|
||||
function blankProject(): Project {
|
||||
return {
|
||||
name: '',
|
||||
tagline: '',
|
||||
bullets: [],
|
||||
stack: [],
|
||||
year: String(new Date().getFullYear()),
|
||||
href: '',
|
||||
links: [],
|
||||
featured: false,
|
||||
hidden: false,
|
||||
status: 'wip',
|
||||
}
|
||||
}
|
||||
|
||||
function addProject() {
|
||||
form.value.projects = [...form.value.projects, blankProject()]
|
||||
openProject.value = form.value.projects.length - 1
|
||||
}
|
||||
|
||||
function removeProject(index: number) {
|
||||
form.value.projects = form.value.projects.filter((_, i) => i !== index)
|
||||
openProject.value = null
|
||||
}
|
||||
|
||||
function moveProject(index: number, delta: number) {
|
||||
const target = index + delta
|
||||
if (target < 0 || target >= form.value.projects.length) return
|
||||
const next = [...form.value.projects]
|
||||
const [item] = next.splice(index, 1)
|
||||
next.splice(target, 0, item as Project)
|
||||
form.value.projects = next
|
||||
openProject.value = target
|
||||
}
|
||||
|
||||
// --- skills ---------------------------------------------------------------
|
||||
|
||||
function addSkillGroup() {
|
||||
form.value.skills = [...form.value.skills, { label: '', items: [] }]
|
||||
}
|
||||
|
||||
function removeSkillGroup(index: number) {
|
||||
form.value.skills = form.value.skills.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
// --- saving ---------------------------------------------------------------
|
||||
|
||||
const busy = ref(false)
|
||||
const flash = ref<{ text: string; kind: 'ok' | 'error' } | null>(null)
|
||||
|
||||
async function save() {
|
||||
if (!session.value.writable || busy.value) return
|
||||
busy.value = true
|
||||
flash.value = null
|
||||
|
||||
try {
|
||||
const result = await $fetch<{ sha: string; commit?: string; data: SiteData }>('/api/dash/site', {
|
||||
method: 'PUT',
|
||||
body: { data: form.value, sha: sha.value, message: message.value },
|
||||
})
|
||||
sha.value = result.sha
|
||||
fromRepo.value = true
|
||||
form.value = structuredClone(result.data)
|
||||
snapshot.value = JSON.stringify(form.value)
|
||||
message.value = ''
|
||||
flash.value = {
|
||||
text: `committed${result.commit ? ` ${result.commit.slice(0, 7)}` : ''} — the site is live with these values`,
|
||||
kind: 'ok',
|
||||
}
|
||||
await refreshNuxtData('site')
|
||||
} catch (e) {
|
||||
flash.value = { text: errorText(e, 'could not save'), kind: 'error' }
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function revert() {
|
||||
hydrate()
|
||||
flash.value = null
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
if (!dirty.value) return true
|
||||
return confirm('you have unsaved changes. leave anyway?')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<DashHeader :session="session" />
|
||||
|
||||
<div class="bar">
|
||||
<p class="small muted">
|
||||
writes <code>{{ session.siteFile }}</code> to {{ session.repo }}.
|
||||
<span v-if="!fromRepo" class="accent">the repo has no {{ session.siteFile }} yet — saving creates it.</span>
|
||||
</p>
|
||||
<span v-if="dirty" class="accent tiny nowrap">● unsaved</span>
|
||||
</div>
|
||||
|
||||
<FrameBox tag="identity" class="block">
|
||||
<div class="grid">
|
||||
<label class="field">
|
||||
<span class="label">name</span>
|
||||
<input v-model="form.name" class="input" type="text">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">handle</span>
|
||||
<input v-model="form.handle" class="input" type="text">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">domain</span>
|
||||
<input v-model="form.domain" class="input" type="text">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">role</span>
|
||||
<input v-model="form.role" class="input" type="text">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">company</span>
|
||||
<input v-model="form.company.name" class="input" type="text">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">company url</span>
|
||||
<input v-model="form.company.href" class="input" type="text">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">location</span>
|
||||
<input v-model="form.location" class="input" type="text">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">timezone (clock in the status bar)</span>
|
||||
<input v-model="form.timezone" class="input" type="text" placeholder="Europe/Paris">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">contact email</span>
|
||||
<input v-model="form.contactEmail" class="input" type="text">
|
||||
</label>
|
||||
</div>
|
||||
</FrameBox>
|
||||
|
||||
<FrameBox tag="copy" class="block">
|
||||
<div class="stack">
|
||||
<label class="field">
|
||||
<span class="label">tagline</span>
|
||||
<input v-model="form.tagline" class="input" type="text">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">meta description (search results, og tags, rss)</span>
|
||||
<textarea v-model="form.description" class="textarea" rows="3" />
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">about — one paragraph per entry</span>
|
||||
<DashStringList v-model="form.about" multiline add-label="paragraph" />
|
||||
</div>
|
||||
</div>
|
||||
</FrameBox>
|
||||
|
||||
<FrameBox tag="skills" class="block">
|
||||
<div class="stack">
|
||||
<div v-for="(group, index) in form.skills" :key="index" class="skill-row">
|
||||
<input v-model="group.label" class="input group-label" type="text" placeholder="languages">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="rust, typescript, python"
|
||||
:value="joinList(group.items)"
|
||||
@input="group.items = parseList(inputValue($event))"
|
||||
>
|
||||
<button class="btn btn-ghost" type="button" title="remove" @click="removeSkillGroup(index)">×</button>
|
||||
</div>
|
||||
<button class="btn" type="button" @click="addSkillGroup">+ group</button>
|
||||
</div>
|
||||
</FrameBox>
|
||||
|
||||
<FrameBox tag="links" class="block">
|
||||
<p class="small muted hint">shown in the footer and in the about box. relative paths work too (<code>/rss.xml</code>).</p>
|
||||
<DashLinkList v-model="form.links" />
|
||||
</FrameBox>
|
||||
|
||||
<FrameBox tag="projects" class="block">
|
||||
<p class="small muted hint">
|
||||
the public list is every repo on github + gitea. these entries only override the ones
|
||||
whose <code>href</code> or <code>name</code> matches — bullets, a nicer name, the featured
|
||||
flag. an entry that matches nothing is shown as-is.
|
||||
</p>
|
||||
<div class="stack">
|
||||
<div v-for="(project, index) in form.projects" :key="index" class="project">
|
||||
<div class="project-head">
|
||||
<button
|
||||
class="btn btn-ghost expand"
|
||||
type="button"
|
||||
@click="openProject = openProject === index ? null : index"
|
||||
>
|
||||
<span class="accent">{{ openProject === index ? '−' : '+' }}</span>
|
||||
<span class="pname">{{ project.name || 'untitled' }}</span>
|
||||
<span class="faint tiny">
|
||||
{{ project.status }}{{ project.featured ? ' · featured' : ''
|
||||
}}{{ project.hidden ? ' · hidden' : '' }}
|
||||
</span>
|
||||
</button>
|
||||
<div class="project-controls">
|
||||
<button class="btn btn-ghost" type="button" title="move up" @click="moveProject(index, -1)">↑</button>
|
||||
<button class="btn btn-ghost" type="button" title="move down" @click="moveProject(index, 1)">↓</button>
|
||||
<button class="btn btn-ghost" type="button" title="remove" @click="removeProject(index)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="openProject === index" class="project-body">
|
||||
<div class="grid">
|
||||
<label class="field">
|
||||
<span class="label">name</span>
|
||||
<input v-model="project.name" class="input" type="text">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">year</span>
|
||||
<input v-model="project.year" class="input" type="text" placeholder="2026 / ongoing">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">status</span>
|
||||
<select v-model="project.status" class="select">
|
||||
<option v-for="value in PROJECT_STATUSES" :key="value" :value="value">{{ value }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">tagline</span>
|
||||
<input v-model="project.tagline" class="input" type="text">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">url</span>
|
||||
<input v-model="project.href" class="input" type="text">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">stack (comma separated)</span>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
:value="joinList(project.stack)"
|
||||
@input="project.stack = parseList(inputValue($event))"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">bullets — shown on featured cards</span>
|
||||
<DashStringList v-model="project.bullets" add-label="bullet" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">extra links</span>
|
||||
<DashLinkList v-model="project.links" />
|
||||
</div>
|
||||
|
||||
<label class="checkbox">
|
||||
<input v-model="project.featured" type="checkbox">
|
||||
<span>featured on the home page</span>
|
||||
</label>
|
||||
|
||||
<label class="checkbox">
|
||||
<input v-model="project.hidden" type="checkbox">
|
||||
<span>hide — keep this repo off the site</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn" type="button" @click="addProject">+ project</button>
|
||||
</div>
|
||||
</FrameBox>
|
||||
|
||||
<FrameBox tag="commit" class="block">
|
||||
<div class="commit-row">
|
||||
<label class="field msg">
|
||||
<span class="label">commit message</span>
|
||||
<input v-model="message" class="input" type="text" placeholder="site: update portfolio content">
|
||||
</label>
|
||||
<div class="row commit-actions">
|
||||
<button class="btn btn-primary" type="button" :disabled="!session.writable || busy || !dirty" @click="save">
|
||||
{{ busy ? 'committing…' : 'save' }}
|
||||
</button>
|
||||
<button class="btn" type="button" :disabled="!dirty || busy" @click="revert">revert</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!session.writable" class="flash flash-error note">
|
||||
no write token configured — set <code>CONTENT_TOKEN</code> to commit from here.
|
||||
</p>
|
||||
<p v-else-if="flash" class="flash note" :class="`flash-${flash.kind}`">{{ flash.text }}</p>
|
||||
</FrameBox>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.block {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.skill-row {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skill-row > .input:not(.group-label) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.group-label {
|
||||
max-width: 9rem;
|
||||
}
|
||||
|
||||
.project {
|
||||
border: 1px solid var(--ink-faint);
|
||||
}
|
||||
|
||||
.project-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.35rem;
|
||||
}
|
||||
|
||||
.expand {
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pname {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-controls {
|
||||
display: flex;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.project-body {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px dashed var(--ink-faint);
|
||||
}
|
||||
|
||||
.commit-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.msg {
|
||||
flex: 1;
|
||||
min-width: 16rem;
|
||||
}
|
||||
|
||||
.commit-actions {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
130
app/pages/index.vue
Normal file
130
app/pages/index.vue
Normal file
@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import type { PostMeta } from '#shared/types/blog'
|
||||
|
||||
const { site } = useSiteData()
|
||||
const { featured } = useProjects()
|
||||
|
||||
const { data } = await useFetch<{ posts: PostMeta[] }>('/api/blog', {
|
||||
key: 'home-posts',
|
||||
default: () => ({ posts: [] }),
|
||||
})
|
||||
|
||||
const latest = computed(() => (data.value?.posts ?? []).slice(0, 3))
|
||||
|
||||
useSeoMeta({
|
||||
title: '',
|
||||
description: () => site.value.description,
|
||||
ogTitle: () => site.value.domain,
|
||||
ogDescription: () => site.value.description,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AsciiBanner />
|
||||
|
||||
<section id="about" class="grid-2">
|
||||
<FrameBox tag="/about">
|
||||
<div class="stack-sm">
|
||||
<p v-for="line in site.about" :key="line" class="small">{{ line }}</p>
|
||||
</div>
|
||||
<dl class="def-list about-meta">
|
||||
<dt>where</dt>
|
||||
<dd>{{ site.location }}</dd>
|
||||
<dt>work</dt>
|
||||
<dd>
|
||||
{{ site.role }} @
|
||||
<a :href="site.company.href" target="_blank" rel="noopener noreferrer">{{ site.company.name }}</a>
|
||||
</dd>
|
||||
<dt>elsewhere</dt>
|
||||
<dd>
|
||||
<span class="inline-links">
|
||||
<a v-for="link in site.links" :key="link.href" :href="link.href">{{ link.label }}</a>
|
||||
</span>
|
||||
</dd>
|
||||
</dl>
|
||||
</FrameBox>
|
||||
|
||||
<FrameBox tag="/skills">
|
||||
<dl class="def-list">
|
||||
<template v-for="group in site.skills" :key="group.label">
|
||||
<dt>{{ group.label }}</dt>
|
||||
<dd>{{ group.items.join(', ') }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<p class="small muted note">
|
||||
the list is descriptive, not aspirational — everything here shipped something.
|
||||
</p>
|
||||
</FrameBox>
|
||||
</section>
|
||||
|
||||
<AsciiRule label="selected work" />
|
||||
|
||||
<section class="grid-2">
|
||||
<ProjectCard v-for="project in featured" :key="project.href" :project="project" />
|
||||
</section>
|
||||
|
||||
<p class="more small">
|
||||
<NuxtLink to="/projects">→ everything else</NuxtLink>
|
||||
</p>
|
||||
|
||||
<AsciiRule label="writing" />
|
||||
|
||||
<section>
|
||||
<FrameBox tag="/blog">
|
||||
<template v-if="latest.length">
|
||||
<PostRow v-for="post in latest" :key="post.slug" :post="post" />
|
||||
<p class="more small">
|
||||
<NuxtLink to="/blog">→ all posts</NuxtLink>
|
||||
</p>
|
||||
</template>
|
||||
<p v-else class="muted small empty">
|
||||
nothing published yet. the feed is wired to the content repo — the first push fills it.
|
||||
</p>
|
||||
</FrameBox>
|
||||
</section>
|
||||
|
||||
<AsciiRule label="contact" />
|
||||
|
||||
<FrameBox tag="/contact" class="contact">
|
||||
<p class="small">
|
||||
mail is the fastest way in.
|
||||
<a :href="`mailto:${site.contactEmail}`">{{ site.contactEmail }}</a>
|
||||
</p>
|
||||
<p class="small muted">
|
||||
patches, questions and "your site broke on my machine" reports all welcome.
|
||||
</p>
|
||||
</FrameBox>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.about-meta {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px dashed var(--ink-faint);
|
||||
}
|
||||
|
||||
.inline-links {
|
||||
display: inline-flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.more {
|
||||
margin-top: 1rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.contact p + p {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
78
app/pages/projects.vue
Normal file
78
app/pages/projects.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
const { site } = useSiteData()
|
||||
const { projects, failed } = useProjects()
|
||||
|
||||
const groups = computed(() => {
|
||||
const all = projects.value
|
||||
const active = all.filter((p) => p.status === 'active' || p.status === 'wip')
|
||||
const rest = all.filter((p) => p.status !== 'active' && p.status !== 'wip')
|
||||
return [
|
||||
{ label: 'in flight', items: active },
|
||||
{ label: 'the shelf', items: rest },
|
||||
].filter((g) => g.items.length)
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: 'projects',
|
||||
description: 'every public repo nak0x owns, on github and on his own gitea.',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header class="head">
|
||||
<h1>projects</h1>
|
||||
<p class="muted small">
|
||||
{{ projects.length }} public repositories, read straight off github and gitea.
|
||||
most of them started as "this should take an evening".
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p v-if="failed.length" class="small faint warn">
|
||||
{{ failed.map((s) => s.kind).join(' and ') }} did not answer — this list is short a few
|
||||
entries.
|
||||
</p>
|
||||
|
||||
<template v-for="group in groups" :key="group.label">
|
||||
<AsciiRule :label="group.label" />
|
||||
<section class="grid-2">
|
||||
<ProjectCard v-for="project in group.items" :key="project.href" :project="project" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<p v-if="!projects.length" class="muted small empty">
|
||||
no repositories came back. either both forges are down, or the accounts are misconfigured.
|
||||
</p>
|
||||
|
||||
<AsciiRule />
|
||||
|
||||
<p class="small muted">
|
||||
more of it lives on
|
||||
<a
|
||||
v-for="(link, i) in site.links.filter((l) => l.href.startsWith('http'))"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ i ? ', ' : '' }}{{ link.label }}</a>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
margin: 2rem 0 0;
|
||||
}
|
||||
|
||||
.head p {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.warn {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
59
content/posts/2026-09-08-hello.md
Normal file
59
content/posts/2026-09-08-hello.md
Normal file
@ -0,0 +1,59 @@
|
||||
---
|
||||
title: how this blog works
|
||||
description: markdown in a git repo, a webhook, and no build step. here is the whole thing.
|
||||
date: 2026-09-08
|
||||
tags: [meta, nuxt, gitea]
|
||||
draft: false
|
||||
---
|
||||
|
||||
This post is a file. It lives in a git repo, it has YAML frontmatter, and pushing
|
||||
it is the entire publishing pipeline. No CMS, no rebuild, no `npm run deploy`.
|
||||
|
||||
## the shape of a post
|
||||
|
||||
Everything above the second `---` is frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: how this blog works # required-ish; falls back to the filename
|
||||
description: one line, used in the # optional; falls back to the first paragraph
|
||||
list view and the og tags
|
||||
date: 2026-09-08 # optional; falls back to a YYYY-MM-DD filename prefix
|
||||
updated: 2026-09-09 # optional
|
||||
tags: [meta, nuxt, gitea] # optional; also accepts "meta, nuxt"
|
||||
slug: how-this-blog-works # optional; falls back to the filename
|
||||
draft: false # true hides it in production
|
||||
---
|
||||
```
|
||||
|
||||
The filename becomes the slug, with a leading date stripped:
|
||||
`2026-09-08-hello.md` → `/blog/hello`.
|
||||
|
||||
## how it gets here
|
||||
|
||||
1. You push to the content repo.
|
||||
2. Gitea fires a webhook at `/api/webhook/content` with an HMAC signature.
|
||||
3. The server checks the signature, drops its cache, and re-reads the tree.
|
||||
4. The next request renders from the fresh copy.
|
||||
|
||||
If the webhook never fires — network hiccup, misconfigured secret — the cache
|
||||
still expires on its own after `CONTENT_TTL` seconds. And if the repo is down
|
||||
when a refresh is due, the last good copy keeps being served instead of a 502.
|
||||
|
||||
## code and images
|
||||
|
||||
Fenced blocks are highlighted server-side by Shiki, in both themes at once:
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
let payload = std::fs::read_to_string("post.md").expect("a post");
|
||||
println!("{} bytes, zero javascript", payload.len());
|
||||
}
|
||||
```
|
||||
|
||||
Relative image paths resolve against the post's own directory in the repo, so
|
||||
`` works if you commit the png next to the markdown.
|
||||
|
||||
> Blockquotes, tables, and lists all render. Anything markdown-it understands.
|
||||
|
||||
That's it. Write a file, push, done.
|
||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@ -0,0 +1,25 @@
|
||||
# Local parity with what Coolify runs. `docker compose up --build`.
|
||||
services:
|
||||
folio:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
SITE_URL: ${NUXT_PUBLIC_SITE_URL:-http://localhost:3000}
|
||||
image: nak0x-folio
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-http://localhost:3000}
|
||||
CONTENT_PROVIDER: ${CONTENT_PROVIDER:-gitea}
|
||||
CONTENT_REPO: ${CONTENT_REPO:-Nak0x/Journal}
|
||||
CONTENT_BRANCH: ${CONTENT_BRANCH:-main}
|
||||
CONTENT_DIR: ${CONTENT_DIR:-posts}
|
||||
GITEA_URL: ${GITEA_URL:-https://git.nak0x.dev}
|
||||
CONTENT_TOKEN: ${CONTENT_TOKEN:-}
|
||||
CONTENT_TTL: ${CONTENT_TTL:-600}
|
||||
CONTENT_WEBHOOK_SECRET: ${CONTENT_WEBHOOK_SECRET:-}
|
||||
CONTENT_SITE_FILE: ${CONTENT_SITE_FILE:-site.json}
|
||||
DASH_PASSWORD: ${DASH_PASSWORD:-}
|
||||
DASH_SESSION_SECRET: ${DASH_SESSION_SECRET:-}
|
||||
DASH_SESSION_HOURS: ${DASH_SESSION_HOURS:-168}
|
||||
88
nuxt.config.ts
Normal file
88
nuxt.config.ts
Normal file
@ -0,0 +1,88 @@
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2026-09-01',
|
||||
devtools: { enabled: false },
|
||||
ssr: true,
|
||||
|
||||
future: { compatibilityVersion: 4 },
|
||||
|
||||
css: ['~/assets/css/main.css'],
|
||||
|
||||
nitro: {
|
||||
preset: 'node-server',
|
||||
compressPublicAssets: true,
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
// --- blog source (server only) ---------------------------------------
|
||||
content: {
|
||||
// 'gitea' | 'github' | 'local'
|
||||
provider: process.env.CONTENT_PROVIDER || 'gitea',
|
||||
// "owner/repo"
|
||||
repo: process.env.CONTENT_REPO || 'Nak0x/Journal',
|
||||
branch: process.env.CONTENT_BRANCH || 'main',
|
||||
// sub-directory inside the repo holding the .md files
|
||||
dir: process.env.CONTENT_DIR || 'posts',
|
||||
// base url of the gitea instance (ignored for github)
|
||||
giteaUrl: process.env.GITEA_URL || 'https://git.nak0x.dev',
|
||||
// optional PAT — only needed for private repos / gh rate-limits
|
||||
token: process.env.CONTENT_TOKEN || '',
|
||||
// cache lifetime in seconds; the webhook busts it early
|
||||
ttl: Number(process.env.CONTENT_TTL || 600),
|
||||
// shared secret for the gitea/github webhook
|
||||
webhookSecret: process.env.CONTENT_WEBHOOK_SECRET || '',
|
||||
// folder used when provider === 'local' (handy for writing offline)
|
||||
localDir: process.env.CONTENT_LOCAL_DIR || 'content',
|
||||
// portfolio data (bio, skills, links, projects), edited from /dash.
|
||||
// relative to the repo root, not to CONTENT_DIR.
|
||||
siteFile: process.env.CONTENT_SITE_FILE || 'site.json',
|
||||
},
|
||||
|
||||
// --- projects: public repos, read from the forges ---------------------
|
||||
projects: {
|
||||
// account names; leave one empty to switch that source off
|
||||
githubUser: process.env.PROJECTS_GITHUB_USER || 'nak0x',
|
||||
giteaUser: process.env.PROJECTS_GITEA_USER || 'Nak0x',
|
||||
// defaults to the gitea instance the content repo lives on
|
||||
giteaUrl: process.env.PROJECTS_GITEA_URL || '',
|
||||
// optional PATs — mostly to escape github's 60/h anonymous rate limit
|
||||
githubToken: process.env.PROJECTS_GITHUB_TOKEN || '',
|
||||
giteaToken: process.env.PROJECTS_GITEA_TOKEN || '',
|
||||
// comma-separated; matches "name" or "owner/name"
|
||||
exclude: process.env.PROJECTS_EXCLUDE || '',
|
||||
// forks are somebody else's project
|
||||
includeForks: process.env.PROJECTS_INCLUDE_FORKS === 'true',
|
||||
ttl: Number(process.env.PROJECTS_TTL || 900),
|
||||
},
|
||||
|
||||
// --- /dash (server only) ----------------------------------------------
|
||||
dash: {
|
||||
// unset => /dash is disabled entirely
|
||||
password: process.env.DASH_PASSWORD || '',
|
||||
// optional; defaults to a key derived from the password
|
||||
sessionSecret: process.env.DASH_SESSION_SECRET || '',
|
||||
// session lifetime in hours
|
||||
sessionHours: Number(process.env.DASH_SESSION_HOURS || 168),
|
||||
},
|
||||
|
||||
public: {
|
||||
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://nak0x.dev',
|
||||
},
|
||||
},
|
||||
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: { lang: 'en' },
|
||||
link: [
|
||||
{ rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' },
|
||||
{ rel: 'alternate', type: 'application/rss+xml', title: 'nak0x.dev', href: '/rss.xml' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
// set the theme before first paint, no flash
|
||||
innerHTML: `(function(){try{var s=localStorage.getItem("theme");var d=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches;document.documentElement.setAttribute("data-theme",s||(d?"dark":"light"))}catch(e){}})()`,
|
||||
tagPosition: 'head',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
10089
package-lock.json
generated
Normal file
10089
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "nak0x-folio",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"preview": "node .output/server/index.mjs",
|
||||
"generate": "nuxt generate",
|
||||
"postinstall": "nuxt prepare",
|
||||
"typecheck": "nuxt typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"github-slugger": "^2.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"markdown-it": "^15.0.1",
|
||||
"markdown-it-anchor": "^10.0.0",
|
||||
"nuxt": "^4.5.2",
|
||||
"shiki": "^4.4.3",
|
||||
"vue": "^3.5.42",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^26.5.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vue-tsc": "^3.1.1"
|
||||
}
|
||||
}
|
||||
5
public/favicon.svg
Normal file
5
public/favicon.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="8" fill="#0e0e10"/>
|
||||
<text x="32" y="44" font-family="ui-monospace, monospace" font-size="38" font-weight="700"
|
||||
text-anchor="middle" fill="#e56fd8">n</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 269 B |
12
server/api/blog/[slug].get.ts
Normal file
12
server/api/blog/[slug].get.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { getNeighbours, getPost } from '~~/server/utils/posts'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
if (!slug) throw createError({ statusCode: 400, message: 'missing slug' })
|
||||
|
||||
const post = await getPost(slug)
|
||||
if (!post) throw createError({ statusCode: 404, message: 'post not found' })
|
||||
|
||||
const neighbours = await getNeighbours(slug)
|
||||
return { post, ...neighbours }
|
||||
})
|
||||
16
server/api/blog/index.get.ts
Normal file
16
server/api/blog/index.get.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { listPosts, listTags } from '~~/server/utils/posts'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { tag } = getQuery(event) as { tag?: string }
|
||||
|
||||
try {
|
||||
const [posts, tags] = await Promise.all([listPosts({ tag }), listTags()])
|
||||
return { posts, tags }
|
||||
} catch (error) {
|
||||
console.error('[api/blog] failed to list posts:', error)
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
message: 'the content repository is unreachable',
|
||||
})
|
||||
}
|
||||
})
|
||||
26
server/api/dash/login.post.ts
Normal file
26
server/api/dash/login.post.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import {
|
||||
assertNotRateLimited,
|
||||
clearFailures,
|
||||
dashEnabled,
|
||||
issueSession,
|
||||
passwordMatches,
|
||||
recordFailure,
|
||||
} from '~~/server/utils/dash-auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
if (!dashEnabled()) {
|
||||
throw createError({ statusCode: 503, message: 'DASH_PASSWORD is not configured' })
|
||||
}
|
||||
|
||||
assertNotRateLimited(event)
|
||||
|
||||
const { password } = await readBody<{ password?: string }>(event)
|
||||
if (!password || !passwordMatches(password)) {
|
||||
recordFailure(event)
|
||||
throw createError({ statusCode: 401, message: 'wrong password' })
|
||||
}
|
||||
|
||||
clearFailures(event)
|
||||
issueSession(event)
|
||||
return { ok: true }
|
||||
})
|
||||
6
server/api/dash/logout.post.ts
Normal file
6
server/api/dash/logout.post.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clearDashSession } from '~~/server/utils/dash-auth'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
clearDashSession(event)
|
||||
return { ok: true }
|
||||
})
|
||||
12
server/api/dash/posts/[slug].delete.ts
Normal file
12
server/api/dash/posts/[slug].delete.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { assertDashSession } from '~~/server/utils/dash-auth'
|
||||
import { deletePost } from '~~/server/utils/post-editor'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
assertDashSession(event)
|
||||
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
if (!slug) throw createError({ statusCode: 400, message: 'missing slug' })
|
||||
|
||||
const body = await readBody<{ message?: string }>(event).catch(() => null)
|
||||
return await deletePost(slug, body?.message)
|
||||
})
|
||||
14
server/api/dash/posts/[slug].get.ts
Normal file
14
server/api/dash/posts/[slug].get.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { assertDashSession } from '~~/server/utils/dash-auth'
|
||||
import { getPostDraft } from '~~/server/utils/post-editor'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
assertDashSession(event)
|
||||
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
if (!slug) throw createError({ statusCode: 400, message: 'missing slug' })
|
||||
|
||||
const draft = await getPostDraft(slug)
|
||||
if (!draft) throw createError({ statusCode: 404, message: 'post not found' })
|
||||
|
||||
return draft
|
||||
})
|
||||
13
server/api/dash/posts/[slug].put.ts
Normal file
13
server/api/dash/posts/[slug].put.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { assertDashSession } from '~~/server/utils/dash-auth'
|
||||
import { updatePost } from '~~/server/utils/post-editor'
|
||||
import type { SavePayload } from '#shared/types/dash'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
assertDashSession(event)
|
||||
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
if (!slug) throw createError({ statusCode: 400, message: 'missing slug' })
|
||||
|
||||
const payload = await readBody<SavePayload>(event)
|
||||
return await updatePost(slug, payload)
|
||||
})
|
||||
12
server/api/dash/posts/index.get.ts
Normal file
12
server/api/dash/posts/index.get.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { assertDashSession } from '~~/server/utils/dash-auth'
|
||||
import { getIndex } from '~~/server/utils/posts'
|
||||
|
||||
/** every post, drafts included, with the sha the editor needs */
|
||||
export default defineEventHandler(async (event) => {
|
||||
assertDashSession(event)
|
||||
|
||||
const index = await getIndex()
|
||||
return {
|
||||
posts: index.map((entry) => ({ ...entry.meta, sha: entry.sha })),
|
||||
}
|
||||
})
|
||||
9
server/api/dash/posts/index.post.ts
Normal file
9
server/api/dash/posts/index.post.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { assertDashSession } from '~~/server/utils/dash-auth'
|
||||
import { createPost } from '~~/server/utils/post-editor'
|
||||
import type { SavePayload } from '#shared/types/dash'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
assertDashSession(event)
|
||||
const payload = await readBody<SavePayload>(event)
|
||||
return await createPost(payload)
|
||||
})
|
||||
21
server/api/dash/preview.post.ts
Normal file
21
server/api/dash/preview.post.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { assertDashSession } from '~~/server/utils/dash-auth'
|
||||
import { renderMarkdown } from '~~/server/utils/markdown'
|
||||
import { useContentProvider } from '~~/server/utils/providers'
|
||||
|
||||
/**
|
||||
* The editor has no markdown renderer of its own — it posts the body here and
|
||||
* gets back exactly the HTML the published page would show, Shiki included.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
assertDashSession(event)
|
||||
|
||||
const { body = '', path = '' } = await readBody<{ body?: string; path?: string }>(event)
|
||||
const provider = useContentProvider()
|
||||
const baseDir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''
|
||||
|
||||
const { html, toc, readingTime } = await renderMarkdown(body, {
|
||||
resolveAsset: (src) => provider.assetUrl(baseDir ? `${baseDir}/${src}` : src),
|
||||
})
|
||||
|
||||
return { html, toc, readingTime }
|
||||
})
|
||||
15
server/api/dash/refresh.post.ts
Normal file
15
server/api/dash/refresh.post.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { assertDashSession } from '~~/server/utils/dash-auth'
|
||||
import { bustCache } from '~~/server/utils/cache'
|
||||
import { getIndex } from '~~/server/utils/posts'
|
||||
import { getSiteDocument } from '~~/server/utils/site'
|
||||
import { getProjects } from '~~/server/utils/repos'
|
||||
|
||||
/** drop everything cached from the content repo and read it again */
|
||||
export default defineEventHandler(async (event) => {
|
||||
assertDashSession(event)
|
||||
|
||||
const cleared = bustCache()
|
||||
await Promise.all([getIndex(), getSiteDocument(), getProjects()])
|
||||
|
||||
return { ok: true, cleared }
|
||||
})
|
||||
35
server/api/dash/session.get.ts
Normal file
35
server/api/dash/session.get.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { dashEnabled, hasValidSession } from '~~/server/utils/dash-auth'
|
||||
import { useContentProvider } from '~~/server/utils/providers'
|
||||
import { contentConfig } from '~~/server/utils/config'
|
||||
|
||||
/** what the dashboard needs before it can render anything */
|
||||
export default defineEventHandler((event) => {
|
||||
const enabled = dashEnabled()
|
||||
const authenticated = enabled && hasValidSession(event)
|
||||
|
||||
if (!authenticated) return { enabled, authenticated, writable: false }
|
||||
|
||||
const content = contentConfig()
|
||||
let writable = false
|
||||
let kind = content.provider
|
||||
try {
|
||||
const provider = useContentProvider()
|
||||
writable = provider.writable
|
||||
kind = provider.kind
|
||||
} catch (error) {
|
||||
console.error('[dash] provider unavailable:', error)
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
authenticated,
|
||||
writable,
|
||||
provider: kind,
|
||||
repo: content.repo,
|
||||
branch: content.branch,
|
||||
dir: content.dir,
|
||||
siteFile: content.siteFile,
|
||||
ttl: content.ttl,
|
||||
webhook: !!content.webhookSecret,
|
||||
}
|
||||
})
|
||||
7
server/api/dash/site.get.ts
Normal file
7
server/api/dash/site.get.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { assertDashSession } from '~~/server/utils/dash-auth'
|
||||
import { getSiteDocument } from '~~/server/utils/site'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
assertDashSession(event)
|
||||
return await getSiteDocument()
|
||||
})
|
||||
25
server/api/dash/site.put.ts
Normal file
25
server/api/dash/site.put.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { assertDashSession } from '~~/server/utils/dash-auth'
|
||||
import { putSiteData } from '~~/server/utils/site'
|
||||
import { useContentProvider } from '~~/server/utils/providers'
|
||||
import { assertWritable } from '~~/server/utils/providers/types'
|
||||
import type { SiteData } from '#shared/site'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
assertDashSession(event)
|
||||
assertWritable(useContentProvider())
|
||||
|
||||
const { data, sha, message } = await readBody<{
|
||||
data: SiteData
|
||||
sha?: string | null
|
||||
message?: string
|
||||
}>(event)
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw createError({ statusCode: 422, message: 'missing site data' })
|
||||
}
|
||||
|
||||
return await putSiteData(data, {
|
||||
sha,
|
||||
message: message?.trim() || 'site: update portfolio content',
|
||||
})
|
||||
})
|
||||
6
server/api/projects.get.ts
Normal file
6
server/api/projects.get.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { getProjects } from '~~/server/utils/repos'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
setHeader(event, 'cache-control', 'public, max-age=300')
|
||||
return await getProjects()
|
||||
})
|
||||
6
server/api/site.get.ts
Normal file
6
server/api/site.get.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { getSiteData } from '~~/server/utils/site'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
setHeader(event, 'cache-control', 'public, max-age=60')
|
||||
return await getSiteData()
|
||||
})
|
||||
58
server/api/webhook/content.post.ts
Normal file
58
server/api/webhook/content.post.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
import { bustCache } from '~~/server/utils/cache'
|
||||
import { getIndex } from '~~/server/utils/posts'
|
||||
|
||||
/**
|
||||
* Push webhook for the content repo.
|
||||
*
|
||||
* Gitea : Settings -> Webhooks -> Gitea, url = https://nak0x.dev/api/webhook/content,
|
||||
* method POST, content type application/json, secret = CONTENT_WEBHOOK_SECRET.
|
||||
* It signs the body into `X-Gitea-Signature` (hex hmac-sha256).
|
||||
* GitHub : same idea, header is `X-Hub-Signature-256` and looks like `sha256=<hex>`.
|
||||
*
|
||||
* A valid call drops the cache and immediately warms the index back up, so the
|
||||
* first visitor after a push does not pay for the refetch.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const secret = contentConfig().webhookSecret
|
||||
|
||||
if (!secret) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
message: 'CONTENT_WEBHOOK_SECRET is not configured',
|
||||
})
|
||||
}
|
||||
|
||||
const raw = await readRawBody(event, false)
|
||||
if (!raw) throw createError({ statusCode: 400, message: 'empty body' })
|
||||
|
||||
const body = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw))
|
||||
const digest = createHmac('sha256', secret).update(body).digest('hex')
|
||||
|
||||
const provided =
|
||||
getHeader(event, 'x-gitea-signature')
|
||||
?? getHeader(event, 'x-hub-signature-256')?.replace(/^sha256=/, '')
|
||||
?? ''
|
||||
|
||||
if (!safeEqual(digest, provided)) {
|
||||
throw createError({ statusCode: 401, message: 'bad signature' })
|
||||
}
|
||||
|
||||
const cleared = bustCache('posts:')
|
||||
|
||||
// warm the index again; failures here are not the webhook's problem
|
||||
getIndex().catch((error) => console.error('[webhook] re-warm failed:', error))
|
||||
|
||||
const eventName =
|
||||
getHeader(event, 'x-gitea-event') ?? getHeader(event, 'x-github-event') ?? 'unknown'
|
||||
console.info(`[webhook] ${eventName}: cleared ${cleared} cache entries`)
|
||||
|
||||
return { ok: true, event: eventName, cleared }
|
||||
})
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a, 'utf8')
|
||||
const bufB = Buffer.from(b, 'utf8')
|
||||
if (bufA.length !== bufB.length) return false
|
||||
return timingSafeEqual(bufA, bufB)
|
||||
}
|
||||
5
server/routes/healthz.get.ts
Normal file
5
server/routes/healthz.get.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export default defineEventHandler(() => ({
|
||||
ok: true,
|
||||
uptime: Math.round(process.uptime()),
|
||||
provider: contentConfig().provider,
|
||||
}))
|
||||
12
server/routes/robots.txt.get.ts
Normal file
12
server/routes/robots.txt.get.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const base = useRuntimeConfig().public.siteUrl.replace(/\/+$/, '')
|
||||
setHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
||||
return [
|
||||
'User-agent: *',
|
||||
'Allow: /',
|
||||
'Disallow: /dash',
|
||||
'',
|
||||
`Sitemap: ${base}/sitemap.xml`,
|
||||
'',
|
||||
].join('\n')
|
||||
})
|
||||
44
server/routes/rss.xml.get.ts
Normal file
44
server/routes/rss.xml.get.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { listPosts } from '~~/server/utils/posts'
|
||||
import { getSiteData } from '~~/server/utils/site'
|
||||
|
||||
const escape = (s: string) =>
|
||||
s.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const base = useRuntimeConfig().public.siteUrl.replace(/\/+$/, '')
|
||||
const posts = (await listPosts()).filter((p) => !p.draft).slice(0, 30)
|
||||
const site = await getSiteData()
|
||||
|
||||
const items = posts
|
||||
.map(
|
||||
(post) => ` <item>
|
||||
<title>${escape(post.title)}</title>
|
||||
<link>${base}/blog/${post.slug}</link>
|
||||
<guid isPermaLink="true">${base}/blog/${post.slug}</guid>
|
||||
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
|
||||
<description>${escape(post.description)}</description>
|
||||
${post.tags.map((t) => ` <category>${escape(t)}</category>`).join('\n')}
|
||||
</item>`,
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>${escape(site.domain)}</title>
|
||||
<link>${base}</link>
|
||||
<description>${escape(site.description)}</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
|
||||
<atom:link href="${base}/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
${items}
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
setHeader(event, 'content-type', 'application/rss+xml; charset=utf-8')
|
||||
setHeader(event, 'cache-control', 'public, max-age=600')
|
||||
return xml
|
||||
})
|
||||
21
server/routes/sitemap.xml.get.ts
Normal file
21
server/routes/sitemap.xml.get.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { listPosts } from '~~/server/utils/posts'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const base = useRuntimeConfig().public.siteUrl.replace(/\/+$/, '')
|
||||
const posts = await listPosts()
|
||||
|
||||
const urls = [
|
||||
{ loc: `${base}/`, lastmod: new Date().toISOString() },
|
||||
{ loc: `${base}/projects`, lastmod: new Date().toISOString() },
|
||||
{ loc: `${base}/blog`, lastmod: posts[0]?.date ?? new Date().toISOString() },
|
||||
...posts.map((p) => ({ loc: `${base}/blog/${p.slug}`, lastmod: p.updated ?? p.date })),
|
||||
]
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls.map((u) => ` <url><loc>${u.loc}</loc><lastmod>${u.lastmod}</lastmod></url>`).join('\n')}
|
||||
</urlset>`
|
||||
|
||||
setHeader(event, 'content-type', 'application/xml; charset=utf-8')
|
||||
return xml
|
||||
})
|
||||
71
server/utils/cache.ts
Normal file
71
server/utils/cache.ts
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* A tiny stale-while-revalidate cache.
|
||||
*
|
||||
* The blog lives in a git repo we do not own the request cycle of, so:
|
||||
* - fresh entries are served straight away
|
||||
* - stale entries are served immediately and refreshed in the background
|
||||
* - if the refresh fails we keep serving the stale copy rather than 500ing
|
||||
* - the webhook calls `bustCache()` so a push shows up without a redeploy
|
||||
*/
|
||||
|
||||
interface Entry<T> {
|
||||
value: T
|
||||
storedAt: number
|
||||
}
|
||||
|
||||
const store = new Map<string, Entry<unknown>>()
|
||||
const inflight = new Map<string, Promise<unknown>>()
|
||||
|
||||
export async function cached<T>(key: string, ttlSeconds: number, load: () => Promise<T>): Promise<T> {
|
||||
const ttl = Math.max(0, ttlSeconds) * 1000
|
||||
const hit = store.get(key) as Entry<T> | undefined
|
||||
const isFresh = hit && Date.now() - hit.storedAt < ttl
|
||||
|
||||
if (hit && isFresh) return hit.value
|
||||
|
||||
const refresh = () => {
|
||||
const running = inflight.get(key) as Promise<T> | undefined
|
||||
if (running) return running
|
||||
|
||||
const p = load()
|
||||
.then((value) => {
|
||||
store.set(key, { value, storedAt: Date.now() })
|
||||
return value
|
||||
})
|
||||
.finally(() => inflight.delete(key))
|
||||
|
||||
inflight.set(key, p)
|
||||
return p
|
||||
}
|
||||
|
||||
// nothing cached yet — we have to wait
|
||||
if (!hit) return refresh()
|
||||
|
||||
// stale: hand back the old copy, warm the new one behind it
|
||||
refresh().catch((error) => {
|
||||
console.error(`[cache] background refresh of "${key}" failed:`, error)
|
||||
})
|
||||
|
||||
return hit.value
|
||||
}
|
||||
|
||||
export function bustCache(prefix?: string): number {
|
||||
if (!prefix) {
|
||||
const n = store.size
|
||||
store.clear()
|
||||
return n
|
||||
}
|
||||
|
||||
let n = 0
|
||||
for (const key of store.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
store.delete(key)
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
export function cacheKeys(): string[] {
|
||||
return [...store.keys()]
|
||||
}
|
||||
92
server/utils/config.ts
Normal file
92
server/utils/config.ts
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Nuxt only overrides runtimeConfig from `NUXT_`-prefixed variables, and the
|
||||
* `process.env.X` fallbacks written in `nuxt.config.ts` are frozen at build
|
||||
* time. That is a bad fit for Coolify, where you set plain `CONTENT_*` vars on
|
||||
* an already-built image — so read them here, at request time, and fall back to
|
||||
* whatever the build baked in.
|
||||
*/
|
||||
export interface ContentConfig {
|
||||
provider: string
|
||||
repo: string
|
||||
branch: string
|
||||
dir: string
|
||||
giteaUrl: string
|
||||
token: string
|
||||
ttl: number
|
||||
webhookSecret: string
|
||||
localDir: string
|
||||
siteFile: string
|
||||
}
|
||||
|
||||
export interface ProjectsConfig {
|
||||
githubUser: string
|
||||
giteaUser: string
|
||||
giteaUrl: string
|
||||
githubToken: string
|
||||
giteaToken: string
|
||||
exclude: string[]
|
||||
includeForks: boolean
|
||||
ttl: number
|
||||
}
|
||||
|
||||
export interface DashConfig {
|
||||
password: string
|
||||
sessionSecret: string
|
||||
sessionHours: number
|
||||
}
|
||||
|
||||
export function contentConfig(): ContentConfig {
|
||||
const baked = useRuntimeConfig().content
|
||||
const env = process.env
|
||||
|
||||
return {
|
||||
provider: env.CONTENT_PROVIDER || baked.provider,
|
||||
repo: env.CONTENT_REPO || baked.repo,
|
||||
branch: env.CONTENT_BRANCH || baked.branch,
|
||||
dir: env.CONTENT_DIR ?? baked.dir,
|
||||
giteaUrl: env.GITEA_URL || baked.giteaUrl,
|
||||
token: env.CONTENT_TOKEN || baked.token,
|
||||
ttl: Number(env.CONTENT_TTL || baked.ttl) || 600,
|
||||
webhookSecret: env.CONTENT_WEBHOOK_SECRET || baked.webhookSecret,
|
||||
localDir: env.CONTENT_LOCAL_DIR || baked.localDir,
|
||||
siteFile: env.CONTENT_SITE_FILE || baked.siteFile,
|
||||
}
|
||||
}
|
||||
|
||||
export function projectsConfig(): ProjectsConfig {
|
||||
const baked = useRuntimeConfig().projects
|
||||
const env = process.env
|
||||
const content = contentConfig()
|
||||
|
||||
return {
|
||||
githubUser: env.PROJECTS_GITHUB_USER ?? baked.githubUser,
|
||||
giteaUser: env.PROJECTS_GITEA_USER ?? baked.giteaUser,
|
||||
// no dedicated instance configured => reuse the content repo's
|
||||
giteaUrl: env.PROJECTS_GITEA_URL || baked.giteaUrl || content.giteaUrl,
|
||||
githubToken: env.PROJECTS_GITHUB_TOKEN || baked.githubToken,
|
||||
// the content token only helps here if it belongs to the same instance
|
||||
giteaToken:
|
||||
env.PROJECTS_GITEA_TOKEN ||
|
||||
baked.giteaToken ||
|
||||
(content.provider === 'gitea' ? content.token : ''),
|
||||
exclude: (env.PROJECTS_EXCLUDE ?? baked.exclude)
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
includeForks: env.PROJECTS_INCLUDE_FORKS
|
||||
? env.PROJECTS_INCLUDE_FORKS === 'true'
|
||||
: baked.includeForks,
|
||||
ttl: Number(env.PROJECTS_TTL || baked.ttl) || 900,
|
||||
}
|
||||
}
|
||||
|
||||
export function dashConfig(): DashConfig {
|
||||
const baked = useRuntimeConfig().dash
|
||||
const env = process.env
|
||||
|
||||
return {
|
||||
password: env.DASH_PASSWORD || baked.password,
|
||||
sessionSecret: env.DASH_SESSION_SECRET || baked.sessionSecret,
|
||||
sessionHours: Number(env.DASH_SESSION_HOURS || baked.sessionHours) || 168,
|
||||
}
|
||||
}
|
||||
123
server/utils/dash-auth.ts
Normal file
123
server/utils/dash-auth.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { createHmac, createHash, timingSafeEqual } from 'node:crypto'
|
||||
import type { H3Event } from 'h3'
|
||||
import { dashConfig } from './config'
|
||||
|
||||
const COOKIE = 'dash_session'
|
||||
|
||||
/** brute-force brake: per-IP, in memory, resets on restart */
|
||||
const attempts = new Map<string, { count: number; until: number }>()
|
||||
const MAX_ATTEMPTS = 8
|
||||
const WINDOW_MS = 10 * 60 * 1000
|
||||
|
||||
export function dashEnabled(): boolean {
|
||||
return !!dashConfig().password
|
||||
}
|
||||
|
||||
/**
|
||||
* Signing key. Deriving it from the password by default means one less variable
|
||||
* to set — and changing the password invalidates every open session, which is
|
||||
* the behaviour you want anyway.
|
||||
*/
|
||||
function sessionKey(): string {
|
||||
const { password, sessionSecret } = dashConfig()
|
||||
if (sessionSecret) return sessionSecret
|
||||
return createHash('sha256').update(`${password}:dash-session`).digest('hex')
|
||||
}
|
||||
|
||||
function sign(payload: string): string {
|
||||
return createHmac('sha256', sessionKey()).update(payload).digest('hex')
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a, 'utf8')
|
||||
const bufB = Buffer.from(b, 'utf8')
|
||||
if (bufA.length !== bufB.length) return false
|
||||
return timingSafeEqual(bufA, bufB)
|
||||
}
|
||||
|
||||
function clientIp(event: H3Event): string {
|
||||
return (
|
||||
getRequestHeader(event, 'cf-connecting-ip')
|
||||
?? getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
|
||||
?? event.node.req.socket.remoteAddress
|
||||
?? 'unknown'
|
||||
)
|
||||
}
|
||||
|
||||
function isHttps(event: H3Event): boolean {
|
||||
return getRequestHeader(event, 'x-forwarded-proto')?.split(',')[0]?.trim() === 'https'
|
||||
}
|
||||
|
||||
export function assertNotRateLimited(event: H3Event): void {
|
||||
const ip = clientIp(event)
|
||||
const entry = attempts.get(ip)
|
||||
if (entry && entry.count >= MAX_ATTEMPTS && Date.now() < entry.until) {
|
||||
const seconds = Math.ceil((entry.until - Date.now()) / 1000)
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
message: `too many attempts, wait ${seconds}s`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function recordFailure(event: H3Event): void {
|
||||
const ip = clientIp(event)
|
||||
const now = Date.now()
|
||||
const entry = attempts.get(ip)
|
||||
if (!entry || now >= entry.until) attempts.set(ip, { count: 1, until: now + WINDOW_MS })
|
||||
else entry.count++
|
||||
}
|
||||
|
||||
export function clearFailures(event: H3Event): void {
|
||||
attempts.delete(clientIp(event))
|
||||
}
|
||||
|
||||
/** compare hashes, so the timing does not leak the password's length */
|
||||
export function passwordMatches(candidate: string): boolean {
|
||||
const { password } = dashConfig()
|
||||
if (!password) return false
|
||||
const a = createHash('sha256').update(candidate).digest('hex')
|
||||
const b = createHash('sha256').update(password).digest('hex')
|
||||
return safeEqual(a, b)
|
||||
}
|
||||
|
||||
export function issueSession(event: H3Event): void {
|
||||
const hours = dashConfig().sessionHours
|
||||
const expires = Date.now() + hours * 3600 * 1000
|
||||
const payload = String(expires)
|
||||
|
||||
setCookie(event, COOKIE, `${payload}.${sign(payload)}`, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isHttps(event),
|
||||
path: '/',
|
||||
maxAge: hours * 3600,
|
||||
})
|
||||
}
|
||||
|
||||
export function clearDashSession(event: H3Event): void {
|
||||
deleteCookie(event, COOKIE, { path: '/' })
|
||||
}
|
||||
|
||||
export function hasValidSession(event: H3Event): boolean {
|
||||
if (!dashEnabled()) return false
|
||||
|
||||
const raw = getCookie(event, COOKIE)
|
||||
if (!raw) return false
|
||||
|
||||
const [payload, signature] = raw.split('.')
|
||||
if (!payload || !signature) return false
|
||||
if (!safeEqual(sign(payload), signature)) return false
|
||||
|
||||
const expires = Number(payload)
|
||||
return Number.isFinite(expires) && Date.now() < expires
|
||||
}
|
||||
|
||||
export function assertDashSession(event: H3Event): void {
|
||||
if (!dashEnabled()) {
|
||||
throw createError({ statusCode: 503, message: 'DASH_PASSWORD is not configured' })
|
||||
}
|
||||
if (!hasValidSession(event)) {
|
||||
throw createError({ statusCode: 401, message: 'not signed in' })
|
||||
}
|
||||
}
|
||||
113
server/utils/markdown.ts
Normal file
113
server/utils/markdown.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import anchor from 'markdown-it-anchor'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
import { createHighlighter, type Highlighter } from 'shiki'
|
||||
import type { TocEntry } from '#shared/types/blog'
|
||||
|
||||
/**
|
||||
* Languages compiled into the image. Shiki's `codeToHtml` is async but
|
||||
* markdown-it's fence rule is not, so we preload a fixed set at boot and fall
|
||||
* back to plain text for anything else.
|
||||
*/
|
||||
const LANGS = [
|
||||
'rust', 'typescript', 'javascript', 'vue', 'python', 'bash', 'shell',
|
||||
'json', 'yaml', 'toml', 'html', 'css', 'c', 'cpp', 'csharp', 'go',
|
||||
'sql', 'markdown', 'diff', 'docker', 'nginx', 'lua', 'glsl', 'ini',
|
||||
] as const
|
||||
|
||||
const THEMES = { light: 'github-light', dark: 'github-dark' } as const
|
||||
|
||||
let highlighterPromise: Promise<Highlighter> | null = null
|
||||
|
||||
function useHighlighter(): Promise<Highlighter> {
|
||||
highlighterPromise ??= createHighlighter({
|
||||
themes: [THEMES.light, THEMES.dark],
|
||||
langs: [...LANGS],
|
||||
})
|
||||
return highlighterPromise
|
||||
}
|
||||
|
||||
export interface RenderResult {
|
||||
html: string
|
||||
toc: TocEntry[]
|
||||
/** minutes, rounded up, floor of 1 */
|
||||
readingTime: number
|
||||
}
|
||||
|
||||
export async function renderMarkdown(
|
||||
source: string,
|
||||
options: { resolveAsset?: (src: string) => string } = {},
|
||||
): Promise<RenderResult> {
|
||||
const highlighter = await useHighlighter()
|
||||
const loaded = new Set(highlighter.getLoadedLanguages())
|
||||
const resolveAsset = options.resolveAsset ?? ((s: string) => s)
|
||||
|
||||
// one slugger per render so duplicate headings get -1, -2 … suffixes
|
||||
const slugger = new GithubSlugger()
|
||||
const toc: TocEntry[] = []
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
breaks: false,
|
||||
highlight(code, lang) {
|
||||
const language = loaded.has(lang) ? lang : 'text'
|
||||
try {
|
||||
return highlighter.codeToHtml(code, { lang: language, themes: THEMES })
|
||||
} catch {
|
||||
return '' // let markdown-it escape it itself
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
md.use(anchor, {
|
||||
slugify: (s: string) => slugger.slug(s),
|
||||
level: [2, 3],
|
||||
permalink: anchor.permalink.linkInsideHeader({
|
||||
symbol: '#',
|
||||
class: 'heading-anchor',
|
||||
placement: 'after',
|
||||
ariaHidden: true,
|
||||
}),
|
||||
callback(token, info) {
|
||||
// `token.tag` is h2 / h3 — deeper levels are filtered out above
|
||||
toc.push({ id: info.slug, text: info.title, depth: Number(token.tag.slice(1)) })
|
||||
},
|
||||
})
|
||||
|
||||
// external links open in a new tab
|
||||
const renderToken = (tokens: any, i: number, opts: any, _env: any, self: any) =>
|
||||
self.renderToken(tokens, i, opts)
|
||||
const defaultLink = md.renderer.rules.link_open ?? renderToken
|
||||
|
||||
md.renderer.rules.link_open = (tokens, i, opts, env, self) => {
|
||||
const token = tokens[i]
|
||||
const href = String(token?.attrGet('href') ?? '')
|
||||
if (token && /^https?:\/\//i.test(href)) {
|
||||
token.attrSet('target', '_blank')
|
||||
token.attrSet('rel', 'noopener noreferrer')
|
||||
}
|
||||
return defaultLink(tokens, i, opts, env, self)
|
||||
}
|
||||
|
||||
// relative images resolve against the content repo
|
||||
const defaultImage = md.renderer.rules.image ?? renderToken
|
||||
md.renderer.rules.image = (tokens, i, opts, env, self) => {
|
||||
const token = tokens[i]
|
||||
if (token) {
|
||||
const src = String(token.attrGet('src') ?? '')
|
||||
if (src && !/^(https?:)?\/\//i.test(src) && !src.startsWith('data:')) {
|
||||
token.attrSet('src', resolveAsset(src))
|
||||
}
|
||||
token.attrSet('loading', 'lazy')
|
||||
}
|
||||
return defaultImage(tokens, i, opts, env, self)
|
||||
}
|
||||
|
||||
const html = md.render(source)
|
||||
|
||||
const words = source.split(/\s+/).filter(Boolean).length
|
||||
const readingTime = Math.max(1, Math.ceil(words / 200))
|
||||
|
||||
return { html, toc, readingTime }
|
||||
}
|
||||
181
server/utils/post-editor.ts
Normal file
181
server/utils/post-editor.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import matter from 'gray-matter'
|
||||
import type { PostDraft, PostFrontmatterInput, SavePayload } from '#shared/types/dash'
|
||||
import { useContentProvider } from './providers'
|
||||
import { assertWritable } from './providers/types'
|
||||
import { contentConfig } from './config'
|
||||
import { bustCache } from './cache'
|
||||
import { findEntry, getIndex, slugify } from './posts'
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
|
||||
function asStringList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((v) => String(v).trim().toLowerCase()).filter(Boolean)
|
||||
if (typeof value === 'string') {
|
||||
return value.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function asDate(value: unknown, fallback: string): string {
|
||||
if (value instanceof Date && !Number.isNaN(value.valueOf())) {
|
||||
return value.toISOString().slice(0, 10)
|
||||
}
|
||||
const text = String(value ?? '').trim()
|
||||
if (DATE_RE.test(text)) return text
|
||||
if (text) {
|
||||
const parsed = new Date(text)
|
||||
if (!Number.isNaN(parsed.valueOf())) return parsed.toISOString().slice(0, 10)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** what the editor sends -> what actually goes in the file */
|
||||
function cleanFrontmatter(
|
||||
input: Partial<PostFrontmatterInput>,
|
||||
fallbackSlug: string,
|
||||
): PostFrontmatterInput {
|
||||
const title = String(input.title ?? '').trim()
|
||||
if (!title) {
|
||||
throw createError({ statusCode: 422, message: 'a post needs a title' })
|
||||
}
|
||||
|
||||
const slug = slugify(String(input.slug ?? '').trim() || title) || fallbackSlug
|
||||
if (!slug) {
|
||||
throw createError({ statusCode: 422, message: 'could not derive a slug from that title' })
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
description: String(input.description ?? '').trim(),
|
||||
date: asDate(input.date, today()),
|
||||
updated: input.updated ? asDate(input.updated, '') : '',
|
||||
tags: asStringList(input.tags),
|
||||
slug,
|
||||
draft: input.draft === true,
|
||||
}
|
||||
}
|
||||
|
||||
/** drop empty fields so the file stays readable */
|
||||
function serialize(frontmatter: PostFrontmatterInput, body: string): string {
|
||||
const data: Record<string, unknown> = {
|
||||
title: frontmatter.title,
|
||||
date: frontmatter.date,
|
||||
slug: frontmatter.slug,
|
||||
}
|
||||
if (frontmatter.description) data.description = frontmatter.description
|
||||
if (frontmatter.updated) data.updated = frontmatter.updated
|
||||
if (frontmatter.tags.length) data.tags = frontmatter.tags
|
||||
if (frontmatter.draft) data.draft = true
|
||||
|
||||
return matter.stringify(`${body.trimEnd()}\n`, data)
|
||||
}
|
||||
|
||||
export async function getPostDraft(slug: string): Promise<PostDraft | null> {
|
||||
const entry = await findEntry(slug)
|
||||
if (!entry) return null
|
||||
|
||||
const provider = useContentProvider()
|
||||
const raw = await provider.read(entry.path)
|
||||
const { data, content } = matter(raw)
|
||||
|
||||
return {
|
||||
path: entry.path,
|
||||
sha: entry.sha,
|
||||
sourceUrl: provider.sourceUrl(entry.path),
|
||||
frontmatter: {
|
||||
title: String(data.title ?? entry.meta.title),
|
||||
description: String(data.description ?? data.summary ?? ''),
|
||||
date: asDate(data.date, entry.meta.date.slice(0, 10)),
|
||||
updated: data.updated ? asDate(data.updated, '') : '',
|
||||
tags: asStringList(data.tags),
|
||||
slug: entry.meta.slug,
|
||||
draft: data.draft === true || data.published === false,
|
||||
},
|
||||
body: content.replace(/^\n+/, ''),
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPost(payload: SavePayload) {
|
||||
const provider = useContentProvider()
|
||||
assertWritable(provider)
|
||||
|
||||
const frontmatter = cleanFrontmatter(payload.frontmatter, '')
|
||||
const dir = contentConfig().dir.replace(/^\/+|\/+$/g, '')
|
||||
const path = `${dir ? `${dir}/` : ''}${frontmatter.date}-${frontmatter.slug}.md`
|
||||
|
||||
const index = await getIndex()
|
||||
if (index.some((e) => e.meta.slug === frontmatter.slug)) {
|
||||
throw createError({ statusCode: 409, message: `a post already uses the slug "${frontmatter.slug}"` })
|
||||
}
|
||||
if (await provider.stat(path)) {
|
||||
throw createError({ statusCode: 409, message: `${path} already exists in the repo` })
|
||||
}
|
||||
|
||||
const result = await provider.write({
|
||||
path,
|
||||
content: serialize(frontmatter, payload.body ?? ''),
|
||||
message: payload.message?.trim() || `post: add ${frontmatter.slug}`,
|
||||
})
|
||||
|
||||
bustCache('posts:')
|
||||
return { slug: frontmatter.slug, path, sha: result.sha, commit: result.commit }
|
||||
}
|
||||
|
||||
export async function updatePost(slug: string, payload: SavePayload) {
|
||||
const provider = useContentProvider()
|
||||
assertWritable(provider)
|
||||
|
||||
const entry = await findEntry(slug)
|
||||
if (!entry) throw createError({ statusCode: 404, message: 'post not found' })
|
||||
|
||||
const frontmatter = cleanFrontmatter(payload.frontmatter, slug)
|
||||
|
||||
// the file keeps its path even when the slug changes — the `slug:` field is
|
||||
// what drives the url, so renaming never orphans a commit history.
|
||||
const live = await provider.stat(entry.path)
|
||||
if (payload.sha && live?.sha && payload.sha !== live.sha) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: 'this post changed in the repo since you opened it — reload and reapply',
|
||||
})
|
||||
}
|
||||
|
||||
if (frontmatter.slug !== slug) {
|
||||
const clash = (await getIndex()).find(
|
||||
(e) => e.meta.slug === frontmatter.slug && e.path !== entry.path,
|
||||
)
|
||||
if (clash) {
|
||||
throw createError({ statusCode: 409, message: `the slug "${frontmatter.slug}" is taken` })
|
||||
}
|
||||
}
|
||||
|
||||
const result = await provider.write({
|
||||
path: entry.path,
|
||||
content: serialize(frontmatter, payload.body ?? ''),
|
||||
message: payload.message?.trim() || `post: update ${frontmatter.slug}`,
|
||||
sha: live?.sha ?? entry.sha,
|
||||
})
|
||||
|
||||
bustCache('posts:')
|
||||
return { slug: frontmatter.slug, path: entry.path, sha: result.sha, commit: result.commit }
|
||||
}
|
||||
|
||||
export async function deletePost(slug: string, message?: string) {
|
||||
const provider = useContentProvider()
|
||||
assertWritable(provider)
|
||||
|
||||
const entry = await findEntry(slug)
|
||||
if (!entry) throw createError({ statusCode: 404, message: 'post not found' })
|
||||
|
||||
const live = await provider.stat(entry.path)
|
||||
await provider.remove({
|
||||
path: entry.path,
|
||||
message: message?.trim() || `post: remove ${slug}`,
|
||||
sha: live?.sha ?? entry.sha,
|
||||
})
|
||||
|
||||
bustCache('posts:')
|
||||
return { slug, path: entry.path }
|
||||
}
|
||||
190
server/utils/posts.ts
Normal file
190
server/utils/posts.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import matter from 'gray-matter'
|
||||
import { dirname, join, normalize } from 'node:path/posix'
|
||||
import type { Post, PostMeta } from '#shared/types/blog'
|
||||
import { useContentProvider } from './providers'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import { cached } from './cache'
|
||||
|
||||
const INDEX_KEY = 'posts:index'
|
||||
const postKey = (slug: string) => `posts:post:${slug}`
|
||||
|
||||
interface IndexEntry {
|
||||
meta: PostMeta
|
||||
path: string
|
||||
/** blob sha, for optimistic concurrency when editing from /dash */
|
||||
sha: string
|
||||
}
|
||||
|
||||
function ttl(): number {
|
||||
return contentConfig().ttl
|
||||
}
|
||||
|
||||
function showDrafts(): boolean {
|
||||
return process.env.SHOW_DRAFTS === 'true' || import.meta.dev
|
||||
}
|
||||
|
||||
export function slugify(input: string): string {
|
||||
return input
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
/** `2026-09-08-hello-world.md` -> `hello-world` */
|
||||
function slugFromPath(path: string): string {
|
||||
const base = path.split('/').pop() ?? path
|
||||
return slugify(base.replace(/\.mdx?$/i, '').replace(/^\d{4}-\d{2}-\d{2}[-_]/, ''))
|
||||
}
|
||||
|
||||
function dateFromPath(path: string): string | null {
|
||||
const match = /(\d{4}-\d{2}-\d{2})/.exec(path.split('/').pop() ?? '')
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function toIso(value: unknown, fallback: string): string {
|
||||
if (value instanceof Date && !Number.isNaN(value.valueOf())) return value.toISOString()
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
const d = new Date(value)
|
||||
if (!Number.isNaN(d.valueOf())) return d.toISOString()
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function toTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((t) => String(t).trim().toLowerCase()).filter(Boolean)
|
||||
if (typeof value === 'string') {
|
||||
return value.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** first paragraph of the body, for posts without a `description:` */
|
||||
function excerpt(body: string, max = 180): string {
|
||||
const text = body
|
||||
.replace(/^#{1,6}\s+.*$/gm, '')
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '')
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/[*_`>#-]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
if (text.length <= max) return text
|
||||
return `${text.slice(0, max).replace(/\s+\S*$/, '')}…`
|
||||
}
|
||||
|
||||
function parse(path: string, raw: string): { meta: PostMeta; body: string } {
|
||||
const provider = useContentProvider()
|
||||
const { data, content } = matter(raw)
|
||||
|
||||
const fallbackDate = dateFromPath(path) ?? '1970-01-01'
|
||||
const words = content.split(/\s+/).filter(Boolean).length
|
||||
|
||||
const meta: PostMeta = {
|
||||
slug: String(data.slug || slugFromPath(path)),
|
||||
title: String(data.title || slugFromPath(path).replace(/-/g, ' ')),
|
||||
description: String(data.description || data.summary || excerpt(content)),
|
||||
date: toIso(data.date, new Date(fallbackDate).toISOString()),
|
||||
updated: data.updated ? toIso(data.updated, '') || undefined : undefined,
|
||||
tags: toTags(data.tags),
|
||||
draft: data.draft === true || data.published === false,
|
||||
readingTime: Math.max(1, Math.ceil(words / 200)),
|
||||
path,
|
||||
sourceUrl: provider.sourceUrl(path),
|
||||
}
|
||||
|
||||
return { meta, body: content }
|
||||
}
|
||||
|
||||
async function loadIndex(): Promise<IndexEntry[]> {
|
||||
const provider = useContentProvider()
|
||||
const files = await provider.list()
|
||||
|
||||
const entries = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
try {
|
||||
const raw = await provider.read(file.path)
|
||||
const { meta } = parse(file.path, raw)
|
||||
return { meta, path: meta.path, sha: file.sha ?? '' }
|
||||
} catch (error) {
|
||||
console.error(`[posts] could not read ${file.path}:`, error)
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return entries
|
||||
.filter((e): e is IndexEntry => e !== null)
|
||||
.sort((a, b) => b.meta.date.localeCompare(a.meta.date))
|
||||
}
|
||||
|
||||
export async function getIndex(): Promise<IndexEntry[]> {
|
||||
return cached(INDEX_KEY, ttl(), loadIndex)
|
||||
}
|
||||
|
||||
/** the index row for a slug — path and sha included, for the editor */
|
||||
export async function findEntry(slug: string): Promise<IndexEntry | null> {
|
||||
return (await getIndex()).find((e) => e.meta.slug === slug) ?? null
|
||||
}
|
||||
|
||||
export { parse as parsePost }
|
||||
export type { IndexEntry }
|
||||
|
||||
export async function listPosts(
|
||||
options: { tag?: string; includeDrafts?: boolean } = {},
|
||||
): Promise<PostMeta[]> {
|
||||
const index = await getIndex()
|
||||
const drafts = options.includeDrafts ?? showDrafts()
|
||||
|
||||
return index
|
||||
.map((e) => e.meta)
|
||||
.filter((m) => drafts || !m.draft)
|
||||
.filter((m) => !options.tag || m.tags.includes(options.tag.toLowerCase()))
|
||||
}
|
||||
|
||||
export async function listTags(): Promise<Array<{ tag: string; count: number }>> {
|
||||
const posts = await listPosts()
|
||||
const counts = new Map<string, number>()
|
||||
for (const post of posts) {
|
||||
for (const tag of post.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([tag, count]) => ({ tag, count }))
|
||||
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
|
||||
}
|
||||
|
||||
export async function getPost(slug: string): Promise<Post | null> {
|
||||
const index = await getIndex()
|
||||
const entry = index.find((e) => e.meta.slug === slug)
|
||||
if (!entry) return null
|
||||
if (entry.meta.draft && !showDrafts()) return null
|
||||
|
||||
return cached(postKey(slug), ttl(), async () => {
|
||||
const provider = useContentProvider()
|
||||
const raw = await provider.read(entry.path)
|
||||
const { meta, body } = parse(entry.path, raw)
|
||||
|
||||
const baseDir = dirname(entry.path)
|
||||
const { html, toc, readingTime } = await renderMarkdown(body, {
|
||||
resolveAsset: (src) => provider.assetUrl(normalize(join(baseDir, src))),
|
||||
})
|
||||
|
||||
return { ...meta, html, toc, readingTime }
|
||||
})
|
||||
}
|
||||
|
||||
/** previous / next in reverse-chronological order */
|
||||
export async function getNeighbours(slug: string): Promise<{
|
||||
previous: PostMeta | null
|
||||
next: PostMeta | null
|
||||
}> {
|
||||
const posts = await listPosts()
|
||||
const i = posts.findIndex((p) => p.slug === slug)
|
||||
if (i === -1) return { previous: null, next: null }
|
||||
return {
|
||||
previous: posts[i + 1] ?? null, // older
|
||||
next: posts[i - 1] ?? null, // newer
|
||||
}
|
||||
}
|
||||
113
server/utils/providers/gitea.ts
Normal file
113
server/utils/providers/gitea.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import type {
|
||||
ContentProvider,
|
||||
ProviderOptions,
|
||||
RemoteFile,
|
||||
WriteOptions,
|
||||
WriteResult,
|
||||
} from './types'
|
||||
import { isMarkdown, isNotFound, normalizeDir, splitRepo, toBase64 } from './types'
|
||||
|
||||
interface GiteaTree {
|
||||
tree?: Array<{ path: string; type: 'blob' | 'tree'; sha: string }>
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
interface GiteaContents {
|
||||
sha: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
interface GiteaFileResponse {
|
||||
content?: GiteaContents | null
|
||||
commit?: { sha?: string }
|
||||
}
|
||||
|
||||
export function createGiteaProvider(opts: ProviderOptions): ContentProvider {
|
||||
const { owner, name } = splitRepo(opts.repo)
|
||||
const base = opts.giteaUrl.replace(/\/+$/, '')
|
||||
const api = `${base}/api/v1/repos/${owner}/${name}`
|
||||
const dir = normalizeDir(opts.dir)
|
||||
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
if (opts.token) headers.Authorization = `token ${opts.token}`
|
||||
|
||||
return {
|
||||
kind: 'gitea',
|
||||
writable: !!opts.token,
|
||||
|
||||
async list(): Promise<RemoteFile[]> {
|
||||
const tree = await $fetch<GiteaTree>(
|
||||
`${api}/git/trees/${encodeURIComponent(opts.branch)}`,
|
||||
{ headers, query: { recursive: 'true', per_page: 1000 } },
|
||||
)
|
||||
|
||||
if (tree.truncated) {
|
||||
console.warn('[gitea] tree response was truncated — some posts may be missing')
|
||||
}
|
||||
|
||||
return (tree.tree ?? [])
|
||||
.filter((e) => e.type === 'blob' && isMarkdown(e.path))
|
||||
.filter((e) => (dir ? e.path.startsWith(`${dir}/`) : true))
|
||||
.map((e) => ({ path: e.path, sha: e.sha }))
|
||||
},
|
||||
|
||||
async read(path: string): Promise<string> {
|
||||
// the raw endpoint returns the file verbatim and honours the token
|
||||
return await $fetch<string>(`${api}/raw/${encodeURI(path)}`, {
|
||||
headers: opts.token ? { Authorization: `token ${opts.token}` } : {},
|
||||
query: { ref: opts.branch },
|
||||
responseType: 'text',
|
||||
})
|
||||
},
|
||||
|
||||
async stat(path: string) {
|
||||
try {
|
||||
const file = await $fetch<GiteaContents>(`${api}/contents/${encodeURI(path)}`, {
|
||||
headers,
|
||||
query: { ref: opts.branch },
|
||||
})
|
||||
return file?.sha ? { sha: file.sha } : null
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return null
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async write({ path, content, message, sha }: WriteOptions): Promise<WriteResult> {
|
||||
const body: Record<string, unknown> = {
|
||||
content: toBase64(content),
|
||||
message,
|
||||
branch: opts.branch,
|
||||
}
|
||||
if (sha) body.sha = sha
|
||||
|
||||
const res = await $fetch<GiteaFileResponse>(`${api}/contents/${encodeURI(path)}`, {
|
||||
method: sha ? 'PUT' : 'POST',
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
|
||||
return {
|
||||
path,
|
||||
sha: res?.content?.sha ?? '',
|
||||
commit: res?.commit?.sha,
|
||||
}
|
||||
},
|
||||
|
||||
async remove({ path, message, sha }) {
|
||||
await $fetch(`${api}/contents/${encodeURI(path)}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
body: { message, sha, branch: opts.branch },
|
||||
})
|
||||
},
|
||||
|
||||
sourceUrl(path: string): string {
|
||||
return `${base}/${owner}/${name}/src/branch/${opts.branch}/${encodeURI(path)}`
|
||||
},
|
||||
|
||||
assetUrl(path: string): string {
|
||||
return `${base}/${owner}/${name}/raw/branch/${opts.branch}/${encodeURI(path)}`
|
||||
},
|
||||
}
|
||||
}
|
||||
110
server/utils/providers/github.ts
Normal file
110
server/utils/providers/github.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import type {
|
||||
ContentProvider,
|
||||
ProviderOptions,
|
||||
RemoteFile,
|
||||
WriteOptions,
|
||||
WriteResult,
|
||||
} from './types'
|
||||
import { isMarkdown, isNotFound, normalizeDir, splitRepo, toBase64 } from './types'
|
||||
|
||||
interface GithubTree {
|
||||
tree?: Array<{ path: string; type: 'blob' | 'tree'; sha: string }>
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
interface GithubFileResponse {
|
||||
content?: { sha?: string } | null
|
||||
commit?: { sha?: string }
|
||||
}
|
||||
|
||||
export function createGithubProvider(opts: ProviderOptions): ContentProvider {
|
||||
const { owner, name } = splitRepo(opts.repo)
|
||||
const api = `https://api.github.com/repos/${owner}/${name}`
|
||||
const dir = normalizeDir(opts.dir)
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'nak0x.dev',
|
||||
}
|
||||
if (opts.token) headers.Authorization = `Bearer ${opts.token}`
|
||||
|
||||
return {
|
||||
kind: 'github',
|
||||
writable: !!opts.token,
|
||||
|
||||
async list(): Promise<RemoteFile[]> {
|
||||
const tree = await $fetch<GithubTree>(
|
||||
`${api}/git/trees/${encodeURIComponent(opts.branch)}`,
|
||||
{ headers, query: { recursive: '1' } },
|
||||
)
|
||||
|
||||
if (tree.truncated) {
|
||||
console.warn('[github] tree response was truncated — some posts may be missing')
|
||||
}
|
||||
|
||||
return (tree.tree ?? [])
|
||||
.filter((e) => e.type === 'blob' && isMarkdown(e.path))
|
||||
.filter((e) => (dir ? e.path.startsWith(`${dir}/`) : true))
|
||||
.map((e) => ({ path: e.path, sha: e.sha }))
|
||||
},
|
||||
|
||||
async read(path: string): Promise<string> {
|
||||
return await $fetch<string>(`${api}/contents/${encodeURI(path)}`, {
|
||||
headers: { ...headers, Accept: 'application/vnd.github.raw' },
|
||||
query: { ref: opts.branch },
|
||||
responseType: 'text',
|
||||
})
|
||||
},
|
||||
|
||||
async stat(path: string) {
|
||||
try {
|
||||
const file = await $fetch<{ sha?: string }>(`${api}/contents/${encodeURI(path)}`, {
|
||||
headers,
|
||||
query: { ref: opts.branch },
|
||||
})
|
||||
return file?.sha ? { sha: file.sha } : null
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return null
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async write({ path, content, message, sha }: WriteOptions): Promise<WriteResult> {
|
||||
const body: Record<string, unknown> = {
|
||||
content: toBase64(content),
|
||||
message,
|
||||
branch: opts.branch,
|
||||
}
|
||||
if (sha) body.sha = sha
|
||||
|
||||
const res = await $fetch<GithubFileResponse>(`${api}/contents/${encodeURI(path)}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
|
||||
return {
|
||||
path,
|
||||
sha: res?.content?.sha ?? '',
|
||||
commit: res?.commit?.sha,
|
||||
}
|
||||
},
|
||||
|
||||
async remove({ path, message, sha }) {
|
||||
await $fetch(`${api}/contents/${encodeURI(path)}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
body: { message, sha, branch: opts.branch },
|
||||
})
|
||||
},
|
||||
|
||||
sourceUrl(path: string): string {
|
||||
return `https://github.com/${owner}/${name}/blob/${opts.branch}/${encodeURI(path)}`
|
||||
},
|
||||
|
||||
assetUrl(path: string): string {
|
||||
return `https://raw.githubusercontent.com/${owner}/${name}/${opts.branch}/${encodeURI(path)}`
|
||||
},
|
||||
}
|
||||
}
|
||||
42
server/utils/providers/index.ts
Normal file
42
server/utils/providers/index.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import type { ContentProvider, ProviderOptions } from './types'
|
||||
import { createGiteaProvider } from './gitea'
|
||||
import { createGithubProvider } from './github'
|
||||
import { createLocalProvider } from './local'
|
||||
|
||||
let cachedProvider: ContentProvider | null = null
|
||||
|
||||
export function useContentProvider(): ContentProvider {
|
||||
if (cachedProvider) return cachedProvider
|
||||
|
||||
const content = contentConfig()
|
||||
const opts: ProviderOptions = {
|
||||
repo: content.repo,
|
||||
branch: content.branch,
|
||||
dir: content.dir,
|
||||
token: content.token,
|
||||
giteaUrl: content.giteaUrl,
|
||||
localDir: content.localDir,
|
||||
}
|
||||
|
||||
switch (content.provider) {
|
||||
case 'github':
|
||||
cachedProvider = createGithubProvider(opts)
|
||||
break
|
||||
case 'local':
|
||||
cachedProvider = createLocalProvider(opts)
|
||||
break
|
||||
case 'gitea':
|
||||
cachedProvider = createGiteaProvider(opts)
|
||||
break
|
||||
default:
|
||||
throw new Error(
|
||||
`unknown CONTENT_PROVIDER "${content.provider}" — expected gitea, github or local`,
|
||||
)
|
||||
}
|
||||
|
||||
return cachedProvider
|
||||
}
|
||||
|
||||
export function resetContentProvider(): void {
|
||||
cachedProvider = null
|
||||
}
|
||||
88
server/utils/providers/local.ts
Normal file
88
server/utils/providers/local.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path'
|
||||
import type { ContentProvider, ProviderOptions, RemoteFile, WriteOptions, WriteResult } from './types'
|
||||
import { isMarkdown, normalizeDir } from './types'
|
||||
|
||||
/**
|
||||
* Reads and writes posts on the local filesystem. Useful for drafting before
|
||||
* the repo exists, and for CI where you do not want to hit the network.
|
||||
*
|
||||
* There is no git here, so "sha" is just a content hash — enough to give the
|
||||
* dashboard the same optimistic-concurrency guarantee as the git backends.
|
||||
*/
|
||||
export function createLocalProvider(opts: ProviderOptions): ContentProvider {
|
||||
// `localDir` stands in for the repo root, so paths look exactly like the git
|
||||
// backends': `posts/foo.md`, `site.json`.
|
||||
const root = resolve(process.cwd(), opts.localDir)
|
||||
const dir = normalizeDir(opts.dir)
|
||||
|
||||
const hash = (content: string) => createHash('sha256').update(content).digest('hex').slice(0, 40)
|
||||
|
||||
function safeResolve(path: string): string {
|
||||
const full = resolve(root, path)
|
||||
if (full !== root && !full.startsWith(root + sep)) {
|
||||
throw new Error(`refusing to touch a path outside ${root}`)
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
async function walk(current: string): Promise<string[]> {
|
||||
const entries = await readdir(current, { withFileTypes: true }).catch(() => [])
|
||||
const out: string[] = []
|
||||
for (const entry of entries) {
|
||||
const full = join(current, entry.name)
|
||||
if (entry.isDirectory()) out.push(...(await walk(full)))
|
||||
else if (isMarkdown(entry.name)) out.push(full)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const toRepoPath = (full: string) => relative(root, full).split(sep).join('/')
|
||||
|
||||
return {
|
||||
kind: 'local',
|
||||
writable: true,
|
||||
|
||||
async list(): Promise<RemoteFile[]> {
|
||||
const files = await walk(dir ? join(root, dir) : root)
|
||||
return await Promise.all(
|
||||
files.map(async (full) => ({
|
||||
path: toRepoPath(full),
|
||||
sha: hash(await readFile(full, 'utf8')),
|
||||
})),
|
||||
)
|
||||
},
|
||||
|
||||
async read(path: string): Promise<string> {
|
||||
return await readFile(safeResolve(path), 'utf8')
|
||||
},
|
||||
|
||||
async stat(path: string) {
|
||||
try {
|
||||
return { sha: hash(await readFile(safeResolve(path), 'utf8')) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
async write({ path, content }: WriteOptions): Promise<WriteResult> {
|
||||
const full = safeResolve(path)
|
||||
await mkdir(dirname(full), { recursive: true })
|
||||
await writeFile(full, content, 'utf8')
|
||||
return { path, sha: hash(content) }
|
||||
},
|
||||
|
||||
async remove({ path }) {
|
||||
await rm(safeResolve(path), { force: true })
|
||||
},
|
||||
|
||||
sourceUrl(path: string): string {
|
||||
return `file://${join(root, path)}`
|
||||
},
|
||||
|
||||
assetUrl(path: string): string {
|
||||
return `/${path}`
|
||||
},
|
||||
}
|
||||
}
|
||||
86
server/utils/providers/types.ts
Normal file
86
server/utils/providers/types.ts
Normal file
@ -0,0 +1,86 @@
|
||||
export interface RemoteFile {
|
||||
/** path relative to the repo root */
|
||||
path: string
|
||||
sha?: string
|
||||
}
|
||||
|
||||
export interface WriteOptions {
|
||||
path: string
|
||||
content: string
|
||||
message: string
|
||||
/** blob sha of the version you are replacing; omit to create */
|
||||
sha?: string
|
||||
}
|
||||
|
||||
export interface WriteResult {
|
||||
path: string
|
||||
/** blob sha of the version that now exists */
|
||||
sha: string
|
||||
/** commit sha, when the backend reports one */
|
||||
commit?: string
|
||||
}
|
||||
|
||||
export interface ContentProvider {
|
||||
readonly kind: string
|
||||
/** false when no token is configured — /dash stays read-only */
|
||||
readonly writable: boolean
|
||||
/** every markdown file under the configured directory */
|
||||
list(): Promise<RemoteFile[]>
|
||||
/** raw utf-8 contents of one file */
|
||||
read(path: string): Promise<string>
|
||||
/** blob sha of one file, or null when it does not exist */
|
||||
stat(path: string): Promise<{ sha: string } | null>
|
||||
/** create (no sha) or replace (with sha) a file, as one commit */
|
||||
write(options: WriteOptions): Promise<WriteResult>
|
||||
/** delete a file, as one commit */
|
||||
remove(options: { path: string; message: string; sha: string }): Promise<void>
|
||||
/** human-facing url of the file, for a "view source" link */
|
||||
sourceUrl(path: string): string
|
||||
/** url that serves a raw asset, used to rewrite relative images */
|
||||
assetUrl(path: string): string
|
||||
}
|
||||
|
||||
export interface ProviderOptions {
|
||||
repo: string
|
||||
branch: string
|
||||
dir: string
|
||||
token: string
|
||||
giteaUrl: string
|
||||
localDir: string
|
||||
}
|
||||
|
||||
export function splitRepo(repo: string): { owner: string; name: string } {
|
||||
const [owner, name] = repo.split('/')
|
||||
if (!owner || !name) {
|
||||
throw new Error(`CONTENT_REPO must look like "owner/repo", got "${repo}"`)
|
||||
}
|
||||
return { owner, name }
|
||||
}
|
||||
|
||||
/** posts/foo/bar.md + dir "posts" -> "foo/bar" */
|
||||
export function normalizeDir(dir: string): string {
|
||||
return dir.replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
export function isMarkdown(path: string): boolean {
|
||||
return /\.mdx?$/i.test(path)
|
||||
}
|
||||
|
||||
export function toBase64(content: string): string {
|
||||
return Buffer.from(content, 'utf8').toString('base64')
|
||||
}
|
||||
|
||||
/** the provider needs a token to commit; say so with a useful message */
|
||||
export function assertWritable(provider: ContentProvider): void {
|
||||
if (!provider.writable) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
message: `the ${provider.kind} provider has no write token — set CONTENT_TOKEN`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** $fetch throws on 404; treat that as "absent" rather than an error */
|
||||
export function isNotFound(error: unknown): boolean {
|
||||
return (error as { statusCode?: number })?.statusCode === 404
|
||||
}
|
||||
271
server/utils/repos.ts
Normal file
271
server/utils/repos.ts
Normal file
@ -0,0 +1,271 @@
|
||||
/**
|
||||
* The project list is the public repositories of the configured github and
|
||||
* gitea accounts — not a list anybody maintains by hand.
|
||||
*
|
||||
* `site.json` still has a say: an entry there whose `href` or `name` matches a
|
||||
* repository *enriches* it (bullets, a nicer name, a status, the featured
|
||||
* flag). Entries that match nothing are kept as `manual` projects, so a thing
|
||||
* with no repo behind it can still be shown.
|
||||
*
|
||||
* Both forges are optional and independent: one being down or rate-limited
|
||||
* means the other's repos still render, and the failure is reported instead of
|
||||
* swallowed.
|
||||
*/
|
||||
import type { Project, ProjectStatus } from '#shared/site'
|
||||
import type {
|
||||
ProjectSourceStatus,
|
||||
ProjectsPayload,
|
||||
RepoProject,
|
||||
} from '#shared/types/projects'
|
||||
import { cached } from './cache'
|
||||
import { projectsConfig, type ProjectsConfig } from './config'
|
||||
import { getSiteData } from './site'
|
||||
|
||||
const PROJECTS_KEY = 'projects:list'
|
||||
|
||||
/** a repo pushed to within this many days counts as alive */
|
||||
const ACTIVE_DAYS = 120
|
||||
|
||||
/** the subset of the forge payloads we actually use — both speak it */
|
||||
interface ForgeRepo {
|
||||
name?: string
|
||||
full_name?: string
|
||||
description?: string | null
|
||||
html_url?: string
|
||||
homepage?: string | null
|
||||
website?: string | null
|
||||
language?: string | null
|
||||
topics?: string[] | null
|
||||
fork?: boolean
|
||||
archived?: boolean
|
||||
private?: boolean
|
||||
stargazers_count?: number
|
||||
stars_count?: number
|
||||
created_at?: string
|
||||
pushed_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
// --- forges ----------------------------------------------------------------
|
||||
|
||||
async function fetchGithubRepos(cfg: ProjectsConfig): Promise<ForgeRepo[]> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'nak0x.dev',
|
||||
}
|
||||
if (cfg.githubToken) headers.Authorization = `Bearer ${cfg.githubToken}`
|
||||
|
||||
const out: ForgeRepo[] = []
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const batch = await $fetch<ForgeRepo[]>(
|
||||
`https://api.github.com/users/${encodeURIComponent(cfg.githubUser)}/repos`,
|
||||
{ headers, query: { per_page: 100, page, type: 'owner', sort: 'pushed' } },
|
||||
)
|
||||
if (!Array.isArray(batch) || !batch.length) break
|
||||
out.push(...batch)
|
||||
if (batch.length < 100) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function fetchGiteaRepos(cfg: ProjectsConfig): Promise<ForgeRepo[]> {
|
||||
const base = cfg.giteaUrl.replace(/\/+$/, '')
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
if (cfg.giteaToken) headers.Authorization = `token ${cfg.giteaToken}`
|
||||
|
||||
const out: ForgeRepo[] = []
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const batch = await $fetch<ForgeRepo[]>(
|
||||
`${base}/api/v1/users/${encodeURIComponent(cfg.giteaUser)}/repos`,
|
||||
{ headers, query: { limit: 50, page } },
|
||||
)
|
||||
if (!Array.isArray(batch) || !batch.length) break
|
||||
out.push(...batch)
|
||||
if (batch.length < 50) break
|
||||
}
|
||||
// a token turns this endpoint into "everything i can see", so filter again
|
||||
return out.filter((r) => !r.private)
|
||||
}
|
||||
|
||||
// --- mapping ---------------------------------------------------------------
|
||||
|
||||
const yearOf = (iso: string): string => {
|
||||
const year = new Date(iso).getFullYear()
|
||||
return Number.isFinite(year) ? String(year) : ''
|
||||
}
|
||||
|
||||
function deriveStatus(repo: ForgeRepo, pushedAt: string): ProjectStatus {
|
||||
if (repo.archived) return 'archived'
|
||||
const age = (Date.now() - Date.parse(pushedAt)) / 86_400_000
|
||||
// anything quiet for a while is "done" rather than "abandoned"; site.json can
|
||||
// still call it 'wip' if that is closer to the truth
|
||||
return Number.isFinite(age) && age <= ACTIVE_DAYS ? 'active' : 'shipped'
|
||||
}
|
||||
|
||||
function toProject(repo: ForgeRepo, source: 'github' | 'gitea'): RepoProject | null {
|
||||
const name = repo.name?.trim()
|
||||
const href = repo.html_url?.trim()
|
||||
if (!name || !href) return null
|
||||
|
||||
const pushedAt = repo.pushed_at || repo.updated_at || repo.created_at || ''
|
||||
const site = (repo.homepage || repo.website || '').trim()
|
||||
const topics = (repo.topics ?? []).filter((t) => typeof t === 'string' && t.trim())
|
||||
const language = repo.language?.trim()
|
||||
|
||||
return {
|
||||
name,
|
||||
tagline: repo.description?.trim() || '',
|
||||
bullets: [],
|
||||
// the language first, then whatever topics the repo declares
|
||||
stack: [...new Set([language, ...topics].filter((s): s is string => !!s))].slice(0, 5),
|
||||
year: yearOf(repo.created_at || pushedAt),
|
||||
href,
|
||||
links: site && site !== href ? [{ label: 'site', href: site }] : [],
|
||||
featured: false,
|
||||
status: deriveStatus(repo, pushedAt),
|
||||
source,
|
||||
fullName: repo.full_name?.trim() || name,
|
||||
stars: repo.stargazers_count ?? repo.stars_count ?? 0,
|
||||
pushedAt,
|
||||
archived: repo.archived === true,
|
||||
}
|
||||
}
|
||||
|
||||
// --- site.json overrides ---------------------------------------------------
|
||||
|
||||
const normalizeHref = (href: string): string =>
|
||||
href.trim().toLowerCase().replace(/\.git$/, '').replace(/\/+$/, '')
|
||||
|
||||
/** site.json wins wherever it actually says something */
|
||||
function enrich(base: RepoProject, override: Project): RepoProject {
|
||||
return {
|
||||
...base,
|
||||
name: override.name || base.name,
|
||||
tagline: override.tagline || base.tagline,
|
||||
bullets: override.bullets.length ? override.bullets : base.bullets,
|
||||
stack: override.stack.length ? override.stack : base.stack,
|
||||
year: override.year || base.year,
|
||||
links: override.links.length ? override.links : base.links,
|
||||
featured: override.featured,
|
||||
hidden: override.hidden,
|
||||
// `status` is never absent after normalisation, so it always overrides
|
||||
status: override.status,
|
||||
}
|
||||
}
|
||||
|
||||
function asManual(project: Project): RepoProject {
|
||||
return {
|
||||
...project,
|
||||
source: 'manual',
|
||||
fullName: project.name,
|
||||
stars: 0,
|
||||
pushedAt: '',
|
||||
archived: project.status === 'archived',
|
||||
}
|
||||
}
|
||||
|
||||
// --- assembly --------------------------------------------------------------
|
||||
|
||||
const rank: Record<ProjectStatus, number> = { active: 0, wip: 1, shipped: 2, archived: 3 }
|
||||
|
||||
function sortProjects(projects: RepoProject[]): RepoProject[] {
|
||||
return projects.sort(
|
||||
(a, b) =>
|
||||
rank[a.status] - rank[b.status] ||
|
||||
Number(b.featured) - Number(a.featured) ||
|
||||
(Date.parse(b.pushedAt) || 0) - (Date.parse(a.pushedAt) || 0) ||
|
||||
b.stars - a.stars ||
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
}
|
||||
|
||||
async function readSource(
|
||||
kind: 'github' | 'gitea',
|
||||
account: string,
|
||||
cfg: ProjectsConfig,
|
||||
load: () => Promise<ForgeRepo[]>,
|
||||
): Promise<{ status: ProjectSourceStatus; projects: RepoProject[] }> {
|
||||
if (!account) {
|
||||
return { status: { kind, account: '', ok: true, count: 0 }, projects: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
const repos = await load()
|
||||
const projects = repos
|
||||
.filter((r) => !r.private)
|
||||
.filter((r) => cfg.includeForks || !r.fork)
|
||||
.filter(
|
||||
(r) =>
|
||||
!cfg.exclude.includes((r.name ?? '').toLowerCase()) &&
|
||||
!cfg.exclude.includes((r.full_name ?? '').toLowerCase()),
|
||||
)
|
||||
.map((r) => toProject(r, kind))
|
||||
.filter((p): p is RepoProject => p !== null)
|
||||
|
||||
return { status: { kind, account, ok: true, count: projects.length }, projects }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[projects] ${kind} (${account}) failed:`, message)
|
||||
return {
|
||||
status: { kind, account, ok: false, count: 0, error: message },
|
||||
projects: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects(): Promise<ProjectsPayload> {
|
||||
const cfg = projectsConfig()
|
||||
|
||||
const [github, gitea, site] = await Promise.all([
|
||||
readSource('github', cfg.githubUser, cfg, () => fetchGithubRepos(cfg)),
|
||||
readSource('gitea', cfg.giteaUser, cfg, () => fetchGiteaRepos(cfg)),
|
||||
getSiteData(),
|
||||
])
|
||||
|
||||
// same repo mirrored on both forges: github is the public-facing one
|
||||
const byKey = new Map<string, RepoProject>()
|
||||
for (const project of [...github.projects, ...gitea.projects]) {
|
||||
const key = project.name.toLowerCase()
|
||||
if (!byKey.has(key)) byKey.set(key, project)
|
||||
}
|
||||
|
||||
const byHref = new Map<string, Project>()
|
||||
const byName = new Map<string, Project>()
|
||||
for (const project of site.projects) {
|
||||
const href = normalizeHref(project.href)
|
||||
if (href) byHref.set(href, project)
|
||||
const name = project.name.trim().toLowerCase()
|
||||
if (name && !byName.has(name)) byName.set(name, project)
|
||||
}
|
||||
|
||||
// urls first: a url names exactly one repo, where a name can match several
|
||||
// (`mycelia` and `mycelia-experience` both answer to "mycelia")
|
||||
const used = new Set<Project>()
|
||||
const matched = [...byKey.values()].map((repo) => {
|
||||
const override = byHref.get(normalizeHref(repo.href)) ?? null
|
||||
if (override) used.add(override)
|
||||
return { repo, override }
|
||||
})
|
||||
|
||||
const projects = matched.map(({ repo, override }) => {
|
||||
if (override) return enrich(repo, override)
|
||||
// fall back to the name, but never let one entry claim a second repo
|
||||
const byname = byName.get(repo.name.toLowerCase())
|
||||
if (!byname || used.has(byname)) return repo
|
||||
used.add(byname)
|
||||
return enrich(repo, byname)
|
||||
})
|
||||
|
||||
// things with no repository behind them (or whose forge is down) still count
|
||||
const orphans = site.projects.filter((p) => !used.has(p)).map(asManual)
|
||||
|
||||
return {
|
||||
projects: sortProjects([...projects, ...orphans].filter((p) => !p.hidden)),
|
||||
sources: [github.status, gitea.status].filter((s) => s.account),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProjects(): Promise<ProjectsPayload> {
|
||||
return cached(PROJECTS_KEY, projectsConfig().ttl, loadProjects)
|
||||
}
|
||||
83
server/utils/site.ts
Normal file
83
server/utils/site.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { normalizeSiteData, defaultSiteData, type SiteData } from '#shared/site'
|
||||
import { useContentProvider } from './providers'
|
||||
import { cached, bustCache } from './cache'
|
||||
import { contentConfig } from './config'
|
||||
|
||||
const SITE_KEY = 'site:data'
|
||||
|
||||
export interface SiteDocument {
|
||||
data: SiteData
|
||||
/** blob sha of site.json, or null when the file does not exist yet */
|
||||
sha: string | null
|
||||
/** false when the repo has no site.json and the built-in seed is in use */
|
||||
fromRepo: boolean
|
||||
}
|
||||
|
||||
async function loadSite(): Promise<SiteDocument> {
|
||||
const provider = useContentProvider()
|
||||
const path = contentConfig().siteFile
|
||||
|
||||
let raw: string
|
||||
try {
|
||||
raw = await provider.read(path)
|
||||
} catch {
|
||||
// no site.json in the repo yet — the seed in shared/site.ts is the site
|
||||
return { data: structuredClone(defaultSiteData), sha: null, fromRepo: false }
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch (error) {
|
||||
console.error(`[site] ${path} is not valid JSON, falling back to the seed:`, error)
|
||||
return { data: structuredClone(defaultSiteData), sha: null, fromRepo: false }
|
||||
}
|
||||
|
||||
const stat = await provider.stat(path).catch(() => null)
|
||||
return { data: normalizeSiteData(parsed), sha: stat?.sha ?? null, fromRepo: true }
|
||||
}
|
||||
|
||||
export async function getSiteDocument(): Promise<SiteDocument> {
|
||||
return cached(SITE_KEY, contentConfig().ttl, loadSite)
|
||||
}
|
||||
|
||||
export async function getSiteData(): Promise<SiteData> {
|
||||
return (await getSiteDocument()).data
|
||||
}
|
||||
|
||||
export function serializeSiteData(data: SiteData): string {
|
||||
return `${JSON.stringify(data, null, 2)}\n`
|
||||
}
|
||||
|
||||
export async function putSiteData(
|
||||
input: unknown,
|
||||
options: { message: string; sha?: string | null },
|
||||
): Promise<{ sha: string; commit?: string; data: SiteData }> {
|
||||
const provider = useContentProvider()
|
||||
const path = contentConfig().siteFile
|
||||
const data = normalizeSiteData(input)
|
||||
|
||||
// trust the live sha over whatever the client believed, unless the client
|
||||
// sent one — then it acts as the optimistic-concurrency check.
|
||||
const current = await provider.stat(path).catch(() => null)
|
||||
const sha = options.sha === undefined ? current?.sha : (options.sha ?? undefined)
|
||||
|
||||
if (options.sha && current?.sha && options.sha !== current.sha) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: 'site.json changed in the repo since you loaded it — reload and reapply',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await provider.write({
|
||||
path,
|
||||
content: serializeSiteData(data),
|
||||
message: options.message,
|
||||
sha,
|
||||
})
|
||||
|
||||
bustCache('site:')
|
||||
// the project list is built on top of site.json, so it is stale too
|
||||
bustCache('projects:')
|
||||
return { sha: result.sha, commit: result.commit, data }
|
||||
}
|
||||
291
shared/site.ts
Normal file
291
shared/site.ts
Normal file
@ -0,0 +1,291 @@
|
||||
/**
|
||||
* The shape of everything the site says about you.
|
||||
*
|
||||
* The values below are the *seed*: they ship with the build and are what the
|
||||
* site falls back to when the content repo has no `site.json` yet, or when it
|
||||
* has one that is broken. Once `/dash` writes a `site.json`, that file wins.
|
||||
*/
|
||||
|
||||
export interface ProjectLink {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export type ProjectStatus = 'active' | 'wip' | 'shipped' | 'archived'
|
||||
|
||||
export interface Project {
|
||||
name: string
|
||||
tagline: string
|
||||
/** one line per bullet, keep them short */
|
||||
bullets: string[]
|
||||
stack: string[]
|
||||
year: string
|
||||
href: string
|
||||
links: ProjectLink[]
|
||||
/** shown on the home page */
|
||||
featured: boolean
|
||||
status: ProjectStatus
|
||||
/** keep this repository out of the public list entirely */
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export interface SkillGroup {
|
||||
label: string
|
||||
items: string[]
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export interface SiteData {
|
||||
handle: string
|
||||
name: string
|
||||
domain: string
|
||||
role: string
|
||||
company: { name: string; href: string }
|
||||
location: string
|
||||
timezone: string
|
||||
tagline: string
|
||||
description: string
|
||||
about: string[]
|
||||
contactEmail: string
|
||||
skills: SkillGroup[]
|
||||
links: NavItem[]
|
||||
nav: NavItem[]
|
||||
projects: Project[]
|
||||
}
|
||||
|
||||
export const PROJECT_STATUSES: ProjectStatus[] = ['active', 'wip', 'shipped', 'archived']
|
||||
|
||||
export const defaultSiteData: SiteData = {
|
||||
handle: 'nak0x',
|
||||
name: 'Théo Lesage',
|
||||
domain: 'nak0x.dev',
|
||||
role: 'software engineer',
|
||||
company: { name: 'Teklia', href: 'https://teklia.com' },
|
||||
location: 'Annecy, France',
|
||||
timezone: 'Europe/Paris',
|
||||
|
||||
tagline:
|
||||
'i write rust for machines that should have been retired, and vue for the ones that were not.',
|
||||
|
||||
description:
|
||||
'software engineer in annecy, france. rust, vue/nuxt, and a homelab that runs more than it should. i like small binaries, linux, and making old hardware feel fast again.',
|
||||
|
||||
about: [
|
||||
"i'm théo — `nak0x` most places. i build tools for myself first and then find out other people wanted them too.",
|
||||
'by day i work at teklia on document understanding infrastructure. by night it is mostly rust, self-hosted things, and shaving megabytes off pages that had no business being megabytes.',
|
||||
'i care about software that is small, legible, and survives a laptop from 2009.',
|
||||
],
|
||||
|
||||
contactEmail: 'nak0x@proton.me',
|
||||
|
||||
skills: [
|
||||
{ label: 'languages', items: ['rust', 'typescript', 'python', 'c#', 'glsl'] },
|
||||
{ label: 'web', items: ['vue', 'nuxt', 'nitro', 'three.js', 'p5.js'] },
|
||||
{ label: 'systems', items: ['linux', 'docker', 'coolify', 'gitea', 'nginx'] },
|
||||
{ label: 'other', items: ['unity', 'shaders', 'iot / embedded', 'neovim'] },
|
||||
],
|
||||
|
||||
links: [
|
||||
{ label: 'github', href: 'https://github.com/nak0x' },
|
||||
{ label: 'gitea', href: 'https://git.nak0x.dev/Nak0x' },
|
||||
{ label: 'email', href: 'mailto:nak0x@proton.me' },
|
||||
{ label: 'rss', href: '/rss.xml' },
|
||||
],
|
||||
|
||||
nav: [
|
||||
{ label: 'about', href: '/#about' },
|
||||
{ label: 'projects', href: '/projects' },
|
||||
{ label: 'blog', href: '/blog' },
|
||||
],
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'furst',
|
||||
tagline: 'browsing the modern web on hardware that cannot run a modern browser.',
|
||||
bullets: [
|
||||
'a news page is 2–5MB over 80+ requests. the article itself is ~20KB.',
|
||||
'extract, proxy, and route media to mpv — keep the engine out of the loop.',
|
||||
'built for a core2 duo with 4GB and a minimal arch install.',
|
||||
],
|
||||
stack: ['rust', 'http', 'readability'],
|
||||
year: '2026',
|
||||
href: 'https://git.nak0x.dev/Nak0x/Furst',
|
||||
links: [
|
||||
{ label: 'guide', href: 'https://git.nak0x.dev/Nak0x/Furst/src/branch/master/GUIDE.md' },
|
||||
],
|
||||
featured: true,
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
name: 'faust',
|
||||
tagline: 'live markdown preview for obsidian vaults, with the look of a TUI.',
|
||||
bullets: [
|
||||
'never writes to your vault — you edit in nvim, it re-renders on disk change.',
|
||||
'gpu rendering, cold start under a blink.',
|
||||
],
|
||||
stack: ['rust', 'wgpu', 'markdown'],
|
||||
year: '2026',
|
||||
href: 'https://github.com/nak0x/faust',
|
||||
links: [],
|
||||
featured: true,
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
name: 'arkindex on coolify',
|
||||
tagline: 'a standalone, one-compose deployment of arkindex.',
|
||||
bullets: ['self-hosting a document-ml platform without the kubernetes tax.'],
|
||||
stack: ['docker', 'coolify', 'python'],
|
||||
year: '2026',
|
||||
href: 'https://git.nak0x.dev/Nak0x/Standalone-Arkindex-Coolify',
|
||||
links: [],
|
||||
featured: true,
|
||||
status: 'shipped',
|
||||
},
|
||||
{
|
||||
name: 'mycelia',
|
||||
tagline: 'an iot mesh that grows the way its namesake does.',
|
||||
bullets: [],
|
||||
stack: ['python', 'mqtt', 'embedded'],
|
||||
year: '2026',
|
||||
href: 'https://github.com/nak0x/mycelia-experience',
|
||||
links: [{ label: 'v1', href: 'https://github.com/nak0x/Mycelia' }],
|
||||
featured: true,
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
name: 'mundara',
|
||||
tagline: 'an immersive AR experience for cultures we forgot.',
|
||||
bullets: ['unity + AR foundation, a website, and a very tired team.'],
|
||||
stack: ['unity', 'c#', 'vue'],
|
||||
year: '2025',
|
||||
href: 'https://github.com/nak0x/Mundara',
|
||||
links: [{ label: 'site', href: 'https://github.com/nak0x/mundara-website' }],
|
||||
featured: false,
|
||||
status: 'shipped',
|
||||
},
|
||||
{
|
||||
name: 'dbz-explorer',
|
||||
tagline: 'a dragon ball game built on a reusable engine of my own.',
|
||||
bullets: [],
|
||||
stack: ['python', 'game engine'],
|
||||
year: '2024',
|
||||
href: 'https://github.com/nak0x/dbz-explorer',
|
||||
links: [],
|
||||
featured: false,
|
||||
status: 'archived',
|
||||
},
|
||||
{
|
||||
name: 'dotfiles',
|
||||
tagline: 'neovim, wm, shell — the whole desk.',
|
||||
bullets: [],
|
||||
stack: ['lua', 'zsh', 'nix-adjacent'],
|
||||
year: 'ongoing',
|
||||
href: 'https://github.com/nak0x/dotfiles',
|
||||
links: [],
|
||||
featured: false,
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
name: 'dither guy',
|
||||
tagline: 'graphic dithering for images and video, low-color-depth on purpose.',
|
||||
bullets: [],
|
||||
stack: ['python', 'pillow'],
|
||||
year: '2026',
|
||||
href: 'https://github.com/nak0x/dither-guy',
|
||||
links: [],
|
||||
featured: false,
|
||||
status: 'wip',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// --- normalisation ---------------------------------------------------------
|
||||
// `site.json` comes from a git repo a human edits by hand. Anything missing or
|
||||
// mistyped falls back to the seed instead of taking the site down.
|
||||
|
||||
const str = (v: unknown, fallback: string): string =>
|
||||
typeof v === 'string' && v.trim() ? v : fallback
|
||||
|
||||
const strList = (v: unknown): string[] =>
|
||||
Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && !!x.trim()) : []
|
||||
|
||||
const linkList = (v: unknown): ProjectLink[] =>
|
||||
Array.isArray(v)
|
||||
? v
|
||||
.filter((x): x is Record<string, unknown> => !!x && typeof x === 'object')
|
||||
.map((x) => ({ label: str(x.label, ''), href: str(x.href, '') }))
|
||||
.filter((x) => x.label && x.href)
|
||||
: []
|
||||
|
||||
function normalizeProject(input: unknown): Project | null {
|
||||
if (!input || typeof input !== 'object') return null
|
||||
const raw = input as Record<string, unknown>
|
||||
const name = str(raw.name, '')
|
||||
if (!name) return null
|
||||
|
||||
const status = raw.status
|
||||
return {
|
||||
name,
|
||||
tagline: str(raw.tagline, ''),
|
||||
bullets: strList(raw.bullets),
|
||||
stack: strList(raw.stack),
|
||||
year: str(raw.year, ''),
|
||||
href: str(raw.href, '#'),
|
||||
links: linkList(raw.links),
|
||||
featured: raw.featured === true,
|
||||
hidden: raw.hidden === true,
|
||||
status: PROJECT_STATUSES.includes(status as ProjectStatus)
|
||||
? (status as ProjectStatus)
|
||||
: 'active',
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSiteData(input: unknown): SiteData {
|
||||
const d = defaultSiteData
|
||||
if (!input || typeof input !== 'object') return structuredClone(d)
|
||||
const raw = input as Record<string, unknown>
|
||||
|
||||
const company = (raw.company ?? {}) as Record<string, unknown>
|
||||
const skills = Array.isArray(raw.skills)
|
||||
? raw.skills
|
||||
.filter((g): g is Record<string, unknown> => !!g && typeof g === 'object')
|
||||
.map((g) => ({ label: str(g.label, ''), items: strList(g.items) }))
|
||||
.filter((g) => g.label && g.items.length)
|
||||
: []
|
||||
|
||||
const projects = Array.isArray(raw.projects)
|
||||
? raw.projects.map((p) => normalizeProject(p)).filter((p): p is Project => p !== null)
|
||||
: []
|
||||
|
||||
const about = strList(raw.about)
|
||||
const links = linkList(raw.links)
|
||||
const nav = linkList(raw.nav)
|
||||
|
||||
return {
|
||||
handle: str(raw.handle, d.handle),
|
||||
name: str(raw.name, d.name),
|
||||
domain: str(raw.domain, d.domain),
|
||||
role: str(raw.role, d.role),
|
||||
company: {
|
||||
name: str(company.name, d.company.name),
|
||||
href: str(company.href, d.company.href),
|
||||
},
|
||||
location: str(raw.location, d.location),
|
||||
timezone: str(raw.timezone, d.timezone),
|
||||
tagline: str(raw.tagline, d.tagline),
|
||||
description: str(raw.description, d.description),
|
||||
about: about.length ? about : [...d.about],
|
||||
contactEmail: str(raw.contactEmail, d.contactEmail),
|
||||
skills: skills.length ? skills : structuredClone(d.skills),
|
||||
links: links.length ? links : structuredClone(d.links),
|
||||
nav: nav.length ? nav : structuredClone(d.nav),
|
||||
// an empty project list is a legitimate choice, so it is not overridden
|
||||
projects: Array.isArray(raw.projects) ? projects : structuredClone(d.projects),
|
||||
}
|
||||
}
|
||||
|
||||
export const featuredOf = (projects: Project[]): Project[] => projects.filter((p) => p.featured)
|
||||
28
shared/types/blog.ts
Normal file
28
shared/types/blog.ts
Normal file
@ -0,0 +1,28 @@
|
||||
export interface TocEntry {
|
||||
id: string
|
||||
text: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
export interface PostMeta {
|
||||
/** url slug, from the frontmatter or the filename */
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
/** ISO date */
|
||||
date: string
|
||||
updated?: string
|
||||
tags: string[]
|
||||
draft: boolean
|
||||
/** minutes, rounded up */
|
||||
readingTime: number
|
||||
/** path of the .md inside the content repo */
|
||||
path: string
|
||||
/** link back to the file in gitea/github */
|
||||
sourceUrl?: string
|
||||
}
|
||||
|
||||
export interface Post extends PostMeta {
|
||||
html: string
|
||||
toc: TocEntry[]
|
||||
}
|
||||
53
shared/types/dash.ts
Normal file
53
shared/types/dash.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import type { PostMeta } from './blog'
|
||||
|
||||
export interface DashSession {
|
||||
/** false when DASH_PASSWORD is unset — the dashboard is off */
|
||||
enabled: boolean
|
||||
authenticated: boolean
|
||||
/** false when the provider has no write token */
|
||||
writable: boolean
|
||||
provider?: string
|
||||
repo?: string
|
||||
branch?: string
|
||||
dir?: string
|
||||
siteFile?: string
|
||||
ttl?: number
|
||||
webhook?: boolean
|
||||
}
|
||||
|
||||
export interface DashPost extends PostMeta {
|
||||
sha: string
|
||||
}
|
||||
|
||||
export interface PostFrontmatterInput {
|
||||
title: string
|
||||
description: string
|
||||
date: string
|
||||
updated: string
|
||||
tags: string[]
|
||||
slug: string
|
||||
draft: boolean
|
||||
}
|
||||
|
||||
export interface PostDraft {
|
||||
path: string
|
||||
sha: string
|
||||
sourceUrl: string
|
||||
frontmatter: PostFrontmatterInput
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface SavePayload {
|
||||
frontmatter: Partial<PostFrontmatterInput>
|
||||
body: string
|
||||
message?: string
|
||||
/** the sha the editor loaded; omit on create */
|
||||
sha?: string
|
||||
}
|
||||
|
||||
export interface SaveResult {
|
||||
slug: string
|
||||
path: string
|
||||
sha: string
|
||||
commit?: string
|
||||
}
|
||||
36
shared/types/projects.ts
Normal file
36
shared/types/projects.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import type { Project } from '../site'
|
||||
|
||||
export type ProjectSource = 'github' | 'gitea' | 'manual'
|
||||
|
||||
/**
|
||||
* A project as the site shows it: a public repository read from a forge,
|
||||
* optionally enriched by a matching entry in `site.json`.
|
||||
*
|
||||
* `manual` entries are the other way round — a `site.json` project that has no
|
||||
* repository behind it (or whose forge was unreachable).
|
||||
*/
|
||||
export interface RepoProject extends Project {
|
||||
source: ProjectSource
|
||||
/** "owner/name", as the forge reports it */
|
||||
fullName: string
|
||||
stars: number
|
||||
/** ISO date of the last push, '' for manual entries */
|
||||
pushedAt: string
|
||||
archived: boolean
|
||||
}
|
||||
|
||||
export interface ProjectSourceStatus {
|
||||
kind: ProjectSource
|
||||
/** the account the repos were read from */
|
||||
account: string
|
||||
ok: boolean
|
||||
count: number
|
||||
/** why it failed, when it did */
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ProjectsPayload {
|
||||
projects: RepoProject[]
|
||||
/** one row per configured forge, so the page can admit a source is down */
|
||||
sources: ProjectSourceStatus[]
|
||||
}
|
||||
9
tsconfig.json
Normal file
9
tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./.nuxt/tsconfig.app.json" },
|
||||
{ "path": "./.nuxt/tsconfig.server.json" },
|
||||
{ "path": "./.nuxt/tsconfig.shared.json" },
|
||||
{ "path": "./.nuxt/tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user