commit 15d5112ded372cc0a281110e2b71dc6d14b72393 Author: nak0x Date: Tue Sep 8 19:54:06 2026 +0200 feat: new-version diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2e32714 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.github +node_modules +.nuxt +.output +.data +dist +*.log +.env +.env.* +!.env.example +.DS_Store +README.md +docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bdba9ea --- /dev/null +++ b/.env.example @@ -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 +# /, site.json at /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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c56f5fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules +.nuxt +.output +.data +dist +.env +.env.* +!.env.example +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8d9c5d3 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..42e3fa2 --- /dev/null +++ b/README.md @@ -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 # ![](./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. diff --git a/app/app.vue b/app/app.vue new file mode 100644 index 0000000..b988d6a --- /dev/null +++ b/app/app.vue @@ -0,0 +1,22 @@ + + + diff --git a/app/assets/css/main.css b/app/assets/css/main.css new file mode 100644 index 0000000..25fa249 --- /dev/null +++ b/app/assets/css/main.css @@ -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 , 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; +} diff --git a/app/components/AsciiBanner.vue b/app/components/AsciiBanner.vue new file mode 100644 index 0000000..5d0535a --- /dev/null +++ b/app/components/AsciiBanner.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/app/components/AsciiRule.vue b/app/components/AsciiRule.vue new file mode 100644 index 0000000..5f4debd --- /dev/null +++ b/app/components/AsciiRule.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/app/components/DashHeader.vue b/app/components/DashHeader.vue new file mode 100644 index 0000000..fa69bdc --- /dev/null +++ b/app/components/DashHeader.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/app/components/DashLinkList.vue b/app/components/DashLinkList.vue new file mode 100644 index 0000000..a16b2fc --- /dev/null +++ b/app/components/DashLinkList.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/app/components/DashLogin.vue b/app/components/DashLogin.vue new file mode 100644 index 0000000..a23fce8 --- /dev/null +++ b/app/components/DashLogin.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/app/components/DashStringList.vue b/app/components/DashStringList.vue new file mode 100644 index 0000000..76cdd89 --- /dev/null +++ b/app/components/DashStringList.vue @@ -0,0 +1,88 @@ + + +