Compare commits
25 Commits
b1f49c63b1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 19999e7104 | |||
| 813c403fcc | |||
| 08df07f221 | |||
| 98c1d81031 | |||
| 4614cd25a0 | |||
| 7aa2910b12 | |||
| 8e5e5cbef8 | |||
| 57721efbcd | |||
| b3cf6bd02e | |||
| f96079f631 | |||
| 02437358cf | |||
| 2a3a15d7ac | |||
| 0f78505e99 | |||
| 5077aeacfd | |||
| 10a7d0a878 | |||
| 5442a8d888 | |||
| 9390a7cbc0 | |||
| a9fb5b3e85 | |||
| 151bed230a | |||
| 3f6167455d | |||
| 449ffcd62d | |||
| bd97720be1 | |||
| 5c3ab14ba3 | |||
| a945bb08cc | |||
| 10f206c756 |
@@ -39,7 +39,9 @@ cp deploy/compose.yaml "$STACK/compose.yaml"
|
||||
set_setting GK_TAG "$TAG"
|
||||
[ -n "$CURRENT" ] && set_setting GK_PREVIOUS_TAG "$CURRENT"
|
||||
|
||||
if ! compose up -d --remove-orphans --wait --wait-timeout 120; then
|
||||
# No --remove-orphans: another stack may share the project name, and its
|
||||
# containers must never be touched by this deploy.
|
||||
if ! compose up -d --wait --wait-timeout 120; then
|
||||
echo "!! $TAG did not come up healthy"
|
||||
compose ps
|
||||
compose logs --tail 50 || true
|
||||
|
||||
@@ -16,9 +16,14 @@ the vault so Obsidian shows them too. Its changes are recorded with the author
|
||||
| Page | What it does |
|
||||
| --- | --- |
|
||||
| Overview | Live and total notes, what waits for review, folder problems, the service's last scan and last error, a Scan now button, each sync job's last run and last success, and Library suggestions: series moved to Reading in PersonalReader with no Library item, each with Add to the Library (title, medium, AniList id, state now, published) and Dismiss. |
|
||||
| Site pages | Each page of the site with its Page text note (lede, photos, portrait) as thumbnails, **Edit page** or **Add page text**, and links to the notes that fill the page (crafts, games, decks, product lines, settings). |
|
||||
| Before launch | What is still a placeholder, worked out from the notes so it shrinks as they are fixed: pages showing their built-in wording (no Page text note, a draft one, no lede, no About portrait), notes holding a comment, Coming soon, TODO, TBD or lorem, posts not published, and live notes missing the field their page is built around (a craft's blurb, a make's finish date, a deck's take). Each line links to Edit or Add page text. |
|
||||
| Review | Held changes with the rules' reasons and a line diff against the live version. Accept puts it live (and into the vault, for API changes). Reject keeps the live version; for an edit made in Obsidian, "Reject and put the live version back" also rewrites the file. Earlier decisions are listed by status. |
|
||||
| Notes, Drafts | Browse and search every note, or only the unpublished ones. |
|
||||
| A note | A preview of the body, its fields, what links to it, Publish or Unpublish, and its history. Any earlier version can be compared with the live one and rolled back. |
|
||||
| Notes, Drafts | Browse and search every note, or only the unpublished ones. Each row has Edit; filtered to one type, the list also offers to add a note of that type. |
|
||||
| A note | A preview of the body, its fields, what links to it, Edit, Publish or Unpublish, and its history. Any earlier version can be compared with the live one and rolled back. |
|
||||
| Posts | Every blog post, drafts first, and **Write a post**: title, topic, kind of post, publish date, summary, a cover picked from the vault or uploaded, the pages it is about, Feature it, case study details, and the Markdown body. Preview shows it rendered with its images; Save draft and Publish write the note to `Posts/`. Images added to the post are uploaded to `_attachments/Posts` and appended as `![[file]]` lines. |
|
||||
| Edit | The form for any note (posts open their own editor). It is built from the note type's schema in `@gk/schema`, so it offers exactly the fields the content service accepts: image fields (`format: vault-image`) show a thumbnail, a pick from the vault and an upload into the type's `_attachments` folder; link fields offer note names; lists of rows (photos, specs, prices, socials) can be added to, removed and moved up or down. An emptied field is removed from the note, an unticked optional box is left out, and fields the form cannot show (a list inside a row) stay as they are. The title is the file name, so it is renamed in Obsidian. |
|
||||
| Images | Everything in `_attachments`, newest first, by folder, with which notes use each one. Upload several at once into a folder, copy an image's `![[file]]` line, and delete images no note uses. |
|
||||
| Capture | A status for the Now log, or a Library item with the option to post a status about it in the same step. |
|
||||
| Store | Orders open or closed per category, with the reopen date and turnaround, and open slots or stock per product line. |
|
||||
| Problems | Whole-folder problems, like links to notes that do not exist. |
|
||||
@@ -34,8 +39,13 @@ the vault so Obsidian shows them too. Its changes are recorded with the author
|
||||
same-origin` (or a matching `Origin`), so a page elsewhere cannot make the
|
||||
browser act on the admin page.
|
||||
- **Drafts cannot run code.** Previews show raw HTML as text and drop links that
|
||||
are not http, https, mailto or relative. The page sends a content security
|
||||
policy that allows no scripts at all; it works entirely with plain forms.
|
||||
are not http, https, mailto or relative. The content security policy allows
|
||||
only the page's own `/admin.js` (Copy buttons and a confirm before deleting
|
||||
an image), never inline script, and every page still works with plain forms
|
||||
when that script does not run.
|
||||
- **Only images go into the vault.** Uploads are checked by extension and by
|
||||
their first bytes, SVG is refused, and an image is deleted only when no note
|
||||
uses it.
|
||||
|
||||
## Running it
|
||||
|
||||
|
||||
@@ -76,4 +76,6 @@ import PageShell from "@gk/ui/components/PageShell.astro";
|
||||
</PageShell>
|
||||
```
|
||||
|
||||
The site is dark only for now; light mode comes in a later phase.
|
||||
The site follows the device's light or dark setting until the visitor presses the sun and moon toggle in the header (`ThemeToggle`), which saves their choice in the browser. A script in PageShell's head sets `<html data-theme="light">` or `"dark"` before the page paints; without script the site stays dark. Style light mode with `:root[data-theme="light"]` (in a component, `:global(:root[data-theme="light"]) .x`), not a `prefers-color-scheme` media query, so the toggle can override it. Colors come from the tokens in `tokens.css`, which has the light values too, so a component that uses `var(--gk-…)` needs nothing more. For a translucent color use a channel token, as in `rgba(var(--gk-gold-rgb), 0.35)`, never a hardcoded rgba. In SVG markup use `style="fill:var(--gk-gold)"`.
|
||||
|
||||
Motif images come in a light recolor and, for seals, a gold foil version for each mode. Show a motif with `Motif` (or `Seal`), which renders both files and shows the one for the current mode; a plain `<img>` stays dark. Pass `foil` only to a seal standing alone at 120px or more and half opacity or more. After changing a motif SVG, run `node packages/ui/scripts/light-motifs.mjs` to rewrite the `light/`, `foil/` and `foil-light/` versions.
|
||||
|
||||
@@ -96,6 +96,9 @@ roles in `CONTENT_TOKENS`.
|
||||
| `POST /api/held/<id>/accept`, `/reject` | editor, admin | Decide a held change. `{ "restore": true }` on reject puts the live version back into the vault. |
|
||||
| `POST /api/note/rollback` | admin | `{ path, rev }` |
|
||||
| `POST /api/scan` | editor, admin | Compare the whole folder now. |
|
||||
| `GET /api/attachments` | editor, admin | Every file in `_attachments`, newest first, with its folder, size and the notes whose text names it (`usedBy`). |
|
||||
| `POST /api/attachments?folder=&name=` | editor, admin | The raw bytes of a PNG, JPEG, WebP, GIF or AVIF image (up to 15 MB; SVG is refused because it can carry script). It is saved in `_attachments/<folder>` under a lowercase, hyphenated name, with `-2`, `-3` added when the name is taken anywhere in `_attachments`. Answers `{ name }`. |
|
||||
| `POST /api/attachments/delete` | editor, admin | `{ name }`. Refused with 409 while any note still uses the file. |
|
||||
| `POST /api/submissions` | intake, admin | File a saved copy of an inquiry or order. Answers `{ id, receipt }`. |
|
||||
| `POST /api/submissions/<id>/delivery` | intake (with the receipt, once), admin | `{ receipt, status: sent, failed or skipped, error? }`: how the email went. |
|
||||
| `GET /api/submissions?handled=`, `/api/submissions/<id>` | admin | Saved copies, unhandled first, newest first, with counts. |
|
||||
@@ -148,7 +151,7 @@ being down never takes a page down. Code: [`services/content/src/sync/`](../serv
|
||||
| `covers` | 15 min | Library items with no `cover`: Open Library (books), TMDB (film, TV; needs `TMDB_API_KEY`), AniList (anime, manga, manhwa, light novels, webnovels; by `anilistId` when set) | `cover` |
|
||||
| `suwayomi` | hour | PersonalReader's manga and manhwa: Suwayomi's GraphQL library (category, furthest chapter read and when, AniList tracker id), matched to Library items by `anilistId`; AniList for covers | `chapterProgress` (`title`, `status`, `chapter`, `readAt`), `cover` (AniList); changes `state` (below) |
|
||||
| `kavita` | day | PersonalReader's light novels and books: Kavita's API, matched by `kavitaSeriesId`, or by `anilistId` against an anilist.co link in the series' metadata | `bookProgress` (`title`, `percent`, `readAt`); changes `state` (below) |
|
||||
| `bambuddy` | hour | Filament spools in stock (**unverified API**, below) | `filamentStock` on product lines with a Filament option |
|
||||
| `bambuddy` | hour | Filament spools in stock | `filamentStock` on product lines with a Filament option |
|
||||
| `homebox` | hour | Homebox items with the labels or locations in `HOMEBOX_LABELS` / `HOMEBOX_LOCATIONS` | `materialStock` on product lines with a Leather or Paper option |
|
||||
|
||||
The covers job looks every 15 minutes, so a new Library item gets a cover
|
||||
@@ -227,15 +230,15 @@ stock when the source does not have it; in-stock colors the note does not
|
||||
list are added after the note's own. With nothing synced, options show
|
||||
exactly as the note says (a missing `inStock` counts as in stock).
|
||||
|
||||
- **Bambuddy: unverified.** Its API is not documented anywhere this project
|
||||
has, and no request has been made to one. The endpoint is
|
||||
`BAMBUDDY_SPOOLS_PATH` (default `/api/v1/inventory/spools`, a guess), and all
|
||||
knowledge of the response is in `mapBambuddySpools` in
|
||||
`sync/jobs/bambuddy.ts`: it accepts a list (or one under `items`, `spools`,
|
||||
`data`, `results`) and reads color name, hex, material and grams left under
|
||||
common spellings. Check it against Greg's Bambuddy and fix that one function
|
||||
before relying on it. A color is in stock when some spool of it has at least
|
||||
`BAMBUDDY_MIN_GRAMS` (default 50) left, or does not say.
|
||||
- **Bambuddy** reads `GET /api/v1/inventory/spools` (`BAMBUDDY_SPOOLS_PATH`)
|
||||
with an API key made in Bambuddy's Settings, sent in the `X-API-Key` header.
|
||||
Field names were checked against Greg's Bambuddy (2026-10-12) and are all in
|
||||
`mapBambuddySpools` in `sync/jobs/bambuddy.ts`. Grams left is `label_weight`
|
||||
minus `weight_used`; archived spools are skipped. A spool with no
|
||||
`color_name` (RFID spools) is named by its nearest plain color (Black,
|
||||
Blue...); setting a color name in Bambuddy overrides that. A color is in
|
||||
stock when some spool of it has at least `BAMBUDDY_MIN_GRAMS` (default 50)
|
||||
left.
|
||||
- **Homebox** logs in at `POST /api/v1/users/login`, looks up the label and
|
||||
location ids, and lists `GET /api/v1/items?labels=<id>` (or
|
||||
`locations=<id>`). Each `HOMEBOX_LABELS` / `HOMEBOX_LOCATIONS` entry is
|
||||
@@ -250,8 +253,8 @@ exactly as the note says (a missing `inStock` counts as in stock).
|
||||
| `SUWAYOMI_AUTH` | `ui_login` (default with a user), `basic`, or `none`. |
|
||||
| `KAVITA_URL`, `KAVITA_API_KEY` | Enable `kavita`, like `http://kavita:5000`; the key of a Kavita user that can see the libraries. |
|
||||
| `BAMBUDDY_URL` | Enables `bambuddy`. |
|
||||
| `BAMBUDDY_SPOOLS_PATH` | Endpoint path (default `/api/v1/inventory/spools`, unverified). |
|
||||
| `BAMBUDDY_API_KEY`, `BAMBUDDY_AUTH_HEADER` | Sent as `Authorization: Bearer <key>`, or as the raw key in the named header. |
|
||||
| `BAMBUDDY_SPOOLS_PATH` | Endpoint path (default `/api/v1/inventory/spools`). |
|
||||
| `BAMBUDDY_API_KEY`, `BAMBUDDY_AUTH_HEADER` | The API key from Bambuddy's Settings, sent in `X-API-Key` (or in the named header). |
|
||||
| `BAMBUDDY_GROUP`, `BAMBUDDY_MATERIALS`, `BAMBUDDY_MIN_GRAMS` | Option group (Filament), materials to include (like `PLA,PETG`; all when empty), grams that count as in stock (50). |
|
||||
| `HOMEBOX_URL`, `HOMEBOX_USER`, `HOMEBOX_PASS` | Enable `homebox`, with a read-only Homebox user. |
|
||||
| `HOMEBOX_LABELS`, `HOMEBOX_LOCATIONS` | Which items count, `name` or `name=Group`, comma separated. Labels default to `leather,paper`. |
|
||||
|
||||
@@ -92,7 +92,8 @@ personal Greg Kilmer letterhead, the version without Patiun LLC
|
||||
branding letterheads; re-copy it when that letterhead changes) with pdf-lib, which keeps both as vectors. It reads top to bottom so
|
||||
applicant tracking systems take it in order, aims for one page (roles marked
|
||||
`earlier` print as a short list; skills share a row with education and
|
||||
projects), uses the brand TTFs in
|
||||
projects), and when a one-page resume ends well above the footer it is laid
|
||||
out again with wider gaps (up to twice as wide) so the page reads as full. It uses the brand TTFs in
|
||||
`src/assets/pdf-fonts`, and is rebuilt only when the notes behind it change.
|
||||
The highlights row and Selected projects come from the `/resume` Page text note's
|
||||
`stats` and `projects`. Greg's Resume Interview answers live in
|
||||
@@ -111,6 +112,17 @@ The highlights row and Selected projects come from the `/resume` Page text note'
|
||||
| Blog, topics, posts, RSS | `pages/blog/`, `rss.xml.ts` | `lib/blog.ts`, `components/blog` |
|
||||
| Store, lines, order | `pages/store/` | `lib/store.ts`, `components/store` |
|
||||
|
||||
## Filters on world pages
|
||||
|
||||
- **Shelf**: How it plays, Players, Play time, Owned, Want to play, Favorites
|
||||
and Has a blog post, plus four sorts. Players and Play time read the
|
||||
BoardGameGeek sync and only appear once some game has those values. With no
|
||||
filter narrowing the list, the first 12 games show and "Show all" opens the
|
||||
rest.
|
||||
- **Craft pages**: when a craft's makes carry two or more `tags` (Dice, Rings,
|
||||
and so on; `sword rack` does not count), pills above the grid filter them.
|
||||
Makes without a tag fall under Other.
|
||||
|
||||
## Forms
|
||||
|
||||
The Inquiries form posts to `POST /api/inquiries` and the order form to
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* Each field carries `title` and `description` metadata. The JSON Schema
|
||||
* export keeps them, so the MCP server, the admin page and any later editor
|
||||
* can render a form or explain a field without a second definition.
|
||||
* can render a form or explain a field without a second definition. Image
|
||||
* and link fields also carry a `format` (`vault-image`, `vault-link`,
|
||||
* `vault-links`) so a form knows to offer a picker.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { normalizeWikilinkInput, wikilinkTarget } from "./names.ts";
|
||||
@@ -30,7 +32,7 @@ export const wikilink = (meta: Meta) =>
|
||||
normalizeWikilinkInput,
|
||||
z.string().refine((v) => wikilinkTarget(v) !== null, "Write a link as [[Note name]]"),
|
||||
)
|
||||
.meta(meta);
|
||||
.meta({ format: "vault-link", ...meta });
|
||||
|
||||
/** A list of links to other notes. */
|
||||
export const wikilinks = (meta: Meta) =>
|
||||
@@ -39,7 +41,7 @@ export const wikilinks = (meta: Meta) =>
|
||||
(v) => (Array.isArray(v) ? v.map(normalizeWikilinkInput) : v),
|
||||
z.array(z.string().refine((v) => wikilinkTarget(v) !== null, "Write each link as [[Note name]]")),
|
||||
)
|
||||
.meta(meta);
|
||||
.meta({ format: "vault-links", ...meta });
|
||||
|
||||
/** An image: a vault attachment as `[[file.jpg]]`, or a full https URL. */
|
||||
export const image = (meta: Meta) =>
|
||||
@@ -53,7 +55,7 @@ export const image = (meta: Meta) =>
|
||||
"Use a vault image as [[file.jpg]] or a full https URL",
|
||||
),
|
||||
)
|
||||
.meta(meta);
|
||||
.meta({ format: "vault-image", ...meta });
|
||||
|
||||
/** One short line of text. */
|
||||
export const line = (meta: Meta) => z.string().trim().min(1).meta(meta);
|
||||
|
||||
@@ -399,6 +399,7 @@ export const productLine = def({
|
||||
channel: z.enum(["here", "Etsy", "Patiun.com", "coming soon"]).meta({ title: "Where to buy" }),
|
||||
channelUrl: z.url().meta({ title: "Shop link", description: "For Etsy or Patiun.com lines." }).optional(),
|
||||
summary: line({ title: "Summary" }),
|
||||
menuName: line({ title: "Menu name", description: "A short name for the Store menu, like Proxy cards." }).optional(),
|
||||
icon: line({ title: "Icon" }).optional(),
|
||||
order: z.number().int().meta({ title: "Order" }).optional(),
|
||||
slots: z
|
||||
@@ -501,6 +502,11 @@ export const page = def({
|
||||
schema: note("page", {
|
||||
page: line({ title: "Page", description: "The page address this text belongs to, like /about." }),
|
||||
lede: line({ title: "Lede", description: "The one line under the page title." }).optional(),
|
||||
portrait: image({ title: "Portrait", description: "Greg's photo on the landing and About pages." }).optional(),
|
||||
photos: z
|
||||
.array(z.object({ file: image({ title: "Image" }), alt: line({ title: "Alt text" }), caption: line({ title: "Caption" }).optional() }))
|
||||
.meta({ title: "Photos", description: "A row of captioned photos, like Kashi and the tea on About." })
|
||||
.optional(),
|
||||
quickFacts: z
|
||||
.array(z.object({ icon: line({ title: "Icon" }), text: line({ title: "Text" }) }))
|
||||
.meta({ title: "Quick facts" })
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Writes the light and foil versions of the motif SVGs from the dark originals
|
||||
* in src/assets/motifs, using the inks Greg picked on the light motif board
|
||||
* (2026-10-18), and the light GK monogram in src/assets/brand/light. Run after
|
||||
* a motif or the monogram changes: node packages/ui/scripts/light-motifs.mjs
|
||||
*
|
||||
* light/ deep gold line: the dark inks swapped for Starlight-page inks, glow removed
|
||||
* foil/ the dark seal with its gold as foil, glow kept
|
||||
* foil-light/ the light seal with its gold as foil
|
||||
*/
|
||||
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const dir = join(import.meta.dirname, "../src/assets/motifs");
|
||||
|
||||
// Dark colors as they appear in the motif files, and their light inks.
|
||||
const LIGHT = {
|
||||
"#C9A24A": "#8A6516",
|
||||
"#14A39A": "#0F8C84",
|
||||
"#0E1522": "#F4F2ED",
|
||||
"#101A2A": "#ECE8DF",
|
||||
"#F4F2ED": "#131C2B",
|
||||
"#F2D58A": "#A57C1F",
|
||||
"#F6E7B8": "#8A6516",
|
||||
"#2BC2B7": "#0F8C84",
|
||||
"#9A70F5": "#6A2FE0",
|
||||
"#FFF": "#F4F2ED",
|
||||
};
|
||||
const GOLD = ["#C9A24A", "#F2D58A"];
|
||||
|
||||
const stops = (s) => s.map(([o, c]) => `<stop offset="${o}" stop-color="${c}"/>`).join("");
|
||||
const DARK_FOIL = stops([[0, "#8A6516"], [0.3, "#C9A24A"], [0.5, "#F6E7B8"], [0.68, "#C9A24A"], [1, "#8A6516"]]);
|
||||
const LIGHT_FOIL = stops([[0, "#6F5112"], [0.35, "#A57C1F"], [0.5, "#C9A24A"], [0.65, "#8A6516"], [1, "#6F5112"]]);
|
||||
|
||||
const hex = (c) => new RegExp(c.replace("#", "#") + "(?![0-9A-Fa-f])", "gi");
|
||||
|
||||
function recolor(svg, map) {
|
||||
// One pass, so a swapped color is never swapped again.
|
||||
const keys = Object.keys(map).sort((a, b) => b.length - a.length);
|
||||
const any = new RegExp(`(${keys.join("|")})(?![0-9A-Fa-f])`, "gi");
|
||||
return svg.replace(any, (m) => map[keys.find((k) => k.toLowerCase() === m.toLowerCase())]);
|
||||
}
|
||||
|
||||
function foil(svg, gradStops, golds) {
|
||||
const [, , , w, h] = svg.match(/viewBox="([-\d.]+) ([-\d.]+) ([\d.]+) ([\d.]+)"/) ?? [0, 0, 0, 400, 400];
|
||||
const grad = `<linearGradient id="gkfoil" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="${w}" y2="${h}">${gradStops}</linearGradient>`;
|
||||
for (const g of golds) svg = svg.replace(hex(g), "url(#gkfoil)");
|
||||
return svg.includes("<defs>") ? svg.replace("<defs>", `<defs>${grad}`) : svg.replace(/(<svg[^>]*>)/, `$1<defs>${grad}</defs>`);
|
||||
}
|
||||
|
||||
// Glow filters are for the night sky only.
|
||||
const unglow = (svg) => svg.replace(/\sfilter="url\(#[^)]*\)"/g, "");
|
||||
|
||||
for (const sub of ["light", "foil", "foil-light"]) mkdirSync(join(dir, sub), { recursive: true });
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith(".svg"));
|
||||
for (const f of files) {
|
||||
const dark = readFileSync(join(dir, f), "utf8");
|
||||
const light = recolor(unglow(dark), LIGHT);
|
||||
writeFileSync(join(dir, "light", f), light);
|
||||
// Only the seals stand alone large enough for foil.
|
||||
if (/seal|astrolabe|transmutation/.test(f) && !f.startsWith("mini-") && !f.startsWith("filament-banner")) {
|
||||
writeFileSync(join(dir, "foil", f), foil(dark, DARK_FOIL, ["#C9A24A"]));
|
||||
writeFileSync(join(dir, "foil-light", f), foil(light, LIGHT_FOIL, ["#8A6516", "#A57C1F"]));
|
||||
}
|
||||
}
|
||||
console.log(`${files.length} motifs written`);
|
||||
|
||||
// The GK monogram on Starlight: a pale disc, deep gold rings and ink letters.
|
||||
const MONOGRAM = {
|
||||
...LIGHT,
|
||||
"#131C2B": "#FFFFFF",
|
||||
"#1F3B49": "#ECE6D8",
|
||||
"#15232F": "#FBF9F4",
|
||||
};
|
||||
const brand = join(dir, "../brand");
|
||||
mkdirSync(join(brand, "light"), { recursive: true });
|
||||
writeFileSync(join(brand, "light", "gk-monogram.svg"), recolor(readFileSync(join(brand, "gk-monogram.svg"), "utf8"), MONOGRAM));
|
||||
console.log("monogram written");
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#F4F2ED" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Download"><path d="M12 4v11M7 10.5l5 5 5-5M5 19.5h14"/></svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#F4F2ED" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Moon"><path d="M19.5 14.6A8 8 0 0 1 9.4 4.5a8 8 0 1 0 10.1 10.1z"/><path d="M17 3.5v2.4M15.8 4.7h2.4" stroke="#C9A24A"/></svg>
|
||||
|
After Width: | Height: | Size: 305 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#F4F2ED" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Sun"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6"/></svg>
|
||||
|
After Width: | Height: | Size: 349 B |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Dice craft seal"><circle cx="200" cy="200" r="190" fill="none" stroke="url(#gkfoil)" stroke-width="2" /><circle cx="200" cy="200" r="184" fill="none" stroke="url(#gkfoil)" stroke-width="0.6" /><defs><linearGradient id="gkfoil" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="400" y2="400"><stop offset="0" stop-color="#6F5112"/><stop offset="0.35" stop-color="#A57C1F"/><stop offset="0.5" stop-color="#C9A24A"/><stop offset="0.65" stop-color="#8A6516"/><stop offset="1" stop-color="#6F5112"/></linearGradient><path id="cscrdice" d="M 200,30.53 A 169.47,169.47 0 1 1 199.99,30.53"/></defs><text font-family="Cinzel,serif" font-weight="700" font-size="11.00" letter-spacing="10.745" fill="url(#gkfoil)"><textPath href="#cscrdice">DICE ✦ AQUA ✦ RESIN ✦ PATIUN ✦ DICE ✦ AQUA ✦ RESIN ✦ PATIUN ✦ </textPath></text><circle cx="200" cy="200" r="160" fill="none" stroke="url(#gkfoil)" stroke-width="1.2" /><path d="M200.00,356.00 L64.90,122.00 L335.10,122.00Z" fill="none" stroke="#0F8C84" stroke-width="1.6" stroke-linejoin="round" /><circle cx="200" cy="200" r="78.0" fill="none" stroke="url(#gkfoil)" stroke-width="1" /><path d="M200.0,142.3 L250.0,171.1 L250.0,228.9 L200.0,257.7 L150.0,228.9 L150.0,171.1Z" fill="none" stroke="#131C2B" stroke-width="1.4" stroke-linejoin="round"/><path d="M200.0,228.9 L175.0,185.6 L225.0,185.6Z" fill="none" stroke="#131C2B" stroke-width="1.2" stroke-linejoin="round"/><line x1="200.0" y1="142.3" x2="175.0" y2="185.6" stroke="#131C2B" stroke-width=".9"/><line x1="200.0" y1="142.3" x2="225.0" y2="185.6" stroke="#131C2B" stroke-width=".9"/><line x1="250.0" y1="171.1" x2="225.0" y2="185.6" stroke="#131C2B" stroke-width=".9"/><line x1="250.0" y1="228.9" x2="225.0" y2="185.6" stroke="#131C2B" stroke-width=".9"/><line x1="250.0" y1="228.9" x2="200.0" y2="228.9" stroke="#131C2B" stroke-width=".9"/><line x1="200.0" y1="257.7" x2="200.0" y2="228.9" stroke="#131C2B" stroke-width=".9"/><line x1="150.0" y1="228.9" x2="200.0" y2="228.9" stroke="#131C2B" stroke-width=".9"/><line x1="150.0" y1="228.9" x2="175.0" y2="185.6" stroke="#131C2B" stroke-width=".9"/><line x1="150.0" y1="171.1" x2="175.0" y2="185.6" stroke="#131C2B" stroke-width=".9"/><text x="200.0" y="206.9" font-family="Cinzel,serif" font-weight="700" font-size="18.5" fill="#131C2B" text-anchor="middle" dominant-baseline="middle">20</text><circle cx="103.0" cy="144.0" r="15" fill="#F4F2ED" stroke="url(#gkfoil)" stroke-width="1"/><text x="95.97" y="149.52" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="16" fill="#131C2B" >🜄</text><circle cx="297.0" cy="144.0" r="15" fill="#F4F2ED" stroke="url(#gkfoil)" stroke-width="1"/><g transform="translate(289.21,137.50) scale(0.1024) translate(-20,-33)"><g fill="none" stroke="#0F8C84" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#0F8C84"/></g><polygon points="200.00,3.00 201.56,11.44 210.00,13.00 201.56,14.56 200.00,23.00 198.44,14.56 190.00,13.00 198.44,11.44" fill="url(#gkfoil)"/><polygon points="387.00,190.00 388.56,198.44 397.00,200.00 388.56,201.56 387.00,210.00 385.44,201.56 377.00,200.00 385.44,198.44" fill="url(#gkfoil)"/><polygon points="200.00,377.00 201.56,385.44 210.00,387.00 201.56,388.56 200.00,397.00 198.44,388.56 190.00,387.00 198.44,385.44" fill="url(#gkfoil)"/><polygon points="13.00,190.00 14.56,198.44 23.00,200.00 14.56,201.56 13.00,210.00 11.44,201.56 3.00,200.00 11.44,198.44" fill="url(#gkfoil)"/></svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Leather craft seal"><circle cx="200" cy="200" r="190" fill="none" stroke="url(#gkfoil)" stroke-width="2" /><circle cx="200" cy="200" r="184" fill="none" stroke="url(#gkfoil)" stroke-width="0.6" /><defs><linearGradient id="gkfoil" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="400" y2="400"><stop offset="0" stop-color="#6F5112"/><stop offset="0.35" stop-color="#A57C1F"/><stop offset="0.5" stop-color="#C9A24A"/><stop offset="0.65" stop-color="#8A6516"/><stop offset="1" stop-color="#6F5112"/></linearGradient><path id="cscrleather" d="M 200,32.014999999999986 A 167.985,167.985 0 1 1 199.99,32.014999999999986"/></defs><text font-family="Cinzel,serif" font-weight="700" font-size="11.00" letter-spacing="8.973" fill="url(#gkfoil)"><textPath href="#cscrleather">LEATHER ✦ TERRA ✦ HIDE ✦ PATIUN ✦ LEATHER ✦ TERRA ✦ HIDE ✦ PATIUN ✦ </textPath></text><circle cx="200" cy="200" r="160" fill="none" stroke="url(#gkfoil)" stroke-width="1.2" /><path d="M200.00,356.00 L64.90,122.00 L335.10,122.00Z" fill="none" stroke="#0F8C84" stroke-width="1.6" stroke-linejoin="round" /><line x1="162.2" y1="290.5" x2="237.8" y2="290.5" stroke="#0F8C84" stroke-width="1.2"/><circle cx="200" cy="200" r="78.0" fill="none" stroke="url(#gkfoil)" stroke-width="1" /><path d="M164.2,145.2 Q188.5,155.0 200.0,155.0 Q211.5,155.0 223.7,150.1 Q235.8,145.2 238.7,159.6 Q241.6,174.0 248.2,181.2 Q254.8,188.5 249.9,201.4 Q245.0,214.4 247.9,228.0 Q250.8,241.6 236.9,248.2 Q223.1,254.8 211.5,250.5 Q200.0,246.2 188.5,250.5 Q176.9,254.8 163.1,248.2 Q149.2,241.6 152.1,228.0 Q155.0,214.4 150.1,201.4 Q145.2,188.5 151.8,181.2 Q158.4,174.0 161.3,159.6 Z" fill="none" stroke="#131C2B" stroke-width="1.4" stroke-linejoin="round"/><path d="M174.2,160.5 Q191.7,167.6 200.0,167.6 Q208.3,167.6 217.0,164.1 Q225.8,160.5 227.8,170.9 Q229.9,181.3 234.7,186.5 Q239.5,191.7 235.9,201.0 Q232.4,210.4 234.5,220.2 Q236.6,229.9 226.6,234.7 Q216.6,239.5 208.3,236.4 Q200.0,233.2 191.7,236.4 Q183.4,239.5 173.4,234.7 Q163.4,229.9 165.5,220.2 Q167.6,210.4 164.1,201.0 Q160.5,191.7 165.3,186.5 Q170.1,181.3 172.2,170.9 Z" fill="none" stroke="#131C2B" stroke-width="1" stroke-dasharray="4 3"/><circle cx="103.0" cy="144.0" r="15" fill="#F4F2ED" stroke="url(#gkfoil)" stroke-width="1"/><text x="95.97" y="149.52" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="16" fill="#131C2B" >🜃</text><circle cx="297.0" cy="144.0" r="15" fill="#F4F2ED" stroke="url(#gkfoil)" stroke-width="1"/><g transform="translate(289.21,137.50) scale(0.1024) translate(-20,-33)"><g fill="none" stroke="#0F8C84" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#0F8C84"/></g><polygon points="200.00,3.00 201.56,11.44 210.00,13.00 201.56,14.56 200.00,23.00 198.44,14.56 190.00,13.00 198.44,11.44" fill="url(#gkfoil)"/><polygon points="387.00,190.00 388.56,198.44 397.00,200.00 388.56,201.56 387.00,210.00 385.44,201.56 377.00,200.00 385.44,198.44" fill="url(#gkfoil)"/><polygon points="200.00,377.00 201.56,385.44 210.00,387.00 201.56,388.56 200.00,397.00 198.44,388.56 190.00,387.00 198.44,385.44" fill="url(#gkfoil)"/><polygon points="13.00,190.00 14.56,198.44 23.00,200.00 14.56,201.56 13.00,210.00 11.44,201.56 3.00,200.00 11.44,198.44" fill="url(#gkfoil)"/></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Print craft seal"><circle cx="200" cy="200" r="190" fill="none" stroke="url(#gkfoil)" stroke-width="2" /><circle cx="200" cy="200" r="184" fill="none" stroke="url(#gkfoil)" stroke-width="0.6" /><defs><linearGradient id="gkfoil" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="400" y2="400"><stop offset="0" stop-color="#6F5112"/><stop offset="0.35" stop-color="#A57C1F"/><stop offset="0.5" stop-color="#C9A24A"/><stop offset="0.65" stop-color="#8A6516"/><stop offset="1" stop-color="#6F5112"/></linearGradient><path id="cscrprint" d="M 200,32.014999999999986 A 167.985,167.985 0 1 1 199.99,32.014999999999986"/></defs><text font-family="Cinzel,serif" font-weight="700" font-size="11.00" letter-spacing="9.692" fill="url(#gkfoil)"><textPath href="#cscrprint">PRINT ✦ IGNIS ✦ LAYER ✦ PATIUN ✦ PRINT ✦ IGNIS ✦ LAYER ✦ PATIUN ✦ </textPath></text><circle cx="200" cy="200" r="160" fill="none" stroke="url(#gkfoil)" stroke-width="1.2" /><path d="M200.00,44.00 L335.10,278.00 L64.90,278.00Z" fill="none" stroke="#0F8C84" stroke-width="1.6" stroke-linejoin="round" /><circle cx="200" cy="200" r="78.0" fill="none" stroke="url(#gkfoil)" stroke-width="1" /><path d="M200.0,148.1 L245.2,174.0 L200.0,200.0 L154.8,174.0Z" fill="none" stroke="#131C2B" stroke-width="1.3" stroke-linejoin="round"/><path d="M154.8,174.0 L154.8,226.0 L200.0,251.9 L245.2,226.0 L245.2,174.0 M200.0,200.0 L200.0,251.9" fill="none" stroke="#131C2B" stroke-width="1.3" stroke-linejoin="round"/><path d="M154.8,184.4 L200.0,210.4 L245.2,184.4" fill="none" stroke="#131C2B" stroke-width=".6"/><path d="M154.8,194.8 L200.0,220.8 L245.2,194.8" fill="none" stroke="#131C2B" stroke-width=".6"/><path d="M154.8,205.2 L200.0,231.2 L245.2,205.2" fill="none" stroke="#131C2B" stroke-width=".6"/><path d="M154.8,215.6 L200.0,241.6 L245.2,215.6" fill="none" stroke="#131C2B" stroke-width=".6"/><circle cx="297.0" cy="256.0" r="15" fill="#F4F2ED" stroke="url(#gkfoil)" stroke-width="1"/><text x="289.95" y="261.92" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="16" fill="#131C2B" >🜂</text><circle cx="103.0" cy="256.0" r="15" fill="#F4F2ED" stroke="url(#gkfoil)" stroke-width="1"/><g transform="translate(95.22,249.50) scale(0.1024) translate(-20,-33)"><g fill="none" stroke="#0F8C84" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#0F8C84"/></g><polygon points="200.00,3.00 201.56,11.44 210.00,13.00 201.56,14.56 200.00,23.00 198.44,14.56 190.00,13.00 198.44,11.44" fill="url(#gkfoil)"/><polygon points="387.00,190.00 388.56,198.44 397.00,200.00 388.56,201.56 387.00,210.00 385.44,201.56 377.00,200.00 385.44,198.44" fill="url(#gkfoil)"/><polygon points="200.00,377.00 201.56,385.44 210.00,387.00 201.56,388.56 200.00,397.00 198.44,388.56 190.00,387.00 198.44,385.44" fill="url(#gkfoil)"/><polygon points="13.00,190.00 14.56,198.44 23.00,200.00 14.56,201.56 13.00,210.00 11.44,201.56 3.00,200.00 11.44,198.44" fill="url(#gkfoil)"/></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Kepler nest seal"><circle cx="200" cy="200" r="194" fill="none" stroke="url(#gkfoil)" stroke-width="2" /><circle cx="200" cy="200" r="178" fill="none" stroke="url(#gkfoil)" stroke-width="0.8" /><defs><linearGradient id="gkfoil" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="400" y2="400"><stop offset="0" stop-color="#6F5112"/><stop offset="0.35" stop-color="#A57C1F"/><stop offset="0.5" stop-color="#C9A24A"/><stop offset="0.65" stop-color="#8A6516"/><stop offset="1" stop-color="#6F5112"/></linearGradient><path id="kpkp" d="M 200,17.4675 A 182.5325,182.5325 0 1 1 199.99,17.4675"/></defs><text font-family="Cinzel,serif" font-weight="700" font-size="9.50" letter-spacing="5.187" fill="url(#gkfoil)"><textPath href="#kpkp">HARMONICES MUNDI ✦ PATIUN ✦ DICE · LEATHER · PRINT ✦ HARMONICES MUNDI ✦ PATIUN ✦ DICE · LEATHER · PRINT ✦ </textPath></text><circle cx="200" cy="200" r="176" fill="none" stroke="url(#gkfoil)" stroke-width="0.5" /><path d="M200.00,24.00 L337.60,90.27 L371.59,239.16 L276.36,358.57 L123.64,358.57 L28.41,239.16 L62.40,90.27Z" fill="none" stroke="url(#gkfoil)" stroke-width="1" stroke-linejoin="round" /><circle cx="200.00" cy="24.00" r="3.2" fill="#131C2B"/><circle cx="337.60" cy="90.27" r="3.2" fill="#131C2B"/><circle cx="371.59" cy="239.16" r="3.2" fill="#131C2B"/><circle cx="276.36" cy="358.57" r="3.2" fill="#131C2B"/><circle cx="123.64" cy="358.57" r="3.2" fill="#131C2B"/><circle cx="28.41" cy="239.16" r="3.2" fill="#131C2B"/><circle cx="62.40" cy="90.27" r="3.2" fill="#131C2B"/><circle cx="200" cy="200" r="158.57052075082578" fill="none" stroke="#0F8C84" stroke-width="0.5" stroke-dasharray="1 3"/><path d="M200.00,41.43 L337.33,120.71 L337.33,279.29 L200.00,358.57 L62.67,279.29 L62.67,120.71Z" fill="none" stroke="#0F8C84" stroke-width="1" stroke-linejoin="round" /><circle cx="200.00" cy="41.43" r="2.4" fill="#131C2B"/><circle cx="337.33" cy="120.71" r="2.4" fill="#131C2B"/><circle cx="337.33" cy="279.29" r="2.4" fill="#131C2B"/><circle cx="200.00" cy="358.57" r="2.4" fill="#131C2B"/><circle cx="62.67" cy="279.29" r="2.4" fill="#131C2B"/><circle cx="62.67" cy="120.71" r="2.4" fill="#131C2B"/><circle cx="200" cy="200" r="137.32609926154262" fill="none" stroke="url(#gkfoil)" stroke-width="0.5" stroke-dasharray="1 3"/><path d="M200.00,62.67 L330.60,157.56 L280.72,311.10 L119.28,311.10 L69.40,157.56Z" fill="none" stroke="url(#gkfoil)" stroke-width="1" stroke-linejoin="round" /><circle cx="200.00" cy="62.67" r="2.4" fill="#131C2B"/><circle cx="330.60" cy="157.56" r="2.4" fill="#131C2B"/><circle cx="280.72" cy="311.10" r="2.4" fill="#131C2B"/><circle cx="119.28" cy="311.10" r="2.4" fill="#131C2B"/><circle cx="69.40" cy="157.56" r="2.4" fill="#131C2B"/><circle cx="200" cy="200" r="111.0991480738089" fill="none" stroke="#0F8C84" stroke-width="0.5" stroke-dasharray="1 3"/><path d="M200.00,88.90 L311.10,200.00 L200.00,311.10 L88.90,200.00Z" fill="none" stroke="#0F8C84" stroke-width="1" stroke-linejoin="round" /><circle cx="200.00" cy="88.90" r="2.4" fill="#131C2B"/><circle cx="311.10" cy="200.00" r="2.4" fill="#131C2B"/><circle cx="200.00" cy="311.10" r="2.4" fill="#131C2B"/><circle cx="88.90" cy="200.00" r="2.4" fill="#131C2B"/><circle cx="200" cy="200" r="78.55896098703865" fill="none" stroke="url(#gkfoil)" stroke-width="0.5" stroke-dasharray="1 3"/><path d="M200.00,121.44 L268.03,239.28 L131.97,239.28Z" fill="none" stroke="url(#gkfoil)" stroke-width="1.3" stroke-linejoin="round" /><circle cx="200.00" cy="121.44" r="2.4" fill="#131C2B"/><circle cx="268.03" cy="239.28" r="2.4" fill="#131C2B"/><circle cx="131.97" cy="239.28" r="2.4" fill="#131C2B"/><circle cx="200" cy="200" r="39.27948049351933" fill="none" stroke="#0F8C84" stroke-width="1.2" /><g transform="translate(177.67,181.34) scale(0.2938) translate(-20,-33)"><g fill="none" stroke="#0F8C84" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#0F8C84"/></g><polygon points="200.00,-4.00 201.56,4.44 210.00,6.00 201.56,7.56 200.00,16.00 198.44,7.56 190.00,6.00 198.44,4.44" fill="url(#gkfoil)"/><polygon points="394.00,190.00 395.56,198.44 404.00,200.00 395.56,201.56 394.00,210.00 392.44,201.56 384.00,200.00 392.44,198.44" fill="url(#gkfoil)"/><polygon points="200.00,384.00 201.56,392.44 210.00,394.00 201.56,395.56 200.00,404.00 198.44,395.56 190.00,394.00 198.44,392.44" fill="url(#gkfoil)"/><polygon points="6.00,190.00 7.56,198.44 16.00,200.00 7.56,201.56 6.00,210.00 4.44,201.56 -4.00,200.00 4.44,198.44" fill="url(#gkfoil)"/></svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Layered seal"><circle cx="200" cy="200" r="192" fill="none" stroke="#0F8C84" stroke-width="2" /><circle cx="200" cy="200" r="170" fill="none" stroke="#0F8C84" stroke-width="0.8" /><text x="240.34" y="30.28" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♈︎</text><text x="321.90" y="77.12" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♉︎</text><text x="368.88" y="158.05" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♊︎</text><text x="368.18" y="251.89" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♋︎</text><text x="322.60" y="331.42" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♌︎</text><text x="240.76" y="378.40" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♍︎</text><text x="146.57" y="379.73" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♎︎</text><text x="65.29" y="331.63" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♏︎</text><text x="19.64" y="251.75" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♐︎</text><text x="18.38" y="156.79" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♑︎</text><text x="65.43" y="76.91" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♒︎</text><text x="147.41" y="30.49" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♓︎</text><line x1="200.00" y1="30.00" x2="200.00" y2="8.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="285.00" y1="52.78" x2="296.00" y2="33.72" stroke="#0F8C84" stroke-width="0.8"/><line x1="347.22" y1="115.00" x2="366.28" y2="104.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="370.00" y1="200.00" x2="392.00" y2="200.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="347.22" y1="285.00" x2="366.28" y2="296.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="285.00" y1="347.22" x2="296.00" y2="366.28" stroke="#0F8C84" stroke-width="0.8"/><line x1="200.00" y1="370.00" x2="200.00" y2="392.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="115.00" y1="347.22" x2="104.00" y2="366.28" stroke="#0F8C84" stroke-width="0.8"/><line x1="52.78" y1="285.00" x2="33.72" y2="296.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="30.00" y1="200.00" x2="8.00" y2="200.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="52.78" y1="115.00" x2="33.72" y2="104.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="115.00" y1="52.78" x2="104.00" y2="33.72" stroke="#0F8C84" stroke-width="0.8"/><defs><linearGradient id="gkfoil" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="400" y2="400"><stop offset="0" stop-color="#6F5112"/><stop offset="0.35" stop-color="#A57C1F"/><stop offset="0.5" stop-color="#C9A24A"/><stop offset="0.65" stop-color="#8A6516"/><stop offset="1" stop-color="#6F5112"/></linearGradient><path id="ltf" d="M 200,49.379999999999995 A 150.62,150.62 0 1 1 199.99,49.379999999999995"/></defs><text font-family="Cinzel,serif" font-weight="700" font-size="12.00" letter-spacing="10.483" fill="url(#gkfoil)"><textPath href="#ltf">PATIUN ✦ PATIUN ✦ PATIUN ✦ PATIUN ✦ PATIUN ✦ PATIUN ✦ </textPath></text><circle cx="200" cy="200" r="140" fill="none" stroke="url(#gkfoil)" stroke-width="1" /><path d="M200.0,60.0 L340.0,200.0 L200.0,340.0 L60.0,200.0Z" fill="none" stroke="#0F8C84" stroke-width="1.2"/><path d="M299.0,101.0 L299.0,299.0 L101.0,299.0 L101.0,101.0Z" fill="none" stroke="#0F8C84" stroke-width="1.2"/><circle cx="200" cy="200" r="99" fill="none" stroke="url(#gkfoil)" stroke-width="0.9" /><text x="238.56" y="96.53" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="15" fill="url(#gkfoil)" >🜂</text><text x="302.42" y="250.33" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="15" fill="url(#gkfoil)" >🜄</text><text x="148.24" y="314.57" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="15" fill="url(#gkfoil)" >🜁</text><text x="84.38" y="160.02" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="15" fill="url(#gkfoil)" >🜃</text><path d="M200.0,101.0 L285.7,249.5 L114.3,249.5Z" fill="none" stroke="url(#gkfoil)" stroke-width=".9"/><path d="M200.0,299.0 L114.3,150.5 L285.7,150.5Z" fill="none" stroke="url(#gkfoil)" stroke-width=".9"/><circle cx="200" cy="200" r="48" fill="none" stroke="#0F8C84" stroke-width="1.4" /><g transform="translate(173.67,178.00) scale(0.3465) translate(-20,-33)"><g fill="none" stroke="#0F8C84" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#0F8C84"/></g></svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Dice craft seal"><circle cx="200" cy="200" r="190" fill="none" stroke="url(#gkfoil)" stroke-width="2" /><circle cx="200" cy="200" r="184" fill="none" stroke="url(#gkfoil)" stroke-width="0.6" /><defs><linearGradient id="gkfoil" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="400" y2="400"><stop offset="0" stop-color="#8A6516"/><stop offset="0.3" stop-color="#C9A24A"/><stop offset="0.5" stop-color="#F6E7B8"/><stop offset="0.68" stop-color="#C9A24A"/><stop offset="1" stop-color="#8A6516"/></linearGradient><path id="cscrdice" d="M 200,30.53 A 169.47,169.47 0 1 1 199.99,30.53"/></defs><text font-family="Cinzel,serif" font-weight="700" font-size="11.00" letter-spacing="10.745" fill="url(#gkfoil)"><textPath href="#cscrdice">DICE ✦ AQUA ✦ RESIN ✦ PATIUN ✦ DICE ✦ AQUA ✦ RESIN ✦ PATIUN ✦ </textPath></text><circle cx="200" cy="200" r="160" fill="none" stroke="url(#gkfoil)" stroke-width="1.2" /><path d="M200.00,356.00 L64.90,122.00 L335.10,122.00Z" fill="none" stroke="#14A39A" stroke-width="1.6" stroke-linejoin="round" /><circle cx="200" cy="200" r="78.0" fill="none" stroke="url(#gkfoil)" stroke-width="1" /><path d="M200.0,142.3 L250.0,171.1 L250.0,228.9 L200.0,257.7 L150.0,228.9 L150.0,171.1Z" fill="none" stroke="#F4F2ED" stroke-width="1.4" stroke-linejoin="round"/><path d="M200.0,228.9 L175.0,185.6 L225.0,185.6Z" fill="none" stroke="#F4F2ED" stroke-width="1.2" stroke-linejoin="round"/><line x1="200.0" y1="142.3" x2="175.0" y2="185.6" stroke="#F4F2ED" stroke-width=".9"/><line x1="200.0" y1="142.3" x2="225.0" y2="185.6" stroke="#F4F2ED" stroke-width=".9"/><line x1="250.0" y1="171.1" x2="225.0" y2="185.6" stroke="#F4F2ED" stroke-width=".9"/><line x1="250.0" y1="228.9" x2="225.0" y2="185.6" stroke="#F4F2ED" stroke-width=".9"/><line x1="250.0" y1="228.9" x2="200.0" y2="228.9" stroke="#F4F2ED" stroke-width=".9"/><line x1="200.0" y1="257.7" x2="200.0" y2="228.9" stroke="#F4F2ED" stroke-width=".9"/><line x1="150.0" y1="228.9" x2="200.0" y2="228.9" stroke="#F4F2ED" stroke-width=".9"/><line x1="150.0" y1="228.9" x2="175.0" y2="185.6" stroke="#F4F2ED" stroke-width=".9"/><line x1="150.0" y1="171.1" x2="175.0" y2="185.6" stroke="#F4F2ED" stroke-width=".9"/><text x="200.0" y="206.9" font-family="Cinzel,serif" font-weight="700" font-size="18.5" fill="#F4F2ED" text-anchor="middle" dominant-baseline="middle">20</text><circle cx="103.0" cy="144.0" r="15" fill="#0E1522" stroke="url(#gkfoil)" stroke-width="1"/><text x="95.97" y="149.52" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="16" fill="#F4F2ED" >🜄</text><circle cx="297.0" cy="144.0" r="15" fill="#0E1522" stroke="url(#gkfoil)" stroke-width="1"/><g transform="translate(289.21,137.50) scale(0.1024) translate(-20,-33)"><g fill="none" stroke="#14A39A" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#14A39A"/></g><polygon points="200.00,3.00 201.56,11.44 210.00,13.00 201.56,14.56 200.00,23.00 198.44,14.56 190.00,13.00 198.44,11.44" fill="url(#gkfoil)"/><polygon points="387.00,190.00 388.56,198.44 397.00,200.00 388.56,201.56 387.00,210.00 385.44,201.56 377.00,200.00 385.44,198.44" fill="url(#gkfoil)"/><polygon points="200.00,377.00 201.56,385.44 210.00,387.00 201.56,388.56 200.00,397.00 198.44,388.56 190.00,387.00 198.44,385.44" fill="url(#gkfoil)"/><polygon points="13.00,190.00 14.56,198.44 23.00,200.00 14.56,201.56 13.00,210.00 11.44,201.56 3.00,200.00 11.44,198.44" fill="url(#gkfoil)"/></svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Leather craft seal"><circle cx="200" cy="200" r="190" fill="none" stroke="url(#gkfoil)" stroke-width="2" /><circle cx="200" cy="200" r="184" fill="none" stroke="url(#gkfoil)" stroke-width="0.6" /><defs><linearGradient id="gkfoil" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="400" y2="400"><stop offset="0" stop-color="#8A6516"/><stop offset="0.3" stop-color="#C9A24A"/><stop offset="0.5" stop-color="#F6E7B8"/><stop offset="0.68" stop-color="#C9A24A"/><stop offset="1" stop-color="#8A6516"/></linearGradient><path id="cscrleather" d="M 200,32.014999999999986 A 167.985,167.985 0 1 1 199.99,32.014999999999986"/></defs><text font-family="Cinzel,serif" font-weight="700" font-size="11.00" letter-spacing="8.973" fill="url(#gkfoil)"><textPath href="#cscrleather">LEATHER ✦ TERRA ✦ HIDE ✦ PATIUN ✦ LEATHER ✦ TERRA ✦ HIDE ✦ PATIUN ✦ </textPath></text><circle cx="200" cy="200" r="160" fill="none" stroke="url(#gkfoil)" stroke-width="1.2" /><path d="M200.00,356.00 L64.90,122.00 L335.10,122.00Z" fill="none" stroke="#14A39A" stroke-width="1.6" stroke-linejoin="round" /><line x1="162.2" y1="290.5" x2="237.8" y2="290.5" stroke="#14A39A" stroke-width="1.2"/><circle cx="200" cy="200" r="78.0" fill="none" stroke="url(#gkfoil)" stroke-width="1" /><path d="M164.2,145.2 Q188.5,155.0 200.0,155.0 Q211.5,155.0 223.7,150.1 Q235.8,145.2 238.7,159.6 Q241.6,174.0 248.2,181.2 Q254.8,188.5 249.9,201.4 Q245.0,214.4 247.9,228.0 Q250.8,241.6 236.9,248.2 Q223.1,254.8 211.5,250.5 Q200.0,246.2 188.5,250.5 Q176.9,254.8 163.1,248.2 Q149.2,241.6 152.1,228.0 Q155.0,214.4 150.1,201.4 Q145.2,188.5 151.8,181.2 Q158.4,174.0 161.3,159.6 Z" fill="none" stroke="#F4F2ED" stroke-width="1.4" stroke-linejoin="round"/><path d="M174.2,160.5 Q191.7,167.6 200.0,167.6 Q208.3,167.6 217.0,164.1 Q225.8,160.5 227.8,170.9 Q229.9,181.3 234.7,186.5 Q239.5,191.7 235.9,201.0 Q232.4,210.4 234.5,220.2 Q236.6,229.9 226.6,234.7 Q216.6,239.5 208.3,236.4 Q200.0,233.2 191.7,236.4 Q183.4,239.5 173.4,234.7 Q163.4,229.9 165.5,220.2 Q167.6,210.4 164.1,201.0 Q160.5,191.7 165.3,186.5 Q170.1,181.3 172.2,170.9 Z" fill="none" stroke="#F4F2ED" stroke-width="1" stroke-dasharray="4 3"/><circle cx="103.0" cy="144.0" r="15" fill="#0E1522" stroke="url(#gkfoil)" stroke-width="1"/><text x="95.97" y="149.52" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="16" fill="#F4F2ED" >🜃</text><circle cx="297.0" cy="144.0" r="15" fill="#0E1522" stroke="url(#gkfoil)" stroke-width="1"/><g transform="translate(289.21,137.50) scale(0.1024) translate(-20,-33)"><g fill="none" stroke="#14A39A" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#14A39A"/></g><polygon points="200.00,3.00 201.56,11.44 210.00,13.00 201.56,14.56 200.00,23.00 198.44,14.56 190.00,13.00 198.44,11.44" fill="url(#gkfoil)"/><polygon points="387.00,190.00 388.56,198.44 397.00,200.00 388.56,201.56 387.00,210.00 385.44,201.56 377.00,200.00 385.44,198.44" fill="url(#gkfoil)"/><polygon points="200.00,377.00 201.56,385.44 210.00,387.00 201.56,388.56 200.00,397.00 198.44,388.56 190.00,387.00 198.44,385.44" fill="url(#gkfoil)"/><polygon points="13.00,190.00 14.56,198.44 23.00,200.00 14.56,201.56 13.00,210.00 11.44,201.56 3.00,200.00 11.44,198.44" fill="url(#gkfoil)"/></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Print craft seal"><circle cx="200" cy="200" r="190" fill="none" stroke="url(#gkfoil)" stroke-width="2" /><circle cx="200" cy="200" r="184" fill="none" stroke="url(#gkfoil)" stroke-width="0.6" /><defs><linearGradient id="gkfoil" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="400" y2="400"><stop offset="0" stop-color="#8A6516"/><stop offset="0.3" stop-color="#C9A24A"/><stop offset="0.5" stop-color="#F6E7B8"/><stop offset="0.68" stop-color="#C9A24A"/><stop offset="1" stop-color="#8A6516"/></linearGradient><path id="cscrprint" d="M 200,32.014999999999986 A 167.985,167.985 0 1 1 199.99,32.014999999999986"/></defs><text font-family="Cinzel,serif" font-weight="700" font-size="11.00" letter-spacing="9.692" fill="url(#gkfoil)"><textPath href="#cscrprint">PRINT ✦ IGNIS ✦ LAYER ✦ PATIUN ✦ PRINT ✦ IGNIS ✦ LAYER ✦ PATIUN ✦ </textPath></text><circle cx="200" cy="200" r="160" fill="none" stroke="url(#gkfoil)" stroke-width="1.2" /><path d="M200.00,44.00 L335.10,278.00 L64.90,278.00Z" fill="none" stroke="#14A39A" stroke-width="1.6" stroke-linejoin="round" /><circle cx="200" cy="200" r="78.0" fill="none" stroke="url(#gkfoil)" stroke-width="1" /><path d="M200.0,148.1 L245.2,174.0 L200.0,200.0 L154.8,174.0Z" fill="none" stroke="#F4F2ED" stroke-width="1.3" stroke-linejoin="round"/><path d="M154.8,174.0 L154.8,226.0 L200.0,251.9 L245.2,226.0 L245.2,174.0 M200.0,200.0 L200.0,251.9" fill="none" stroke="#F4F2ED" stroke-width="1.3" stroke-linejoin="round"/><path d="M154.8,184.4 L200.0,210.4 L245.2,184.4" fill="none" stroke="#F4F2ED" stroke-width=".6"/><path d="M154.8,194.8 L200.0,220.8 L245.2,194.8" fill="none" stroke="#F4F2ED" stroke-width=".6"/><path d="M154.8,205.2 L200.0,231.2 L245.2,205.2" fill="none" stroke="#F4F2ED" stroke-width=".6"/><path d="M154.8,215.6 L200.0,241.6 L245.2,215.6" fill="none" stroke="#F4F2ED" stroke-width=".6"/><circle cx="297.0" cy="256.0" r="15" fill="#0E1522" stroke="url(#gkfoil)" stroke-width="1"/><text x="289.95" y="261.92" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="16" fill="#F4F2ED" >🜂</text><circle cx="103.0" cy="256.0" r="15" fill="#0E1522" stroke="url(#gkfoil)" stroke-width="1"/><g transform="translate(95.22,249.50) scale(0.1024) translate(-20,-33)"><g fill="none" stroke="#14A39A" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#14A39A"/></g><polygon points="200.00,3.00 201.56,11.44 210.00,13.00 201.56,14.56 200.00,23.00 198.44,14.56 190.00,13.00 198.44,11.44" fill="url(#gkfoil)"/><polygon points="387.00,190.00 388.56,198.44 397.00,200.00 388.56,201.56 387.00,210.00 385.44,201.56 377.00,200.00 385.44,198.44" fill="url(#gkfoil)"/><polygon points="200.00,377.00 201.56,385.44 210.00,387.00 201.56,388.56 200.00,397.00 198.44,388.56 190.00,387.00 198.44,385.44" fill="url(#gkfoil)"/><polygon points="13.00,190.00 14.56,198.44 23.00,200.00 14.56,201.56 13.00,210.00 11.44,201.56 3.00,200.00 11.44,198.44" fill="url(#gkfoil)"/></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Kepler nest seal"><circle cx="200" cy="200" r="194" fill="none" stroke="url(#gkfoil)" stroke-width="2" /><circle cx="200" cy="200" r="178" fill="none" stroke="url(#gkfoil)" stroke-width="0.8" /><defs><linearGradient id="gkfoil" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="400" y2="400"><stop offset="0" stop-color="#8A6516"/><stop offset="0.3" stop-color="#C9A24A"/><stop offset="0.5" stop-color="#F6E7B8"/><stop offset="0.68" stop-color="#C9A24A"/><stop offset="1" stop-color="#8A6516"/></linearGradient><path id="kpkp" d="M 200,17.4675 A 182.5325,182.5325 0 1 1 199.99,17.4675"/></defs><text font-family="Cinzel,serif" font-weight="700" font-size="9.50" letter-spacing="5.187" fill="url(#gkfoil)"><textPath href="#kpkp">HARMONICES MUNDI ✦ PATIUN ✦ DICE · LEATHER · PRINT ✦ HARMONICES MUNDI ✦ PATIUN ✦ DICE · LEATHER · PRINT ✦ </textPath></text><circle cx="200" cy="200" r="176" fill="none" stroke="url(#gkfoil)" stroke-width="0.5" /><path d="M200.00,24.00 L337.60,90.27 L371.59,239.16 L276.36,358.57 L123.64,358.57 L28.41,239.16 L62.40,90.27Z" fill="none" stroke="url(#gkfoil)" stroke-width="1" stroke-linejoin="round" /><circle cx="200.00" cy="24.00" r="3.2" fill="#F4F2ED"/><circle cx="337.60" cy="90.27" r="3.2" fill="#F4F2ED"/><circle cx="371.59" cy="239.16" r="3.2" fill="#F4F2ED"/><circle cx="276.36" cy="358.57" r="3.2" fill="#F4F2ED"/><circle cx="123.64" cy="358.57" r="3.2" fill="#F4F2ED"/><circle cx="28.41" cy="239.16" r="3.2" fill="#F4F2ED"/><circle cx="62.40" cy="90.27" r="3.2" fill="#F4F2ED"/><circle cx="200" cy="200" r="158.57052075082578" fill="none" stroke="#14A39A" stroke-width="0.5" stroke-dasharray="1 3"/><path d="M200.00,41.43 L337.33,120.71 L337.33,279.29 L200.00,358.57 L62.67,279.29 L62.67,120.71Z" fill="none" stroke="#14A39A" stroke-width="1" stroke-linejoin="round" /><circle cx="200.00" cy="41.43" r="2.4" fill="#F4F2ED"/><circle cx="337.33" cy="120.71" r="2.4" fill="#F4F2ED"/><circle cx="337.33" cy="279.29" r="2.4" fill="#F4F2ED"/><circle cx="200.00" cy="358.57" r="2.4" fill="#F4F2ED"/><circle cx="62.67" cy="279.29" r="2.4" fill="#F4F2ED"/><circle cx="62.67" cy="120.71" r="2.4" fill="#F4F2ED"/><circle cx="200" cy="200" r="137.32609926154262" fill="none" stroke="url(#gkfoil)" stroke-width="0.5" stroke-dasharray="1 3"/><path d="M200.00,62.67 L330.60,157.56 L280.72,311.10 L119.28,311.10 L69.40,157.56Z" fill="none" stroke="url(#gkfoil)" stroke-width="1" stroke-linejoin="round" /><circle cx="200.00" cy="62.67" r="2.4" fill="#F4F2ED"/><circle cx="330.60" cy="157.56" r="2.4" fill="#F4F2ED"/><circle cx="280.72" cy="311.10" r="2.4" fill="#F4F2ED"/><circle cx="119.28" cy="311.10" r="2.4" fill="#F4F2ED"/><circle cx="69.40" cy="157.56" r="2.4" fill="#F4F2ED"/><circle cx="200" cy="200" r="111.0991480738089" fill="none" stroke="#14A39A" stroke-width="0.5" stroke-dasharray="1 3"/><path d="M200.00,88.90 L311.10,200.00 L200.00,311.10 L88.90,200.00Z" fill="none" stroke="#14A39A" stroke-width="1" stroke-linejoin="round" /><circle cx="200.00" cy="88.90" r="2.4" fill="#F4F2ED"/><circle cx="311.10" cy="200.00" r="2.4" fill="#F4F2ED"/><circle cx="200.00" cy="311.10" r="2.4" fill="#F4F2ED"/><circle cx="88.90" cy="200.00" r="2.4" fill="#F4F2ED"/><circle cx="200" cy="200" r="78.55896098703865" fill="none" stroke="url(#gkfoil)" stroke-width="0.5" stroke-dasharray="1 3"/><path d="M200.00,121.44 L268.03,239.28 L131.97,239.28Z" fill="none" stroke="url(#gkfoil)" stroke-width="1.3" stroke-linejoin="round" /><circle cx="200.00" cy="121.44" r="2.4" fill="#F4F2ED"/><circle cx="268.03" cy="239.28" r="2.4" fill="#F4F2ED"/><circle cx="131.97" cy="239.28" r="2.4" fill="#F4F2ED"/><circle cx="200" cy="200" r="39.27948049351933" fill="none" stroke="#14A39A" stroke-width="1.2" /><g transform="translate(177.67,181.34) scale(0.2938) translate(-20,-33)"><g fill="none" stroke="#14A39A" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#14A39A"/></g><polygon points="200.00,-4.00 201.56,4.44 210.00,6.00 201.56,7.56 200.00,16.00 198.44,7.56 190.00,6.00 198.44,4.44" fill="url(#gkfoil)"/><polygon points="394.00,190.00 395.56,198.44 404.00,200.00 395.56,201.56 394.00,210.00 392.44,201.56 384.00,200.00 392.44,198.44" fill="url(#gkfoil)"/><polygon points="200.00,384.00 201.56,392.44 210.00,394.00 201.56,395.56 200.00,404.00 198.44,395.56 190.00,394.00 198.44,392.44" fill="url(#gkfoil)"/><polygon points="6.00,190.00 7.56,198.44 16.00,200.00 7.56,201.56 6.00,210.00 4.44,201.56 -4.00,200.00 4.44,198.44" fill="url(#gkfoil)"/></svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Layered seal"><circle cx="200" cy="200" r="192" fill="none" stroke="#14A39A" stroke-width="2" /><circle cx="200" cy="200" r="170" fill="none" stroke="#14A39A" stroke-width="0.8" /><text x="240.34" y="30.28" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#14A39A" >♈︎</text><text x="321.90" y="77.12" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#14A39A" >♉︎</text><text x="368.88" y="158.05" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#14A39A" >♊︎</text><text x="368.18" y="251.89" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#14A39A" >♋︎</text><text x="322.60" y="331.42" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#14A39A" >♌︎</text><text x="240.76" y="378.40" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#14A39A" >♍︎</text><text x="146.57" y="379.73" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#14A39A" >♎︎</text><text x="65.29" y="331.63" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#14A39A" >♏︎</text><text x="19.64" y="251.75" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#14A39A" >♐︎</text><text x="18.38" y="156.79" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#14A39A" >♑︎</text><text x="65.43" y="76.91" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#14A39A" >♒︎</text><text x="147.41" y="30.49" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#14A39A" >♓︎</text><line x1="200.00" y1="30.00" x2="200.00" y2="8.00" stroke="#14A39A" stroke-width="0.8"/><line x1="285.00" y1="52.78" x2="296.00" y2="33.72" stroke="#14A39A" stroke-width="0.8"/><line x1="347.22" y1="115.00" x2="366.28" y2="104.00" stroke="#14A39A" stroke-width="0.8"/><line x1="370.00" y1="200.00" x2="392.00" y2="200.00" stroke="#14A39A" stroke-width="0.8"/><line x1="347.22" y1="285.00" x2="366.28" y2="296.00" stroke="#14A39A" stroke-width="0.8"/><line x1="285.00" y1="347.22" x2="296.00" y2="366.28" stroke="#14A39A" stroke-width="0.8"/><line x1="200.00" y1="370.00" x2="200.00" y2="392.00" stroke="#14A39A" stroke-width="0.8"/><line x1="115.00" y1="347.22" x2="104.00" y2="366.28" stroke="#14A39A" stroke-width="0.8"/><line x1="52.78" y1="285.00" x2="33.72" y2="296.00" stroke="#14A39A" stroke-width="0.8"/><line x1="30.00" y1="200.00" x2="8.00" y2="200.00" stroke="#14A39A" stroke-width="0.8"/><line x1="52.78" y1="115.00" x2="33.72" y2="104.00" stroke="#14A39A" stroke-width="0.8"/><line x1="115.00" y1="52.78" x2="104.00" y2="33.72" stroke="#14A39A" stroke-width="0.8"/><defs><linearGradient id="gkfoil" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="400" y2="400"><stop offset="0" stop-color="#8A6516"/><stop offset="0.3" stop-color="#C9A24A"/><stop offset="0.5" stop-color="#F6E7B8"/><stop offset="0.68" stop-color="#C9A24A"/><stop offset="1" stop-color="#8A6516"/></linearGradient><path id="ltf" d="M 200,49.379999999999995 A 150.62,150.62 0 1 1 199.99,49.379999999999995"/></defs><text font-family="Cinzel,serif" font-weight="700" font-size="12.00" letter-spacing="10.483" fill="url(#gkfoil)"><textPath href="#ltf">PATIUN ✦ PATIUN ✦ PATIUN ✦ PATIUN ✦ PATIUN ✦ PATIUN ✦ </textPath></text><circle cx="200" cy="200" r="140" fill="none" stroke="url(#gkfoil)" stroke-width="1" /><path d="M200.0,60.0 L340.0,200.0 L200.0,340.0 L60.0,200.0Z" fill="none" stroke="#14A39A" stroke-width="1.2"/><path d="M299.0,101.0 L299.0,299.0 L101.0,299.0 L101.0,101.0Z" fill="none" stroke="#14A39A" stroke-width="1.2"/><circle cx="200" cy="200" r="99" fill="none" stroke="url(#gkfoil)" stroke-width="0.9" /><text x="238.56" y="96.53" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="15" fill="url(#gkfoil)" >🜂</text><text x="302.42" y="250.33" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="15" fill="url(#gkfoil)" >🜄</text><text x="148.24" y="314.57" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="15" fill="url(#gkfoil)" >🜁</text><text x="84.38" y="160.02" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="15" fill="url(#gkfoil)" >🜃</text><path d="M200.0,101.0 L285.7,249.5 L114.3,249.5Z" fill="none" stroke="url(#gkfoil)" stroke-width=".9"/><path d="M200.0,299.0 L114.3,150.5 L285.7,150.5Z" fill="none" stroke="url(#gkfoil)" stroke-width=".9"/><circle cx="200" cy="200" r="48" fill="none" stroke="#14A39A" stroke-width="1.4" /><g transform="translate(173.67,178.00) scale(0.3465) translate(-20,-33)"><g fill="none" stroke="#14A39A" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#14A39A"/></g></svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Dice craft seal"><circle cx="200" cy="200" r="190" fill="none" stroke="#8A6516" stroke-width="2" /><circle cx="200" cy="200" r="184" fill="none" stroke="#8A6516" stroke-width="0.6" /><defs><path id="cscrdice" d="M 200,30.53 A 169.47,169.47 0 1 1 199.99,30.53"/></defs><text font-family="Cinzel,serif" font-weight="700" font-size="11.00" letter-spacing="10.745" fill="#8A6516"><textPath href="#cscrdice">DICE ✦ AQUA ✦ RESIN ✦ PATIUN ✦ DICE ✦ AQUA ✦ RESIN ✦ PATIUN ✦ </textPath></text><circle cx="200" cy="200" r="160" fill="none" stroke="#8A6516" stroke-width="1.2" /><path d="M200.00,356.00 L64.90,122.00 L335.10,122.00Z" fill="none" stroke="#0F8C84" stroke-width="1.6" stroke-linejoin="round" /><circle cx="200" cy="200" r="78.0" fill="none" stroke="#8A6516" stroke-width="1" /><path d="M200.0,142.3 L250.0,171.1 L250.0,228.9 L200.0,257.7 L150.0,228.9 L150.0,171.1Z" fill="none" stroke="#131C2B" stroke-width="1.4" stroke-linejoin="round"/><path d="M200.0,228.9 L175.0,185.6 L225.0,185.6Z" fill="none" stroke="#131C2B" stroke-width="1.2" stroke-linejoin="round"/><line x1="200.0" y1="142.3" x2="175.0" y2="185.6" stroke="#131C2B" stroke-width=".9"/><line x1="200.0" y1="142.3" x2="225.0" y2="185.6" stroke="#131C2B" stroke-width=".9"/><line x1="250.0" y1="171.1" x2="225.0" y2="185.6" stroke="#131C2B" stroke-width=".9"/><line x1="250.0" y1="228.9" x2="225.0" y2="185.6" stroke="#131C2B" stroke-width=".9"/><line x1="250.0" y1="228.9" x2="200.0" y2="228.9" stroke="#131C2B" stroke-width=".9"/><line x1="200.0" y1="257.7" x2="200.0" y2="228.9" stroke="#131C2B" stroke-width=".9"/><line x1="150.0" y1="228.9" x2="200.0" y2="228.9" stroke="#131C2B" stroke-width=".9"/><line x1="150.0" y1="228.9" x2="175.0" y2="185.6" stroke="#131C2B" stroke-width=".9"/><line x1="150.0" y1="171.1" x2="175.0" y2="185.6" stroke="#131C2B" stroke-width=".9"/><text x="200.0" y="206.9" font-family="Cinzel,serif" font-weight="700" font-size="18.5" fill="#131C2B" text-anchor="middle" dominant-baseline="middle">20</text><circle cx="103.0" cy="144.0" r="15" fill="#F4F2ED" stroke="#8A6516" stroke-width="1"/><text x="95.97" y="149.52" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="16" fill="#131C2B" >🜄</text><circle cx="297.0" cy="144.0" r="15" fill="#F4F2ED" stroke="#8A6516" stroke-width="1"/><g transform="translate(289.21,137.50) scale(0.1024) translate(-20,-33)"><g fill="none" stroke="#0F8C84" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#0F8C84"/></g><polygon points="200.00,3.00 201.56,11.44 210.00,13.00 201.56,14.56 200.00,23.00 198.44,14.56 190.00,13.00 198.44,11.44" fill="#8A6516"/><polygon points="387.00,190.00 388.56,198.44 397.00,200.00 388.56,201.56 387.00,210.00 385.44,201.56 377.00,200.00 385.44,198.44" fill="#8A6516"/><polygon points="200.00,377.00 201.56,385.44 210.00,387.00 201.56,388.56 200.00,397.00 198.44,388.56 190.00,387.00 198.44,385.44" fill="#8A6516"/><polygon points="13.00,190.00 14.56,198.44 23.00,200.00 14.56,201.56 13.00,210.00 11.44,201.56 3.00,200.00 11.44,198.44" fill="#8A6516"/></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Leather craft seal"><circle cx="200" cy="200" r="190" fill="none" stroke="#8A6516" stroke-width="2" /><circle cx="200" cy="200" r="184" fill="none" stroke="#8A6516" stroke-width="0.6" /><defs><path id="cscrleather" d="M 200,32.014999999999986 A 167.985,167.985 0 1 1 199.99,32.014999999999986"/></defs><text font-family="Cinzel,serif" font-weight="700" font-size="11.00" letter-spacing="8.973" fill="#8A6516"><textPath href="#cscrleather">LEATHER ✦ TERRA ✦ HIDE ✦ PATIUN ✦ LEATHER ✦ TERRA ✦ HIDE ✦ PATIUN ✦ </textPath></text><circle cx="200" cy="200" r="160" fill="none" stroke="#8A6516" stroke-width="1.2" /><path d="M200.00,356.00 L64.90,122.00 L335.10,122.00Z" fill="none" stroke="#0F8C84" stroke-width="1.6" stroke-linejoin="round" /><line x1="162.2" y1="290.5" x2="237.8" y2="290.5" stroke="#0F8C84" stroke-width="1.2"/><circle cx="200" cy="200" r="78.0" fill="none" stroke="#8A6516" stroke-width="1" /><path d="M164.2,145.2 Q188.5,155.0 200.0,155.0 Q211.5,155.0 223.7,150.1 Q235.8,145.2 238.7,159.6 Q241.6,174.0 248.2,181.2 Q254.8,188.5 249.9,201.4 Q245.0,214.4 247.9,228.0 Q250.8,241.6 236.9,248.2 Q223.1,254.8 211.5,250.5 Q200.0,246.2 188.5,250.5 Q176.9,254.8 163.1,248.2 Q149.2,241.6 152.1,228.0 Q155.0,214.4 150.1,201.4 Q145.2,188.5 151.8,181.2 Q158.4,174.0 161.3,159.6 Z" fill="none" stroke="#131C2B" stroke-width="1.4" stroke-linejoin="round"/><path d="M174.2,160.5 Q191.7,167.6 200.0,167.6 Q208.3,167.6 217.0,164.1 Q225.8,160.5 227.8,170.9 Q229.9,181.3 234.7,186.5 Q239.5,191.7 235.9,201.0 Q232.4,210.4 234.5,220.2 Q236.6,229.9 226.6,234.7 Q216.6,239.5 208.3,236.4 Q200.0,233.2 191.7,236.4 Q183.4,239.5 173.4,234.7 Q163.4,229.9 165.5,220.2 Q167.6,210.4 164.1,201.0 Q160.5,191.7 165.3,186.5 Q170.1,181.3 172.2,170.9 Z" fill="none" stroke="#131C2B" stroke-width="1" stroke-dasharray="4 3"/><circle cx="103.0" cy="144.0" r="15" fill="#F4F2ED" stroke="#8A6516" stroke-width="1"/><text x="95.97" y="149.52" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="16" fill="#131C2B" >🜃</text><circle cx="297.0" cy="144.0" r="15" fill="#F4F2ED" stroke="#8A6516" stroke-width="1"/><g transform="translate(289.21,137.50) scale(0.1024) translate(-20,-33)"><g fill="none" stroke="#0F8C84" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#0F8C84"/></g><polygon points="200.00,3.00 201.56,11.44 210.00,13.00 201.56,14.56 200.00,23.00 198.44,14.56 190.00,13.00 198.44,11.44" fill="#8A6516"/><polygon points="387.00,190.00 388.56,198.44 397.00,200.00 388.56,201.56 387.00,210.00 385.44,201.56 377.00,200.00 385.44,198.44" fill="#8A6516"/><polygon points="200.00,377.00 201.56,385.44 210.00,387.00 201.56,388.56 200.00,397.00 198.44,388.56 190.00,387.00 198.44,385.44" fill="#8A6516"/><polygon points="13.00,190.00 14.56,198.44 23.00,200.00 14.56,201.56 13.00,210.00 11.44,201.56 3.00,200.00 11.44,198.44" fill="#8A6516"/></svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Print craft seal"><circle cx="200" cy="200" r="190" fill="none" stroke="#8A6516" stroke-width="2" /><circle cx="200" cy="200" r="184" fill="none" stroke="#8A6516" stroke-width="0.6" /><defs><path id="cscrprint" d="M 200,32.014999999999986 A 167.985,167.985 0 1 1 199.99,32.014999999999986"/></defs><text font-family="Cinzel,serif" font-weight="700" font-size="11.00" letter-spacing="9.692" fill="#8A6516"><textPath href="#cscrprint">PRINT ✦ IGNIS ✦ LAYER ✦ PATIUN ✦ PRINT ✦ IGNIS ✦ LAYER ✦ PATIUN ✦ </textPath></text><circle cx="200" cy="200" r="160" fill="none" stroke="#8A6516" stroke-width="1.2" /><path d="M200.00,44.00 L335.10,278.00 L64.90,278.00Z" fill="none" stroke="#0F8C84" stroke-width="1.6" stroke-linejoin="round" /><circle cx="200" cy="200" r="78.0" fill="none" stroke="#8A6516" stroke-width="1" /><path d="M200.0,148.1 L245.2,174.0 L200.0,200.0 L154.8,174.0Z" fill="none" stroke="#131C2B" stroke-width="1.3" stroke-linejoin="round"/><path d="M154.8,174.0 L154.8,226.0 L200.0,251.9 L245.2,226.0 L245.2,174.0 M200.0,200.0 L200.0,251.9" fill="none" stroke="#131C2B" stroke-width="1.3" stroke-linejoin="round"/><path d="M154.8,184.4 L200.0,210.4 L245.2,184.4" fill="none" stroke="#131C2B" stroke-width=".6"/><path d="M154.8,194.8 L200.0,220.8 L245.2,194.8" fill="none" stroke="#131C2B" stroke-width=".6"/><path d="M154.8,205.2 L200.0,231.2 L245.2,205.2" fill="none" stroke="#131C2B" stroke-width=".6"/><path d="M154.8,215.6 L200.0,241.6 L245.2,215.6" fill="none" stroke="#131C2B" stroke-width=".6"/><circle cx="297.0" cy="256.0" r="15" fill="#F4F2ED" stroke="#8A6516" stroke-width="1"/><text x="289.95" y="261.92" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="16" fill="#131C2B" >🜂</text><circle cx="103.0" cy="256.0" r="15" fill="#F4F2ED" stroke="#8A6516" stroke-width="1"/><g transform="translate(95.22,249.50) scale(0.1024) translate(-20,-33)"><g fill="none" stroke="#0F8C84" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#0F8C84"/></g><polygon points="200.00,3.00 201.56,11.44 210.00,13.00 201.56,14.56 200.00,23.00 198.44,14.56 190.00,13.00 198.44,11.44" fill="#8A6516"/><polygon points="387.00,190.00 388.56,198.44 397.00,200.00 388.56,201.56 387.00,210.00 385.44,201.56 377.00,200.00 385.44,198.44" fill="#8A6516"/><polygon points="200.00,377.00 201.56,385.44 210.00,387.00 201.56,388.56 200.00,397.00 198.44,388.56 190.00,387.00 198.44,385.44" fill="#8A6516"/><polygon points="13.00,190.00 14.56,198.44 23.00,200.00 14.56,201.56 13.00,210.00 11.44,201.56 3.00,200.00 11.44,198.44" fill="#8A6516"/></svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Kepler nest seal"><circle cx="200" cy="200" r="194" fill="none" stroke="#8A6516" stroke-width="2" /><circle cx="200" cy="200" r="178" fill="none" stroke="#8A6516" stroke-width="0.8" /><defs><path id="kpkp" d="M 200,17.4675 A 182.5325,182.5325 0 1 1 199.99,17.4675"/></defs><text font-family="Cinzel,serif" font-weight="700" font-size="9.50" letter-spacing="5.187" fill="#8A6516"><textPath href="#kpkp">HARMONICES MUNDI ✦ PATIUN ✦ DICE · LEATHER · PRINT ✦ HARMONICES MUNDI ✦ PATIUN ✦ DICE · LEATHER · PRINT ✦ </textPath></text><circle cx="200" cy="200" r="176" fill="none" stroke="#8A6516" stroke-width="0.5" /><path d="M200.00,24.00 L337.60,90.27 L371.59,239.16 L276.36,358.57 L123.64,358.57 L28.41,239.16 L62.40,90.27Z" fill="none" stroke="#8A6516" stroke-width="1" stroke-linejoin="round" /><circle cx="200.00" cy="24.00" r="3.2" fill="#131C2B"/><circle cx="337.60" cy="90.27" r="3.2" fill="#131C2B"/><circle cx="371.59" cy="239.16" r="3.2" fill="#131C2B"/><circle cx="276.36" cy="358.57" r="3.2" fill="#131C2B"/><circle cx="123.64" cy="358.57" r="3.2" fill="#131C2B"/><circle cx="28.41" cy="239.16" r="3.2" fill="#131C2B"/><circle cx="62.40" cy="90.27" r="3.2" fill="#131C2B"/><circle cx="200" cy="200" r="158.57052075082578" fill="none" stroke="#0F8C84" stroke-width="0.5" stroke-dasharray="1 3"/><path d="M200.00,41.43 L337.33,120.71 L337.33,279.29 L200.00,358.57 L62.67,279.29 L62.67,120.71Z" fill="none" stroke="#0F8C84" stroke-width="1" stroke-linejoin="round" /><circle cx="200.00" cy="41.43" r="2.4" fill="#131C2B"/><circle cx="337.33" cy="120.71" r="2.4" fill="#131C2B"/><circle cx="337.33" cy="279.29" r="2.4" fill="#131C2B"/><circle cx="200.00" cy="358.57" r="2.4" fill="#131C2B"/><circle cx="62.67" cy="279.29" r="2.4" fill="#131C2B"/><circle cx="62.67" cy="120.71" r="2.4" fill="#131C2B"/><circle cx="200" cy="200" r="137.32609926154262" fill="none" stroke="#8A6516" stroke-width="0.5" stroke-dasharray="1 3"/><path d="M200.00,62.67 L330.60,157.56 L280.72,311.10 L119.28,311.10 L69.40,157.56Z" fill="none" stroke="#8A6516" stroke-width="1" stroke-linejoin="round" /><circle cx="200.00" cy="62.67" r="2.4" fill="#131C2B"/><circle cx="330.60" cy="157.56" r="2.4" fill="#131C2B"/><circle cx="280.72" cy="311.10" r="2.4" fill="#131C2B"/><circle cx="119.28" cy="311.10" r="2.4" fill="#131C2B"/><circle cx="69.40" cy="157.56" r="2.4" fill="#131C2B"/><circle cx="200" cy="200" r="111.0991480738089" fill="none" stroke="#0F8C84" stroke-width="0.5" stroke-dasharray="1 3"/><path d="M200.00,88.90 L311.10,200.00 L200.00,311.10 L88.90,200.00Z" fill="none" stroke="#0F8C84" stroke-width="1" stroke-linejoin="round" /><circle cx="200.00" cy="88.90" r="2.4" fill="#131C2B"/><circle cx="311.10" cy="200.00" r="2.4" fill="#131C2B"/><circle cx="200.00" cy="311.10" r="2.4" fill="#131C2B"/><circle cx="88.90" cy="200.00" r="2.4" fill="#131C2B"/><circle cx="200" cy="200" r="78.55896098703865" fill="none" stroke="#8A6516" stroke-width="0.5" stroke-dasharray="1 3"/><path d="M200.00,121.44 L268.03,239.28 L131.97,239.28Z" fill="none" stroke="#8A6516" stroke-width="1.3" stroke-linejoin="round" /><circle cx="200.00" cy="121.44" r="2.4" fill="#131C2B"/><circle cx="268.03" cy="239.28" r="2.4" fill="#131C2B"/><circle cx="131.97" cy="239.28" r="2.4" fill="#131C2B"/><circle cx="200" cy="200" r="39.27948049351933" fill="none" stroke="#0F8C84" stroke-width="1.2" /><g transform="translate(177.67,181.34) scale(0.2938) translate(-20,-33)"><g fill="none" stroke="#0F8C84" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#0F8C84"/></g><polygon points="200.00,-4.00 201.56,4.44 210.00,6.00 201.56,7.56 200.00,16.00 198.44,7.56 190.00,6.00 198.44,4.44" fill="#8A6516"/><polygon points="394.00,190.00 395.56,198.44 404.00,200.00 395.56,201.56 394.00,210.00 392.44,201.56 384.00,200.00 392.44,198.44" fill="#8A6516"/><polygon points="200.00,384.00 201.56,392.44 210.00,394.00 201.56,395.56 200.00,404.00 198.44,395.56 190.00,394.00 198.44,392.44" fill="#8A6516"/><polygon points="6.00,190.00 7.56,198.44 16.00,200.00 7.56,201.56 6.00,210.00 4.44,201.56 -4.00,200.00 4.44,198.44" fill="#8A6516"/></svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Layered seal"><circle cx="200" cy="200" r="192" fill="none" stroke="#0F8C84" stroke-width="2" /><circle cx="200" cy="200" r="170" fill="none" stroke="#0F8C84" stroke-width="0.8" /><text x="240.34" y="30.28" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♈︎</text><text x="321.90" y="77.12" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♉︎</text><text x="368.88" y="158.05" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♊︎</text><text x="368.18" y="251.89" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♋︎</text><text x="322.60" y="331.42" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♌︎</text><text x="240.76" y="378.40" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♍︎</text><text x="146.57" y="379.73" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♎︎</text><text x="65.29" y="331.63" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♏︎</text><text x="19.64" y="251.75" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♐︎</text><text x="18.38" y="156.79" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♑︎</text><text x="65.43" y="76.91" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♒︎</text><text x="147.41" y="30.49" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="14" fill="#0F8C84" >♓︎</text><line x1="200.00" y1="30.00" x2="200.00" y2="8.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="285.00" y1="52.78" x2="296.00" y2="33.72" stroke="#0F8C84" stroke-width="0.8"/><line x1="347.22" y1="115.00" x2="366.28" y2="104.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="370.00" y1="200.00" x2="392.00" y2="200.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="347.22" y1="285.00" x2="366.28" y2="296.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="285.00" y1="347.22" x2="296.00" y2="366.28" stroke="#0F8C84" stroke-width="0.8"/><line x1="200.00" y1="370.00" x2="200.00" y2="392.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="115.00" y1="347.22" x2="104.00" y2="366.28" stroke="#0F8C84" stroke-width="0.8"/><line x1="52.78" y1="285.00" x2="33.72" y2="296.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="30.00" y1="200.00" x2="8.00" y2="200.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="52.78" y1="115.00" x2="33.72" y2="104.00" stroke="#0F8C84" stroke-width="0.8"/><line x1="115.00" y1="52.78" x2="104.00" y2="33.72" stroke="#0F8C84" stroke-width="0.8"/><defs><path id="ltf" d="M 200,49.379999999999995 A 150.62,150.62 0 1 1 199.99,49.379999999999995"/></defs><text font-family="Cinzel,serif" font-weight="700" font-size="12.00" letter-spacing="10.483" fill="#8A6516"><textPath href="#ltf">PATIUN ✦ PATIUN ✦ PATIUN ✦ PATIUN ✦ PATIUN ✦ PATIUN ✦ </textPath></text><circle cx="200" cy="200" r="140" fill="none" stroke="#8A6516" stroke-width="1" /><path d="M200.0,60.0 L340.0,200.0 L200.0,340.0 L60.0,200.0Z" fill="none" stroke="#0F8C84" stroke-width="1.2"/><path d="M299.0,101.0 L299.0,299.0 L101.0,299.0 L101.0,101.0Z" fill="none" stroke="#0F8C84" stroke-width="1.2"/><circle cx="200" cy="200" r="99" fill="none" stroke="#8A6516" stroke-width="0.9" /><text x="238.56" y="96.53" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="15" fill="#8A6516" >🜂</text><text x="302.42" y="250.33" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="15" fill="#8A6516" >🜄</text><text x="148.24" y="314.57" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="15" fill="#8A6516" >🜁</text><text x="84.38" y="160.02" font-family="'Noto Sans Symbols','Segoe UI Symbol',serif" font-size="15" fill="#8A6516" >🜃</text><path d="M200.0,101.0 L285.7,249.5 L114.3,249.5Z" fill="none" stroke="#8A6516" stroke-width=".9"/><path d="M200.0,299.0 L114.3,150.5 L285.7,150.5Z" fill="none" stroke="#8A6516" stroke-width=".9"/><circle cx="200" cy="200" r="48" fill="none" stroke="#0F8C84" stroke-width="1.4" /><g transform="translate(173.67,178.00) scale(0.3465) translate(-20,-33)"><g fill="none" stroke="#0F8C84" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#0F8C84"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Small astrolabe"><circle cx="50" cy="56" r="40" fill="#F4F2ED" stroke="#8A6516" stroke-width="3"/><circle cx="50" cy="8" r="5" fill="none" stroke="#8A6516" stroke-width="2.4" /><path d="M36,18 Q50,10 64,18" fill="none" stroke="#8A6516" stroke-width="2.4"/><line x1="50.00" y1="24.00" x2="50.00" y2="16.00" stroke="#8A6516" stroke-width="2.2"/><line x1="66.00" y1="28.29" x2="70.00" y2="21.36" stroke="#8A6516" stroke-width="2.2"/><line x1="77.71" y1="40.00" x2="84.64" y2="36.00" stroke="#8A6516" stroke-width="2.2"/><line x1="82.00" y1="56.00" x2="90.00" y2="56.00" stroke="#8A6516" stroke-width="2.2"/><line x1="77.71" y1="72.00" x2="84.64" y2="76.00" stroke="#8A6516" stroke-width="2.2"/><line x1="66.00" y1="83.71" x2="70.00" y2="90.64" stroke="#8A6516" stroke-width="2.2"/><line x1="50.00" y1="88.00" x2="50.00" y2="96.00" stroke="#8A6516" stroke-width="2.2"/><line x1="34.00" y1="83.71" x2="30.00" y2="90.64" stroke="#8A6516" stroke-width="2.2"/><line x1="22.29" y1="72.00" x2="15.36" y2="76.00" stroke="#8A6516" stroke-width="2.2"/><line x1="18.00" y1="56.00" x2="10.00" y2="56.00" stroke="#8A6516" stroke-width="2.2"/><line x1="22.29" y1="40.00" x2="15.36" y2="36.00" stroke="#8A6516" stroke-width="2.2"/><line x1="34.00" y1="28.29" x2="30.00" y2="21.36" stroke="#8A6516" stroke-width="2.2"/><circle cx="50" cy="48" r="22" fill="none" stroke="#8A6516" stroke-width="2.2" /><line x1="73.1" y1="28.4" x2="26.9" y2="83.6" stroke="#0F8C84" stroke-width="4" stroke-linecap="round"/><circle cx="50" cy="56" r="7" fill="#F4F2ED" stroke="#8A6516" stroke-width="2"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Small layered seal"><circle cx="50" cy="50" r="46" fill="none" stroke="#0F8C84" stroke-width="3" /><circle cx="50" cy="50" r="36" fill="none" stroke="#8A6516" stroke-width="2" /><path d="M50.0,14.0 L86.0,50.0 L50.0,86.0 L14.0,50.0Z" fill="none" stroke="#0F8C84" stroke-width="2"/><path d="M75.5,24.5 L75.5,75.5 L24.5,75.5 L24.5,24.5Z" fill="none" stroke="#0F8C84" stroke-width="2"/><g transform="translate(35.64,38.00) scale(0.1890) translate(-20,-33)"><g fill="none" stroke="#0F8C84" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#0F8C84"/></g></svg>
|
||||
|
After Width: | Height: | Size: 793 B |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Small tome medallion"><rect x="8" y="4" width="84" height="92" rx="5" fill="#1E2348"/><rect x="15" y="11" width="70" height="78" fill="none" stroke="#8A6516" stroke-width="2.6"/><path transform="translate(15,11) rotate(90)" d="M0,-14 A14,14 0 0 1 14,0" fill="none" stroke="#8A6516" stroke-width="2"/><path transform="translate(85,11) rotate(180)" d="M0,-14 A14,14 0 0 1 14,0" fill="none" stroke="#8A6516" stroke-width="2"/><path transform="translate(85,89) rotate(270)" d="M0,-14 A14,14 0 0 1 14,0" fill="none" stroke="#8A6516" stroke-width="2"/><path transform="translate(15,89) rotate(0)" d="M0,-14 A14,14 0 0 1 14,0" fill="none" stroke="#8A6516" stroke-width="2"/><circle cx="50" cy="50" r="20" fill="none" stroke="#8A6516" stroke-width="2.4" /><g transform="translate(39.22,41.00) scale(0.1417) translate(-20,-33)"><g fill="none" stroke="#0F8C84" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#0F8C84"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Small transmutation seal"><circle cx="50" cy="50" r="47" fill="none" stroke="#8A6516" stroke-width="3" /><path d="M50.00,14.00 L85.10,58.01 L34.38,82.43 L21.85,27.55 L78.15,27.55 L65.62,82.43 L14.90,58.01 L50.00,14.00" fill="none" stroke="#0F8C84" stroke-width="2.2" stroke-linejoin="round"/><circle cx="50.00" cy="14.00" r="5.2" fill="#F4F2ED" stroke="#8A6516" stroke-width="1.8"/><circle cx="78.15" cy="27.55" r="5.2" fill="#F4F2ED" stroke="#8A6516" stroke-width="1.8"/><circle cx="85.10" cy="58.01" r="5.2" fill="#F4F2ED" stroke="#8A6516" stroke-width="1.8"/><circle cx="65.62" cy="82.43" r="5.2" fill="#F4F2ED" stroke="#8A6516" stroke-width="1.8"/><circle cx="34.38" cy="82.43" r="5.2" fill="#F4F2ED" stroke="#8A6516" stroke-width="1.8"/><circle cx="14.90" cy="58.01" r="5.2" fill="#F4F2ED" stroke="#8A6516" stroke-width="1.8"/><circle cx="21.85" cy="27.55" r="5.2" fill="#F4F2ED" stroke="#8A6516" stroke-width="1.8"/><g transform="translate(36.83,39.00) scale(0.1732) translate(-20,-33)"><g fill="none" stroke="#0F8C84" stroke-width="7.2" stroke-linejoin="round">
|
||||
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#0F8C84"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 400"><line x1="565.0" y1="34.9" x2="578.5" y2="43.6" stroke="#0F8C84" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="591.7" y1="55.4" x2="605.3" y2="72.1" stroke="#0F8C84" stroke-width="1.2" opacity="0.8" stroke-dasharray="2 5" stroke-linecap="round"/><line x1="582.8" y1="56.8" x2="567.6" y2="96.5" stroke="#0F8C84" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="559.7" y1="112.7" x2="557.7" y2="116.1" stroke="#0F8C84" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="552.6" y1="132.8" x2="552.2" y2="140.5" stroke="#0F8C84" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="553.1" y1="158.4" x2="561.7" y2="216.6" stroke="#0F8C84" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="567.7" y1="233.2" x2="580.0" y2="253.6" stroke="#0F8C84" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="588.7" y1="269.4" x2="603.1" y2="298.0" stroke="#0F8C84" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="601.1" y1="312.7" x2="584.4" y2="330.7" stroke="#0F8C84" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="570.7" y1="342.2" x2="535.2" y2="365.1" stroke="#0F8C84" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="519.1" y1="367.2" x2="497.5" y2="360.0" stroke="#0F8C84" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="490.2" y1="348.3" x2="490.0" y2="349.4" stroke="#0F8C84" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="492.8" y1="331.6" x2="495.0" y2="319.5" stroke="#0F8C84" stroke-width="1.2" opacity="0.8" stroke-dasharray="2 5" stroke-linecap="round"/><line x1="505.2" y1="307.9" x2="495.2" y2="311.0" stroke="#0F8C84" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><polygon points="557.4,23.0 558.5,28.9 564.4,30.0 558.5,31.1 557.4,37.0 556.3,31.1 550.4,30.0 556.3,28.9" fill="#6A2FE0"/><polygon points="586.1,41.4 587.2,47.3 593.1,48.4 587.2,49.6 586.1,55.4 584.9,49.6 579.1,48.4 584.9,47.3" fill="#6A2FE0"/><circle cx="611.0" cy="79.1" r="2.4" fill="#131C2B"/><circle cx="564.3" cy="104.9" r="2.4" fill="#131C2B"/><polygon points="553.1,110.8 555.2,121.7 566.1,123.8 555.2,125.9 553.1,136.8 551.0,125.9 540.1,123.8 551.0,121.7" fill="#8A6516"/><circle cx="551.8" cy="149.5" r="2.4" fill="#131C2B"/><polygon points="563.0,218.5 564.2,224.4 570.0,225.5 564.2,226.7 563.0,232.5 561.9,226.7 556.0,225.5 561.9,224.4" fill="#6A2FE0"/><circle cx="584.6" cy="261.3" r="2.4" fill="#131C2B"/><circle cx="607.2" cy="306.1" r="2.4" fill="#131C2B"/><circle cx="578.3" cy="337.3" r="2.4" fill="#131C2B"/><polygon points="527.7,363.0 528.8,368.9 534.7,370.0 528.8,371.1 527.7,377.0 526.6,371.1 520.7,370.0 526.6,368.9" fill="#6A2FE0"/><circle cx="489.0" cy="357.2" r="2.4" fill="#131C2B"/><circle cx="491.3" cy="340.5" r="2.4" fill="#131C2B"/><circle cx="503.8" cy="308.3" r="2.4" fill="#131C2B"/><polygon points="496.6,303.6 497.7,309.5 503.6,310.6 497.7,311.7 496.6,317.6 495.5,311.7 489.6,310.6 495.5,309.5" fill="#6A2FE0"/></svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 400"><line x1="565.0" y1="34.9" x2="578.5" y2="43.6" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="591.7" y1="55.4" x2="605.3" y2="72.1" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-dasharray="2 5" stroke-linecap="round"/><line x1="582.8" y1="56.8" x2="567.6" y2="96.5" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="559.7" y1="112.7" x2="557.7" y2="116.1" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="552.6" y1="132.8" x2="552.2" y2="140.5" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="553.1" y1="158.4" x2="561.7" y2="216.6" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="567.7" y1="233.2" x2="580.0" y2="253.6" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="588.7" y1="269.4" x2="603.1" y2="298.0" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="601.1" y1="312.7" x2="584.4" y2="330.7" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="570.7" y1="342.2" x2="535.2" y2="365.1" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="519.1" y1="367.2" x2="497.5" y2="360.0" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="490.2" y1="348.3" x2="490.0" y2="349.4" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="492.8" y1="331.6" x2="495.0" y2="319.5" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-dasharray="2 5" stroke-linecap="round"/><line x1="505.2" y1="307.9" x2="495.2" y2="311.0" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><polygon points="557.4,23.0 558.5,28.9 564.4,30.0 558.5,31.1 557.4,37.0 556.3,31.1 550.4,30.0 556.3,28.9" fill="#9A70F5"/><polygon points="586.1,41.4 587.2,47.3 593.1,48.4 587.2,49.6 586.1,55.4 584.9,49.6 579.1,48.4 584.9,47.3" fill="#9A70F5"/><circle cx="611.0" cy="79.1" r="2.4" fill="#F4F2ED"/><circle cx="564.3" cy="104.9" r="2.4" fill="#F4F2ED"/><polygon points="553.1,110.8 555.2,121.7 566.1,123.8 555.2,125.9 553.1,136.8 551.0,125.9 540.1,123.8 551.0,121.7" fill="#C9A24A"/><circle cx="551.8" cy="149.5" r="2.4" fill="#F4F2ED"/><polygon points="563.0,218.5 564.2,224.4 570.0,225.5 564.2,226.7 563.0,232.5 561.9,226.7 556.0,225.5 561.9,224.4" fill="#9A70F5"/><circle cx="584.6" cy="261.3" r="2.4" fill="#F4F2ED"/><circle cx="607.2" cy="306.1" r="2.4" fill="#F4F2ED"/><circle cx="578.3" cy="337.3" r="2.4" fill="#F4F2ED"/><polygon points="527.7,363.0 528.8,368.9 534.7,370.0 528.8,371.1 527.7,377.0 526.6,371.1 520.7,370.0 526.6,368.9" fill="#9A70F5"/><circle cx="489.0" cy="357.2" r="2.4" fill="#F4F2ED"/><circle cx="491.3" cy="340.5" r="2.4" fill="#F4F2ED"/><circle cx="503.8" cy="308.3" r="2.4" fill="#F4F2ED"/><polygon points="496.6,303.6 497.7,309.5 503.6,310.6 497.7,311.7 496.6,317.6 495.5,311.7 489.6,310.6 495.5,309.5" fill="#9A70F5"/></svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 29 KiB |
@@ -1,14 +1,21 @@
|
||||
---
|
||||
/** Sparse, static specks behind a page; about one in five gold. */
|
||||
/**
|
||||
* Static specks behind a page; about one in five gold. Two tiles of different
|
||||
* sizes repeat across the page, so long pages are as starry as short ones and
|
||||
* the repeat is hard to spot.
|
||||
*/
|
||||
import { dust, seedOf } from "../lib/ornaments.ts";
|
||||
|
||||
interface Props {
|
||||
count?: number;
|
||||
seed?: string;
|
||||
}
|
||||
const { count = 48, seed = Astro.url.pathname } = Astro.props;
|
||||
const { seed = Astro.url.pathname } = Astro.props;
|
||||
const base = seedOf(seed);
|
||||
---
|
||||
<div class="gk-dust" aria-hidden="true" style={`background:${dust(count, seedOf(seed))}`}></div>
|
||||
<div class="gk-dust" aria-hidden="true">
|
||||
<div class="layer near" style={`background-image:${dust(26, base)}`}></div>
|
||||
<div class="layer far" style={`background-image:${dust(30, base + 7919)}`}></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.gk-dust {
|
||||
@@ -16,4 +23,16 @@ const { count = 48, seed = Astro.url.pathname } = Astro.props;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-repeat: repeat;
|
||||
}
|
||||
.near {
|
||||
background-size: 520px 520px;
|
||||
}
|
||||
.far {
|
||||
background-size: 790px 790px;
|
||||
background-position: 213px 157px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,17 +18,17 @@ const left = starPath({ x1: 540, x2: 76, y, steps: 8, seed: s, openEnds: false,
|
||||
const right = starPath({ x1: 660, x2: 1124, y, steps: 8, seed: s + 1, openEnds: false, dot: 2, star: 4.5 });
|
||||
---
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox={`0 0 ${W} 40`} aria-hidden="true" class={className} width="100%" style="display: block; width: 100%; height: auto">
|
||||
<line x1="40" y1={y} x2="566" y2={y} stroke="#C9A24A" stroke-width="1" />
|
||||
<line x1="634" y1={y} x2="1160" y2={y} stroke="#C9A24A" stroke-width="1" />
|
||||
<polygon points={starPoints(600, y, 14)} fill="#C9A24A" />
|
||||
<polygon points={starPoints(40, y, 5)} fill="#C9A24A" />
|
||||
<polygon points={starPoints(1160, y, 5)} fill="#C9A24A" />
|
||||
<line x1="40" y1={y} x2="566" y2={y} style="stroke:var(--gk-gold)" stroke-width="1" />
|
||||
<line x1="634" y1={y} x2="1160" y2={y} style="stroke:var(--gk-gold)" stroke-width="1" />
|
||||
<polygon points={starPoints(600, y, 14)} style="fill:var(--gk-gold)" />
|
||||
<polygon points={starPoints(40, y, 5)} style="fill:var(--gk-gold)" />
|
||||
<polygon points={starPoints(1160, y, 5)} style="fill:var(--gk-gold)" />
|
||||
{
|
||||
[left, right].map((p) => (
|
||||
<>
|
||||
{p.links.map((l) => <line {...l} stroke="#2BC2B7" stroke-width="1" stroke-dasharray="1.5 4" stroke-linecap="round" />)}
|
||||
{p.links.map((l) => <line {...l} style="stroke:var(--gk-orn-link)" stroke-width="1" stroke-dasharray="1.5 4" stroke-linecap="round" />)}
|
||||
{p.marks.map((m) =>
|
||||
m.kind === "dot" ? <circle cx={m.x} cy={m.y} r={m.r} fill="#F4F2ED" opacity="0.85" /> : <polygon points={starPoints(m.x, m.y, m.r)} fill="#9A70F5" />,
|
||||
m.kind === "dot" ? <circle cx={m.x} cy={m.y} r={m.r} style="fill:var(--gk-orn-dot)" opacity="0.85" /> : <polygon points={starPoints(m.x, m.y, m.r)} style="fill:var(--gk-violet)" />,
|
||||
)}
|
||||
</>
|
||||
))
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* alternating sides. Never more than two, and a section with a photo gets no
|
||||
* seal: place this in the page background, not in a photo section.
|
||||
*/
|
||||
import Motif from "./Motif.astro";
|
||||
import { pageSeals, seals, type PageFamily, type SealName } from "./seals.ts";
|
||||
|
||||
interface Props {
|
||||
@@ -18,8 +19,8 @@ const first = seal ?? pageSeals[family];
|
||||
const other = second === false ? null : (second ?? first);
|
||||
---
|
||||
<div class="gk-edge-seals" aria-hidden="true">
|
||||
<img src={seals[first]} alt="" class="seal left" width="620" height="620" loading="lazy" decoding="async" />
|
||||
{other && <img src={seals[other]} alt="" class="seal right" width="560" height="560" loading="lazy" decoding="async" />}
|
||||
<Motif src={seals[first]} alt="" class="seal left" width="620" height="620" loading="lazy" decoding="async" />
|
||||
{other && <Motif src={seals[other]} alt="" class="seal right" width="560" height="560" loading="lazy" decoding="async" />}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -38,7 +39,7 @@ const other = second === false ? null : (second ?? first);
|
||||
top: 760px;
|
||||
width: 620px;
|
||||
height: 620px;
|
||||
opacity: 0.07;
|
||||
opacity: calc(0.07 * var(--gk-ghost));
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
.right {
|
||||
@@ -46,7 +47,7 @@ const other = second === false ? null : (second ?? first);
|
||||
top: 64%;
|
||||
width: 560px;
|
||||
height: 560px;
|
||||
opacity: 0.06;
|
||||
opacity: calc(0.06 * var(--gk-ghost));
|
||||
transform: rotate(8deg);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -39,6 +39,6 @@ const common = { id, name, class: "gk-field", "aria-describedby": describedBy, "
|
||||
<style>
|
||||
.error {
|
||||
font-size: 12px;
|
||||
color: #f0857a;
|
||||
color: var(--gk-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,9 +18,9 @@ const corners = [
|
||||
{
|
||||
corners.map((c) => (
|
||||
<svg aria-hidden="true" class="gk-corner" width={size} height={size} viewBox="0 0 38 38" style={`${c.pos};transform:${c.t}`}>
|
||||
<path d="M2 30 V8 a6 6 0 0 1 6 -6 H30" fill="none" stroke="#C9A24A" stroke-width="1.4" />
|
||||
<path d="M7 30 V12 a5 5 0 0 1 5 -5 H30" fill="none" stroke="#C9A24A" stroke-width="0.6" opacity="0.7" />
|
||||
<polygon points="2,36 2.6,33.6 5,33 2.6,32.4 2,30 1.4,32.4 -1,33 1.4,33.6" fill="#C9A24A" />
|
||||
<path d="M2 30 V8 a6 6 0 0 1 6 -6 H30" fill="none" style="stroke:var(--gk-gold)" stroke-width="1.4" />
|
||||
<path d="M7 30 V12 a5 5 0 0 1 5 -5 H30" fill="none" style="stroke:var(--gk-gold)" stroke-width="0.6" opacity="0.7" />
|
||||
<polygon points="2,36 2.6,33.6 5,33 2.6,32.4 2,30 1.4,32.4 -1,33 1.4,33.6" style="fill:var(--gk-gold)" />
|
||||
</svg>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -20,6 +20,6 @@ import Star from "./Star.astro";
|
||||
.line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(201, 162, 74, 0.7) 30%, rgba(201, 162, 74, 0.7) 70%, transparent);
|
||||
background: linear-gradient(90deg, transparent, rgba(var(--gk-gold-rgb), 0.7) 30%, rgba(var(--gk-gold-rgb), 0.7) 70%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
/**
|
||||
* A motif image that follows the site's light or dark mode: the dark
|
||||
* original, or its light recolor. Both are in the page and CSS shows one
|
||||
* (base.css); the hidden one is lazy, so it never loads. Pass `foil` for a
|
||||
* large standalone seal.
|
||||
*/
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
import { motifSources } from "./seals.ts";
|
||||
|
||||
interface Props extends Omit<HTMLAttributes<"img">, "src"> {
|
||||
src: string;
|
||||
foil?: boolean;
|
||||
}
|
||||
const { src, foil, class: className, loading = "lazy", ...rest } = Astro.props;
|
||||
const v = motifSources(src, { foil });
|
||||
---
|
||||
{
|
||||
v.light === v.dark ? (
|
||||
<img src={v.dark} class={className} loading={loading} {...rest} />
|
||||
) : (
|
||||
<>
|
||||
<img src={v.dark} class:list={[className, "gk-motif-dark"]} loading={loading} {...rest} />
|
||||
<img src={v.light} class:list={[className, "gk-motif-light"]} loading={loading} {...rest} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import ConstellationDust from "./ConstellationDust.astro";
|
||||
import EdgeSeals from "./EdgeSeals.astro";
|
||||
import SiteFooter from "./SiteFooter.astro";
|
||||
import SiteHeader from "./SiteHeader.astro";
|
||||
import type { NavKey } from "./nav.ts";
|
||||
import type { NavKey, StoreNav } from "./nav.ts";
|
||||
import type { PageFamily, SealName } from "./seals.ts";
|
||||
|
||||
interface Props {
|
||||
@@ -22,10 +22,12 @@ interface Props {
|
||||
/** A craft page's own seal, overriding the family's. */
|
||||
seal?: SealName;
|
||||
current?: NavKey;
|
||||
/** The Store's own header, on Store pages. */
|
||||
store?: StoreNav;
|
||||
/** Leave the edge seals off, for a page led by a photo. */
|
||||
noSeals?: boolean;
|
||||
}
|
||||
const { title, description, family = "landing", seal, current, noSeals } = Astro.props;
|
||||
const { title, description, family = "landing", seal, current, store, noSeals } = Astro.props;
|
||||
const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
|
||||
---
|
||||
<!doctype html>
|
||||
@@ -33,8 +35,36 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="theme-color" content="#0E1522" />
|
||||
<!-- Before first paint: the visitor's saved mode, else the device's. Without script the site stays dark. -->
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const root = document.documentElement;
|
||||
const device = matchMedia("(prefers-color-scheme: light)");
|
||||
const saved = () => {
|
||||
try {
|
||||
const t = localStorage.getItem("gk-theme");
|
||||
return t === "light" || t === "dark" ? t : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const apply = () => {
|
||||
const t = saved() ?? (device.matches ? "light" : "dark");
|
||||
root.dataset.theme = t;
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", t === "light" ? "#F4F2ED" : "#0E1522");
|
||||
};
|
||||
apply();
|
||||
device.addEventListener("change", apply);
|
||||
window.addEventListener("storage", apply);
|
||||
window.gkApplyTheme = apply;
|
||||
})();
|
||||
</script>
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<title>{fullTitle}</title>
|
||||
{description && <meta name="description" content={description} />}
|
||||
<slot name="head" />
|
||||
@@ -44,7 +74,7 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
|
||||
<div class="gk-page">
|
||||
<ConstellationDust />
|
||||
{!noSeals && <EdgeSeals family={family} seal={seal} />}
|
||||
<SiteHeader current={current}><slot name="hero" /></SiteHeader>
|
||||
<SiteHeader current={current} store={store} hero={Astro.slots.has("hero")}><slot name="hero" /></SiteHeader>
|
||||
<main id="main" class="gk-wrap gk-main">
|
||||
<slot />
|
||||
</main>
|
||||
@@ -58,6 +88,13 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow-x: clip;
|
||||
/* The page's nebula: violet from the top right, teal from the bottom left.
|
||||
In light mode it is the dawn wash over faint star chart lines. */
|
||||
background:
|
||||
radial-gradient(ellipse at 100% 0%, rgba(var(--gk-violet-rgb), calc(0.28 * var(--gk-wash))), transparent 50%),
|
||||
radial-gradient(ellipse at 0% 100%, rgba(var(--gk-teal-deep-rgb), calc(0.2 * var(--gk-wash))), transparent 55%),
|
||||
var(--gk-chart),
|
||||
var(--gk-sky);
|
||||
}
|
||||
.gk-main {
|
||||
position: relative;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
---
|
||||
/** One seal as an image, for seal marks, medallions and placeholder art. */
|
||||
import Motif from "./Motif.astro";
|
||||
import { seals, type SealName } from "./seals.ts";
|
||||
|
||||
interface Props {
|
||||
name: SealName;
|
||||
size?: number;
|
||||
class?: string;
|
||||
/** Gold foil, for a seal standing alone at 120px or more. */
|
||||
foil?: boolean;
|
||||
}
|
||||
const { name, size = 40, class: className } = Astro.props;
|
||||
const { name, size = 40, class: className, foil } = Astro.props;
|
||||
---
|
||||
<img src={seals[name]} alt="" aria-hidden="true" width={size} height={size} class={className} loading="lazy" decoding="async" />
|
||||
<Motif src={seals[name]} foil={foil} alt="" aria-hidden="true" width={size} height={size} class={className} loading="lazy" decoding="async" />
|
||||
|
||||
@@ -35,7 +35,7 @@ const H = `h${level}` as "h1" | "h2" | "h3";
|
||||
max-width: 60%;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
|
||||
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
|
||||
background: linear-gradient(90deg, var(--gk-gold), rgba(var(--gk-gold-rgb), 0.15) 70%, transparent);
|
||||
box-shadow: 0 0 8px rgba(var(--gk-gold-rgb), 0.35);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
/** The site footer: the header divider again, then the monogram line and links. */
|
||||
import monogram from "../assets/brand/gk-monogram.svg?url";
|
||||
import Divider from "./Divider.astro";
|
||||
import Motif from "./Motif.astro";
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
<footer class="gk-site-footer">
|
||||
<div class="gk-wrap"><Divider seed="footer" /></div>
|
||||
<div class="gk-wrap row">
|
||||
<span class="mark"><img src={monogram} alt="" width="40" height="40" />© {year} Greg Kilmer · Web Development · a Patiun LLC company</span>
|
||||
<span class="mark"><Motif src={monogram} alt="" width="40" height="40" />© {year} Greg Kilmer · Web Development · a Patiun LLC company</span>
|
||||
<span class="links">
|
||||
<a href="/inquiries" class="gk-text strong">Start an inquiry</a>
|
||||
<a href="/about" class="gk-text gk-quiet">About</a>
|
||||
@@ -37,6 +38,12 @@ const year = new Date().getFullYear();
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.mark :global(img) {
|
||||
border-radius: 50%;
|
||||
}
|
||||
:global(:root[data-theme="light"]) .mark :global(img) {
|
||||
box-shadow: 0 1px 3px rgba(19, 28, 43, 0.14);
|
||||
}
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -2,28 +2,59 @@
|
||||
/**
|
||||
* The site header: nebula and filament banner behind the primary nav, an
|
||||
* optional page hero (the `hero` slot), and the header divider. The filament
|
||||
* banner and divider appear here only, never elsewhere on a page.
|
||||
* banner and divider appear here only, never elsewhere on a page. Store pages
|
||||
* pass `store` and get the Store's own header: Store.GregKilmer.com, its
|
||||
* product lines, Order request and a way back to the main site.
|
||||
*/
|
||||
import monogram from "../assets/brand/gk-monogram.svg?url";
|
||||
import Divider from "./Divider.astro";
|
||||
import Motif from "./Motif.astro";
|
||||
import Star from "./Star.astro";
|
||||
import { main, tail, world, type NavKey } from "./nav.ts";
|
||||
import ThemeToggle from "./ThemeToggle.astro";
|
||||
import { main, tail, world, type NavKey, type StoreNav } from "./nav.ts";
|
||||
import { filamentBanner } from "./seals.ts";
|
||||
|
||||
interface Props {
|
||||
/** Which nav entry the page belongs to. */
|
||||
current?: NavKey;
|
||||
store?: StoreNav;
|
||||
/** Whether the page has a hero; PageShell always fills the slot, so it says. */
|
||||
hero?: boolean;
|
||||
}
|
||||
const { current } = Astro.props;
|
||||
const { current, store, hero = Astro.slots.has("default") } = Astro.props;
|
||||
const inWorld = world.some((w) => w.key === current);
|
||||
const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||
// Light mode's atlas marks: right ascension hours along the top of the hero,
|
||||
// declination down its right side.
|
||||
const hours = Array.from({ length: 12 }, (_, i) => ({ at: (i / 12) * 100, label: `${i * 2}h` }));
|
||||
const decs = [60, 30, 0, -30].map((d, i) => ({ at: (i / 4) * 100, label: `${d > 0 ? "+" : d < 0 ? "\u2212" : "+"}${Math.abs(d)}°` }));
|
||||
---
|
||||
<header class="gk-site-header">
|
||||
<div class="nebula" aria-hidden="true"></div>
|
||||
<img src={filamentBanner} alt="" aria-hidden="true" class="banner" />
|
||||
<div class="decor" aria-hidden="true">
|
||||
<div class="nebula"></div>
|
||||
<Motif src={filamentBanner} alt="" class="banner" />
|
||||
</div>
|
||||
{
|
||||
store ? (
|
||||
<nav aria-label="Store" class="nav">
|
||||
<a href="/store" class="brand" aria-current={store.current === "/store" ? "page" : undefined}>
|
||||
<Motif src={monogram} alt="" width="72" height="72" class="mono" loading="eager" />
|
||||
<span class="words">
|
||||
<span class="name store-name">Store.GregKilmer.com</span>
|
||||
<span class="store-tag">a Patiun LLC workshop</span>
|
||||
</span>
|
||||
</a>
|
||||
<ul class="links store-links">
|
||||
{store.links.map((l) => <li><a href={l.href} aria-current={l.href === store.current ? "page" : undefined}>{l.label}</a></li>)}
|
||||
<li class="sep" aria-hidden="true"><Star size={12} /></li>
|
||||
<li><a href="/" class="dim">Back to GregKilmer.com</a></li>
|
||||
<li class="mode"><ThemeToggle /></li>
|
||||
</ul>
|
||||
</nav>
|
||||
) : (
|
||||
<nav aria-label="Primary" class="nav">
|
||||
<a href="/" class="brand" aria-current={aria("home")}>
|
||||
<img src={monogram} alt="" width="72" height="72" class="mono" />
|
||||
<Motif src={monogram} alt="" width="72" height="72" class="mono" loading="eager" />
|
||||
<span class="words">
|
||||
<span class="name">GREG KILMER</span>
|
||||
<span class="tag">DEVELOPER<Star size={8} tone="teal" />MAKER<Star size={8} tone="teal" />STORYTELLER</span>
|
||||
@@ -51,33 +82,47 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||
</ul>
|
||||
</li>
|
||||
{tail.map((l) => <li><a href={l.href} aria-current={aria(l.key)} class="dim">{l.label}</a></li>)}
|
||||
<li class="mode"><ThemeToggle /></li>
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
{
|
||||
Astro.slots.has("default") && (
|
||||
hero && (
|
||||
<div class="gk-wrap hero">
|
||||
<div class="atlas" aria-hidden="true">
|
||||
<span class="ra">{hours.map((h) => <span style={`left:${h.at}%`}>{h.label}</span>)}</span>
|
||||
<span class="dec">{decs.map((d) => <span style={`top:${d.at}%`}>{d.label}</span>)}</span>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="gk-wrap divider"><Divider /></div>
|
||||
{hero && <div class="gk-wrap divider"><Divider /></div>}
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/* The header sits above the page so the My world menu opens over it; only
|
||||
the nebula and banner are clipped to the header. */
|
||||
.gk-site-header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
color: var(--gk-text);
|
||||
isolation: isolate;
|
||||
}
|
||||
.decor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.nebula {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 90% at 100% 0%, rgba(106, 47, 224, 0.28), transparent 70%),
|
||||
radial-gradient(ellipse 55% 80% at 0% 70%, rgba(20, 163, 154, 0.18), transparent 70%);
|
||||
radial-gradient(ellipse 70% 90% at 100% 0%, rgba(var(--gk-violet-rgb), calc(0.28 * var(--gk-wash))), transparent 70%),
|
||||
radial-gradient(ellipse 55% 80% at 0% 70%, rgba(var(--gk-teal-deep-rgb), calc(0.18 * var(--gk-wash))), transparent 70%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 45%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, #000 45%, transparent 100%);
|
||||
}
|
||||
@@ -87,9 +132,7 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||
top: -40px;
|
||||
width: 900px;
|
||||
max-width: none;
|
||||
opacity: 0.22;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: calc(0.22 * var(--gk-ghost));
|
||||
}
|
||||
.nav {
|
||||
max-width: var(--gk-page-max);
|
||||
@@ -102,7 +145,7 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
border-bottom: 1px solid rgba(201, 162, 74, 0.35);
|
||||
border-bottom: 1px solid rgba(var(--gk-gold-rgb), 0.35);
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
@@ -114,6 +157,21 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||
.mono {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
transition: filter 0.25s ease;
|
||||
}
|
||||
/* On Starlight the pale disc needs a soft edge to sit apart from the page. */
|
||||
:global(:root[data-theme="light"]) .mono {
|
||||
box-shadow: 0 1px 4px rgba(19, 28, 43, 0.14);
|
||||
}
|
||||
.brand:hover .mono,
|
||||
.brand:focus-visible .mono {
|
||||
filter: drop-shadow(0 0 6px rgba(var(--gk-gold-bright-rgb), 0.7)) drop-shadow(0 0 16px rgba(var(--gk-teal-rgb), 0.45));
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mono {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.words {
|
||||
display: flex;
|
||||
@@ -126,6 +184,15 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 17px;
|
||||
}
|
||||
.store-name {
|
||||
font-variant: small-caps;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 19px;
|
||||
}
|
||||
.store-tag {
|
||||
font-size: 12px;
|
||||
color: var(--gk-soft);
|
||||
}
|
||||
.tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -146,6 +213,10 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
/* The Store's longer links fit one row at desktop width. */
|
||||
.store-links {
|
||||
column-gap: 14px;
|
||||
}
|
||||
.links a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -164,7 +235,8 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||
.links a[aria-current] {
|
||||
color: var(--gk-teal-bright);
|
||||
}
|
||||
.sep {
|
||||
.sep,
|
||||
.mode {
|
||||
display: flex;
|
||||
}
|
||||
.mw {
|
||||
@@ -202,7 +274,7 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||
}
|
||||
.mw-menu a:hover,
|
||||
.mw-menu a:focus-visible {
|
||||
background: rgba(201, 162, 74, 0.1);
|
||||
background: rgba(var(--gk-gold-rgb), 0.1);
|
||||
}
|
||||
.mw-name {
|
||||
font-weight: 600;
|
||||
@@ -225,6 +297,49 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
}
|
||||
.atlas {
|
||||
display: none;
|
||||
}
|
||||
:global(:root[data-theme="light"]) .atlas {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--gk-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ra {
|
||||
position: absolute;
|
||||
left: var(--gk-gutter);
|
||||
right: var(--gk-gutter);
|
||||
top: 10px;
|
||||
}
|
||||
.ra span {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.ra span:first-child {
|
||||
transform: none;
|
||||
}
|
||||
/* Declination sits in the right gutter, clear of the hero's content. */
|
||||
.dec {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 44px;
|
||||
bottom: 20px;
|
||||
}
|
||||
.dec span {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.dec,
|
||||
.ra span:nth-child(even) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.divider {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 24px;
|
||||
|
||||
@@ -11,5 +11,5 @@ const { size = 10, tone = "gold", class: className } = Astro.props;
|
||||
const fill = { gold: "var(--gk-gold)", violet: "var(--gk-violet)", teal: "var(--gk-teal-bright)", starlight: "var(--gk-starlight)", outline: "none" }[tone];
|
||||
---
|
||||
<svg aria-hidden="true" class={className} width={size} height={size} viewBox="0 0 20 20" style="flex: none">
|
||||
<polygon points={starPoints(10, 10, 10)} fill={fill} stroke={tone === "outline" ? "#4A5F6B" : undefined} stroke-width={tone === "outline" ? 1 : undefined} />
|
||||
<polygon points={starPoints(10, 10, 10)} fill={fill} stroke={tone === "outline" ? "var(--gk-orn-dim)" : undefined} stroke-width={tone === "outline" ? 1 : undefined} />
|
||||
</svg>
|
||||
|
||||
@@ -31,16 +31,25 @@ const { seal = "transmutation", ratio = "4 / 3", src, alt = "", class: className
|
||||
overflow: hidden;
|
||||
border-radius: var(--gk-radius);
|
||||
background:
|
||||
radial-gradient(ellipse 70% 90% at 100% 0%, rgba(106, 47, 224, 0.28), transparent 70%),
|
||||
radial-gradient(ellipse 55% 80% at 0% 70%, rgba(20, 163, 154, 0.18), transparent 70%),
|
||||
repeating-linear-gradient(0deg, rgba(244, 242, 237, 0.035) 0 1px, transparent 1px 40px),
|
||||
repeating-linear-gradient(90deg, rgba(244, 242, 237, 0.035) 0 1px, transparent 1px 40px),
|
||||
radial-gradient(ellipse 70% 90% at 100% 0%, rgba(var(--gk-violet-rgb), calc(0.28 * var(--gk-wash))), transparent 70%),
|
||||
radial-gradient(ellipse 55% 80% at 0% 70%, rgba(var(--gk-teal-deep-rgb), calc(0.18 * var(--gk-wash))), transparent 70%),
|
||||
repeating-linear-gradient(0deg, rgba(var(--gk-star-rgb), 0.035) 0 1px, transparent 1px 40px),
|
||||
repeating-linear-gradient(90deg, rgba(var(--gk-star-rgb), 0.035) 0 1px, transparent 1px 40px),
|
||||
var(--gk-sky);
|
||||
}
|
||||
/* Light mode: a morning sky tile, a touch cooler than the page. */
|
||||
:global(:root[data-theme="light"]) .gk-art {
|
||||
background:
|
||||
radial-gradient(ellipse 70% 90% at 100% 0%, rgba(106, 47, 224, 0.16), transparent 70%),
|
||||
radial-gradient(ellipse 55% 80% at 0% 70%, rgba(15, 140, 132, 0.14), transparent 70%),
|
||||
repeating-linear-gradient(0deg, rgba(19, 28, 43, 0.06) 0 1px, transparent 1px 40px),
|
||||
repeating-linear-gradient(90deg, rgba(19, 28, 43, 0.06) 0 1px, transparent 1px 40px),
|
||||
#e9e6ee;
|
||||
}
|
||||
.mat {
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
border: 1px solid rgba(201, 162, 74, 0.45);
|
||||
border: 1px solid rgba(var(--gk-gold-rgb), 0.45);
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -25,13 +25,13 @@ const bottom = Math.max(...p.marks.map((m) => m.y + m.r)) + 2;
|
||||
const span = bottom - top;
|
||||
---
|
||||
<svg aria-hidden="true" class={className} viewBox={`-6 ${top} ${width + 12} ${span}`} width="100%" height={height ?? Math.ceil(span)} preserveAspectRatio="xMidYMid meet" style="display: block">
|
||||
{p.links.map((l) => <line {...l} stroke="#2BC2B7" stroke-width="1" stroke-dasharray="1.5 4" stroke-linecap="round" opacity="0.7" />)}
|
||||
{p.links.map((l) => <line {...l} style="stroke:var(--gk-orn-link)" stroke-width="1" stroke-dasharray="1.5 4" stroke-linecap="round" opacity="0.7" />)}
|
||||
{
|
||||
p.marks.map((m) =>
|
||||
m.kind === "dot" ? (
|
||||
<circle cx={m.x} cy={m.y} r={m.r} fill="#F4F2ED" opacity="0.85" />
|
||||
<circle cx={m.x} cy={m.y} r={m.r} style="fill:var(--gk-orn-dot)" opacity="0.85" />
|
||||
) : (
|
||||
<polygon points={starPoints(m.x, m.y, m.r * (m.gold ? 0.75 : 1))} fill={m.gold ? "#C9A24A" : "#9A70F5"} />
|
||||
<polygon points={starPoints(m.x, m.y, m.r * (m.gold ? 0.75 : 1))} fill={m.gold ? "var(--gk-gold)" : "var(--gk-violet)"} />
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
/**
|
||||
* Swaps between dark and light mode. Shows the moon in dark mode and the sun
|
||||
* in light. Until it is pressed the site follows the device setting; a press
|
||||
* saves the choice in this browser (PageShell's head script reads it back).
|
||||
*/
|
||||
import Icon from "./Icon.astro";
|
||||
---
|
||||
<button type="button" class="gk-theme-toggle" data-theme-toggle aria-label="Switch to light mode" title="Switch to light mode">
|
||||
<Icon name="moon" size={20} class="moon" />
|
||||
<Icon name="sun" size={20} class="sun" />
|
||||
</button>
|
||||
|
||||
<script>
|
||||
for (const b of document.querySelectorAll<HTMLButtonElement>("[data-theme-toggle]")) {
|
||||
const label = () => {
|
||||
const next = document.documentElement.dataset.theme === "light" ? "dark" : "light";
|
||||
b.setAttribute("aria-label", `Switch to ${next} mode`);
|
||||
b.title = `Switch to ${next} mode`;
|
||||
};
|
||||
label();
|
||||
new MutationObserver(label).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
||||
b.addEventListener("click", () => {
|
||||
const next = document.documentElement.dataset.theme === "light" ? "dark" : "light";
|
||||
try {
|
||||
localStorage.setItem("gk-theme", next);
|
||||
} catch {
|
||||
// Private browsing can refuse storage; the swap still holds for this page.
|
||||
}
|
||||
document.documentElement.dataset.theme = next;
|
||||
(window as { gkApplyTheme?: () => void }).gkApplyTheme?.();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.gk-theme-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: 0 -10px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--gk-soft);
|
||||
cursor: pointer;
|
||||
transition: color var(--gk-ease), background var(--gk-ease);
|
||||
}
|
||||
.gk-theme-toggle:hover {
|
||||
color: var(--gk-teal-bright);
|
||||
background: var(--gk-gold-tint);
|
||||
}
|
||||
.gk-theme-toggle:focus-visible {
|
||||
outline: 2px solid var(--gk-teal-bright);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
:global(:root[data-theme="light"]) .gk-theme-toggle :global(.moon),
|
||||
:global(:root:not([data-theme="light"])) .gk-theme-toggle :global(.sun) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@ const marks = ringTicks(size, size >= 60 ? 48 : 32, 4);
|
||||
---
|
||||
<span class:list={["gk-ring", { medallion }, className]} style={`width:${size}px;height:${size}px`}>
|
||||
<svg aria-hidden="true" width={size} height={size} viewBox={`0 0 ${size} ${size}`} class="ticks">
|
||||
{marks.map((m) => <line x1={m.x1} y1={m.y1} x2={m.x2} y2={m.y2} stroke="#C9A24A" stroke-width="0.9" opacity={m.long ? 0.85 : 0.5} />)}
|
||||
{marks.map((m) => <line x1={m.x1} y1={m.y1} x2={m.x2} y2={m.y2} style="stroke:var(--gk-gold)" stroke-width="0.9" opacity={m.long ? 0.85 : 0.5} />)}
|
||||
</svg>
|
||||
<span class="inner"><slot /></span>
|
||||
</span>
|
||||
@@ -35,7 +35,7 @@ const marks = ringTicks(size, size >= 60 ? 48 : 32, 4);
|
||||
}
|
||||
.medallion {
|
||||
background: var(--gk-midnight);
|
||||
box-shadow: 0 0 0 1px rgba(201, 162, 74, 0.55), 0 0 18px rgba(201, 162, 74, 0.25);
|
||||
box-shadow: 0 0 0 1px rgba(var(--gk-gold-rgb), 0.55), 0 0 18px rgba(var(--gk-gold-rgb), 0.25);
|
||||
}
|
||||
.ticks {
|
||||
position: absolute;
|
||||
|
||||
@@ -26,7 +26,7 @@ const lit = (x: number) => value === undefined || x <= width * value + 0.6;
|
||||
y1="0"
|
||||
x2={m.x}
|
||||
y2={m.long ? height : height / 2}
|
||||
stroke={lit(m.x) ? "var(--gk-gold)" : "#4A5F6B"}
|
||||
stroke={lit(m.x) ? "var(--gk-gold)" : "var(--gk-orn-dim)"}
|
||||
stroke-width="1"
|
||||
opacity={m.long ? 0.8 : 0.45}
|
||||
/>
|
||||
|
||||
@@ -17,3 +17,9 @@ export const tail: { key: NavKey; label: string; href: string }[] = [
|
||||
{ key: "blog", label: "Blog", href: "/blog" },
|
||||
{ key: "store", label: "Store", href: "/store" },
|
||||
];
|
||||
|
||||
/** The Store's own header: its pages in order, and which one is open. */
|
||||
export interface StoreNav {
|
||||
links: { label: string; href: string }[];
|
||||
current?: string;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,33 @@ export type SealName = keyof typeof seals;
|
||||
|
||||
export { filamentBanner };
|
||||
|
||||
// Light mode and foil versions (and the light monogram), written by scripts/light-motifs.mjs.
|
||||
const byFile = (m: Record<string, string>) => Object.fromEntries(Object.entries(m).map(([p, u]) => [p.slice(p.lastIndexOf("/") + 1), u]));
|
||||
const darkFiles = byFile({
|
||||
...import.meta.glob<string>("../assets/motifs/*.svg", { query: "?url", import: "default", eager: true }),
|
||||
...import.meta.glob<string>("../assets/brand/gk-monogram.svg", { query: "?url", import: "default", eager: true }),
|
||||
});
|
||||
const lightFiles = byFile({
|
||||
...import.meta.glob<string>("../assets/motifs/light/*.svg", { query: "?url", import: "default", eager: true }),
|
||||
...import.meta.glob<string>("../assets/brand/light/*.svg", { query: "?url", import: "default", eager: true }),
|
||||
});
|
||||
const foilFiles = byFile(import.meta.glob<string>("../assets/motifs/foil/*.svg", { query: "?url", import: "default", eager: true }));
|
||||
const foilLightFiles = byFile(import.meta.glob<string>("../assets/motifs/foil-light/*.svg", { query: "?url", import: "default", eager: true }));
|
||||
const fileOf = Object.fromEntries(Object.entries(darkFiles).map(([f, u]) => [u, f]));
|
||||
|
||||
/**
|
||||
* A motif's image for each mode. Foil is only for a seal standing alone at
|
||||
* 120px or more and half opacity or more; smaller or fainter seals stay flat.
|
||||
*/
|
||||
export function motifSources(url: string, { foil = false } = {}): { dark: string; light: string } {
|
||||
const f = fileOf[url];
|
||||
if (!f) return { dark: url, light: url };
|
||||
return {
|
||||
dark: (foil && foilFiles[f]) || url,
|
||||
light: (foil && foilLightFiles[f]) || lightFiles[f] || url,
|
||||
};
|
||||
}
|
||||
|
||||
/** Each page family's edge seal. A craft page uses its own craft seal. */
|
||||
export const pageSeals = {
|
||||
landing: "transmutation-star-paths",
|
||||
|
||||
@@ -146,7 +146,8 @@ export function ringTicks(size: number, count = 48, major = 4): { x1: number; y1
|
||||
|
||||
/**
|
||||
* Constellation dust: sparse specks for a page background as CSS
|
||||
* radial-gradients, about one in five gold. Static by design.
|
||||
* radial-gradients, about one in five gold and, where the theme sets an
|
||||
* accent (violet in light mode), one in five of the rest in it. Static by design.
|
||||
*/
|
||||
export function dust(count: number, seed: number): string {
|
||||
const rand = seeded(seed);
|
||||
@@ -155,7 +156,8 @@ export function dust(count: number, seed: number): string {
|
||||
const size = [1, 1, 1.2, 1.6][Math.floor(rand() * 4)];
|
||||
const alpha = [0.25, 0.35, 0.6][Math.floor(rand() * 3)];
|
||||
const gold = rand() < 0.2;
|
||||
const color = gold ? `rgba(216,179,90,${alpha})` : `rgba(236,233,226,${alpha})`;
|
||||
const ink = gold ? "--gk-gold-bright-rgb" : i % 4 === 1 ? "--gk-dust-accent-rgb" : "--gk-text-rgb";
|
||||
const color = `rgba(var(${ink}), ${alpha})`;
|
||||
specks.push(`radial-gradient(${size}px ${size}px at ${r1(rand() * 100)}% ${r1(rand() * 100)}%, ${color}, transparent)`);
|
||||
}
|
||||
return specks.join(", ");
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* A component's own display never un-hides what a script hid. */
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
@@ -43,7 +48,7 @@ a {
|
||||
p a:not([class]) {
|
||||
color: var(--gk-teal-bright);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(43, 194, 183, 0.5);
|
||||
text-decoration-color: rgba(var(--gk-teal-rgb), 0.5);
|
||||
text-underline-offset: 3px;
|
||||
transition: color var(--gk-ease), text-decoration-color var(--gk-ease);
|
||||
}
|
||||
@@ -60,7 +65,7 @@ p a:not([class]):hover {
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(43, 194, 183, 0.35);
|
||||
background: rgba(var(--gk-teal-rgb), 0.35);
|
||||
}
|
||||
|
||||
.gk-visually-hidden {
|
||||
@@ -87,3 +92,9 @@ p a:not([class]):hover {
|
||||
animation-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* A motif has a dark and a light image; show the one for the mode. */
|
||||
:root:not([data-theme="light"]) .gk-motif-light,
|
||||
:root[data-theme="light"] .gk-motif-dark {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,18 @@
|
||||
.gk-pri:hover {
|
||||
background: var(--gk-teal-bright);
|
||||
color: var(--gk-sky);
|
||||
box-shadow: 0 0 0 4px rgba(43, 194, 183, 0.16);
|
||||
box-shadow: 0 0 0 4px rgba(var(--gk-teal-rgb), 0.16);
|
||||
}
|
||||
/* On Starlight the deep teal carries white text (5.8:1); the lighter teal is too faint. */
|
||||
:root[data-theme="light"] .gk-pri,
|
||||
:root[data-theme="light"] .gk-pri:hover {
|
||||
color: #fff;
|
||||
}
|
||||
:root[data-theme="light"] .gk-pri {
|
||||
background: var(--gk-teal-bright);
|
||||
}
|
||||
:root[data-theme="light"] .gk-pri:hover {
|
||||
background: #085e58;
|
||||
}
|
||||
.gk-sec {
|
||||
background: transparent;
|
||||
@@ -92,7 +103,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gk-pill.live {
|
||||
border-color: rgba(43, 194, 183, 0.6);
|
||||
border-color: rgba(var(--gk-teal-rgb), 0.6);
|
||||
color: var(--gk-teal-bright);
|
||||
}
|
||||
.gk-pill.live::before {
|
||||
@@ -102,11 +113,11 @@
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--gk-teal-bright);
|
||||
box-shadow: 0 0 6px rgba(43, 194, 183, 0.8);
|
||||
box-shadow: 0 0 6px rgba(var(--gk-teal-rgb), 0.8);
|
||||
}
|
||||
/* A state that is not happening now keeps the round pill without the lit dot. */
|
||||
.gk-pill.state {
|
||||
border-color: rgba(43, 194, 183, 0.6);
|
||||
border-color: rgba(var(--gk-teal-rgb), 0.6);
|
||||
color: var(--gk-teal-bright);
|
||||
}
|
||||
.gk-pill.cur {
|
||||
@@ -128,7 +139,7 @@
|
||||
transition: border-color var(--gk-ease), color var(--gk-ease);
|
||||
}
|
||||
.gk-chip.core {
|
||||
border-color: rgba(43, 194, 183, 0.55);
|
||||
border-color: rgba(var(--gk-teal-rgb), 0.55);
|
||||
color: var(--gk-text);
|
||||
}
|
||||
a.gk-chip:hover {
|
||||
@@ -206,7 +217,7 @@ a.gk-chip:hover {
|
||||
padding: 12px 16px;
|
||||
border-left: 2px solid var(--gk-gold);
|
||||
border-radius: 0 var(--gk-radius) var(--gk-radius) 0;
|
||||
background: rgba(201, 162, 74, 0.08);
|
||||
background: rgba(var(--gk-gold-rgb), 0.08);
|
||||
color: var(--gk-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -262,7 +273,7 @@ textarea.gk-field {
|
||||
background-size: 92% 2px;
|
||||
}
|
||||
.gk-field[aria-invalid="true"] {
|
||||
border-color: #f0857a;
|
||||
border-color: var(--gk-danger);
|
||||
}
|
||||
.gk-search {
|
||||
display: flex;
|
||||
@@ -324,7 +335,7 @@ textarea.gk-field {
|
||||
}
|
||||
a.gk-card:hover,
|
||||
.gk-card.gk-linked:hover {
|
||||
border-color: rgba(201, 162, 74, 0.55);
|
||||
border-color: rgba(var(--gk-gold-rgb), 0.55);
|
||||
}
|
||||
a.gk-card:hover::before,
|
||||
.gk-card.gk-linked:hover::before {
|
||||
@@ -332,7 +343,7 @@ a.gk-card:hover::before,
|
||||
}
|
||||
.gk-featured {
|
||||
position: relative;
|
||||
border: 1px solid rgba(201, 162, 74, 0.55);
|
||||
border: 1px solid rgba(var(--gk-gold-rgb), 0.55);
|
||||
border-radius: var(--gk-radius);
|
||||
background: var(--gk-midnight);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/*
|
||||
* Brand tokens for the all-dark site (Greg's decision, 2026-10-11). Light mode
|
||||
* is a later phase and gets its own values and graphics; nothing here assumes
|
||||
* it yet.
|
||||
* Brand tokens. Dark is the default (Greg's decision, 2026-10-11); the light
|
||||
* values below (picked 2026-10-18 on the light motif board: Dawn chart sky,
|
||||
* deep gold line seals) apply when <html data-theme="light">. PageShell sets
|
||||
* that from the device setting, or from the visitor's choice on the toggle.
|
||||
*/
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
@@ -24,6 +25,32 @@
|
||||
/* Violet only ever marks a star. On the sky it is lifted to read (#6A2FE0 is 2.7:1). */
|
||||
--gk-violet: #9a70f5;
|
||||
|
||||
--gk-danger: #f0857a;
|
||||
|
||||
/* Ornament inks: star path links and dots, and unlit ticks. */
|
||||
--gk-orn-link: #2bc2b7;
|
||||
--gk-orn-dot: #f4f2ed;
|
||||
--gk-orn-dim: #4a5f6b;
|
||||
|
||||
/* Channels for translucent colors, as rgba(var(--gk-gold-rgb), 0.35). */
|
||||
--gk-gold-rgb: 201, 162, 74;
|
||||
--gk-gold-bright-rgb: 216, 179, 90;
|
||||
--gk-star-rgb: 244, 242, 237;
|
||||
--gk-text-rgb: 236, 233, 226;
|
||||
--gk-teal-rgb: 43, 194, 183;
|
||||
--gk-teal-deep-rgb: 20, 163, 154;
|
||||
--gk-violet-rgb: 106, 47, 224;
|
||||
--gk-sky-rgb: 14, 21, 34;
|
||||
--gk-midnight-rgb: 19, 28, 43;
|
||||
/* Constellation dust: one in five specks takes this instead of the text color. */
|
||||
--gk-dust-accent-rgb: 236, 233, 226;
|
||||
/* How strong the violet and teal washes are; light mode halves them. */
|
||||
--gk-wash: 1;
|
||||
/* The star chart grid behind the page; off in dark, faint ink lines in light. */
|
||||
--gk-chart: none;
|
||||
/* How strong the faint seals behind pages and panels are; light mode lifts them. */
|
||||
--gk-ghost: 1;
|
||||
|
||||
--gk-filament: linear-gradient(90deg, transparent, var(--gk-gold) 25%, var(--gk-teal-bright) 75%, transparent);
|
||||
|
||||
--gk-font-display: Cinzel, "Trajan Pro", Georgia, serif;
|
||||
@@ -50,6 +77,50 @@
|
||||
--gk-ease: 0.15s;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
|
||||
--gk-sky: #f4f2ed;
|
||||
--gk-midnight: #ffffff;
|
||||
--gk-lines: #d9d2c2;
|
||||
--gk-lines-strong: #b8ad97;
|
||||
--gk-starlight: #131c2b;
|
||||
--gk-text: #131c2b;
|
||||
--gk-soft: #33465a;
|
||||
--gk-muted: #52646f;
|
||||
--gk-placeholder: #5e6e78;
|
||||
/* Teal graphics; teal text, links and the primary button use the deeper teal-bright. */
|
||||
--gk-teal: #0f8c84;
|
||||
--gk-teal-bright: #0a726b;
|
||||
--gk-gold: #8a6516;
|
||||
--gk-gold-bright: #6f5112;
|
||||
--gk-gold-line: rgba(138, 101, 22, 0.55);
|
||||
--gk-gold-tint: rgba(138, 101, 22, 0.1);
|
||||
--gk-violet: #6a2fe0;
|
||||
--gk-danger: #b3261e;
|
||||
|
||||
--gk-orn-link: #0f8c84;
|
||||
--gk-orn-dot: #131c2b;
|
||||
--gk-orn-dim: #a9b3b9;
|
||||
|
||||
--gk-gold-rgb: 138, 101, 22;
|
||||
--gk-gold-bright-rgb: 111, 81, 18;
|
||||
--gk-star-rgb: 19, 28, 43;
|
||||
--gk-text-rgb: 19, 28, 43;
|
||||
--gk-teal-rgb: 10, 114, 107;
|
||||
--gk-teal-deep-rgb: 15, 140, 132;
|
||||
--gk-sky-rgb: 244, 242, 237;
|
||||
--gk-midnight-rgb: 255, 255, 255;
|
||||
--gk-dust-accent-rgb: 106, 47, 224;
|
||||
--gk-wash: 0.5;
|
||||
--gk-ghost: 1.6;
|
||||
--gk-chart:
|
||||
repeating-linear-gradient(0deg, rgba(19, 28, 43, 0.032) 0 1px, transparent 1px 44px),
|
||||
repeating-linear-gradient(90deg, rgba(19, 28, 43, 0.032) 0 1px, transparent 1px 44px);
|
||||
|
||||
--gk-filament: linear-gradient(90deg, transparent, #8a6516 25%, #0f8c84 75%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
:root {
|
||||
--gk-gutter: 16px;
|
||||
|
||||
@@ -77,3 +77,70 @@ dl.fields dt { color: var(--muted); }
|
||||
dl.fields dd { margin: 0; word-break: break-word; }
|
||||
dl.fields pre { margin: 0; white-space: pre-wrap; }
|
||||
input.num { width: 90px; }
|
||||
|
||||
/* Posts and Images */
|
||||
.head-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
|
||||
.button.primary { background: var(--teal); border-color: var(--teal); color: var(--midnight); font-weight: 600; }
|
||||
.hint { margin: 4px 0 0; font-size: 13px; color: var(--muted); }
|
||||
.post-grid { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr) 320px; align-items: start; }
|
||||
@media (max-width: 900px) { .post-grid { grid-template-columns: 1fr; } }
|
||||
.side .panel h2 { margin-top: 0; font-size: 1.05rem; }
|
||||
.side input[name="links"] { margin-bottom: 8px; }
|
||||
.body-text { font-family: ui-monospace, monospace; font-size: 14px; line-height: 1.55; resize: vertical; min-height: 360px; }
|
||||
.actions.stack { flex-direction: column; align-items: stretch; }
|
||||
.actions.stack button { justify-content: center; }
|
||||
.cover-preview { display: block; width: 100%; max-height: 180px; object-fit: cover; border-radius: 8px; margin-bottom: 6px; }
|
||||
.cover-preview.wide { max-height: 320px; }
|
||||
.post-preview h1 { margin-top: 4px; }
|
||||
details summary { cursor: pointer; color: var(--muted); margin-top: 10px; }
|
||||
.copy-row { display: flex; gap: 6px; align-items: center; }
|
||||
.copy-row input { font-family: ui-monospace, monospace; font-size: 12px; padding: 4px 8px; }
|
||||
.copy-row button { min-height: 30px; padding: 2px 12px; font-size: 13px; }
|
||||
.thumbs { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); }
|
||||
.thumbs img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; border: 1px solid var(--line); display: block; margin-bottom: 6px; }
|
||||
.folders { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; }
|
||||
.folders a { padding: 4px 14px; border: 1px solid var(--line); border-radius: 999px; color: var(--text); text-decoration: none; font-size: 14px; }
|
||||
.folders a[aria-current] { border-color: var(--gold); color: var(--gold-bright); }
|
||||
.images { list-style: none; padding: 0; margin: 0; display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
|
||||
.images li { margin: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.images img, .images .file { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; display: block; background: var(--sky); }
|
||||
.images .file { display: flex; align-items: center; justify-content: center; color: var(--muted); text-transform: uppercase; }
|
||||
.images .meta { display: flex; flex-direction: column; gap: 6px; font-size: 14px; word-break: break-all; }
|
||||
.images .used { font-size: 13px; }
|
||||
form.inline { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 13px; }
|
||||
form.inline button { min-height: 30px; padding: 2px 12px; font-size: 13px; }
|
||||
.preview img { max-height: 480px; object-fit: contain; }
|
||||
|
||||
/* Note editor */
|
||||
.note-form .field { margin-bottom: 14px; }
|
||||
.note-form fieldset { border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px 12px; margin: 0 0 14px; min-width: 0; }
|
||||
.note-form legend { padding: 0 6px; font-weight: 600; }
|
||||
.note-form .label { font-weight: 600; }
|
||||
.image-pick { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; align-items: start; }
|
||||
.image-pick img, .image-pick .no-image { width: 120px; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; border: 1px solid var(--line); display: block; }
|
||||
.image-pick .no-image { display: grid; place-items: center; color: var(--muted); font-size: 0.85rem; }
|
||||
.image-pick .sub { margin-top: 8px; font-weight: 400; color: var(--muted); }
|
||||
.side .image-pick { grid-template-columns: 1fr; }
|
||||
.side .image-pick img, .side .image-pick .no-image { width: 100%; aspect-ratio: 16 / 9; }
|
||||
.row-item { border-top: 1px solid var(--line); padding: 10px 0; }
|
||||
.row-item:first-of-type { border-top: 0; }
|
||||
.row-item.blank { opacity: 0.85; }
|
||||
.row-fields { display: grid; gap: 0 12px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
|
||||
.row-fields .field:has(.image-pick) { grid-column: 1 / -1; }
|
||||
.row-tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 4px; }
|
||||
.row-tools button { padding: 4px 10px; font-size: 0.85rem; }
|
||||
|
||||
/* Site pages */
|
||||
.site-pages { list-style: none; padding: 0; margin: 0; display: grid; gap: 16px; }
|
||||
.site-pages h2 { margin: 0; font-size: 1.1rem; }
|
||||
.page-images { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.page-images li { width: 120px; font-size: 0.8rem; color: var(--muted); }
|
||||
.page-images img { width: 120px; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; border: 1px solid var(--line); display: block; margin-bottom: 4px; }
|
||||
.feeds { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.pill-link { border: 1px solid var(--line); border-radius: 999px; padding: 2px 10px; font-size: 0.85rem; }
|
||||
|
||||
/* Before launch */
|
||||
.launch ul { list-style: none; padding: 0; margin: 0; }
|
||||
.launch li { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; border-top: 1px solid var(--line); }
|
||||
.launch li:first-child { border-top: 0; }
|
||||
.count-pill { font-size: 0.8rem; border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px; margin-left: 6px; vertical-align: middle; }
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// The admin page's only script, and it is optional: every page works without
|
||||
// it. It adds Copy buttons for embed lines and asks before a delete.
|
||||
for (const b of document.querySelectorAll("button.copy[data-copy]")) {
|
||||
b.addEventListener("click", async () => {
|
||||
const text = b.getAttribute("data-copy") || "";
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const input = b.previousElementSibling;
|
||||
if (input && "select" in input) {
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
}
|
||||
}
|
||||
const was = b.textContent;
|
||||
b.textContent = "Copied";
|
||||
setTimeout(() => (b.textContent = was), 1500);
|
||||
});
|
||||
}
|
||||
for (const f of document.querySelectorAll("form[action='/images/delete']")) {
|
||||
f.addEventListener("submit", (e) => {
|
||||
const name = f.querySelector("input[name=name]")?.value ?? "this image";
|
||||
if (!confirm(`Delete ${name} from the vault? This cannot be undone.`)) e.preventDefault();
|
||||
});
|
||||
}
|
||||
@@ -117,6 +117,15 @@ export interface Suggestion {
|
||||
cover?: string;
|
||||
}
|
||||
|
||||
/** A file in the vault's `_attachments`, with the notes that use it. */
|
||||
export interface Attachment {
|
||||
name: string;
|
||||
folder: string;
|
||||
size: number;
|
||||
modified: string;
|
||||
usedBy: { path: string; title: string; type: string }[];
|
||||
}
|
||||
|
||||
export type FullNote = Omit<Note, "text" | "backlinks" | "pending">;
|
||||
|
||||
export class ContentApi {
|
||||
@@ -146,6 +155,15 @@ export class ContentApi {
|
||||
notes = (p: { type?: string; q?: string; limit?: number; full?: boolean } = {}) =>
|
||||
this.get<Summary[]>("/api/notes", { type: p.type, q: p.q, limit: p.limit, full: p.full ? "true" : undefined });
|
||||
fullNotes = (type: string) => this.get<FullNote[]>("/api/notes", { type, full: "true", limit: 1000 });
|
||||
/** Every note, whole, a thousand at a time. */
|
||||
async allNotes(): Promise<FullNote[]> {
|
||||
const all: FullNote[] = [];
|
||||
for (let offset = 0; ; offset += 1000) {
|
||||
const page = await this.get<FullNote[]>("/api/notes", { full: "true", limit: 1000, offset });
|
||||
all.push(...page);
|
||||
if (page.length < 1000) return all;
|
||||
}
|
||||
}
|
||||
note = (path: string) => this.get<Note>("/api/note", { path });
|
||||
noteByName = (name: string) => this.get<Note>("/api/note", { name });
|
||||
history = (path: string) => this.get<Revision[]>("/api/note/history", { path });
|
||||
@@ -172,6 +190,20 @@ export class ContentApi {
|
||||
suggestions = () => this.get<Suggestion[]>("/api/suggestions");
|
||||
dismissSuggestion = (key: string) => this.call<{ ok: boolean }>("POST", `/api/suggestions/${encodeURIComponent(key)}/dismiss`, {});
|
||||
|
||||
attachments = () => this.get<Attachment[]>("/api/attachments");
|
||||
/** Add an image to `_attachments/<folder>`; answers with the name it was saved under. */
|
||||
async upload(folder: string, name: string, bytes: Uint8Array<ArrayBuffer>): Promise<string> {
|
||||
const res = await fetch(new URL(`/api/attachments?${new URLSearchParams({ folder, name })}`, this.base), {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${this.token}`, "content-type": "application/octet-stream" },
|
||||
body: bytes,
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as { name?: string; error?: string } | null;
|
||||
if (!res.ok || !data?.name) throw new ApiError(res.status, data?.error ?? `The content service answered ${res.status}`, data);
|
||||
return data.name;
|
||||
}
|
||||
deleteAttachment = (name: string) => this.call<{ ok: boolean }>("POST", "/api/attachments/delete", { name });
|
||||
|
||||
/** Fetch an attachment to pass through to the browser. */
|
||||
attachment = (name: string) =>
|
||||
fetch(new URL(`/attachments/${encodeURIComponent(name)}`, this.base), { headers: { authorization: `Bearer ${this.token}` } });
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* The note editor: a form for any note type, made from the note type's
|
||||
* schema so it accepts exactly what the content service accepts. Image
|
||||
* fields get a picker (a thumbnail, the vault's images, an upload), link
|
||||
* fields get the note names, and lists of rows (photos, specs, socials) can
|
||||
* be added to, removed and reordered.
|
||||
*
|
||||
* The form round-trips through the note's own data shape: `formData` turns a
|
||||
* posted form back into frontmatter values, and `noteEditor` renders from
|
||||
* them, so a reorder or a failed save shows exactly what was typed.
|
||||
*/
|
||||
import { noteTypes, type NoteType } from "@gk/schema";
|
||||
import { z } from "zod";
|
||||
import type { Attachment, Note } from "./client.ts";
|
||||
import { html, qs, type Html } from "./html.ts";
|
||||
import { noteHref } from "./pages.ts";
|
||||
|
||||
/** One field as the JSON Schema export describes it. */
|
||||
export interface Prop {
|
||||
type?: string;
|
||||
enum?: unknown[];
|
||||
const?: unknown;
|
||||
format?: string;
|
||||
pattern?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
items?: Prop;
|
||||
properties?: Record<string, Prop>;
|
||||
required?: string[];
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
interface Shape {
|
||||
properties: Record<string, Prop>;
|
||||
required: string[];
|
||||
}
|
||||
|
||||
/** Fields the service or the file name owns; the editor never sends them. */
|
||||
const OWNED = new Set(["type", "title", "updated", "updatedBy", "created"]);
|
||||
/** Shared fields, shown after the note type's own. */
|
||||
const SHARED = ["publish", "favorite", "cover", "links", "tags", "slug", "agentEdits"];
|
||||
|
||||
const shapes = new Map<string, Shape>();
|
||||
export function shapeOf(type: string): Shape {
|
||||
let s = shapes.get(type);
|
||||
if (!s) {
|
||||
const def = noteTypes[type as NoteType];
|
||||
if (!def) throw new Error(`Unknown note type ${type}`);
|
||||
const js = z.toJSONSchema(def.schema, { io: "input", unrepresentable: "any" }) as { properties: Record<string, Prop>; required?: string[] };
|
||||
s = { properties: js.properties, required: js.required ?? [] };
|
||||
shapes.set(type, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Whether notes of this type have Markdown under the frontmatter. */
|
||||
export const hasBody = (type: string) => noteTypes[type as NoteType]?.body != null;
|
||||
|
||||
export const editHref = (path: string) => `/edit${qs({ path })}`;
|
||||
export const newHref = (type: string, extra: Record<string, string> = {}) => `/edit/new${qs({ type, ...extra })}`;
|
||||
|
||||
/** The `_attachments` folder an upload from this note type goes to. */
|
||||
export function uploadFolder(type: string): string {
|
||||
if (["craft", "station", "tool", "make"].includes(type)) return "Workshop";
|
||||
if (["library-item", "board-game"].includes(type)) return "Library";
|
||||
if (["tabletop-system", "campaign", "card-game", "deck"].includes(type)) return "Tabletop";
|
||||
if (["product-line", "store-settings"].includes(type)) return "Store";
|
||||
if (type === "post") return "Posts";
|
||||
return "Pages";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ field kinds
|
||||
|
||||
type Kind = "bool" | "enum" | "number" | "date" | "month" | "url" | "image" | "link" | "text" | "lines" | "links" | "images" | "choices" | "rows" | "group" | "other";
|
||||
|
||||
const DATE = /^\^\\d\{4\}-\\d\{2\}-\\d\{2\}\$$/;
|
||||
const MONTH = /^\^\\d\{4\}-\\d\{2\}\$$/;
|
||||
|
||||
export function kindOf(p: Prop, nested = false): Kind {
|
||||
if (p.type === "boolean") return "bool";
|
||||
if (p.type === "integer" || p.type === "number") return "number";
|
||||
if (p.type === "string" || (!p.type && p.format)) {
|
||||
if (p.enum) return "enum";
|
||||
if (p.format === "vault-image") return "image";
|
||||
if (p.format === "vault-link") return "link";
|
||||
if (p.format === "uri") return "url";
|
||||
if (p.pattern && DATE.test(p.pattern)) return "date";
|
||||
if (p.pattern && MONTH.test(p.pattern)) return "month";
|
||||
return "text";
|
||||
}
|
||||
if (p.type === "array" && p.items) {
|
||||
const it = p.items;
|
||||
if (p.format === "vault-links") return "links";
|
||||
if (it.format === "vault-image") return "images";
|
||||
if (it.type === "string" && it.enum) return "choices";
|
||||
if (it.type === "string") return "lines";
|
||||
// Rows of simple fields; a row inside a row is left to Obsidian.
|
||||
if (!nested && it.type === "object" && Object.values(it.properties ?? {}).every((s) => !["rows", "group", "other", "images"].includes(kindOf(s, true)))) return "rows";
|
||||
return "other";
|
||||
}
|
||||
if (p.type === "object" && !nested && Object.values(p.properties ?? {}).every((s) => ["bool", "enum", "number", "date", "month", "url", "text", "link", "image"].includes(kindOf(s, true)))) return "group";
|
||||
return "other";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ values
|
||||
|
||||
/** `[[file.jpg]]` or `![[file.jpg|300]]` to `file.jpg`; a URL stays as it is. */
|
||||
export const linkName = (v: unknown) =>
|
||||
String(v ?? "")
|
||||
.trim()
|
||||
.replace(/^!?\[\[|\]\]$/g, "")
|
||||
.split("|")[0]!
|
||||
.trim();
|
||||
const wrap = (v: string) => (/^https?:\/\//.test(v) ? v : `[[${v}]]`);
|
||||
const str = (v: unknown) => (v === undefined || v === null ? "" : String(v));
|
||||
|
||||
// ------------------------------------------------------------ reading a form
|
||||
|
||||
export interface Upload {
|
||||
field: string;
|
||||
name: string;
|
||||
bytes: Uint8Array<ArrayBuffer>;
|
||||
}
|
||||
|
||||
/** Turn a posted field into a value, or undefined when it was left empty. */
|
||||
function scalar(kind: Kind, raw: string | null): unknown {
|
||||
const v = (raw ?? "").trim();
|
||||
if (!v) return undefined;
|
||||
if (kind === "number") {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : v;
|
||||
}
|
||||
if (kind === "image" || kind === "link") return wrap(linkName(v));
|
||||
return v;
|
||||
}
|
||||
|
||||
function lines(raw: string | null, link: boolean): string[] {
|
||||
return (raw ?? "")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((l) => (link ? wrap(linkName(l)) : l));
|
||||
}
|
||||
|
||||
/** Indexes of the rows the form posted for `name`, in their order on the page. */
|
||||
function rowIndexes(f: URLSearchParams, name: string): number[] {
|
||||
const seen = new Set<number>();
|
||||
const re = new RegExp(`^${name.replace(/\./g, "\\.")}\\.(\\d+)(\\.|$)`);
|
||||
for (const k of f.keys()) {
|
||||
const m = re.exec(k);
|
||||
if (m) seen.add(Number(m[1]));
|
||||
}
|
||||
return [...seen].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* The note's values from a posted editor form. Uploaded images are saved
|
||||
* first (by `save`) and arrive in `uploaded` keyed by the field that chose
|
||||
* them. An empty field comes back as undefined.
|
||||
*/
|
||||
export function formData(type: string, f: URLSearchParams, uploaded: Map<string, string[]> = new Map()): Record<string, unknown> {
|
||||
const shape = shapeOf(type);
|
||||
const { properties } = shape;
|
||||
const required = new Set(shape.required);
|
||||
const out: Record<string, unknown> = {};
|
||||
const field = (name: string, p: Prop, kind: Kind, required = false): unknown => {
|
||||
const up = uploaded.get(name);
|
||||
switch (kind) {
|
||||
case "bool":
|
||||
// An unticked optional box is left out rather than written as false.
|
||||
if (!f.has(`has.${name}`)) return undefined;
|
||||
return f.get(name) === "1" ? true : required || name === "publish" ? false : undefined;
|
||||
case "image":
|
||||
return up?.length ? wrap(up[0]!) : scalar(kind, f.get(name));
|
||||
case "lines":
|
||||
case "links": {
|
||||
const l = lines(f.get(name), kind === "links");
|
||||
return l.length ? l : undefined;
|
||||
}
|
||||
case "choices": {
|
||||
const l = f.getAll(name).filter(Boolean);
|
||||
return l.length ? l : undefined;
|
||||
}
|
||||
case "images": {
|
||||
const l = rowIndexes(f, name)
|
||||
.filter((i) => f.get(`${name}.${i}.remove`) !== "1")
|
||||
.map((i) => scalar("image", f.get(`${name}.${i}`)))
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
for (const u of up ?? []) l.push(wrap(u));
|
||||
return l.length ? l : undefined;
|
||||
}
|
||||
case "rows": {
|
||||
const subs = Object.entries(p.items?.properties ?? {});
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
for (const i of rowIndexes(f, name)) {
|
||||
if (f.get(`${name}.${i}.remove`) === "1") continue;
|
||||
const row: Record<string, unknown> = {};
|
||||
const req = new Set(p.items?.required ?? []);
|
||||
for (const [k, s] of subs) {
|
||||
const v = field(`${name}.${i}.${k}`, s, kindOf(s, true), req.has(k));
|
||||
if (v !== undefined) row[k] = v;
|
||||
}
|
||||
// A row with nothing typed is the blank "add one" row.
|
||||
if (Object.values(row).some((v) => v !== false)) rows.push(row);
|
||||
}
|
||||
return rows.length ? rows : undefined;
|
||||
}
|
||||
case "group": {
|
||||
const g: Record<string, unknown> = {};
|
||||
const req = new Set(p.required ?? []);
|
||||
for (const [k, s] of Object.entries(p.properties ?? {})) {
|
||||
const v = field(`${name}.${k}`, s, kindOf(s, true), req.has(k));
|
||||
if (v !== undefined) g[k] = v;
|
||||
}
|
||||
return Object.keys(g).length ? g : undefined;
|
||||
}
|
||||
case "other":
|
||||
return undefined;
|
||||
default:
|
||||
return scalar(kind, f.get(name));
|
||||
}
|
||||
};
|
||||
for (const [key, p] of Object.entries(properties)) {
|
||||
if (OWNED.has(key)) continue;
|
||||
const kind = kindOf(p);
|
||||
if (kind === "other") continue;
|
||||
out[key] = field(key, p, kind, required.has(key));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields to send. On an update, a field left empty is sent as null so
|
||||
* the content service drops it, and fields the form cannot edit are left
|
||||
* out so the note keeps them.
|
||||
*/
|
||||
export function changeFields(values: Record<string, unknown>, update: boolean): Record<string, unknown> {
|
||||
const fields: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
if (v !== undefined) fields[k] = v;
|
||||
else if (update) fields[k] = null;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** Move row `i` of `name` up or down in posted form values. */
|
||||
export function moveRow(values: Record<string, unknown>, move: string): Record<string, unknown> {
|
||||
const m = /^(.+):(\d+):(up|down)$/.exec(move);
|
||||
if (!m) return values;
|
||||
const [, key, at, dir] = m;
|
||||
const list = values[key!];
|
||||
if (!Array.isArray(list)) return values;
|
||||
const i = Number(at);
|
||||
const j = dir === "up" ? i - 1 : i + 1;
|
||||
if (j < 0 || j >= list.length) return values;
|
||||
const next = [...list];
|
||||
[next[i], next[j]] = [next[j], next[i]];
|
||||
return { ...values, [key!]: next };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ rendering
|
||||
|
||||
export interface EditorDraft {
|
||||
type: string;
|
||||
/** Set when editing a saved note. */
|
||||
path?: string;
|
||||
title: string;
|
||||
publish: boolean;
|
||||
data: Record<string, unknown>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function draftFromNote(n: Note): EditorDraft {
|
||||
return { type: n.type, path: n.path, title: n.title, publish: n.publish, data: n.data, body: n.body };
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
images: Attachment[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
const isImage = (name: string) => /\.(png|jpe?g|webp|gif|avif|svg)$/i.test(name);
|
||||
const imageSrc = (v: string) => (/^https:\/\//.test(v) ? v : `/attachments/${encodeURIComponent(v)}`);
|
||||
const id = (name: string) => `e-${name.replace(/[^a-zA-Z0-9]+/g, "-")}`;
|
||||
const ACCEPT = "image/png,image/jpeg,image/webp,image/gif,image/avif";
|
||||
|
||||
function labelFor(key: string, p: Prop) {
|
||||
return p.title ?? key;
|
||||
}
|
||||
|
||||
function hint(p: Prop): Html | string {
|
||||
return p.description ? html`<p class="hint">${p.description}</p>` : "";
|
||||
}
|
||||
|
||||
function imagePicker(name: string, value: string, ctx: Ctx, label: string): Html {
|
||||
const current = linkName(value);
|
||||
const known = ctx.images.some((a) => a.name === current);
|
||||
return html`<div class="image-pick">
|
||||
${current ? html`<img src="${imageSrc(current)}" alt="" loading="lazy">` : html`<div class="no-image">No image</div>`}
|
||||
<div>
|
||||
<label for="${id(name)}">${label}</label>
|
||||
<select id="${id(name)}" name="${name}">
|
||||
<option value="">None</option>
|
||||
${current && !known ? html`<option value="${current}" selected>${current}</option>` : ""}
|
||||
${ctx.images.map((a) => html`<option value="${a.name}" ${a.name === current ? html`selected` : ""}>${a.folder ? `${a.folder} / ` : ""}${a.name}</option>`)}
|
||||
</select>
|
||||
<label for="${id(name)}-up" class="sub">Or upload a new one</label>
|
||||
<input id="${id(name)}-up" name="upload.${name}" type="file" accept="${ACCEPT}">
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function input(name: string, kind: Kind, p: Prop, value: unknown, ctx: Ctx, label: string, required: boolean): Html {
|
||||
const i = id(name);
|
||||
const req = required ? html`required` : "";
|
||||
switch (kind) {
|
||||
case "bool":
|
||||
return html`<input type="hidden" name="has.${name}" value="1"><label class="check"><input type="checkbox" name="${name}" value="1" ${value === true ? html`checked` : ""}> ${label}</label>`;
|
||||
case "enum":
|
||||
return html`<label for="${i}">${label}</label><select id="${i}" name="${name}" ${req}>
|
||||
${required ? "" : html`<option value="">Not set</option>`}
|
||||
${(p.enum ?? []).map((o) => html`<option ${String(o) === str(value) ? html`selected` : ""}>${String(o)}</option>`)}
|
||||
</select>`;
|
||||
case "number":
|
||||
return html`<label for="${i}">${label}</label><input id="${i}" name="${name}" type="number" step="${p.type === "integer" ? "1" : "any"}" value="${str(value)}" ${req}>`;
|
||||
case "date":
|
||||
return html`<label for="${i}">${label}</label><input id="${i}" name="${name}" type="date" value="${str(value).slice(0, 10)}" ${req}>`;
|
||||
case "month":
|
||||
return html`<label for="${i}">${label}</label><input id="${i}" name="${name}" type="month" value="${str(value)}" ${req}>`;
|
||||
case "url":
|
||||
return html`<label for="${i}">${label}</label><input id="${i}" name="${name}" type="url" value="${str(value)}" ${req}>`;
|
||||
case "link":
|
||||
return html`<label for="${i}">${label}</label><input id="${i}" name="${name}" list="note-names" value="${linkName(value)}" placeholder="A note's name" ${req}>`;
|
||||
case "image":
|
||||
return imagePicker(name, str(value), ctx, label);
|
||||
default:
|
||||
return html`<label for="${i}">${label}</label><input id="${i}" name="${name}" value="${str(value)}" ${req}>`;
|
||||
}
|
||||
}
|
||||
|
||||
function fieldBlock(key: string, p: Prop, value: unknown, ctx: Ctx, required: boolean): Html {
|
||||
const kind = kindOf(p);
|
||||
const label = labelFor(key, p);
|
||||
const i = id(key);
|
||||
switch (kind) {
|
||||
case "lines":
|
||||
case "links": {
|
||||
const list = Array.isArray(value) ? value.map((v) => (kind === "links" ? linkName(v) : str(v))) : [];
|
||||
return html`<div class="field"><label for="${i}">${label}</label>
|
||||
<textarea id="${i}" name="${key}" rows="${Math.max(3, list.length + 1)}" ${kind === "links" ? html`placeholder="One note name per line"` : html`placeholder="One per line"`}>${list.join("\n")}</textarea>
|
||||
${hint(p)}</div>`;
|
||||
}
|
||||
case "choices": {
|
||||
const set = new Set(Array.isArray(value) ? value.map(String) : []);
|
||||
return html`<fieldset class="field"><legend>${label}</legend>${(p.items?.enum ?? []).map(
|
||||
(o) => html`<label class="check"><input type="checkbox" name="${key}" value="${String(o)}" ${set.has(String(o)) ? html`checked` : ""}> ${String(o)}</label>`,
|
||||
)}${hint(p)}</fieldset>`;
|
||||
}
|
||||
case "images": {
|
||||
const list = Array.isArray(value) ? value.map(str) : [];
|
||||
return html`<fieldset class="field rows"><legend>${label}</legend>${hint(p)}
|
||||
${list.map(
|
||||
(v, n) => html`<div class="row-item">
|
||||
${imagePicker(`${key}.${n}`, v, ctx, `Image ${n + 1}`)}
|
||||
${rowTools(key, n, list.length)}
|
||||
</div>`,
|
||||
)}
|
||||
<label for="${i}-add">Add images</label>
|
||||
<input id="${i}-add" name="upload.${key}" type="file" accept="${ACCEPT}" multiple>
|
||||
</fieldset>`;
|
||||
}
|
||||
case "rows": {
|
||||
const list = Array.isArray(value) ? (value as Record<string, unknown>[]) : [];
|
||||
const subs = Object.entries(p.items?.properties ?? {});
|
||||
const req = new Set(p.items?.required ?? []);
|
||||
const row = (r: Record<string, unknown>, n: number, blank: boolean) => html`<div class="row-item ${blank ? "blank" : ""}">
|
||||
${blank ? html`<p class="hint">Add one</p>` : ""}
|
||||
<div class="row-fields">${subs.map(([k, s]) => {
|
||||
const sk = kindOf(s, true);
|
||||
const name = `${key}.${n}.${k}`;
|
||||
if (sk === "lines") {
|
||||
const l = Array.isArray(r[k]) ? (r[k] as unknown[]).map(str) : [];
|
||||
return html`<div class="field"><label for="${id(name)}">${labelFor(k, s)}</label><textarea id="${id(name)}" name="${name}" rows="2" placeholder="One per line">${l.join("\n")}</textarea></div>`;
|
||||
}
|
||||
if (sk === "choices") return html`<div class="field">${labelFor(k, s)}: edit in Obsidian</div>`;
|
||||
return html`<div class="field">${input(name, sk, s, r[k], ctx, labelFor(k, s), !blank && req.has(k))}</div>`;
|
||||
})}</div>
|
||||
${blank ? "" : rowTools(key, n, list.length)}
|
||||
</div>`;
|
||||
return html`<fieldset class="field rows"><legend>${label}</legend>${hint(p)}
|
||||
${list.map((r, n) => row(r, n, false))}
|
||||
${row({}, list.length, true)}
|
||||
</fieldset>`;
|
||||
}
|
||||
case "group": {
|
||||
const g = (value ?? {}) as Record<string, unknown>;
|
||||
const req = new Set(p.required ?? []);
|
||||
return html`<fieldset class="field"><legend>${label}</legend>${hint(p)}<div class="row-fields">${Object.entries(p.properties ?? {}).map(
|
||||
([k, s]) => html`<div class="field">${input(`${key}.${k}`, kindOf(s, true), s, g[k], ctx, labelFor(k, s), req.has(k))}</div>`,
|
||||
)}</div></fieldset>`;
|
||||
}
|
||||
case "other":
|
||||
return value === undefined
|
||||
? html``
|
||||
: html`<div class="field"><span class="label">${label}</span><p class="hint">This field is edited in Obsidian.</p></div>`;
|
||||
default:
|
||||
return html`<div class="field">${input(key, kind, p, value, ctx, label, required)}${kind === "bool" ? "" : hint(p)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function rowTools(key: string, n: number, count: number): Html {
|
||||
return html`<div class="row-tools">
|
||||
<button type="submit" name="move" value="${key}:${n}:up" formnovalidate ${n === 0 ? html`disabled` : ""}>Move up</button>
|
||||
<button type="submit" name="move" value="${key}:${n}:down" formnovalidate ${n === count - 1 ? html`disabled` : ""}>Move down</button>
|
||||
<label class="check"><input type="checkbox" name="${key}.${n}.remove" value="1"> Remove</label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function noteEditor(d: EditorDraft, ctx: Ctx): Html {
|
||||
const def = noteTypes[d.type as NoteType];
|
||||
const { properties, required } = shapeOf(d.type);
|
||||
const req = new Set(required);
|
||||
const images = { ...ctx, images: ctx.images.filter((a) => isImage(a.name)) };
|
||||
const own = Object.entries(properties).filter(([k]) => !OWNED.has(k) && !SHARED.includes(k));
|
||||
const shared = SHARED.filter((k) => properties[k]).map((k) => [k, properties[k]!] as const);
|
||||
return html`<h1>${d.path ? html`Edit ${d.title}` : html`New ${def.label.toLowerCase()}`}</h1>
|
||||
<p class="lede">${def.description} ${d.path ? html`Saved as <code>${d.path}</code>. <a href="${noteHref(d.path)}">Note and history</a>` : ""}</p>
|
||||
<form method="post" action="/edit/save" enctype="multipart/form-data" class="note-form">
|
||||
<input type="hidden" name="type" value="${d.type}">
|
||||
${d.path ? html`<input type="hidden" name="path" value="${d.path}">` : ""}
|
||||
<div class="post-grid">
|
||||
<div class="panel">
|
||||
<div class="field"><label for="e-title">Title</label>
|
||||
<input id="e-title" name="title" value="${d.title}" required ${d.path ? html`readonly aria-describedby="e-title-note"` : ""}>
|
||||
${d.path ? html`<p class="hint" id="e-title-note">The title is the note's file name; rename it in Obsidian.</p>` : ""}</div>
|
||||
${own.map(([k, p]) => fieldBlock(k, p, d.data[k], images, req.has(k)))}
|
||||
${def.body !== null
|
||||
? html`<div class="field"><label for="e-body">${d.type === "page" ? "Introduction" : "Body"}</label>
|
||||
<textarea id="e-body" name="body" rows="14" class="body-text">${d.body}</textarea>
|
||||
<p class="hint">${def.body} Markdown; embed a vault image with ![[file.jpg]].</p></div>`
|
||||
: ""}
|
||||
</div>
|
||||
<div class="side">
|
||||
<div class="panel">
|
||||
<h2>On the site</h2>
|
||||
${shared.map(([k, p]) => fieldBlock(k, p, k === "publish" ? d.publish : d.data[k], images, false))}
|
||||
</div>
|
||||
<div class="panel actions stack">
|
||||
<button class="primary" name="action" value="save">Save</button>
|
||||
${d.path ? html`<a href="${noteHref(d.path)}">Cancel</a>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<datalist id="note-names">${ctx.notes.map((n) => html`<option value="${n}">`)}</datalist>
|
||||
</form>`;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Before launch: what on the live site is still a placeholder, worked out
|
||||
* from the notes themselves so it stays true as they change. Pages using
|
||||
* their built-in wording, notes that still hold placeholder text, posts not
|
||||
* published yet, and the thin spots the page designs expect to be filled.
|
||||
*/
|
||||
import type { FullNote } from "./client.ts";
|
||||
import { editHref, newHref } from "./editor.ts";
|
||||
import { html, type Html } from "./html.ts";
|
||||
import { sitePages } from "./sitepages.ts";
|
||||
|
||||
export interface Finding {
|
||||
what: string;
|
||||
/** The note to open, or a page to add. */
|
||||
href: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
title: string;
|
||||
hint: string;
|
||||
findings: Finding[];
|
||||
}
|
||||
|
||||
/** Text that marks something as not written yet. */
|
||||
const PLACEHOLDER = /<!--|\bcoming soon\b|\bplaceholder\b|\blorem\b|\bTODO\b|\bTBD\b/i;
|
||||
/** Pages whose lede or portrait the site fills in when the note has none. */
|
||||
const BUILT_IN = new Set(["/", "/about", "/now", "/blog", "/library", "/library/shelf", "/inquiries", "/tabletop", "/workshop", "/work", "/store", "/404"]);
|
||||
|
||||
const fieldsText = (n: FullNote) => JSON.stringify(n.data);
|
||||
const edit = (n: FullNote) => (n.type === "post" ? `/posts/edit?path=${encodeURIComponent(n.path)}` : editHref(n.path));
|
||||
|
||||
export function launchCheck(notes: FullNote[]): Section[] {
|
||||
const live = notes.filter((n) => n.publish);
|
||||
const of = (type: string, list = live) => list.filter((n) => n.type === type);
|
||||
const pageNotes = of("page", notes);
|
||||
|
||||
const pages: Finding[] = [];
|
||||
for (const p of sitePages) {
|
||||
if (!BUILT_IN.has(p.page)) continue;
|
||||
const n = pageNotes.find((x) => x.data.page === p.page);
|
||||
if (!n) pages.push({ what: `${p.name} (${p.page}) has no Page text note, so it shows its built-in wording.`, href: newHref("page", { page: p.page, title: p.name }), action: "Add page text" });
|
||||
else if (!n.publish) pages.push({ what: `${p.name} (${p.page}): its Page text note is a draft, so the page shows its built-in wording.`, href: edit(n), action: "Edit" });
|
||||
else if (!n.data.lede && p.page !== "/") pages.push({ what: `${p.name} (${p.page}) has no lede, so it shows the built-in one.`, href: edit(n), action: "Edit" });
|
||||
}
|
||||
const about = pageNotes.find((x) => x.data.page === "/about");
|
||||
if (!about?.data.portrait) pages.push({ what: "About has no portrait, so star-chart art stands in.", href: about ? edit(about) : newHref("page", { page: "/about", title: "About" }), action: about ? "Edit" : "Add page text" });
|
||||
|
||||
const text = notes
|
||||
.filter((n) => !n.path.startsWith("_templates/") && (PLACEHOLDER.test(n.body) || PLACEHOLDER.test(fieldsText(n))))
|
||||
.map((n) => ({ what: `${n.title}${n.publish ? "" : " (draft)"}: ${PLACEHOLDER.exec(n.body)?.[0] ?? PLACEHOLDER.exec(fieldsText(n))?.[0]}`, href: edit(n), action: "Edit" }));
|
||||
|
||||
const drafts = of("post", notes)
|
||||
.filter((n) => !n.publish)
|
||||
.map((n) => ({ what: `${n.title}${n.data.postType ? ` (${String(n.data.postType)})` : ""}`, href: edit(n), action: "Finish" }));
|
||||
|
||||
const thin: Finding[] = [
|
||||
...of("craft").filter((n) => !n.data.blurb).map((n) => ({ what: `Craft ${n.title} has no blurb.`, href: edit(n), action: "Edit" })),
|
||||
...of("make").filter((n) => !n.data.date).map((n) => ({ what: `Make ${n.title} has no finish date, so Recently finished can't order it.`, href: edit(n), action: "Edit" })),
|
||||
...of("campaign").filter((n) => !n.data.blurb).map((n) => ({ what: `Campaign ${n.title} has no blurb.`, href: edit(n), action: "Edit" })),
|
||||
...of("deck").filter((n) => !n.data.take).map((n) => ({ what: `Deck ${n.title} has no take.`, href: edit(n), action: "Edit" })),
|
||||
...of("card-game").filter((n) => !n.data.blurb).map((n) => ({ what: `Card game ${n.title} has no blurb.`, href: edit(n), action: "Edit" })),
|
||||
...of("experience").filter((n) => !n.data.focus && !n.data.earlier).map((n) => ({ what: `${n.title} has no focus line.`, href: edit(n), action: "Edit" })),
|
||||
];
|
||||
|
||||
return [
|
||||
{ title: "Pages using built-in wording", hint: "Write the page's text so the site says it in your words.", findings: pages },
|
||||
{ title: "Placeholder text in notes", hint: "Notes holding a comment, Coming soon, TODO or similar. A draft note isn't on the site, but it still needs finishing before it goes live.", findings: text },
|
||||
{ title: "Articles not published", hint: "Drafts on the Blog and Work pages.", findings: drafts },
|
||||
{ title: "Thin spots", hint: "Live notes missing a field their page is designed around.", findings: thin },
|
||||
];
|
||||
}
|
||||
|
||||
export function launchPage(sections: Section[]): Html {
|
||||
const total = sections.reduce((n, s) => n + s.findings.length, 0);
|
||||
return html`<h1>Before launch</h1>
|
||||
<p class="lede">${total ? html`${total} things are still placeholders or unfinished. The list comes from the notes, so it shrinks as you fix them.` : "Nothing left: every page uses your words and nothing holds placeholder text."}</p>
|
||||
${sections.map(
|
||||
(s) => html`<section class="panel launch">
|
||||
<h2>${s.title} <span class="count-pill">${s.findings.length}</span></h2>
|
||||
<p class="hint">${s.hint}</p>
|
||||
${s.findings.length
|
||||
? html`<ul>${s.findings.map((f) => html`<li><span>${f.what}</span> <a href="${f.href}">${f.action}</a></li>`)}</ul>`
|
||||
: html`<p class="muted">All done.</p>`}
|
||||
</section>`,
|
||||
)}`;
|
||||
}
|
||||
@@ -8,10 +8,14 @@ export interface Flash {
|
||||
|
||||
const nav: [string, string][] = [
|
||||
["/", "Overview"],
|
||||
["/site", "Site pages"],
|
||||
["/launch", "Before launch"],
|
||||
["/review", "Review"],
|
||||
["/inbox", "Inbox"],
|
||||
["/notes", "Notes"],
|
||||
["/drafts", "Drafts"],
|
||||
["/posts", "Posts"],
|
||||
["/images", "Images"],
|
||||
["/capture", "Capture"],
|
||||
["/store", "Store"],
|
||||
["/problems", "Problems"],
|
||||
@@ -29,6 +33,7 @@ export function layout(p: { title: string; path: string; held: number; inbox?: n
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cinzel:wght@600&family=Montserrat:wght@400;600;700&display=swap">
|
||||
<link rel="stylesheet" href="/admin.css">
|
||||
<script src="/admin.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
|
||||
@@ -9,6 +9,8 @@ import { html, qs, raw, when, type Html } from "./html.ts";
|
||||
import { renderMarkdown } from "./markdown.ts";
|
||||
|
||||
export const noteHref = (path: string) => `/note${qs({ path })}`;
|
||||
/** The editor for a note: posts have their own. */
|
||||
const editLink = (n: { path: string; type: string }) => (n.type === "post" ? `/posts/edit${qs({ path: n.path })}` : `/edit${qs({ path: n.path })}`);
|
||||
|
||||
const authorLabel: Record<string, string> = { greg: "You", agent: "An agent", admin: "Admin page" };
|
||||
const who = (a: string) => authorLabel[a] ?? a;
|
||||
@@ -134,9 +136,10 @@ export function notesList(p: { title: string; lede: string; action: string; note
|
||||
)}</select></div>
|
||||
<button>Show</button>
|
||||
</form>
|
||||
${p.type ? html`<p><a class="button" href="${p.type === "post" ? "/posts/new" : `/edit/new${qs({ type: p.type })}`}">Add a ${p.type} note</a></p>` : ""}
|
||||
${p.notes.length
|
||||
? html`<div class="table-wrap"><table><thead><tr><th>Title</th><th>Type</th><th>State</th><th>Changed</th></tr></thead><tbody>
|
||||
${p.notes.map((n) => html`<tr><td><a href="${noteHref(n.path)}">${n.title}</a><div class="muted mono">${n.path}</div></td><td>${n.type}</td><td>${publishPill(n.publish)}</td><td>${when(n.acceptedAt)}</td></tr>`)}
|
||||
? html`<div class="table-wrap"><table><thead><tr><th>Title</th><th>Type</th><th>State</th><th>Changed</th><th></th></tr></thead><tbody>
|
||||
${p.notes.map((n) => html`<tr><td><a href="${noteHref(n.path)}">${n.title}</a><div class="muted mono">${n.path}</div></td><td>${n.type}</td><td>${publishPill(n.publish)}</td><td>${when(n.acceptedAt)}</td><td><a href="${editLink(n)}">Edit</a></td></tr>`)}
|
||||
</tbody></table></div>`
|
||||
: html`<p class="muted">No notes match.</p>`}`;
|
||||
}
|
||||
@@ -150,10 +153,11 @@ export function noteView(n: Note, history: Revision[]): Html {
|
||||
<p class="lede">${publishPill(n.publish)} ${n.type} · <span class="mono">${n.path}</span> · last changed by ${who(n.author).toLowerCase()} ${when(n.acceptedAt)}</p>
|
||||
${n.pending ? html`<div class="flash">A change to this note is waiting: <a href="/review/${n.pending.id}">review it</a>.</div>` : ""}
|
||||
<div class="actions">
|
||||
<a class="button primary" href="${editLink(n)}">Edit</a>
|
||||
<form method="post" action="/note/publish" class="inline">
|
||||
<input type="hidden" name="path" value="${n.path}">
|
||||
<input type="hidden" name="publish" value="${n.publish ? "0" : "1"}">
|
||||
<button class="${n.publish ? "" : "primary"}">${n.publish ? "Unpublish" : "Publish"}</button>
|
||||
<button>${n.publish ? "Unpublish" : "Publish"}</button>
|
||||
</form>
|
||||
</div>
|
||||
<h2>Preview</h2>
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Writing blog posts and managing images. A post is a `post` note written
|
||||
* through the content service like any other change; images are files in the
|
||||
* vault's `_attachments`, so Obsidian sees both.
|
||||
*/
|
||||
import { topics } from "@gk/schema";
|
||||
import type { Attachment, Note, Summary } from "./client.ts";
|
||||
import { html, qs, when, type Html } from "./html.ts";
|
||||
import { noteHref, preview } from "./pages.ts";
|
||||
|
||||
/** What the post form holds, from a note or from a posted form. */
|
||||
export interface PostDraft {
|
||||
path?: string;
|
||||
title: string;
|
||||
date: string;
|
||||
topic: string;
|
||||
postType: string;
|
||||
summary: string;
|
||||
cover: string;
|
||||
links: string[];
|
||||
pinned: boolean;
|
||||
role: string;
|
||||
employer: string;
|
||||
period: string;
|
||||
body: string;
|
||||
publish: boolean;
|
||||
}
|
||||
|
||||
export const postTypes = ["article", "build log", "case study"] as const;
|
||||
|
||||
/** Folders under `_attachments` the upload form offers; Posts comes first. */
|
||||
export const imageFolders = ["Posts", "Pages", "Workshop", "Library", "Tabletop", "Store"] as const;
|
||||
|
||||
export const editHref = (path: string) => `/posts/edit${qs({ path })}`;
|
||||
export const unlink = (v: unknown) => String(v ?? "").trim().replace(/^!?\[\[|\]\]$/g, "").split("|")[0]!.trim();
|
||||
|
||||
export function emptyPost(today: string): PostDraft {
|
||||
return { title: "", date: today, topic: topics[0], postType: "article", summary: "", cover: "", links: [], pinned: false, role: "", employer: "", period: "", body: "", publish: false };
|
||||
}
|
||||
|
||||
export function draftFromNote(n: Note): PostDraft {
|
||||
const d = n.data;
|
||||
return {
|
||||
path: n.path,
|
||||
title: n.title,
|
||||
date: String(d.date ?? ""),
|
||||
topic: String(d.topic ?? topics[0]),
|
||||
postType: String(d.postType ?? "article"),
|
||||
summary: String(d.summary ?? ""),
|
||||
cover: unlink(d.cover),
|
||||
links: Array.isArray(d.links) ? d.links.map(unlink).filter(Boolean) : [],
|
||||
pinned: d.pinned === true,
|
||||
role: String(d.role ?? ""),
|
||||
employer: String(d.employer ?? ""),
|
||||
period: String(d.period ?? ""),
|
||||
body: n.body,
|
||||
publish: n.publish,
|
||||
};
|
||||
}
|
||||
|
||||
export function draftFromForm(f: URLSearchParams): PostDraft {
|
||||
const t = (k: string) => (f.get(k) ?? "").trim();
|
||||
return {
|
||||
path: t("path") || undefined,
|
||||
title: t("title"),
|
||||
date: t("date"),
|
||||
topic: t("topic"),
|
||||
postType: t("postType") || "article",
|
||||
summary: t("summary"),
|
||||
cover: unlink(t("cover")),
|
||||
links: f
|
||||
.getAll("links")
|
||||
.flatMap((v) => v.split("\n"))
|
||||
.map(unlink)
|
||||
.filter(Boolean),
|
||||
pinned: f.get("pinned") === "1",
|
||||
role: t("role"),
|
||||
employer: t("employer"),
|
||||
period: t("period"),
|
||||
body: (f.get("body") ?? "").replace(/\r\n/g, "\n"),
|
||||
publish: f.get("wasLive") === "1",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The note fields for a draft. On an update, an emptied optional field is
|
||||
* sent as null so the content service drops it from the note.
|
||||
*/
|
||||
export function postFields(d: PostDraft, publish: boolean, update: boolean): Record<string, unknown> {
|
||||
const opt = (v: unknown, keep: boolean) => (keep ? v : update ? null : undefined);
|
||||
const caseStudy = d.postType === "case study";
|
||||
const fields: Record<string, unknown> = {
|
||||
title: d.title,
|
||||
date: d.date,
|
||||
topic: d.topic,
|
||||
postType: d.postType,
|
||||
summary: d.summary,
|
||||
publish,
|
||||
cover: opt(`[[${d.cover}]]`, !!d.cover),
|
||||
links: opt(
|
||||
d.links.map((l) => `[[${l}]]`),
|
||||
d.links.length > 0,
|
||||
),
|
||||
pinned: opt(true, d.pinned),
|
||||
role: opt(d.role, caseStudy && !!d.role),
|
||||
employer: opt(d.employer, caseStudy && !!d.employer),
|
||||
period: opt(d.period, caseStudy && !!d.period),
|
||||
};
|
||||
for (const k of Object.keys(fields)) if (fields[k] === undefined) delete fields[k];
|
||||
return fields;
|
||||
}
|
||||
|
||||
const imageSrc = (name: string) => `/attachments/${encodeURIComponent(name)}`;
|
||||
const kb = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1000))} KB`);
|
||||
const isImage = (name: string) => /\.(png|jpe?g|webp|gif|avif|svg)$/i.test(name);
|
||||
|
||||
// ------------------------------------------------------------ posts list
|
||||
|
||||
export function postsList(posts: Summary[]): Html {
|
||||
const sorted = [...posts].sort((a, b) => Number(a.publish) - Number(b.publish) || b.acceptedAt.localeCompare(a.acceptedAt));
|
||||
return html`<div class="head-row"><h1>Posts</h1><a class="button primary" href="/posts/new">Write a post</a></div>
|
||||
<p class="lede">Every blog post, drafts first. A post saved here is a note in the vault's Posts folder, so you can also finish it in Obsidian.</p>
|
||||
${sorted.length
|
||||
? html`<table class="list">
|
||||
<thead><tr><th>Post</th><th>State</th><th>Changed</th><th></th></tr></thead>
|
||||
<tbody>${sorted.map(
|
||||
(p) => html`<tr>
|
||||
<td><a href="${editHref(p.path)}">${p.title}</a></td>
|
||||
<td>${p.publish ? html`<span class="pill live">Live</span>` : html`<span class="pill draft">Draft</span>`}</td>
|
||||
<td class="muted">${when(p.acceptedAt)}</td>
|
||||
<td><a href="${noteHref(p.path)}">Note and history</a></td>
|
||||
</tr>`,
|
||||
)}</tbody></table>`
|
||||
: html`<p class="muted">No posts yet.</p>`}`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ editor
|
||||
|
||||
export function postEditor(d: PostDraft, opts: { images: Attachment[]; notes: string[]; showPreview?: boolean }): Html {
|
||||
const images = opts.images.filter((a) => isImage(a.name));
|
||||
const linkRows = [...d.links, "", "", ""].slice(0, Math.max(3, d.links.length + 1));
|
||||
return html`<h1>${d.path ? html`Edit post` : html`Write a post`}</h1>
|
||||
<p class="lede">${d.path
|
||||
? html`${d.publish ? html`<span class="pill live">Live</span>` : html`<span class="pill draft">Draft</span>`} Saved as <code>${d.path}</code>.`
|
||||
: html`Write in Markdown. Preview it, save it as a draft, or publish it to the Blog.`}</p>
|
||||
<form method="post" action="/posts/save" enctype="multipart/form-data" class="post-form">
|
||||
${d.path ? html`<input type="hidden" name="path" value="${d.path}">` : ""}
|
||||
${d.publish ? html`<input type="hidden" name="wasLive" value="1">` : ""}
|
||||
<div class="post-grid">
|
||||
<div class="panel">
|
||||
<label for="p-title">Title</label>
|
||||
<input id="p-title" name="title" value="${d.title}" required ${d.path ? html`readonly aria-describedby="p-title-note"` : ""}>
|
||||
${d.path ? html`<p class="hint" id="p-title-note">The title is the note's file name; rename it in Obsidian.</p>` : ""}
|
||||
<div class="row">
|
||||
<div><label for="p-topic">Topic</label><select id="p-topic" name="topic">${topics.map((t) => html`<option ${t === d.topic ? html`selected` : ""}>${t}</option>`)}</select></div>
|
||||
<div><label for="p-type">Kind of post</label><select id="p-type" name="postType">${postTypes.map((t) => html`<option ${t === d.postType ? html`selected` : ""}>${t}</option>`)}</select></div>
|
||||
<div><label for="p-date">Publish date</label><input id="p-date" name="date" type="date" value="${d.date}" required></div>
|
||||
</div>
|
||||
<label for="p-summary">Summary</label>
|
||||
<input id="p-summary" name="summary" value="${d.summary}" required placeholder="One or two sentences for the Blog list and link previews">
|
||||
<label for="p-body">Post</label>
|
||||
<textarea id="p-body" name="body" rows="22" class="body-text" placeholder="Write in Markdown. Embed a vault image with ![[file.jpg]] and link a page with [[Note name]].">${d.body}</textarea>
|
||||
<label for="p-add">Add images to the post</label>
|
||||
<input id="p-add" name="bodyImages" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" multiple>
|
||||
<p class="hint">They are saved to the vault's Posts images and added to the end of the post; move the lines where you want them.</p>
|
||||
</div>
|
||||
<div class="side">
|
||||
<div class="panel">
|
||||
<h2>Cover image</h2>
|
||||
${d.cover ? html`<img class="cover-preview" src="${imageSrc(d.cover)}" alt="">` : ""}
|
||||
<label for="p-cover">Pick from the vault</label>
|
||||
<select id="p-cover" name="cover">
|
||||
<option value="">No cover</option>
|
||||
${images.map((a) => html`<option value="${a.name}" ${a.name === d.cover ? html`selected` : ""}>${a.folder ? `${a.folder} / ` : ""}${a.name}</option>`)}
|
||||
</select>
|
||||
<label for="p-cover-file">Or upload one</label>
|
||||
<input id="p-cover-file" name="coverFile" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif">
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>Pages it is about</h2>
|
||||
<p class="hint">Library items, crafts, makes, games: the site links the post from each page and back.</p>
|
||||
${linkRows.map((l, i) => html`<input name="links" list="note-names" value="${l}" aria-label="Page ${i + 1}" placeholder="A note's name">`)}
|
||||
<datalist id="note-names">${opts.notes.map((n) => html`<option value="${n}">`)}</datalist>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<label class="check"><input type="checkbox" name="pinned" value="1" ${d.pinned ? html`checked` : ""}> Feature it at the top of the Blog</label>
|
||||
<details ${d.postType === "case study" ? html`open` : ""}>
|
||||
<summary>Case study details</summary>
|
||||
<label for="p-role">Role</label><input id="p-role" name="role" value="${d.role}">
|
||||
<label for="p-employer">Employer</label><input id="p-employer" name="employer" value="${d.employer}">
|
||||
<label for="p-period">Period</label><input id="p-period" name="period" value="${d.period}" placeholder="2021 to now">
|
||||
</details>
|
||||
</div>
|
||||
<div class="panel actions stack">
|
||||
<button name="action" value="preview">Preview</button>
|
||||
<button name="action" value="draft">${d.publish ? "Save and take off the site" : "Save draft"}</button>
|
||||
<button name="action" value="publish" class="primary">${d.publish ? "Save" : "Publish"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
${opts.showPreview
|
||||
? html`<h2 id="preview">Preview</h2>
|
||||
<div class="panel post-preview">
|
||||
${d.cover ? html`<img class="cover-preview wide" src="${imageSrc(d.cover)}" alt="">` : ""}
|
||||
<p class="muted">${d.topic} · ${d.date}</p>
|
||||
<h1>${d.title || "Untitled"}</h1>
|
||||
${d.summary ? html`<p class="lede">${d.summary}</p>` : ""}
|
||||
${preview(d.body)}
|
||||
</div>`
|
||||
: ""}
|
||||
${imageStrip(images)}`;
|
||||
}
|
||||
|
||||
/** The newest vault images under the editor, each with its embed line to copy. */
|
||||
function imageStrip(images: Attachment[]): Html {
|
||||
if (!images.length) return html``;
|
||||
return html`<h2>Images in the vault</h2>
|
||||
<p class="hint">Copy a line into the post to show that image. <a href="/images">Manage images</a></p>
|
||||
<ul class="thumbs">${images.slice(0, 24).map(
|
||||
(a) => html`<li><img src="${imageSrc(a.name)}" alt="" loading="lazy"><span class="copy-row"><input readonly value="![[${a.name}]]" aria-label="Embed line for ${a.name}"><button type="button" class="copy" data-copy="![[${a.name}]]">Copy</button></span></li>`,
|
||||
)}</ul>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ images
|
||||
|
||||
export function imagesPage(list: Attachment[], folder: string): Html {
|
||||
const folders = [...new Set(list.map((a) => a.folder))].sort((a, b) => a.localeCompare(b));
|
||||
const shown = folder ? list.filter((a) => a.folder === folder) : list;
|
||||
return html`<h1>Images</h1>
|
||||
<p class="lede">Everything in the vault's <code>_attachments</code>, newest first. Images added here land in the vault, so Obsidian shows them too.</p>
|
||||
<form class="panel" method="post" action="/images/upload" enctype="multipart/form-data">
|
||||
<h2>Add images</h2>
|
||||
<div class="row">
|
||||
<div><label for="i-files">Images</label><input id="i-files" name="files" type="file" multiple required accept="image/png,image/jpeg,image/webp,image/gif,image/avif"></div>
|
||||
<div><label for="i-folder">Folder</label><select id="i-folder" name="folder">${imageFolders.map((f) => html`<option ${f === (folder || "Posts") ? html`selected` : ""}>${f}</option>`)}</select></div>
|
||||
</div>
|
||||
<p class="hint">PNG, JPEG, WebP, GIF or AVIF, up to 15 MB each. Names are made lowercase with hyphens, and a name already in the vault gets a number.</p>
|
||||
<div class="actions"><button class="primary">Upload</button></div>
|
||||
</form>
|
||||
<nav class="folders" aria-label="Folder">
|
||||
<a href="/images" ${!folder ? html`aria-current="page"` : ""}>All (${list.length})</a>
|
||||
${folders.map((f) => html`<a href="/images${qs({ folder: f || "-" })}" ${f === folder ? html`aria-current="page"` : ""}>${f || "Top level"} (${list.filter((a) => a.folder === f).length})</a>`)}
|
||||
</nav>
|
||||
${shown.length
|
||||
? html`<ul class="images">${shown.map(
|
||||
(a) => html`<li class="panel">
|
||||
${isImage(a.name) ? html`<a href="${imageSrc(a.name)}"><img src="${imageSrc(a.name)}" alt="" loading="lazy"></a>` : html`<div class="file">${a.name.split(".").pop()}</div>`}
|
||||
<div class="meta">
|
||||
<strong>${a.name}</strong>
|
||||
<span class="muted">${a.folder || "Top level"} · ${kb(a.size)} · ${when(a.modified)}</span>
|
||||
<span class="copy-row"><input readonly value="![[${a.name}]]" aria-label="Embed line for ${a.name}"><button type="button" class="copy" data-copy="![[${a.name}]]">Copy</button></span>
|
||||
${a.usedBy.length
|
||||
? html`<span class="used">Used by ${a.usedBy.map((u, i) => html`${i ? ", " : ""}<a href="${noteHref(u.path)}">${u.title}</a>`)}</span>`
|
||||
: html`<form method="post" action="/images/delete" class="inline"><input type="hidden" name="name" value="${a.name}"><input type="hidden" name="folder" value="${folder}"><span class="muted">Not used by any note.</span> <button class="danger">Delete</button></form>`}
|
||||
</div>
|
||||
</li>`,
|
||||
)}</ul>`
|
||||
: html`<p class="muted">No images here yet.</p>`}`;
|
||||
}
|
||||
@@ -16,15 +16,24 @@ import { html, qs, type Html } from "./html.ts";
|
||||
import { layout, type Flash } from "./layout.ts";
|
||||
import * as pages from "./pages.ts";
|
||||
import * as inbox from "./inbox.ts";
|
||||
import * as posts from "./posts.ts";
|
||||
import * as editor from "./editor.ts";
|
||||
import { sitePagesView } from "./sitepages.ts";
|
||||
import { launchCheck, launchPage } from "./launch.ts";
|
||||
import { suggestionChange, suggestionsPanel } from "./suggestions.ts";
|
||||
|
||||
const css = readFileSync(new URL("../public/admin.css", import.meta.url), "utf8");
|
||||
const js = readFileSync(new URL("../public/admin.js", import.meta.url), "utf8");
|
||||
|
||||
/** The largest form with files: a few photos at a time. */
|
||||
const MAX_UPLOAD = 60_000_000;
|
||||
|
||||
class Redirect {
|
||||
constructor(readonly to: string) {}
|
||||
}
|
||||
|
||||
type Form = URLSearchParams;
|
||||
type Upload = { field: string; name: string; bytes: Uint8Array<ArrayBuffer> };
|
||||
type Page = { title: string; body: Html };
|
||||
|
||||
export function createAdminServer(api: ContentApi, opts: { now?: () => Date } = {}) {
|
||||
@@ -112,6 +121,39 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
}
|
||||
case "/problems":
|
||||
return { title: "Problems", body: pages.problemsView(await api.problems()) };
|
||||
case "/posts":
|
||||
return { title: "Posts", body: posts.postsList(await api.notes({ type: "post", limit: 1000 })) };
|
||||
case "/posts/new":
|
||||
return editorPage(posts.emptyPost(today()));
|
||||
case "/posts/edit": {
|
||||
const note = await api.note(required(q, "path"));
|
||||
if (note.type !== "post") return new Redirect(pages.noteHref(note.path));
|
||||
return editorPage(posts.draftFromNote(note));
|
||||
}
|
||||
case "/site": {
|
||||
const all = await Promise.all(["page", "site-settings", "store-settings", "status-kinds", "skills", "education"].map((t) => api.fullNotes(t)));
|
||||
const singles = new Map(all.slice(1).flatMap((l) => (l[0] ? [[l[0].type, l[0]] as const] : [])));
|
||||
return { title: "Site pages", body: sitePagesView(all[0]!, singles) };
|
||||
}
|
||||
case "/launch":
|
||||
return { title: "Before launch", body: launchPage(launchCheck(await api.allNotes())) };
|
||||
case "/edit": {
|
||||
const note = await api.note(required(q, "path"));
|
||||
if (note.type === "post") return new Redirect(posts.editHref(note.path));
|
||||
return noteEditorPage(editor.draftFromNote(note));
|
||||
}
|
||||
case "/edit/new": {
|
||||
const type = required(q, "type");
|
||||
if (type === "post") return new Redirect("/posts/new");
|
||||
if (!noteTypeNames.includes(type as never)) throw new ApiError(404, `There is no note type ${type}.`, null);
|
||||
const data: Record<string, unknown> = {};
|
||||
if (type === "page" && q.get("page")) data.page = q.get("page");
|
||||
return noteEditorPage({ type, title: q.get("title") ?? "", publish: false, data, body: "" });
|
||||
}
|
||||
case "/images": {
|
||||
const folder = q.get("folder") ?? "";
|
||||
return { title: "Images", body: posts.imagesPage(await api.attachments(), folder === "-" ? "" : folder) };
|
||||
}
|
||||
}
|
||||
if (path === "/inbox") {
|
||||
const show = q.get("show") ?? "open";
|
||||
@@ -131,8 +173,94 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
throw new ApiError(404, "There is no page here.", null);
|
||||
}
|
||||
|
||||
async function post(path: string, f: Form): Promise<Redirect> {
|
||||
async function editorPage(d: posts.PostDraft, showPreview = false): Promise<Page> {
|
||||
const [images, notes] = await Promise.all([api.attachments(), api.notes({ limit: 5000 })]);
|
||||
const names = [...new Set(notes.filter((n) => n.type !== "post" && n.type !== "status").map((n) => n.title))].sort((a, b) => a.localeCompare(b));
|
||||
return { title: d.title || "Write a post", body: posts.postEditor(d, { images, notes: names, showPreview }) };
|
||||
}
|
||||
|
||||
async function noteEditorPage(d: editor.EditorDraft, reasons: string[] = []): Promise<Page> {
|
||||
const [images, notes] = await Promise.all([api.attachments(), api.notes({ limit: 5000 })]);
|
||||
const names = [...new Set(notes.filter((n) => n.type !== "status").map((n) => n.title))].sort((a, b) => a.localeCompare(b));
|
||||
const body = editor.noteEditor(d, { images, notes: names });
|
||||
return {
|
||||
title: d.title || "New note",
|
||||
body: reasons.length ? html`<div class="flash err" role="alert">Not saved.<ul>${reasons.map((r) => html`<li>${r}</li>`)}</ul></div>${body}` : body,
|
||||
};
|
||||
}
|
||||
|
||||
/** Save the note editor: upload chosen images, then reorder a row or save. */
|
||||
async function saveNote(f: Form, files: Upload[]): Promise<Page | Redirect> {
|
||||
const type = required(f, "type");
|
||||
if (!noteTypeNames.includes(type as never)) throw new ApiError(400, `There is no note type ${type}.`, null);
|
||||
const path = f.get("path") || undefined;
|
||||
const uploaded = new Map<string, string[]>();
|
||||
for (const u of files.filter((x) => x.field.startsWith("upload."))) {
|
||||
const key = u.field.slice("upload.".length);
|
||||
uploaded.set(key, [...(uploaded.get(key) ?? []), await api.upload(editor.uploadFolder(type), u.name, u.bytes)]);
|
||||
}
|
||||
let values = editor.formData(type, f, uploaded);
|
||||
const title = (f.get("title") ?? "").trim();
|
||||
const body = (f.get("body") ?? "").replace(/\r\n/g, "\n");
|
||||
const draft = (): editor.EditorDraft => ({ type, path, title, publish: values.publish === true, data: values, body });
|
||||
const move = f.get("move");
|
||||
if (move) {
|
||||
values = editor.moveRow(values, move);
|
||||
return noteEditorPage(draft());
|
||||
}
|
||||
if (!title) return noteEditorPage(draft(), ["A note needs a title."]);
|
||||
const fields = editor.changeFields(values, !!path);
|
||||
const text = editor.hasBody(type) ? body : undefined;
|
||||
const o = path
|
||||
? await api.apply({ op: "update", path, fields, body: text })
|
||||
: await api.apply({ op: "create", data: { type, title, ...fields }, body: text });
|
||||
if (o.status === "rejected") return noteEditorPage(draft(), o.reasons.map((r) => r.message));
|
||||
return after(editor.editHref(o.path), o, "Saved. The site shows it within a minute.");
|
||||
}
|
||||
|
||||
/** Save the post form: upload its images, then preview, save a draft or publish. */
|
||||
async function savePost(f: Form, files: Upload[]): Promise<Page | Redirect> {
|
||||
const d = posts.draftFromForm(f);
|
||||
const cover = files.find((u) => u.field === "coverFile");
|
||||
if (cover) d.cover = await api.upload("Posts", cover.name, cover.bytes);
|
||||
for (const u of files.filter((x) => x.field === "bodyImages")) {
|
||||
const name = await api.upload("Posts", u.name, u.bytes);
|
||||
d.body = `${d.body.trimEnd()}\n\n![[${name}]]\n`;
|
||||
}
|
||||
const action = f.get("action") ?? "draft";
|
||||
if (action === "preview") return editorPage(d, true);
|
||||
if (!d.title) throw new ApiError(400, "A post needs a title", null);
|
||||
const publish = action === "publish";
|
||||
const o = d.path
|
||||
? await api.apply({ op: "update", path: d.path, fields: posts.postFields(d, publish, true), body: d.body })
|
||||
: await api.apply({ op: "create", data: { type: "post", ...posts.postFields(d, publish, false) }, body: d.body });
|
||||
const saved = publish ? (d.publish ? "Saved. The live post is updated." : "Published. It is on the Blog now.") : d.publish ? "Taken off the site and saved as a draft." : "Draft saved.";
|
||||
if (o.status === "rejected") {
|
||||
// Keep what was typed: show the form again with the reasons.
|
||||
const page = await editorPage(d);
|
||||
return { ...page, body: html`<div class="flash err" role="alert">Not saved.<ul>${o.reasons.map((r) => html`<li>${r.message}</li>`)}</ul></div>${page.body}` };
|
||||
}
|
||||
return after(posts.editHref(o.path), o, saved);
|
||||
}
|
||||
|
||||
async function post(path: string, f: Form, files: Upload[] = []): Promise<Redirect | Page> {
|
||||
switch (path) {
|
||||
case "/posts/save":
|
||||
return savePost(f, files);
|
||||
case "/edit/save":
|
||||
return saveNote(f, files);
|
||||
case "/images/upload": {
|
||||
const folder = f.get("folder") || "Posts";
|
||||
const added: string[] = [];
|
||||
for (const u of files.filter((x) => x.field === "files")) added.push(await api.upload(folder, u.name, u.bytes));
|
||||
if (!added.length) throw new ApiError(400, "Choose at least one image", null);
|
||||
return new Redirect(`/images${qs({ folder, msg: `Added ${added.join(", ")}.` })}`);
|
||||
}
|
||||
case "/images/delete": {
|
||||
const name = required(f, "name");
|
||||
await api.deleteAttachment(name);
|
||||
return new Redirect(`/images${qs({ folder: f.get("folder") || undefined, msg: `Deleted ${name}.` })}`);
|
||||
}
|
||||
case "/scan": {
|
||||
const r = await api.scan();
|
||||
return new Redirect(`/${qs({ msg: `Scanned: ${r.changed} changed, ${r.held} held.` })}`);
|
||||
@@ -258,13 +386,17 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
res.setHeader("referrer-policy", "same-origin");
|
||||
res.setHeader(
|
||||
"content-security-policy",
|
||||
"default-src 'self'; img-src 'self' data: https:; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'none'; form-action 'self'; frame-ancestors 'none'",
|
||||
"default-src 'self'; img-src 'self' data: https:; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'self'; form-action 'self'; frame-ancestors 'none'",
|
||||
);
|
||||
|
||||
if (url.pathname === "/admin.css") {
|
||||
res.writeHead(200, { "content-type": "text/css; charset=utf-8", "cache-control": "max-age=300" });
|
||||
return res.end(css);
|
||||
}
|
||||
if (url.pathname === "/admin.js") {
|
||||
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "max-age=300" });
|
||||
return res.end(js);
|
||||
}
|
||||
if (url.pathname === "/health") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end('{"ok":true}');
|
||||
@@ -296,11 +428,15 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
|
||||
if (method === "POST") {
|
||||
if (!sameOrigin(req)) return text(res, 403, "Form posts must come from the admin page itself.");
|
||||
const form = new URLSearchParams(await readBody(req));
|
||||
const r = await post(url.pathname, form);
|
||||
const { form, files } = await readForm(req);
|
||||
const r = await post(url.pathname, form, files);
|
||||
if (r instanceof Redirect) {
|
||||
res.writeHead(303, { location: r.to });
|
||||
return res.end();
|
||||
}
|
||||
const held = await api.held().then((h) => h.length).catch(() => 0);
|
||||
return page(res, 200, r, url, held);
|
||||
}
|
||||
if (method !== "GET") return text(res, 405, "Method not allowed");
|
||||
|
||||
const out = await get(url.pathname, url.searchParams);
|
||||
@@ -392,15 +528,32 @@ function safeBack(referer: string | undefined): URL | null {
|
||||
}
|
||||
}
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<string> {
|
||||
async function readBody(req: IncomingMessage, max: number): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const c of req) {
|
||||
size += (c as Buffer).length;
|
||||
if (size > 200_000) throw new ApiError(413, "The form is too large", null);
|
||||
if (size > max) throw new ApiError(413, "The form is too large", null);
|
||||
chunks.push(c as Buffer);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/** A posted form: plain fields, and any chosen files when it was sent as multipart. */
|
||||
async function readForm(req: IncomingMessage): Promise<{ form: URLSearchParams; files: Upload[] }> {
|
||||
const type = req.headers["content-type"] ?? "";
|
||||
if (!type.startsWith("multipart/form-data")) {
|
||||
return { form: new URLSearchParams((await readBody(req, 200_000)).toString("utf8")), files: [] };
|
||||
}
|
||||
const data = await new Request("http://admin/", { method: "POST", headers: { "content-type": type }, body: new Uint8Array(await readBody(req, MAX_UPLOAD)) }).formData();
|
||||
const form = new URLSearchParams();
|
||||
const files: Upload[] = [];
|
||||
for (const [field, v] of data.entries()) {
|
||||
if (typeof v === "string") form.append(field, v);
|
||||
// An empty file input still posts a nameless, empty part.
|
||||
else if (v.size > 0 && v.name) files.push({ field, name: v.name, bytes: new Uint8Array(await v.arrayBuffer()) });
|
||||
}
|
||||
return { form, files };
|
||||
}
|
||||
|
||||
function text(res: ServerResponse, status: number, body: string) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* The Site pages view: each page of the public site, the Page text note
|
||||
* behind it (its lede, introduction and photos) and the kinds of notes that
|
||||
* fill it, so changing what a page shows starts from the page itself.
|
||||
*/
|
||||
import { noteTypes, type NoteType } from "@gk/schema";
|
||||
import type { FullNote } from "./client.ts";
|
||||
import { editHref, linkName, newHref } from "./editor.ts";
|
||||
import { html, qs, type Html } from "./html.ts";
|
||||
|
||||
export interface SitePage {
|
||||
page: string;
|
||||
name: string;
|
||||
/** Note types whose notes appear on the page. */
|
||||
feeds: NoteType[];
|
||||
}
|
||||
|
||||
export const sitePages: SitePage[] = [
|
||||
{ page: "/", name: "Landing", feeds: ["site-settings"] },
|
||||
{ page: "/about", name: "About", feeds: [] },
|
||||
{ page: "/now", name: "Now", feeds: ["status", "status-kinds"] },
|
||||
{ page: "/work", name: "Work", feeds: ["post", "experience"] },
|
||||
{ page: "/resume", name: "Resume", feeds: ["experience", "skills", "education", "site-settings"] },
|
||||
{ page: "/workshop", name: "Workshop", feeds: ["craft", "make", "station", "tool"] },
|
||||
{ page: "/workshop/makes", name: "All makes", feeds: ["make"] },
|
||||
{ page: "/library", name: "Library", feeds: ["library-item"] },
|
||||
{ page: "/library/shelf", name: "Shelf", feeds: ["board-game"] },
|
||||
{ page: "/tabletop", name: "Tabletop", feeds: ["tabletop-system", "campaign", "card-game", "deck"] },
|
||||
{ page: "/blog", name: "Blog", feeds: ["post"] },
|
||||
{ page: "/store", name: "Store", feeds: ["product-line", "store-settings"] },
|
||||
{ page: "/inquiries", name: "Inquiries", feeds: ["site-settings"] },
|
||||
{ page: "/404", name: "Not found", feeds: [] },
|
||||
];
|
||||
|
||||
const imageSrc = (v: string) => (/^https:\/\//.test(v) ? v : `/attachments/${encodeURIComponent(v)}`);
|
||||
|
||||
/** Every image a Page text note shows: portrait, cover and photos. */
|
||||
export function pageImages(n: FullNote): { label: string; file: string }[] {
|
||||
const d = n.data;
|
||||
const out: { label: string; file: string }[] = [];
|
||||
if (d.portrait) out.push({ label: "Portrait", file: linkName(d.portrait) });
|
||||
if (d.cover) out.push({ label: "Cover", file: linkName(d.cover) });
|
||||
if (Array.isArray(d.photos))
|
||||
for (const p of d.photos as { file?: unknown; caption?: unknown; alt?: unknown }[])
|
||||
if (p?.file) out.push({ label: String(p.caption ?? p.alt ?? "Photo"), file: linkName(p.file) });
|
||||
return out;
|
||||
}
|
||||
|
||||
const singletons = new Set<string>(["site-settings", "store-settings", "status-kinds", "skills", "education"]);
|
||||
|
||||
function feedLink(type: NoteType, singles: Map<string, FullNote>): Html {
|
||||
const def = noteTypes[type];
|
||||
const one = singles.get(type);
|
||||
if (singletons.has(type)) {
|
||||
return one
|
||||
? html`<a class="pill-link" href="${editHref(one.path)}">${def.label}</a>`
|
||||
: html`<a class="pill-link" href="${newHref(type)}">${def.label} (add)</a>`;
|
||||
}
|
||||
return html`<a class="pill-link" href="/notes${qs({ type })}">${def.label}</a>`;
|
||||
}
|
||||
|
||||
export function sitePagesView(pageNotes: FullNote[], singles: Map<string, FullNote>): Html {
|
||||
const byPage = new Map(pageNotes.map((n) => [String(n.data.page ?? ""), n]));
|
||||
return html`<h1>Site pages</h1>
|
||||
<p class="lede">Every page of the site with its text and images. Edit a page's lede, introduction and photos here; the lists on a page come from the notes linked under it.</p>
|
||||
<ul class="site-pages">${sitePages.map((p) => {
|
||||
const n = byPage.get(p.page);
|
||||
const images = n ? pageImages(n) : [];
|
||||
return html`<li class="panel">
|
||||
<div class="head-row">
|
||||
<h2>${p.name} <code>${p.page}</code></h2>
|
||||
${n
|
||||
? html`<a class="button primary" href="${editHref(n.path)}">Edit page</a>`
|
||||
: html`<a class="button" href="${newHref("page", { page: p.page, title: p.name })}">Add page text</a>`}
|
||||
</div>
|
||||
${n
|
||||
? html`<p>${n.data.lede ? String(n.data.lede) : html`<span class="muted">No lede yet.</span>`} ${n.publish ? "" : html`<span class="pill draft">Draft</span>`}</p>`
|
||||
: html`<p class="muted">No Page text note yet, so the page uses its built-in wording.</p>`}
|
||||
${images.length
|
||||
? html`<ul class="page-images">${images.map(
|
||||
(i) => html`<li><img src="${imageSrc(i.file)}" alt="" loading="lazy"><span>${i.label}</span></li>`,
|
||||
)}</ul>`
|
||||
: ""}
|
||||
${p.feeds.length ? html`<p class="feeds"><span class="muted">Also on this page:</span> ${p.feeds.map((t) => feedLink(t, singles))}</p>` : ""}
|
||||
</li>`;
|
||||
})}</ul>`;
|
||||
}
|
||||
@@ -69,7 +69,7 @@ const post = async (path: string, form: Record<string, string>, headers: Record<
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", "sec-fetch-site": "same-origin", ...headers },
|
||||
body: new URLSearchParams(form).toString(),
|
||||
});
|
||||
return { status: res.status, location: res.headers.get("location") ?? "" };
|
||||
return { status: res.status, location: res.headers.get("location") ?? "", text: await res.text() };
|
||||
};
|
||||
|
||||
describe("admin page", () => {
|
||||
@@ -149,4 +149,48 @@ describe("admin page", () => {
|
||||
expect(page.text).toContain("personalreader");
|
||||
expect(page.text).toContain("Suwayomi did not answer");
|
||||
});
|
||||
|
||||
it("lists what is still a placeholder before launch", async () => {
|
||||
await post("/edit/save", { type: "craft", title: "Leather", status: "Practicing", "has.publish": "1", publish: "1" });
|
||||
const text = (await get("/launch")).text;
|
||||
expect(text).toContain("Before launch");
|
||||
expect(text).toContain("Blog (/blog) has no Page text note");
|
||||
expect(text).toContain("Craft Leather has no blurb.");
|
||||
});
|
||||
|
||||
it("edits a page's text and images from Site pages", async () => {
|
||||
const site = await get("/site");
|
||||
expect(site.text).toContain("Add page text");
|
||||
expect((await get("/edit/new?type=page&page=%2Fabout&title=About")).text).toContain('value="/about"');
|
||||
|
||||
// A tiny PNG: the content service checks the file's first bytes.
|
||||
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
|
||||
const fd = new FormData();
|
||||
for (const [k, v] of Object.entries({ type: "page", title: "About", page: "/about", lede: "Maker of things", "has.publish": "1", publish: "1", "photos.0.file": "", "photos.0.alt": "" })) fd.append(k, v);
|
||||
fd.append("upload.portrait", new Blob([png], { type: "image/png" }), "Greg Portrait.png");
|
||||
const res = await fetch(base + "/edit/save", { method: "POST", redirect: "manual", headers: { "sec-fetch-site": "same-origin" }, body: fd });
|
||||
expect(res.status).toBe(303);
|
||||
const note = r.store.list({ type: "page" }).find((n) => n.data.page === "/about")!;
|
||||
expect(note.data).toMatchObject({ lede: "Maker of things", portrait: "[[greg-portrait.png]]" });
|
||||
expect(note.publish).toBe(true);
|
||||
expect((await get("/site")).text).toContain("/attachments/greg-portrait.png");
|
||||
|
||||
// Clearing a field removes it; a reorder only redraws the form.
|
||||
await post("/edit/save", { type: "page", path: note.path, title: "About", page: "/about", lede: "", portrait: "greg-portrait.png", "has.publish": "1", publish: "1" });
|
||||
expect(r.store.get(note.path)!.data.lede).toBeUndefined();
|
||||
const moved = await post("/edit/save", {
|
||||
type: "page",
|
||||
path: note.path,
|
||||
title: "About",
|
||||
page: "/about",
|
||||
"photos.0.file": "a.jpg",
|
||||
"photos.0.alt": "A",
|
||||
"photos.1.file": "b.jpg",
|
||||
"photos.1.alt": "B",
|
||||
move: "photos:1:up",
|
||||
});
|
||||
expect(moved.status).toBe(200);
|
||||
expect(moved.text.indexOf('value="b.jpg"')).toBeLessThan(moved.text.indexOf('value="a.jpg"'));
|
||||
expect(r.store.get(note.path)!.data.photos).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { changeFields, formData, kindOf, moveRow, noteEditor, shapeOf } from "../src/editor.ts";
|
||||
|
||||
const form = (pairs: [string, string][]) => new URLSearchParams(pairs);
|
||||
|
||||
describe("note editor fields", () => {
|
||||
it("knows every field of every kind of page it edits", () => {
|
||||
const p = shapeOf("page").properties;
|
||||
expect(kindOf(p.portrait!)).toBe("image");
|
||||
expect(kindOf(p.cover!)).toBe("image");
|
||||
expect(kindOf(p.photos!)).toBe("rows");
|
||||
expect(kindOf(p.links!)).toBe("links");
|
||||
expect(kindOf(p.tags!)).toBe("lines");
|
||||
expect(kindOf(shapeOf("make").properties.photos!)).toBe("images");
|
||||
expect(kindOf(shapeOf("make").properties.date!)).toBe("date");
|
||||
expect(kindOf(shapeOf("experience").properties.start!)).toBe("month");
|
||||
expect(kindOf(shapeOf("product-line").properties.slots!)).toBe("group");
|
||||
expect(kindOf(shapeOf("craft").properties.status!)).toBe("enum");
|
||||
});
|
||||
|
||||
it("reads a Page text form back into note values", () => {
|
||||
const v = formData(
|
||||
"page",
|
||||
form([
|
||||
["page", "/about"],
|
||||
["lede", " Maker of things "],
|
||||
["portrait", "greg.jpg"],
|
||||
["photos.0.file", "kashi.jpg"],
|
||||
["photos.0.alt", "Kashi asleep"],
|
||||
["photos.0.caption", "Kashi"],
|
||||
["photos.1.file", "tea.jpg"],
|
||||
["photos.1.alt", "Tea"],
|
||||
["photos.1.remove", "1"],
|
||||
["photos.2.file", ""],
|
||||
["photos.2.alt", ""],
|
||||
["has.publish", "1"],
|
||||
["publish", "1"],
|
||||
["has.favorite", "1"],
|
||||
["links", "Resin\n[[Galaxy dice]]\n"],
|
||||
]),
|
||||
new Map([["cover", ["new-cover.jpg"]]]),
|
||||
);
|
||||
expect(v).toMatchObject({
|
||||
page: "/about",
|
||||
lede: "Maker of things",
|
||||
portrait: "[[greg.jpg]]",
|
||||
cover: "[[new-cover.jpg]]",
|
||||
photos: [{ file: "[[kashi.jpg]]", alt: "Kashi asleep", caption: "Kashi" }],
|
||||
publish: true,
|
||||
links: ["[[Resin]]", "[[Galaxy dice]]"],
|
||||
});
|
||||
expect(v.quickFacts).toBeUndefined();
|
||||
expect(v.favorite).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds uploaded photos to an image list and keeps https images", () => {
|
||||
const v = formData(
|
||||
"make",
|
||||
form([
|
||||
["photos.0", "https://example.com/a.jpg"],
|
||||
["photos.1", "b.jpg"],
|
||||
["photos.1.remove", "1"],
|
||||
]),
|
||||
new Map([["photos", ["c.jpg", "d.jpg"]]]),
|
||||
);
|
||||
expect(v.photos).toEqual(["https://example.com/a.jpg", "[[c.jpg]]", "[[d.jpg]]"]);
|
||||
});
|
||||
|
||||
it("clears emptied fields on an update and leaves them out on a create", () => {
|
||||
const v = { lede: "Hi", portrait: undefined, photos: undefined };
|
||||
expect(changeFields(v, true)).toEqual({ lede: "Hi", portrait: null, photos: null });
|
||||
expect(changeFields(v, false)).toEqual({ lede: "Hi" });
|
||||
});
|
||||
|
||||
it("moves a row up or down", () => {
|
||||
const v = { photos: ["a", "b", "c"] };
|
||||
expect(moveRow(v, "photos:2:up").photos).toEqual(["a", "c", "b"]);
|
||||
expect(moveRow(v, "photos:0:down").photos).toEqual(["b", "a", "c"]);
|
||||
expect(moveRow(v, "photos:0:up").photos).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("renders a picker with a thumbnail for each image", () => {
|
||||
const page = String(
|
||||
noteEditor(
|
||||
{ type: "page", path: "Pages/About.md", title: "About", publish: true, data: { page: "/about", portrait: "[[greg.jpg]]", photos: [{ file: "[[kashi.jpg]]", alt: "Kashi" }] }, body: "Hello" },
|
||||
{ images: [{ name: "greg.jpg", folder: "Pages", size: 1, modified: "", usedBy: [] }], notes: [] },
|
||||
),
|
||||
);
|
||||
expect(page).toContain('<img src="/attachments/greg.jpg"');
|
||||
expect(page).toContain('<img src="/attachments/kashi.jpg"');
|
||||
expect(page).toContain('name="upload.portrait"');
|
||||
expect(page).toContain('value="photos:0:down"');
|
||||
expect(page).toContain(">Hello</textarea>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { draftFromForm, postFields } from "../src/posts.ts";
|
||||
|
||||
const form = (pairs: [string, string][]) => new URLSearchParams(pairs);
|
||||
|
||||
describe("post form", () => {
|
||||
const d = draftFromForm(
|
||||
form([
|
||||
["title", " Pouring galaxy dice "],
|
||||
["date", "2026-10-12"],
|
||||
["topic", "Making"],
|
||||
["postType", "article"],
|
||||
["summary", "How the set came together."],
|
||||
["cover", "[[galaxy.jpg]]"],
|
||||
["links", "Resin"],
|
||||
["links", "[[Galaxy dice]]"],
|
||||
["links", ""],
|
||||
["role", "Ignored for articles"],
|
||||
["body", "Line one\r\nLine two"],
|
||||
]),
|
||||
);
|
||||
|
||||
it("reads the form into a draft", () => {
|
||||
expect(d).toMatchObject({ title: "Pouring galaxy dice", cover: "galaxy.jpg", links: ["Resin", "Galaxy dice"], body: "Line one\nLine two" });
|
||||
});
|
||||
|
||||
it("writes note fields, with links and the cover as wikilinks", () => {
|
||||
expect(postFields(d, true, false)).toEqual({
|
||||
title: "Pouring galaxy dice",
|
||||
date: "2026-10-12",
|
||||
topic: "Making",
|
||||
postType: "article",
|
||||
summary: "How the set came together.",
|
||||
publish: true,
|
||||
cover: "[[galaxy.jpg]]",
|
||||
links: ["[[Resin]]", "[[Galaxy dice]]"],
|
||||
});
|
||||
});
|
||||
|
||||
it("clears emptied fields on an update", () => {
|
||||
const f = postFields({ ...d, cover: "", links: [] }, false, true);
|
||||
expect(f).toMatchObject({ cover: null, links: null, pinned: null, role: null, publish: false });
|
||||
});
|
||||
});
|
||||
@@ -43,10 +43,11 @@ SUWAYOMI_PASS=
|
||||
# PersonalReader, light novels and books (daily).
|
||||
KAVITA_URL=
|
||||
KAVITA_API_KEY=
|
||||
# Filament in stock (hourly). UNVERIFIED API: check the path and mapping first.
|
||||
# Filament in stock (hourly).
|
||||
BAMBUDDY_URL=
|
||||
# BAMBUDDY_SPOOLS_PATH=/api/v1/inventory/spools
|
||||
# BAMBUDDY_API_KEY=
|
||||
# API key from Bambuddy's Settings, sent in X-API-Key
|
||||
BAMBUDDY_API_KEY=
|
||||
# BAMBUDDY_AUTH_HEADER=
|
||||
# BAMBUDDY_GROUP=Filament
|
||||
# BAMBUDDY_MATERIALS=PLA,PETG
|
||||
|
||||
@@ -15,9 +15,12 @@ import type { Change, ContentService } from "./service.ts";
|
||||
import type { Author, StoredNote } from "./store.ts";
|
||||
import { handleSubmissions } from "./submissions.ts";
|
||||
import { handleSuggestions } from "./suggestions.ts";
|
||||
import { uploadTypes } from "./vault.ts";
|
||||
import { heldView, noteView, outcomeView, revisionView, summaryView } from "./views.ts";
|
||||
|
||||
const MAX_BODY = 1_000_000;
|
||||
/** The largest image the admin page can add. */
|
||||
const MAX_IMAGE = 15_000_000;
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(readonly status: number, message: string) {
|
||||
@@ -73,6 +76,15 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
|
||||
|
||||
const visible = (n: StoredNote | null, role: Role) => (n && !n.removed && (n.publish || can.readDrafts(role)) ? n : null);
|
||||
|
||||
/** Every attachment with the notes that link or embed it. */
|
||||
function attachmentList() {
|
||||
const notes = store.list({ limit: 100_000 });
|
||||
return service.vault.attachmentFiles().map((a) => ({
|
||||
...a,
|
||||
usedBy: notes.filter((n) => n.text.includes(a.name)).map((n) => ({ path: n.path, title: n.title, type: n.type })),
|
||||
}));
|
||||
}
|
||||
|
||||
async function route(req: IncomingMessage, res: ServerResponse) {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const path = url.pathname;
|
||||
@@ -196,6 +208,11 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
|
||||
return send(res, 200, { ...heldView(h, { text: true }), current: current && !current.removed ? current.text : null });
|
||||
}
|
||||
|
||||
if (path === "/api/attachments") {
|
||||
need(can.review(role));
|
||||
return send(res, 200, attachmentList());
|
||||
}
|
||||
|
||||
const attachment = path.match(/^\/attachments\/([^/]+)$/);
|
||||
if (attachment) {
|
||||
let name = "";
|
||||
@@ -250,6 +267,34 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (path === "/api/attachments") {
|
||||
need(can.review(role));
|
||||
const folder = q("folder") ?? "";
|
||||
const name = q("name") ?? "";
|
||||
const bytes = await readBytes(req, MAX_IMAGE);
|
||||
if (!bytes.length) throw new HttpError(400, "The image is empty");
|
||||
const ext = extname(name).toLowerCase();
|
||||
if (!uploadTypes[ext] || !looksLike(ext, bytes)) throw new HttpError(400, `${name || "That file"} is not a PNG, JPEG, WebP, GIF or AVIF image`);
|
||||
try {
|
||||
return send(res, 200, { name: service.vault.addAttachment(folder, name, bytes) });
|
||||
} catch (err) {
|
||||
throw new HttpError(400, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
if (path === "/api/attachments/delete") {
|
||||
need(can.review(role));
|
||||
const body = z.object({ name: z.string().min(1) }).safeParse(await readJson(req));
|
||||
if (!body.success) throw new HttpError(400, z.prettifyError(body.error));
|
||||
const one = attachmentList().find((a) => a.name === body.data.name);
|
||||
if (!one) throw new HttpError(404, "No such attachment");
|
||||
if (one.usedBy.length) {
|
||||
throw new HttpError(409, `${one.name} is still used by ${one.usedBy.map((u) => u.title).join(", ")}`);
|
||||
}
|
||||
service.vault.removeAttachment(one.name);
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (path === "/api/scan") {
|
||||
need(can.review(role));
|
||||
return send(res, 200, service.scan());
|
||||
@@ -296,6 +341,36 @@ async function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
async function readBytes(req: IncomingMessage, max: number): Promise<Buffer> {
|
||||
let size = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
size += (chunk as Buffer).length;
|
||||
if (size > max) throw new HttpError(413, `Images can be up to ${Math.round(max / 1_000_000)} MB`);
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/** Whether the bytes start the way the extension says, so only images land in the vault. */
|
||||
function looksLike(ext: string, b: Buffer): boolean {
|
||||
const at = (i: number, s: string) => b.subarray(i, i + s.length).toString("latin1") === s;
|
||||
switch (ext) {
|
||||
case ".png":
|
||||
return at(0, "\x89PNG");
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff;
|
||||
case ".gif":
|
||||
return at(0, "GIF8");
|
||||
case ".webp":
|
||||
return at(0, "RIFF") && at(8, "WEBP");
|
||||
case ".avif":
|
||||
return at(4, "ftyp");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function send(res: ServerResponse, status: number, body: unknown) {
|
||||
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
||||
res.end(JSON.stringify(body));
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
/**
|
||||
* Bambuddy: filament colors in stock, for the Store's Filament options.
|
||||
* Hourly. Read only.
|
||||
*
|
||||
* UNVERIFIED: Bambuddy's API is not documented anywhere this project has, and
|
||||
* no request has been made to a real Bambuddy. The endpoint path is a setting
|
||||
* (BAMBUDDY_SPOOLS_PATH) and everything that depends on the response's shape
|
||||
* is in `mapBambuddySpools` below, so fixing it against the real API is a
|
||||
* change to one function. Check it against a live Bambuddy before relying on
|
||||
* it (docs/content-service.md, "Sync jobs").
|
||||
* Hourly. Read only. GET /api/v1/inventory/spools with an API key (made in
|
||||
* Bambuddy's Settings) in the X-API-Key header. The field names below were
|
||||
* checked against Greg's Bambuddy on 2026-10-12.
|
||||
*/
|
||||
import type { SyncJob } from "../types.ts";
|
||||
import { mergeOptions, normalizeHex, storeOnLines, type StockOption } from "./stock.ts";
|
||||
@@ -18,7 +13,7 @@ export interface BambuddyOptions {
|
||||
url: string;
|
||||
/** Path of the spool or filament inventory endpoint. */
|
||||
path?: string;
|
||||
/** Sent as `Authorization: Bearer`, or in `authHeader` when that is set. */
|
||||
/** Sent in the X-API-Key header, or in `authHeader` when that is set. */
|
||||
apiKey?: string;
|
||||
authHeader?: string;
|
||||
/** Option group the colors feed. Default Filament. */
|
||||
@@ -39,40 +34,58 @@ export interface Spool {
|
||||
remaining?: number;
|
||||
}
|
||||
|
||||
const pick = (o: Record<string, unknown>, keys: string[]) => {
|
||||
for (const k of keys) {
|
||||
const v = o[k];
|
||||
if (v !== undefined && v !== null && v !== "") return v;
|
||||
/** Plain names for spools Bambuddy leaves unnamed (RFID spools have no color_name). */
|
||||
const BASIC_COLORS: [string, number, number, number][] = [
|
||||
["Black", 0, 0, 0],
|
||||
["White", 255, 255, 255],
|
||||
["Gray", 128, 128, 128],
|
||||
["Silver", 192, 192, 192],
|
||||
["Red", 200, 30, 40],
|
||||
["Orange", 255, 120, 0],
|
||||
["Yellow", 255, 210, 0],
|
||||
["Green", 0, 160, 70],
|
||||
["Blue", 20, 90, 200],
|
||||
["Purple", 120, 40, 160],
|
||||
["Pink", 240, 110, 170],
|
||||
["Brown", 110, 70, 40],
|
||||
["Beige", 240, 225, 190],
|
||||
];
|
||||
|
||||
/** The nearest of BASIC_COLORS to a #rrggbb hex. */
|
||||
export function basicColorName(hex: string): string {
|
||||
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as [number, number, number];
|
||||
let best = BASIC_COLORS[0]!;
|
||||
let bestD = Infinity;
|
||||
for (const c of BASIC_COLORS) {
|
||||
const d = (c[1] - r) ** 2 + (c[2] - g) ** 2 + (c[3] - b) ** 2;
|
||||
if (d < bestD) [best, bestD] = [c, d];
|
||||
}
|
||||
return best[0];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* THE ONE PLACE that knows Bambuddy's response shape (a guess; see above).
|
||||
* Accepts a list, or an object holding one under items, spools, data or
|
||||
* results, and reads each spool's color name, hex, material and grams left
|
||||
* under the common spellings. Archived or empty-named entries are skipped.
|
||||
* THE ONE PLACE that knows Bambuddy's spool shape. The response is a list (or
|
||||
* an object holding one under items). Each spool has material, color_name
|
||||
* (often null), rgba like "000000FF", label_weight (grams of filament when
|
||||
* full), weight_used and archived_at. A spool with no color_name is named
|
||||
* from its color (Black, Blue...); a color name set in Bambuddy wins.
|
||||
*/
|
||||
export function mapBambuddySpools(json: unknown): Spool[] {
|
||||
const root = json as Record<string, unknown> | unknown[];
|
||||
const list = Array.isArray(root)
|
||||
? root
|
||||
: (["items", "spools", "data", "results"].map((k) => (root as Record<string, unknown>)?.[k]).find(Array.isArray) as unknown[] | undefined);
|
||||
if (!list) throw new Error("Bambuddy answered with no list of spools");
|
||||
const list = Array.isArray(json) ? json : (json as Record<string, unknown> | null)?.items;
|
||||
if (!Array.isArray(list)) throw new Error("Bambuddy answered with no list of spools");
|
||||
const out: Spool[] = [];
|
||||
for (const raw of list) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (o.archived === true || o.deleted === true) continue;
|
||||
const name = pick(o, ["color_name", "colorName", "color", "name"]);
|
||||
if (typeof name !== "string" || !name.trim()) continue;
|
||||
const s: Spool = { name: name.trim() };
|
||||
const hex = normalizeHex(pick(o, ["color_hex", "colorHex", "hex", "rgba", "color_code"]));
|
||||
if (o.archived_at) continue;
|
||||
const hex = normalizeHex(o.rgba);
|
||||
const named = typeof o.color_name === "string" && o.color_name.trim() ? o.color_name.trim() : undefined;
|
||||
const name = named ?? (hex ? basicColorName(hex) : undefined);
|
||||
if (!name) continue;
|
||||
const s: Spool = { name };
|
||||
if (hex) s.hex = hex;
|
||||
const material = pick(o, ["material", "filament_type", "type"]);
|
||||
if (typeof material === "string") s.material = material.trim();
|
||||
const grams = Number(pick(o, ["remaining_weight", "remainingWeight", "weight_remaining", "remaining", "remaining_grams"]));
|
||||
if (Number.isFinite(grams)) s.remaining = grams;
|
||||
if (typeof o.material === "string") s.material = o.material.trim();
|
||||
if (typeof o.label_weight === "number") s.remaining = o.label_weight - (typeof o.weight_used === "number" ? o.weight_used : 0);
|
||||
out.push(s);
|
||||
}
|
||||
return out;
|
||||
@@ -97,10 +110,10 @@ export function bambuddyJob(o: BambuddyOptions): SyncJob {
|
||||
healthcheckUrl: o.healthcheckUrl,
|
||||
async run(ctx) {
|
||||
const headers: Record<string, string> = { accept: "application/json" };
|
||||
if (o.apiKey) headers[o.authHeader?.toLowerCase() || "authorization"] = o.authHeader ? o.apiKey : `Bearer ${o.apiKey}`;
|
||||
if (o.apiKey) headers[(o.authHeader || "x-api-key").toLowerCase()] = o.apiKey;
|
||||
const url = `${o.url.replace(/\/$/, "")}/${(o.path ?? BAMBUDDY_DEFAULT_PATH).replace(/^\//, "")}`;
|
||||
const res = await ctx.fetch(url, { headers });
|
||||
if (res.status === 401 || res.status === 403) throw new Error(`Bambuddy refused the request (${res.status}); check BAMBUDDY_API_KEY`);
|
||||
if (res.status === 401 || res.status === 403) throw new Error(`Bambuddy refused the request (${res.status}); check BAMBUDDY_API_KEY (made in Bambuddy's Settings)`);
|
||||
if (!res.ok) throw new Error(`Bambuddy answered ${res.status}; check BAMBUDDY_SPOOLS_PATH`);
|
||||
const options = filamentOptions(mapBambuddySpools(await res.json()), o);
|
||||
const lines = storeOnLines(ctx, "filamentStock", [{ group, options }]);
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* API (Homebox's REST API, v1):
|
||||
* - `POST /api/v1/users/login` with username and password gives a token
|
||||
* (already prefixed `Bearer `), sent as `Authorization`.
|
||||
* - `GET /api/v1/labels` and `GET /api/v1/locations` name the ids to filter on.
|
||||
* - `GET /api/v1/labels` (`/api/v1/tags` on newer releases) and `GET /api/v1/locations`
|
||||
* name the ids to filter on.
|
||||
* - `GET /api/v1/items?labels=<id>&page=&pageSize=` (or `locations=<id>`)
|
||||
* lists the items, each with name, quantity and description.
|
||||
*
|
||||
@@ -84,7 +85,10 @@ export function materialOptions(items: HomeboxItem[]): StockOption[] {
|
||||
}
|
||||
|
||||
async function call(ctx: SyncContext, url: string, init: RequestInit = {}) {
|
||||
const res = await ctx.fetch(url, init);
|
||||
return checked(await ctx.fetch(url, init));
|
||||
}
|
||||
|
||||
async function checked(res: Response) {
|
||||
if (res.status === 401 || res.status === 403) throw new Error(`Homebox refused the request (${res.status}); check HOMEBOX_USER and HOMEBOX_PASS`);
|
||||
if (!res.ok) throw new Error(`Homebox answered ${res.status}`);
|
||||
return res.json() as Promise<unknown>;
|
||||
@@ -110,9 +114,14 @@ export function homeboxJob(o: HomeboxOptions): SyncJob {
|
||||
...(o.locations ?? []).map((f) => ({ kind: "locations" as const, f })),
|
||||
];
|
||||
const ids = { labels: new Map<string, string>(), locations: new Map<string, string>() };
|
||||
for (const kind of ["labels", "locations"] as const) {
|
||||
if (filters.some((x) => x.kind === kind)) ids[kind] = parseNamed(await call(ctx, api(`/${kind}`), { headers }));
|
||||
// Newer Homebox releases call labels "tags" (`/api/v1/tags`, `items?tags=`).
|
||||
let labelParam = "labels";
|
||||
if (filters.some((x) => x.kind === "labels")) {
|
||||
const res = await ctx.fetch(api("/labels"), { headers });
|
||||
if (res.status === 404) labelParam = "tags";
|
||||
ids.labels = parseNamed(res.status === 404 ? await call(ctx, api("/tags"), { headers }) : await checked(res));
|
||||
}
|
||||
if (filters.some((x) => x.kind === "locations")) ids.locations = parseNamed(await call(ctx, api("/locations"), { headers }));
|
||||
|
||||
const byGroup = new Map<string, HomeboxItem[]>();
|
||||
const unknown: string[] = [];
|
||||
@@ -124,7 +133,7 @@ export function homeboxJob(o: HomeboxOptions): SyncJob {
|
||||
}
|
||||
const items = byGroup.get(f.group) ?? [];
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const p = new URLSearchParams({ [kind]: id, page: String(page), pageSize: "100" });
|
||||
const p = new URLSearchParams({ [kind === "labels" ? labelParam : kind]: id, page: String(page), pageSize: "100" });
|
||||
const r = parseItems(await call(ctx, api(`/items?${p}`), { headers }));
|
||||
items.push(...r.items);
|
||||
if (r.items.length < 100 || page * 100 >= r.total) break;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* The Site folder on disk. Only the content service reads or writes it.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, join, relative, sep } from "node:path";
|
||||
|
||||
export const hashText = (text: string) => createHash("sha256").update(text).digest("hex");
|
||||
@@ -22,6 +22,42 @@ export function isNotePath(rel: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Image types the admin page may add to `_attachments`. SVG stays out: it can carry script. */
|
||||
export const uploadTypes: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".avif": "image/avif",
|
||||
};
|
||||
|
||||
/** A safe attachment file name: lowercase words joined by hyphens, plus the extension. */
|
||||
export function attachmentName(original: string): string | null {
|
||||
const m = /^(.*?)(\.[A-Za-z0-9]+)?$/.exec(basename(original.replace(/\\/g, "/")));
|
||||
const ext = (m?.[2] ?? "").toLowerCase();
|
||||
if (!uploadTypes[ext]) return null;
|
||||
const stem = (m?.[1] ?? "")
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
return `${stem || "image"}${ext === ".jpeg" ? ".jpg" : ext}`;
|
||||
}
|
||||
|
||||
/** A folder under `_attachments`: one plain name like Posts or Pages. */
|
||||
const folderOk = (f: string) => /^[A-Za-z0-9][A-Za-z0-9 _-]{0,40}$/.test(f);
|
||||
|
||||
export interface AttachmentFile {
|
||||
name: string;
|
||||
/** The folder under `_attachments`, or "" for its top level. */
|
||||
folder: string;
|
||||
size: number;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
export class Vault {
|
||||
constructor(readonly dir: string) {}
|
||||
|
||||
@@ -77,6 +113,56 @@ export class Vault {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every file in `_attachments` with its folder, size and time. */
|
||||
attachmentFiles(): AttachmentFile[] {
|
||||
const root = join(this.dir, "_attachments");
|
||||
const out: AttachmentFile[] = [];
|
||||
const walk = (d: string) => {
|
||||
if (!existsSync(d)) return;
|
||||
for (const e of readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.name.startsWith(".")) continue;
|
||||
const full = join(d, e.name);
|
||||
if (e.isDirectory()) walk(full);
|
||||
else if (e.isFile()) {
|
||||
const st = statSync(full);
|
||||
const folder = relative(root, d).split(sep).join("/");
|
||||
out.push({ name: e.name, folder, size: st.size, modified: st.mtime.toISOString() });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return out.sort((a, b) => b.modified.localeCompare(a.modified) || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an image to `_attachments/<folder>`. Names are made safe, and a name
|
||||
* already used anywhere in `_attachments` gets -2, -3 and so on, because
|
||||
* Obsidian links attachments by file name alone.
|
||||
*/
|
||||
addAttachment(folder: string, original: string, bytes: Uint8Array): string {
|
||||
if (!folderOk(folder)) throw new Error("Use a plain folder name, like Posts");
|
||||
const safe = attachmentName(original);
|
||||
if (!safe) throw new Error(`Only ${Object.keys(uploadTypes).join(", ")} images can be added`);
|
||||
const taken = new Set(this.listAttachments().map((n) => n.toLowerCase()));
|
||||
const dot = safe.lastIndexOf(".");
|
||||
let name = safe;
|
||||
for (let i = 2; taken.has(name.toLowerCase()); i++) name = `${safe.slice(0, dot)}-${i}${safe.slice(dot)}`;
|
||||
const full = join(this.dir, "_attachments", folder, name);
|
||||
mkdirSync(dirname(full), { recursive: true });
|
||||
const tmp = join(dirname(full), `.${name}.${process.pid}.tmp`);
|
||||
writeFileSync(tmp, bytes);
|
||||
renameSync(tmp, full);
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Delete an attachment by file name. False when there is none. */
|
||||
removeAttachment(name: string): boolean {
|
||||
const file = name.includes("/") || name.includes("\\") || name.includes("..") ? null : this.attachmentPath(name);
|
||||
if (!file) return false;
|
||||
unlinkSync(file);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Absolute path of an attachment by file name, for serving images. */
|
||||
attachmentPath(name: string): string | null {
|
||||
const walk = (d: string): string | null => {
|
||||
|
||||