diff options
91 files changed, 5117 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca2f874 --- /dev/null +++ b/.gitignore | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | site | ||
| 2 | cache | ||
| 3 | .env | ||
diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..e480ec8 --- /dev/null +++ b/.oxfmtrc.json | |||
| @@ -0,0 +1,20 @@ | |||
| 1 | { | ||
| 2 | "$schema": "https://cdn.jsdelivr.net/npm/oxfmt@0.57.0/configuration_schema.json", | ||
| 3 | "ignorePatterns": ["submodules", "cache", "site"], | ||
| 4 | "useTabs": true, | ||
| 5 | "endOfLine": "lf", | ||
| 6 | "sortImports": { | ||
| 7 | "newlinesBetween": true, | ||
| 8 | "groups": [ | ||
| 9 | "type-import", | ||
| 10 | ["value-builtin", "value-external"], | ||
| 11 | "type-internal", | ||
| 12 | "value-internal", | ||
| 13 | ["type-parent", "type-sibling", "type-index"], | ||
| 14 | ["value-parent", "value-sibling", "value-index"], | ||
| 15 | "unknown" | ||
| 16 | ], | ||
| 17 | "internalPattern": ["~/", "@/", "$/", "#"] | ||
| 18 | }, | ||
| 19 | "quoteProps": "consistent" | ||
| 20 | } | ||
diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..c4522bb --- /dev/null +++ b/.oxlintrc.json | |||
| @@ -0,0 +1,12 @@ | |||
| 1 | { | ||
| 2 | "$schema": "https://cdn.jsdelivr.net/npm/oxlint@1.73.0/configuration_schema.json", | ||
| 3 | "ignorePatterns": [".zed", ".vscode", "submodules", "cache", "site", "*.scss"], | ||
| 4 | "plugins": ["eslint", "typescript", "unicorn", "oxc", "import"], | ||
| 5 | "categories": { | ||
| 6 | "correctness": "error" | ||
| 7 | }, | ||
| 8 | "rules": { | ||
| 9 | "typescript/consistent-type-imports": "error", | ||
| 10 | "typescript/consistent-type-definitions": "error" | ||
| 11 | } | ||
| 12 | } | ||
diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..3013fe6 --- /dev/null +++ b/.zed/settings.json | |||
| @@ -0,0 +1,184 @@ | |||
| 1 | // Folder-specific settings | ||
| 2 | // | ||
| 3 | // For a full list of overridable settings, and general information on folder-specific settings, | ||
| 4 | // see the documentation: https://zed.dev/docs/configuring-zed#settings-files | ||
| 5 | { | ||
| 6 | "languages": { | ||
| 7 | "MDX": { | ||
| 8 | "format_on_save": "on", | ||
| 9 | "prettier": { | ||
| 10 | "allowed": false | ||
| 11 | }, | ||
| 12 | "formatter": [ | ||
| 13 | { | ||
| 14 | "code_action": "source.fixAll.oxc" | ||
| 15 | }, | ||
| 16 | { | ||
| 17 | "language_server": { | ||
| 18 | "name": "oxfmt" | ||
| 19 | } | ||
| 20 | } | ||
| 21 | ] | ||
| 22 | }, | ||
| 23 | "JavaScript": { | ||
| 24 | "language_servers": [ | ||
| 25 | "deno", | ||
| 26 | "oxfmt", | ||
| 27 | "!typescript-language-server", | ||
| 28 | "!vtsls", | ||
| 29 | "!eslint", | ||
| 30 | "!codebook", | ||
| 31 | "..." | ||
| 32 | ], | ||
| 33 | "format_on_save": "on", | ||
| 34 | "prettier": { | ||
| 35 | "allowed": false | ||
| 36 | }, | ||
| 37 | "formatter": [ | ||
| 38 | { | ||
| 39 | "code_action": "source.fixAll.oxc" | ||
| 40 | }, | ||
| 41 | { | ||
| 42 | "language_server": { | ||
| 43 | "name": "oxfmt" | ||
| 44 | } | ||
| 45 | } | ||
| 46 | ] | ||
| 47 | }, | ||
| 48 | "TypeScript": { | ||
| 49 | "language_servers": [ | ||
| 50 | "deno", | ||
| 51 | "oxfmt", | ||
| 52 | "!typescript-language-server", | ||
| 53 | "!vtsls", | ||
| 54 | "!eslint", | ||
| 55 | "!codebook", | ||
| 56 | "..." | ||
| 57 | ], | ||
| 58 | "format_on_save": "on", | ||
| 59 | "prettier": { | ||
| 60 | "allowed": false | ||
| 61 | }, | ||
| 62 | "formatter": [ | ||
| 63 | { | ||
| 64 | "code_action": "source.fixAll.oxc" | ||
| 65 | }, | ||
| 66 | { | ||
| 67 | "language_server": { | ||
| 68 | "name": "oxfmt" | ||
| 69 | } | ||
| 70 | } | ||
| 71 | ] | ||
| 72 | }, | ||
| 73 | "TSX": { | ||
| 74 | "language_servers": [ | ||
| 75 | "deno", | ||
| 76 | "oxfmt", | ||
| 77 | "!typescript-language-server", | ||
| 78 | "!vtsls", | ||
| 79 | "!eslint", | ||
| 80 | "!codebook", | ||
| 81 | "..." | ||
| 82 | ], | ||
| 83 | "format_on_save": "on", | ||
| 84 | "prettier": { | ||
| 85 | "allowed": false | ||
| 86 | }, | ||
| 87 | "formatter": [ | ||
| 88 | { | ||
| 89 | "code_action": "source.fixAll.oxc" | ||
| 90 | }, | ||
| 91 | { | ||
| 92 | "language_server": { | ||
| 93 | "name": "oxfmt" | ||
| 94 | } | ||
| 95 | } | ||
| 96 | ] | ||
| 97 | }, | ||
| 98 | "JSON": { | ||
| 99 | "language_servers": ["!codebook", "..."], | ||
| 100 | "format_on_save": "on", | ||
| 101 | "prettier": { | ||
| 102 | "allowed": false | ||
| 103 | }, | ||
| 104 | "formatter": [ | ||
| 105 | { | ||
| 106 | "code_action": "source.fixAll.oxc" | ||
| 107 | }, | ||
| 108 | { | ||
| 109 | "language_server": { | ||
| 110 | "name": "oxfmt" | ||
| 111 | } | ||
| 112 | } | ||
| 113 | ] | ||
| 114 | }, | ||
| 115 | "JSONC": { | ||
| 116 | "format_on_save": "on", | ||
| 117 | "prettier": { | ||
| 118 | "allowed": false | ||
| 119 | }, | ||
| 120 | "formatter": [ | ||
| 121 | { | ||
| 122 | "code_action": "source.fixAll.oxc" | ||
| 123 | }, | ||
| 124 | { | ||
| 125 | "language_server": { | ||
| 126 | "name": "oxfmt" | ||
| 127 | } | ||
| 128 | } | ||
| 129 | ] | ||
| 130 | }, | ||
| 131 | "SCSS": { | ||
| 132 | "format_on_save": "on", | ||
| 133 | "prettier": { | ||
| 134 | "allowed": false | ||
| 135 | }, | ||
| 136 | "formatter": [ | ||
| 137 | { | ||
| 138 | "code_action": "source.fixAll.oxc" | ||
| 139 | }, | ||
| 140 | { | ||
| 141 | "language_server": { | ||
| 142 | "name": "oxfmt" | ||
| 143 | } | ||
| 144 | } | ||
| 145 | ] | ||
| 146 | } | ||
| 147 | }, | ||
| 148 | "lsp": { | ||
| 149 | "deno": { | ||
| 150 | "settings": { | ||
| 151 | "deno": { | ||
| 152 | "enable": true | ||
| 153 | } | ||
| 154 | } | ||
| 155 | }, | ||
| 156 | "oxlint": { | ||
| 157 | "initialization_options": { | ||
| 158 | "settings": { | ||
| 159 | "configPath": null, | ||
| 160 | "run": "onType", | ||
| 161 | "disableNestedConfig": false, | ||
| 162 | "fixKind": "safe_fix", | ||
| 163 | "typeAware": true, | ||
| 164 | "unusedDisableDirectives": "deny" | ||
| 165 | } | ||
| 166 | } | ||
| 167 | }, | ||
| 168 | "oxfmt": { | ||
| 169 | "initialization_options": { | ||
| 170 | "settings": { | ||
| 171 | "fmt.configPath": null, | ||
| 172 | "run": "onSave" | ||
| 173 | } | ||
| 174 | } | ||
| 175 | }, | ||
| 176 | "mdx-analyzer": { | ||
| 177 | "initialization_options": { | ||
| 178 | "typescript": { | ||
| 179 | "enabled": true | ||
| 180 | } | ||
| 181 | } | ||
| 182 | } | ||
| 183 | } | ||
| 184 | } | ||
diff --git a/.zed/tasks.json b/.zed/tasks.json new file mode 100644 index 0000000..b706900 --- /dev/null +++ b/.zed/tasks.json | |||
| @@ -0,0 +1,8 @@ | |||
| 1 | // Project tasks configuration. See https://zed.dev/docs/tasks for documentation. | ||
| 2 | [ | ||
| 3 | { | ||
| 4 | "label": "Serve", | ||
| 5 | "command": "deno", | ||
| 6 | "args": ["run", "serve"] | ||
| 7 | } | ||
| 8 | ] | ||
diff --git a/corvos.config.ts b/corvos.config.ts new file mode 100644 index 0000000..deae101 --- /dev/null +++ b/corvos.config.ts | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | import "https://deno.land/std@0.224.0/dotenv/load.ts"; | ||
| 2 | import type { Page } from "corvos"; | ||
| 3 | |||
| 4 | import { Pipeline, Site } from "corvos"; | ||
| 5 | import { assets } from "corvos/plugins/assets.ts"; | ||
| 6 | import { brotli } from "corvos/plugins/brotli.ts"; | ||
| 7 | import { cache } from "corvos/plugins/cache.ts"; | ||
| 8 | import { esbuild } from "corvos/plugins/esbuild.ts"; | ||
| 9 | import { feeds } from "corvos/plugins/feeds.ts"; | ||
| 10 | import { gzip } from "corvos/plugins/gzip.ts"; | ||
| 11 | import { hash } from "corvos/plugins/hash.ts"; | ||
| 12 | import { icons } from "corvos/plugins/icons.ts"; | ||
| 13 | import { images } from "corvos/plugins/images.ts"; | ||
| 14 | import { jsx } from "corvos/plugins/jsx.ts"; | ||
| 15 | import { markdown } from "corvos/plugins/markdown.ts"; | ||
| 16 | import { mdx } from "corvos/plugins/mdx.ts"; | ||
| 17 | import { modules } from "corvos/plugins/modules.ts"; | ||
| 18 | import { queryBuilder } from "corvos/plugins/query_builder.ts"; | ||
| 19 | import { sass } from "corvos/plugins/sass.ts"; | ||
| 20 | import { sitemap } from "corvos/plugins/sitemap.ts"; | ||
| 21 | import { templates } from "corvos/plugins/templates.ts"; | ||
| 22 | import { transformImages } from "corvos/plugins/transform_images.ts"; | ||
| 23 | import { urls } from "corvos/plugins/urls.ts"; | ||
| 24 | |||
| 25 | import type { Node } from "$/deps/strapi.tsx"; | ||
| 26 | import type { PageData } from "$/schemas/mod.ts"; | ||
| 27 | |||
| 28 | import { strapi } from "$/deps/strapi.tsx"; | ||
| 29 | import { pageDataSchema } from "$/schemas/mod.ts"; | ||
| 30 | |||
| 31 | const assetPipeline = Pipeline.create() | ||
| 32 | .use(cache()) | ||
| 33 | |||
| 34 | .use(assets()) | ||
| 35 | .use(hash()) | ||
| 36 | |||
| 37 | .use(esbuild()) | ||
| 38 | .use(sass()) | ||
| 39 | .use(icons()) | ||
| 40 | .use( | ||
| 41 | images({ | ||
| 42 | allowedOrigins: ["strapi.volpeon.ink"], | ||
| 43 | defaults: { formats: ["avif", "webp", "png"], densities: [1, 2] }, | ||
| 44 | }), | ||
| 45 | ) | ||
| 46 | .use(transformImages()) | ||
| 47 | |||
| 48 | .use(brotli()) | ||
| 49 | .use(gzip()); | ||
| 50 | |||
| 51 | const pagePipeline = Pipeline.create() | ||
| 52 | // Helpers | ||
| 53 | .use(queryBuilder()) | ||
| 54 | |||
| 55 | // Content | ||
| 56 | .use(modules<Page<".strapi", Node[], PageData> | Page<".", Uint8Array<ArrayBuffer>>>()) | ||
| 57 | .use(markdown({ frontmatterSchema: pageDataSchema })) | ||
| 58 | .use(mdx({ frontmatterSchema: pageDataSchema })) | ||
| 59 | .use(strapi()) | ||
| 60 | .use(jsx()) | ||
| 61 | .use(urls({ base: new URL("https://corvos.volpeon.ink/") })) | ||
| 62 | |||
| 63 | // Page listings | ||
| 64 | .use(feeds<{ type: "releases" }>()) | ||
| 65 | .use(sitemap()) | ||
| 66 | |||
| 67 | // Templates | ||
| 68 | .use(templates()) | ||
| 69 | .use(jsx()) | ||
| 70 | |||
| 71 | .use(brotli()) | ||
| 72 | .use(gzip()); | ||
| 73 | |||
| 74 | const site = new Site({ | ||
| 75 | assetPipeline, | ||
| 76 | pagePipeline, | ||
| 77 | redirects: [[/^\/(scripts|styles)\//, "/assets/"]], | ||
| 78 | }); | ||
| 79 | |||
| 80 | export default site; | ||
diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..caf143e --- /dev/null +++ b/deno.json | |||
| @@ -0,0 +1,84 @@ | |||
| 1 | { | ||
| 2 | "$schema": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json", | ||
| 3 | "imports": { | ||
| 4 | "$/": "./src/", | ||
| 5 | "@/corvos.config.ts": "./corvos.config.ts", | ||
| 6 | "@std/datetime": "jsr:@std/datetime@^0.225.7", | ||
| 7 | "@std/encoding": "jsr:@std/encoding@^1.0.11", | ||
| 8 | "@std/streams": "jsr:@std/streams@^1.1.1", | ||
| 9 | "@types/alpinejs": "npm:@types/alpinejs@^3.13.11", | ||
| 10 | "@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.8.26", | ||
| 11 | "alpinejs": "npm:alpinejs@^3.15.12", | ||
| 12 | "arktype": "npm:arktype@2.2.3", | ||
| 13 | "corvos": "../corvos/mod.ts", | ||
| 14 | "corvos/": "../corvos/", | ||
| 15 | "corvos/jsx-runtime": "../corvos/jsx-runtime.ts", | ||
| 16 | "include-media/": "https://raw.githubusercontent.com/eduardoboucas/include-media/refs/heads/master/", | ||
| 17 | "iro-sass/": "https://git.wyvern.rip/iro-sass/plain/src/", | ||
| 18 | "iro-design/": "https://git.wyvern.rip/iro-design/plain/src/", | ||
| 19 | "mrmime": "npm:mrmime@^2.0.1", | ||
| 20 | "oxfmt": "npm:oxfmt@^0.58.0", | ||
| 21 | "oxlint": "npm:oxlint@^1.73.0" | ||
| 22 | }, | ||
| 23 | "permissions": { | ||
| 24 | "corvos": { | ||
| 25 | "read": true, | ||
| 26 | "write": ["./"], | ||
| 27 | "import": [ | ||
| 28 | "cdn.jsdelivr.net:443", | ||
| 29 | "jsr.io:443", | ||
| 30 | "deno.land:443", | ||
| 31 | "esm.sh:443", | ||
| 32 | "corvos.wyvern.rip:443" | ||
| 33 | ], | ||
| 34 | "net": [ | ||
| 35 | "0.0.0.0", | ||
| 36 | "cdn.jsdelivr.net:443", | ||
| 37 | "data.jsdelivr.com:443", | ||
| 38 | "jsr.io:443", | ||
| 39 | "deno.land:443", | ||
| 40 | "esm.sh:443", | ||
| 41 | "registry.npmjs.org:443", | ||
| 42 | "raw.githubusercontent.com:443", | ||
| 43 | "git.wyvern.rip:443", | ||
| 44 | "strapi.volpeon.ink:443", | ||
| 45 | "icy.wyvern.rip:443", | ||
| 46 | "public.api.bsky.app:443", | ||
| 47 | "127.0.0.1:1337" | ||
| 48 | ], | ||
| 49 | "env": true, | ||
| 50 | "run": true, | ||
| 51 | "ffi": true, | ||
| 52 | "sys": true | ||
| 53 | } | ||
| 54 | }, | ||
| 55 | "tasks": { | ||
| 56 | "corvos": { | ||
| 57 | "description": "Run corvos command", | ||
| 58 | "command": "deno run -P=corvos corvos/cli.ts" | ||
| 59 | }, | ||
| 60 | "build": { | ||
| 61 | "description": "Build the site for production", | ||
| 62 | "command": "deno task corvos" | ||
| 63 | }, | ||
| 64 | "serve": { | ||
| 65 | "description": "Run and serve the site for development", | ||
| 66 | "command": "deno task corvos -s" | ||
| 67 | } | ||
| 68 | }, | ||
| 69 | "compilerOptions": { | ||
| 70 | "types": ["@types/alpinejs"], | ||
| 71 | "lib": ["deno.window", "deno.unstable", "node", "dom"], | ||
| 72 | "jsx": "react-jsx", | ||
| 73 | "jsxImportSource": "corvos", | ||
| 74 | "strict": true, | ||
| 75 | "noUncheckedIndexedAccess": true | ||
| 76 | }, | ||
| 77 | "unstable": ["temporal", "fmt-component"], | ||
| 78 | "lock": false, | ||
| 79 | "lint": { | ||
| 80 | "rules": { | ||
| 81 | "exclude": ["no-import-prefix", "ban-types", "jsx-key"] | ||
| 82 | } | ||
| 83 | } | ||
| 84 | } | ||
diff --git a/src/assets/InterVariable-Italic.woff2 b/src/assets/InterVariable-Italic.woff2 new file mode 100644 index 0000000..b3530f3 --- /dev/null +++ b/src/assets/InterVariable-Italic.woff2 | |||
| Binary files differ | |||
diff --git a/src/assets/InterVariable.woff2 b/src/assets/InterVariable.woff2 new file mode 100644 index 0000000..5a8d3e7 --- /dev/null +++ b/src/assets/InterVariable.woff2 | |||
| Binary files differ | |||
diff --git a/src/assets/favicon.svg b/src/assets/favicon.svg new file mode 100644 index 0000000..ec8d3f1 --- /dev/null +++ b/src/assets/favicon.svg | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32"> | ||
| 2 | <path | ||
| 3 | d="M26.993,16L22.988,11.995L18.219,11.995L22.224,16L16.86,16L30.001,29.141L27.141,32L19.808,32L24.277,27.531L19.758,23.012L15.187,23.012L17.589,25.413L11.002,32L11.002,28.396L12.559,26.839L8.274,22.554L10.739,20.088L6.013,15.362L7.483,13.891L4.604,11.009L1.517,11.009L0,9.492L6.006,3.486L6.006,6.006L10.008,6.005L10.008,7.272L12.005,9.269L14.002,7.272L14.002,0L18.432,0L16.438,1.994L20.427,1.994L34.435,16L26.993,16ZM28,17L32,21L27.231,21L23.231,17L28,17Z" /> | ||
| 4 | <style> | ||
| 5 | path { | ||
| 6 | fill: #000; | ||
| 7 | } | ||
| 8 | |||
| 9 | @media (prefers-color-scheme: dark) { | ||
| 10 | path { | ||
| 11 | fill: #FFF; | ||
| 12 | } | ||
| 13 | } | ||
| 14 | </style> | ||
| 15 | </svg> | ||
diff --git a/src/assets/symbols.svg b/src/assets/symbols.svg new file mode 100644 index 0000000..e54a7e3 --- /dev/null +++ b/src/assets/symbols.svg | |||
| @@ -0,0 +1,413 @@ | |||
| 1 | <svg | ||
| 2 | version="1.1" | ||
| 3 | xmlns="http://www.w3.org/2000/svg" | ||
| 4 | xmlns:xlink="http://www.w3.org/1999/xlink" | ||
| 5 | xml:space="preserve" | ||
| 6 | xmlns:serif="http://www.serif.com/" | ||
| 7 | > | ||
| 8 | <symbol id="logo" viewBox="0 0 2700 600" preserveAspectRatio="xMinYMin"> | ||
| 9 | <use href="#logo-sm" /> | ||
| 10 | |||
| 11 | <path | ||
| 12 | d="M798.1,165.25L872.79,165.25L906.285,257.65L938.24,343.89L932.08,343.89L963.65,257.65L997.53,165.25L1071.45,165.25L969.04,434.75L900.51,434.75L798.1,165.25Z" | ||
| 13 | style="fill:currentColor;" | ||
| 14 | /> | ||
| 15 | <path | ||
| 16 | d="M1223.14,440.14C1194.91,440.14 1169.82,434.173 1147.87,422.238C1125.93,410.303 1108.73,393.683 1096.28,372.38C1083.83,351.077 1077.61,326.95 1077.61,300C1077.61,272.793 1083.83,248.603 1096.28,227.428C1108.73,206.253 1125.99,189.698 1148.07,177.762C1170.14,165.827 1195.16,159.86 1223.14,159.86C1251.12,159.86 1276.08,165.827 1298.02,177.762C1319.97,189.698 1337.16,206.253 1349.61,227.428C1362.06,248.603 1368.29,272.793 1368.29,300C1368.29,326.95 1362.06,351.077 1349.61,372.38C1337.16,393.683 1319.97,410.303 1298.02,422.238C1276.08,434.173 1251.12,440.14 1223.14,440.14ZM1223.14,375.46C1237.51,375.46 1250.35,372.252 1261.64,365.835C1272.93,359.418 1281.72,350.499 1288.01,339.078C1294.3,327.656 1297.44,314.63 1297.44,300C1297.44,285.37 1294.3,272.344 1288.01,260.923C1281.72,249.501 1272.93,240.582 1261.64,234.165C1250.35,227.748 1237.51,224.54 1223.14,224.54C1208.77,224.54 1195.93,227.748 1184.64,234.165C1173.35,240.582 1164.49,249.501 1158.08,260.923C1151.66,272.344 1148.45,285.37 1148.45,300C1148.45,314.63 1151.66,327.656 1158.08,339.078C1164.49,350.499 1173.35,359.418 1184.64,365.835C1195.93,372.252 1208.77,375.46 1223.14,375.46Z" | ||
| 17 | style="fill:currentColor;" | ||
| 18 | /> | ||
| 19 | <path | ||
| 20 | d="M1413.71,165.25L1483.02,165.25L1483.02,399.715L1453.37,370.07L1596.59,370.07L1596.59,434.75L1413.71,434.75L1413.71,165.25Z" | ||
| 21 | style="fill:currentColor;" | ||
| 22 | /> | ||
| 23 | <path | ||
| 24 | d="M1637.79,165.25L1747.12,165.25C1768.94,165.25 1788.13,169.292 1804.68,177.378C1821.24,185.463 1834.01,197.012 1842.99,212.028C1851.97,227.043 1856.47,244.56 1856.47,264.58C1856.47,284.6 1851.97,302.117 1842.99,317.133C1834.01,332.148 1821.24,343.698 1804.68,351.783C1788.13,359.868 1768.94,363.91 1747.12,363.91L1668.2,363.91L1668.2,305.005L1740.97,305.005C1755.34,305.005 1766.5,301.476 1774.46,294.418C1782.42,287.359 1786.39,277.542 1786.39,264.965C1786.39,252.388 1782.35,242.571 1774.27,235.513C1766.18,228.454 1755.08,224.925 1740.97,224.925L1668.2,224.925L1706.32,185.655L1706.32,331.955L1707.09,340.81L1707.09,434.75L1637.79,434.75L1637.79,165.25Z" | ||
| 25 | style="fill:currentColor;" | ||
| 26 | /> | ||
| 27 | <path | ||
| 28 | d="M1897.66,165.25L2078.22,165.25L2078.22,222.23L1927.31,222.23L1966.19,183.73L1966.19,416.27L1927.31,377L2082.46,377L2082.46,434.75L1897.66,434.75L1897.66,165.25ZM1935.39,269.97L2067.83,269.97L2067.83,326.565L1935.39,326.565L1935.39,269.97Z" | ||
| 29 | style="fill:currentColor;" | ||
| 30 | /> | ||
| 31 | <path | ||
| 32 | d="M2259.18,440.14C2230.94,440.14 2205.85,434.173 2183.91,422.238C2161.96,410.303 2144.77,393.683 2132.32,372.38C2119.87,351.077 2113.64,326.95 2113.64,300C2113.64,272.793 2119.87,248.603 2132.32,227.428C2144.77,206.253 2162.03,189.698 2184.1,177.762C2206.17,165.827 2231.2,159.86 2259.18,159.86C2287.15,159.86 2312.11,165.827 2334.06,177.762C2356,189.698 2373.2,206.253 2385.65,227.428C2398.1,248.603 2404.32,272.793 2404.32,300C2404.32,326.95 2398.1,351.077 2385.65,372.38C2373.2,393.683 2356,410.303 2334.06,422.238C2312.11,434.173 2287.15,440.14 2259.18,440.14ZM2259.18,375.46C2273.55,375.46 2286.38,372.252 2297.68,365.835C2308.97,359.418 2317.76,350.499 2324.05,339.078C2330.34,327.656 2333.48,314.63 2333.48,300C2333.48,285.37 2330.34,272.344 2324.05,260.923C2317.76,249.501 2308.97,240.582 2297.68,234.165C2286.38,227.748 2273.55,224.54 2259.18,224.54C2244.8,224.54 2231.97,227.748 2220.68,234.165C2209.38,240.582 2200.53,249.501 2194.11,260.923C2187.69,272.344 2184.49,285.37 2184.49,300C2184.49,314.63 2187.69,327.656 2194.11,339.078C2200.53,350.499 2209.38,359.418 2220.68,365.835C2231.97,372.252 2244.8,375.46 2259.18,375.46Z" | ||
| 33 | style="fill:currentColor;" | ||
| 34 | /> | ||
| 35 | <path | ||
| 36 | d="M2449.75,165.25L2507.12,165.25L2623,322.715L2617.99,323.485L2617.99,165.25L2686.53,165.25L2686.53,434.75L2629.16,434.75L2512.89,275.745L2518.28,274.59L2518.28,434.75L2449.75,434.75L2449.75,165.25Z" | ||
| 37 | style="fill:currentColor;" | ||
| 38 | /> | ||
| 39 | </symbol> | ||
| 40 | |||
| 41 | <symbol id="logo-sm" viewBox="0 0 200 200" preserveAspectRatio="xMinYMin"> | ||
| 42 | <path | ||
| 43 | d="M171.812,105.796L144.61,78.594L115.919,78.594L137.325,100L105.375,100L189.94,184.565L174.505,200L125.258,200L153.187,172.07L124.939,143.822L94.919,143.822L109.929,158.832L68.762,200L68.762,177.478L78.493,167.746L51.71,140.963L67.12,125.552L37.579,96.012L45.759,87.832L29.112,71.185L11.859,71.185L0,59.326L44.124,15.201L44.124,34.031L67.12,34.031L67.12,45.45L78.695,57.025L90.269,45.45L90.269,0L115.201,0L100,15.201L130.405,15.201L150.785,35.579L123.803,35.573L143.829,55.6L170.86,55.651L221.016,105.796L171.812,105.796ZM31.055,43.344C26.613,43.344 23.006,46.951 23.006,51.393C23.006,55.836 26.613,59.443 31.055,59.443C35.498,59.443 39.105,55.836 39.105,51.393C39.105,46.951 35.498,43.344 31.055,43.344ZM178.102,112.047L200,133.945L171.309,133.945L149.411,112.047L178.102,112.047Z" | ||
| 44 | style="fill:currentColor" | ||
| 45 | /> | ||
| 46 | </symbol> | ||
| 47 | |||
| 48 | <symbol id="site-footer" viewBox="0 0 3840 1600" preserveAspectRatio="xMidYMax"> | ||
| 49 | <rect | ||
| 50 | x="0" | ||
| 51 | y="1360" | ||
| 52 | width="3840" | ||
| 53 | height="240" | ||
| 54 | style="fill:var(--site-footer--bg, rgb(41,39,45));" | ||
| 55 | /> | ||
| 56 | |||
| 57 | <g id="Moon" style="transform: translateY(var(--site-footer--moon-y, 0px));"> | ||
| 58 | <g> | ||
| 59 | <circle | ||
| 60 | cx="1043.51" | ||
| 61 | cy="677.436" | ||
| 62 | r="141.34" | ||
| 63 | style="fill:var(--site-footer--star, #fff);" | ||
| 64 | /> | ||
| 65 | </g> | ||
| 66 | </g> | ||
| 67 | <g id="Stars" style="transform: translateY(var(--site-footer--star-y, 0px));"> | ||
| 68 | <g> | ||
| 69 | <circle | ||
| 70 | cx="1699.27" | ||
| 71 | cy="544.216" | ||
| 72 | r="5.328" | ||
| 73 | style="fill:var(--site-footer--star, #fff);" | ||
| 74 | /> | ||
| 75 | <circle | ||
| 76 | cx="1646.14" | ||
| 77 | cy="856.455" | ||
| 78 | r="5.328" | ||
| 79 | style="fill:var(--site-footer--star, #fff);" | ||
| 80 | /> | ||
| 81 | <circle | ||
| 82 | cx="2416.95" | ||
| 83 | cy="623.907" | ||
| 84 | r="5.328" | ||
| 85 | style="fill:var(--site-footer--star, #fff);" | ||
| 86 | /> | ||
| 87 | <circle | ||
| 88 | cx="3379.97" | ||
| 89 | cy="783.855" | ||
| 90 | r="5.328" | ||
| 91 | style="fill:var(--site-footer--star, #fff);" | ||
| 92 | /> | ||
| 93 | <circle | ||
| 94 | cx="2736.62" | ||
| 95 | cy="912.99" | ||
| 96 | r="5.328" | ||
| 97 | style="fill:var(--site-footer--star, #fff);" | ||
| 98 | /> | ||
| 99 | <circle | ||
| 100 | cx="3016.91" | ||
| 101 | cy="533.56" | ||
| 102 | r="5.328" | ||
| 103 | style="fill:var(--site-footer--star, #fff);" | ||
| 104 | /> | ||
| 105 | <circle | ||
| 106 | cx="1484.2" | ||
| 107 | cy="778.528" | ||
| 108 | r="2.664" | ||
| 109 | style="fill:var(--site-footer--star, #fff);" | ||
| 110 | /> | ||
| 111 | <circle | ||
| 112 | cx="1100.74" | ||
| 113 | cy="981.29" | ||
| 114 | r="2.664" | ||
| 115 | style="fill:var(--site-footer--star, #fff);" | ||
| 116 | /> | ||
| 117 | <circle | ||
| 118 | cx="196.68" | ||
| 119 | cy="942.185" | ||
| 120 | r="2.664" | ||
| 121 | style="fill:var(--site-footer--star, #fff);" | ||
| 122 | /> | ||
| 123 | <circle | ||
| 124 | cx="774.638" | ||
| 125 | cy="1080.71" | ||
| 126 | r="2.664" | ||
| 127 | style="fill:var(--site-footer--star, #fff);" | ||
| 128 | /> | ||
| 129 | <circle | ||
| 130 | cx="851.735" | ||
| 131 | cy="860.82" | ||
| 132 | r="2.664" | ||
| 133 | style="fill:var(--site-footer--star, #fff);" | ||
| 134 | /> | ||
| 135 | <circle | ||
| 136 | cx="371.871" | ||
| 137 | cy="584.434" | ||
| 138 | r="2.664" | ||
| 139 | style="fill:var(--site-footer--star, #fff);" | ||
| 140 | /> | ||
| 141 | <circle | ||
| 142 | cx="1720.66" | ||
| 143 | cy="1145.82" | ||
| 144 | r="2.664" | ||
| 145 | style="fill:var(--site-footer--star, #fff);" | ||
| 146 | /> | ||
| 147 | <circle | ||
| 148 | cx="1764.59" | ||
| 149 | cy="711.93" | ||
| 150 | r="2.664" | ||
| 151 | style="fill:var(--site-footer--star, #fff);" | ||
| 152 | /> | ||
| 153 | <circle | ||
| 154 | cx="2262.17" | ||
| 155 | cy="666.541" | ||
| 156 | r="2.664" | ||
| 157 | style="fill:var(--site-footer--star, #fff);" | ||
| 158 | /> | ||
| 159 | <circle | ||
| 160 | cx="2691.87" | ||
| 161 | cy="647.565" | ||
| 162 | r="2.664" | ||
| 163 | style="fill:var(--site-footer--star, #fff);" | ||
| 164 | /> | ||
| 165 | <circle | ||
| 166 | cx="3088.24" | ||
| 167 | cy="866.505" | ||
| 168 | r="2.664" | ||
| 169 | style="fill:var(--site-footer--star, #fff);" | ||
| 170 | /> | ||
| 171 | <circle | ||
| 172 | cx="3657.07" | ||
| 173 | cy="834.16" | ||
| 174 | r="2.664" | ||
| 175 | style="fill:var(--site-footer--star, #fff);" | ||
| 176 | /> | ||
| 177 | <circle | ||
| 178 | cx="3542.12" | ||
| 179 | cy="553.771" | ||
| 180 | r="2.664" | ||
| 181 | style="fill:var(--site-footer--star, #fff);" | ||
| 182 | /> | ||
| 183 | <circle | ||
| 184 | cx="2958.94" | ||
| 185 | cy="963.22" | ||
| 186 | r="2.664" | ||
| 187 | style="fill:var(--site-footer--star, #fff);" | ||
| 188 | /> | ||
| 189 | <circle | ||
| 190 | cx="2434.39" | ||
| 191 | cy="1041.53" | ||
| 192 | r="2.664" | ||
| 193 | style="fill:var(--site-footer--star, #fff);" | ||
| 194 | /> | ||
| 195 | </g> | ||
| 196 | </g> | ||
| 197 | <g id="Cloud" style="transform: translateY(var(--site-footer--cloud-y, 0px));"> | ||
| 198 | <g> | ||
| 199 | <path | ||
| 200 | d="M1046.44,761.258C1037.57,738.638 1015.54,722.604 989.792,722.604C982.836,722.604 976.151,723.774 969.923,725.928C952.459,692.734 917.626,670.076 877.543,670.076C865.972,670.076 854.839,671.965 844.434,675.449C809.767,624.335 751.197,590.721 684.839,590.721C616.252,590.721 555.986,626.63 521.832,680.658C499.625,658.932 469.24,645.533 435.749,645.533C370.278,645.533 316.673,696.743 312.838,761.258L1046.44,761.258Z" | ||
| 201 | style="fill:var(--site-footer--cloud, #36343A);" | ||
| 202 | /> | ||
| 203 | </g> | ||
| 204 | </g> | ||
| 205 | <g | ||
| 206 | id="Cloud1" | ||
| 207 | serif:id="Cloud" | ||
| 208 | style="transform: translateY(var(--site-footer--cloud-y, 0px));" | ||
| 209 | > | ||
| 210 | <g> | ||
| 211 | <path | ||
| 212 | d="M3238.9,776.138C3237.32,733.626 3202.31,699.598 3159.41,699.598C3148.5,699.598 3138.09,701.801 3128.62,705.787C3102.93,655.293 3050.46,620.67 2989.97,620.67C2935.04,620.67 2886.73,649.214 2859.07,692.265C2845.15,684.431 2829.09,679.961 2812,679.961C2776.74,679.961 2745.89,698.974 2729.14,727.298C2720.82,723.501 2711.58,721.385 2701.84,721.385C2669.26,721.385 2642.18,745.067 2636.87,776.138L3238.9,776.138Z" | ||
| 213 | style="fill:var(--site-footer--cloud, #36343A);" | ||
| 214 | /> | ||
| 215 | </g> | ||
| 216 | </g> | ||
| 217 | <g | ||
| 218 | id="Cloud2" | ||
| 219 | serif:id="Cloud" | ||
| 220 | style="transform: translateY(var(--site-footer--cloud-y, 0px));" | ||
| 221 | > | ||
| 222 | <g> | ||
| 223 | <path | ||
| 224 | d="M1781.38,740.368C1781.53,738.122 1781.6,735.858 1781.6,733.577C1781.6,675.957 1734.82,629.178 1677.2,629.178C1659.2,629.178 1642.27,633.742 1627.48,641.774C1606.62,610.476 1571,589.839 1530.61,589.839C1478.27,589.839 1433.97,624.469 1419.37,672.044C1404.68,661.872 1386.85,655.911 1367.64,655.911C1319.61,655.911 1280.22,693.19 1276.85,740.368L1781.38,740.368Z" | ||
| 225 | style="fill:var(--site-footer--cloud, #36343A);" | ||
| 226 | /> | ||
| 227 | </g> | ||
| 228 | </g> | ||
| 229 | <g id="BG" style="transform: translateY(var(--site-footer--l1-y, 0px));"> | ||
| 230 | <g> | ||
| 231 | <path | ||
| 232 | d="M0,1404.44L50.539,1404.44L103.371,1345.75L135.693,1345.75L181.301,1279.9L1049.31,1279.9L1111.63,1351.9L1509.23,1351.9L1553.81,1404.44L1813.22,1404.44L1864.66,1330.21L2171.38,1330.21L2224.59,1262.98L2577.01,1262.98L2674.55,1367.11L2992.16,1367.11L3027.66,1404.44L3840,1404.44L3840,2000L0,2000L0,1404.44Z" | ||
| 233 | style="fill:var(--site-footer--l1, rgb(24,22,29));" | ||
| 234 | /> | ||
| 235 | <path | ||
| 236 | d="M849.371,1111.33L898.466,1310.88L800.276,1310.88L849.371,1111.33Z" | ||
| 237 | style="fill:var(--site-footer--l1, rgb(24,22,29));" | ||
| 238 | /> | ||
| 239 | <path | ||
| 240 | d="M931.197,1192.12L980.292,1391.67L882.102,1391.67L931.197,1192.12Z" | ||
| 241 | style="fill:var(--site-footer--l1, rgb(24,22,29));" | ||
| 242 | /> | ||
| 243 | <path | ||
| 244 | d="M1236.74,1223.18L1285.84,1422.73L1187.64,1422.73L1236.74,1223.18Z" | ||
| 245 | style="fill:var(--site-footer--l1, rgb(24,22,29));" | ||
| 246 | /> | ||
| 247 | <path | ||
| 248 | d="M1365.43,1175.49L1414.53,1375.05L1316.34,1375.05L1365.43,1175.49Z" | ||
| 249 | style="fill:var(--site-footer--l1, rgb(24,22,29));" | ||
| 250 | /> | ||
| 251 | <path | ||
| 252 | d="M2460.51,1102.33L2509.61,1301.89L2411.42,1301.89L2460.51,1102.33Z" | ||
| 253 | style="fill:var(--site-footer--l1, rgb(24,22,29));" | ||
| 254 | /> | ||
| 255 | <path | ||
| 256 | d="M2369.71,1151.81L2418.8,1351.37L2320.61,1351.37L2369.71,1151.81Z" | ||
| 257 | style="fill:var(--site-footer--l1, rgb(24,22,29));" | ||
| 258 | /> | ||
| 259 | <path | ||
| 260 | d="M3748.44,1235.46L3797.53,1435.02L3699.35,1435.02L3748.44,1235.46Z" | ||
| 261 | style="fill:var(--site-footer--l1, rgb(24,22,29));" | ||
| 262 | /> | ||
| 263 | </g> | ||
| 264 | </g> | ||
| 265 | <g id="Dragon-Awake" serif:id="Dragon Awake"> | ||
| 266 | <g> | ||
| 267 | <path | ||
| 268 | d="M3129.18,1304.49C3129.18,1304.49 3135.89,1291.42 3162.48,1239.64C3189.58,1186.9 3176.08,1147.44 3135.34,1147.44L3067.07,1147.44L3056.05,1131.05L3072.58,1111.46C3072.58,1092.59 3083.19,1074.18 3095.14,1067.91L3095.14,1092.76L3103.87,1092.76C3114.47,1077.86 3130.57,1067.91 3144.76,1067.91L3130.66,1098.47L3162.79,1098.47L3153.57,1111.46L3187.93,1121.1L3175.42,1131.05L3220.77,1160.43L3347.94,1067.91C3347.94,1067.91 3324.76,1097.7 3295.98,1134.5C3255.53,1186.21 3279,1230.05 3340.18,1206.24C3364.42,1196.81 3386.19,1188.58 3386.19,1188.58L3249.46,1337.7L3129.18,1337.7L3129.18,1304.49ZM3109.76,1105.31C3103.54,1105.31 3098.5,1110.36 3098.5,1116.58C3098.5,1122.79 3103.54,1127.84 3109.76,1127.84C3115.98,1127.84 3121.03,1122.79 3121.03,1116.58C3121.03,1110.36 3115.98,1105.31 3109.76,1105.31Z" | ||
| 269 | style="fill:var(--site-footer--dragon-awake, transparent);" | ||
| 270 | /> | ||
| 271 | <path | ||
| 272 | d="M3117.9,1156.8C3131.98,1162.99 3136.11,1180.55 3124.53,1195.94L3137.91,1195.94C3134.63,1214.49 3139.03,1229.93 3153.92,1239.94C3147.6,1240.52 3139.46,1238.69 3134.46,1235.24L3081.9,1337.7L3036.77,1337.7L3036.77,1304.49L3068.03,1216.52L3117.9,1156.8Z" | ||
| 273 | style="fill:var(--site-footer--dragon-awake, transparent);" | ||
| 274 | /> | ||
| 275 | <path | ||
| 276 | d="M3283.36,1337.7L3312.28,1306.45L3312.28,1322.65C3344.81,1287.03 3380.18,1295.56 3397.01,1337.7L3283.36,1337.7Z" | ||
| 277 | style="fill:var(--site-footer--dragon-awake, transparent);" | ||
| 278 | /> | ||
| 279 | </g> | ||
| 280 | </g> | ||
| 281 | <g id="Dragon-Asleep" serif:id="Dragon Asleep"> | ||
| 282 | <g> | ||
| 283 | <circle | ||
| 284 | cx="3309.17" | ||
| 285 | cy="1197.08" | ||
| 286 | r="15.426" | ||
| 287 | style="fill:var(--site-footer--dragon-asleep--eye, #fff);" | ||
| 288 | /> | ||
| 289 | <path | ||
| 290 | d="M2994.68,1254.32C3066.74,1218.37 3133.74,1254.32 3178.54,1254.32C3208.13,1254.32 3219.6,1240.67 3249.2,1240.67L3281.09,1240.67C3298.06,1240.67 3315.37,1261.06 3324.43,1261.06C3333.5,1261.06 3341.03,1253.52 3341.03,1244.46C3341.03,1235.39 3333.5,1227.86 3324.43,1227.86L3266.35,1227.86L3255.32,1211.48L3271.86,1191.88C3271.86,1173.02 3282.47,1154.61 3294.41,1148.34L3294.41,1173.18L3303.14,1173.18C3313.75,1158.28 3329.85,1148.34 3344.04,1148.34L3329.93,1178.9L3362.07,1178.9L3352.84,1191.88L3387.21,1201.52L3374.7,1211.48C3374.7,1211.48 3384.74,1223.8 3398.51,1240.67C3414.68,1260.47 3420.79,1278.2 3420.79,1303.77L3466.54,1303.77C3491.29,1303.77 3504.57,1307.41 3515.99,1332.43L2848.75,1332.43C2756.41,1332.43 2733.27,1542.79 2629.7,1542.79L2473.31,1542.79C2501.14,1485.67 2551.42,1477.75 2592.95,1527.35L2605.71,1507.26C2605.71,1519.21 2614.89,1530.05 2626.85,1530.05C2706.65,1530.05 2716.87,1302.62 2846.08,1280.36C2908.65,1269.58 2955.39,1273.92 2994.68,1254.32ZM3309.04,1185.73C3302.82,1185.73 3297.77,1190.78 3297.77,1197C3297.77,1203.22 3302.82,1208.26 3309.04,1208.26C3315.26,1208.26 3320.3,1203.22 3320.3,1197C3320.3,1190.78 3315.26,1185.73 3309.04,1185.73Z" | ||
| 291 | style="fill:var(--site-footer--dragon-asleep, #000);" | ||
| 292 | /> | ||
| 293 | </g> | ||
| 294 | </g> | ||
| 295 | <path | ||
| 296 | id="FG" | ||
| 297 | d="M1426.79,1449.74L1598.5,1449.74L1684.36,1540L1808.3,1540L1817.66,1516.69L1878.3,1521.85L1884.85,1540L2335.12,1540L2379.15,1467.78L2477.43,1496.57L2494.17,1540L2670.66,1540L2751.39,1427.41L2783.87,1427.41L2816.63,1330.21L3536.24,1330.21L3630.37,1415.61L3659.42,1477.48L3840,1477.48L3840,2000L0,2000L0,1477.48L169.867,1477.48L237.724,1380.1L740.493,1380.1L800.556,1449.74L1094.6,1449.74L1119.79,1485.2L1247.73,1485.2L1270.13,1449.74L1288.6,1390.04L1355.63,1358.1L1382.43,1408.08L1417.17,1422.49L1426.79,1449.74Z" | ||
| 298 | style="fill:var(--site-footer--fg, #000);" | ||
| 299 | /> | ||
| 300 | <g id="Grass"> | ||
| 301 | <g> | ||
| 302 | <path | ||
| 303 | d="M1964.26,1447.6C1964.5,1447.67 1964.76,1447.65 1964.99,1447.73C1965,1447.73 1966.48,1448.43 1968.05,1449.28C1994.05,1463.24 2010.58,1495.73 2012.41,1534.31C2012.76,1541.77 2012.39,1546.56 2012.39,1546.58C2012.21,1547.21 2012.14,1547.88 2011.84,1548.47C2011.7,1548.74 2010.91,1550.28 2008.97,1550.91C2006.97,1551.56 2005.38,1550.74 2005.11,1550.59C2003.3,1549.66 2002.76,1548.01 2002.67,1547.72C2002.19,1546.28 2002.4,1546.25 2002.49,1544.73C2002.83,1538.77 2003.83,1502.46 1984.46,1471.42C1976.87,1459.26 1967.66,1450.66 1964.26,1447.6Z" | ||
| 304 | style="fill:var(--site-footer--fg, #000);" | ||
| 305 | /> | ||
| 306 | <path | ||
| 307 | d="M1940.18,1496.65C1942.03,1496.46 1956.02,1494.27 1972.12,1501.52C1996.72,1512.59 2003.83,1536 2004.87,1545.54C2005.11,1547.75 2003.92,1549.15 2003.71,1549.4C2003.29,1549.88 2002.78,1550.3 2002.21,1550.6C2000.34,1551.56 1998.64,1551 1998.35,1550.9C1996.42,1550.27 1995.62,1548.73 1995.48,1548.46C1994.76,1547.07 1995.02,1546.99 1994.78,1545.43C1993.81,1538.91 1990.55,1529.71 1984.21,1521.7C1981.44,1518.19 1976.23,1512.33 1966.76,1506.98C1965.27,1506.14 1958.34,1501.96 1940.18,1496.65Z" | ||
| 308 | style="fill:var(--site-footer--fg, #000);" | ||
| 309 | /> | ||
| 310 | </g> | ||
| 311 | </g> | ||
| 312 | <g id="Grass1" serif:id="Grass"> | ||
| 313 | <g> | ||
| 314 | <path | ||
| 315 | d="M914.865,1353.59C918.601,1356.1 936.504,1366.93 943.547,1399.88C944.1,1402.46 949.844,1426.61 942.861,1462.86C941.841,1468.15 940.963,1471.42 940.959,1471.43C940.664,1472.02 940.472,1472.67 940.068,1473.19C938.816,1474.79 937.094,1475.01 936.795,1475.05C934.71,1475.32 933.3,1474.22 933.056,1474.03C931.454,1472.78 931.229,1471.05 931.19,1470.76C931.006,1469.35 931.176,1469.35 931.528,1467.98C932.629,1463.67 945.08,1413.35 928.571,1375.31C923.046,1362.59 915.993,1354.89 914.865,1353.59Z" | ||
| 316 | style="fill:var(--site-footer--fg, #000);" | ||
| 317 | /> | ||
| 318 | <path | ||
| 319 | d="M883.65,1402.93C885.951,1403.27 901.116,1404.64 915.997,1418.61C938.217,1439.47 939.399,1469.8 939.375,1469.94C939.267,1470.59 939.277,1471.27 939.044,1471.89C938.937,1472.17 938.322,1473.79 936.469,1474.64C934.557,1475.51 932.887,1474.87 932.597,1474.76C930.698,1474.04 929.973,1472.46 929.847,1472.19C929.211,1470.81 929.434,1470.75 929.319,1469.23C928.997,1464.99 926.888,1445.48 913.454,1427.58C908.608,1421.13 900.749,1412.52 883.65,1402.93Z" | ||
| 320 | style="fill:var(--site-footer--fg, #000);" | ||
| 321 | /> | ||
| 322 | <path | ||
| 323 | d="M951.126,1470.45C951.127,1470.44 951.128,1470.43 951.128,1470.43C951.126,1470.46 951.125,1470.47 951.126,1470.45ZM951.128,1470.43C951.13,1470.41 951.13,1470.41 951.128,1470.43ZM951.126,1470.45C951.103,1470.64 950.982,1471.57 950.599,1472.34C950.464,1472.61 949.687,1474.16 947.765,1474.82C947.472,1474.92 945.782,1475.5 943.899,1474.57C942.075,1473.66 941.514,1472.02 941.416,1471.73C940.957,1470.39 941.08,1470.36 941.224,1468.94C941.598,1465.28 943.546,1450.5 951.826,1435C963.679,1412.82 982.921,1399.84 1002.9,1397.08C1004.23,1396.9 1005.61,1396.76 1005.62,1396.76C1005.87,1396.77 1006.11,1396.89 1006.36,1396.9C986.272,1405.86 973.972,1416.5 965.016,1430.57C952.605,1450.08 951.153,1469.9 951.126,1470.45Z" | ||
| 324 | style="fill:var(--site-footer--fg, #000);" | ||
| 325 | /> | ||
| 326 | </g> | ||
| 327 | </g> | ||
| 328 | <g id="Grass2" serif:id="Grass"> | ||
| 329 | <g> | ||
| 330 | <path | ||
| 331 | d="M458.456,1404C458.477,1404.38 458.483,1405.06 458.326,1405.68C458.251,1405.98 457.825,1407.66 456.085,1408.71C455.82,1408.87 454.289,1409.8 452.25,1409.29C451.957,1409.21 450.274,1408.79 449.221,1407.05C448.556,1405.95 448.639,1405.9 448.467,1404.63C446.814,1392.39 446.731,1379.27 446.717,1377.06C446.608,1359.92 448.297,1342.01 452.941,1325.57C460.174,1299.96 471.1,1289.59 474.677,1286C467.832,1297.99 462.126,1313.35 459.008,1333.34C453.666,1367.57 457.88,1400.23 458.456,1404ZM458.456,1404C458.447,1403.85 458.437,1403.74 458.433,1403.7C458.547,1404.48 458.54,1404.55 458.456,1404ZM458.433,1403.7C458.43,1403.69 458.431,1403.69 458.433,1403.7Z" | ||
| 332 | style="fill:var(--site-footer--fg, #000);" | ||
| 333 | /> | ||
| 334 | <path | ||
| 335 | d="M392.718,1331.94C395.17,1332.02 412.732,1331.6 428.759,1344.91C455.13,1366.81 451.323,1404.53 451.175,1405.03C450.986,1405.65 450.883,1406.33 450.562,1406.9C449.569,1408.68 447.901,1409.16 447.612,1409.24C445.592,1409.83 444.033,1408.95 443.763,1408.8C441.991,1407.81 441.505,1406.14 441.42,1405.85C441.018,1404.47 441.209,1404.45 441.32,1403.02C441.634,1398.95 442.726,1377.03 430.267,1358.46C421.782,1345.81 409.582,1338.25 392.718,1331.94Z" | ||
| 336 | style="fill:var(--site-footer--fg, #000);" | ||
| 337 | /> | ||
| 338 | <path | ||
| 339 | d="M462.967,1404.49C462.969,1404.46 462.97,1404.44 462.97,1404.42C462.97,1404.53 462.968,1404.55 462.967,1404.49ZM462.967,1404.49C462.953,1404.75 462.887,1405.62 462.577,1406.37C462.461,1406.65 461.795,1408.25 459.923,1409.04C459.638,1409.16 457.991,1409.86 456.048,1409.06C455.769,1408.94 454.165,1408.28 453.373,1406.4C452.876,1405.23 452.956,1405.2 452.977,1403.92C453.12,1395.18 454.889,1386.49 455.193,1385C462.837,1347.44 486.107,1331.12 510.591,1335.1C511.659,1335.27 511.652,1335.28 512.824,1335.49C495.33,1338.05 478.5,1345.1 469.039,1370.99C463.05,1387.37 462.952,1403.61 462.967,1404.49ZM462.97,1404.42C462.97,1404.4 462.971,1404.41 462.97,1404.42Z" | ||
| 340 | style="fill:var(--site-footer--fg, #000);" | ||
| 341 | /> | ||
| 342 | </g> | ||
| 343 | </g> | ||
| 344 | <g id="Tree"> | ||
| 345 | <g> | ||
| 346 | <path | ||
| 347 | d="M2070.78,639.766C2070.78,710.098 2135.65,740.217 2188.85,759.283C2206.09,765.464 2159.42,776.932 2140.44,771.373C2168.96,792.343 2207.29,804.943 2243.82,818.39C2266.38,826.697 2188.43,832.896 2153.66,818.39C2153.66,818.39 2163.7,837.928 2170.2,850.792C2179.2,868.606 2095.6,825.355 2070.78,806.784L2025.44,806.784C1999.8,825.821 1915.56,869.191 1925.33,851.276C1932.06,838.946 1943.15,818.39 1943.15,818.39C1909.87,837.85 1840.29,826.677 1860.34,818.39C1893.38,804.74 1929.15,791.382 1957.84,769.618C1940.08,772.877 1892.24,764.541 1907.77,759.283C1968.01,738.879 2025.44,710.502 2025.44,639.766L2025.44,616.15L2070.78,616.15L2070.78,639.766Z" | ||
| 348 | style="fill:var(--site-footer--fg, #000);" | ||
| 349 | /> | ||
| 350 | <rect | ||
| 351 | x="2018.84" | ||
| 352 | y="1165.1" | ||
| 353 | width="59.623" | ||
| 354 | height="692.272" | ||
| 355 | style="fill:var(--site-footer--fg, #000);" | ||
| 356 | /> | ||
| 357 | <path | ||
| 358 | d="M2076.84,1043.84C2076.84,1114.17 2135.51,1144.29 2188.71,1163.36C2205.95,1169.54 2146.96,1181.01 2127.98,1175.45C2156.5,1196.42 2207.15,1218.3 2243.68,1231.75C2263.02,1238.87 2210.31,1269.17 2130.83,1255.59C2130.83,1255.59 2133.04,1265.37 2136.13,1279.52C2138.33,1289.6 2086.1,1271.11 2070.64,1244.83L2025.3,1244.83C2010.05,1271.11 1957.4,1289.99 1961.34,1279.76C1966.27,1266.98 1969.86,1258.18 1969.86,1258.18C1895.25,1274.47 1843.08,1238.82 1860.2,1231.75C1893.24,1218.1 1941.51,1195.46 1970.2,1173.69C1952.44,1176.95 1892.1,1168.62 1907.63,1163.36C1967.88,1142.95 2019.1,1114.58 2019.1,1043.84L2019.1,1020.23L2076.84,1020.23L2076.84,1043.84Z" | ||
| 359 | style="fill:var(--site-footer--fg, #000);" | ||
| 360 | /> | ||
| 361 | <path | ||
| 362 | d="M2071.08,805.061C2071.08,875.393 2136.13,914.507 2199.21,924.578C2218.34,927.632 2167.4,953.246 2146.89,947.687C2177.72,968.658 2253.82,995.302 2311.02,1006.41C2332.2,1010.52 2259.3,1039.94 2179.01,1032.81C2179.01,1032.81 2221.8,1056.94 2257.49,1069.58C2293.18,1082.22 2166.02,1109.84 2071.08,1057.76L2025.74,1057.76C1942.32,1109.32 1805.06,1082.46 1837.24,1071.75C1883.04,1056.51 1915.35,1034 1915.35,1034C1844.15,1036.32 1772.71,1011.23 1793.58,1006.41C1846.44,994.199 1918.52,967.697 1949.53,945.932C1930.33,949.191 1878.24,927.289 1895.4,924.578C1959.08,914.518 2025.74,875.796 2025.74,805.061L2025.74,781.445L2071.08,781.445L2071.08,805.061Z" | ||
| 363 | style="fill:var(--site-footer--fg, #000);" | ||
| 364 | /> | ||
| 365 | <path | ||
| 366 | d="M2051.79,446.256C2051.79,446.256 2097.14,551.378 2179.34,614.851C2194.93,626.89 2136.67,633.349 2110.62,626.401C2110.62,626.401 2127.97,653.465 2136.47,666.47C2147.5,683.333 2093.49,658.353 2071.08,629.181L2025.74,629.181C2003.5,658.781 1949.29,682.946 1961.19,665.734C1970.94,651.633 1988.47,626.401 1988.47,626.401C1962.52,633.418 1909.82,626.446 1923.59,615.533C2005.91,550.291 2051.79,446.256 2051.79,446.256Z" | ||
| 367 | style="fill:var(--site-footer--fg, #000);" | ||
| 368 | /> | ||
| 369 | </g> | ||
| 370 | </g> | ||
| 371 | <g id="Tree1" serif:id="Tree"> | ||
| 372 | <g> | ||
| 373 | <path | ||
| 374 | d="M565.291,486.652C565.291,486.652 608.588,590.252 689.204,634.627C704.448,640.091 655.441,650.227 640.039,645.314C663.182,663.848 690.806,674.985 719.625,686.871C737.428,694.213 675.927,699.692 648.49,686.871C648.49,686.871 656.409,704.139 661.538,715.51C668.64,731.255 603.657,693.027 583.511,676.612L546.72,676.612C525.916,693.438 460.627,731.772 468.339,715.937C473.646,705.039 482.397,686.871 482.397,686.871C456.138,704.07 401.237,694.195 417.062,686.871C443.13,674.805 468.586,662.999 491.865,643.762C477.454,646.643 428.009,641.497 440.765,634.627C520.33,591.782 565.291,486.652 565.291,486.652Z" | ||
| 375 | style="fill:var(--site-footer--fg, #000);" | ||
| 376 | /> | ||
| 377 | <rect | ||
| 378 | x="535.054" | ||
| 379 | y="1042.06" | ||
| 380 | width="59.623" | ||
| 381 | height="692.272" | ||
| 382 | style="fill:var(--site-footer--fg, #000);" | ||
| 383 | /> | ||
| 384 | <path | ||
| 385 | d="M593.05,920.803C593.05,991.135 651.724,1021.25 704.92,1040.32C722.167,1046.5 663.177,1057.97 644.196,1052.41C672.717,1073.38 723.367,1095.26 759.894,1108.71C779.235,1115.83 726.523,1146.13 647.047,1132.55C647.047,1132.55 649.257,1142.33 652.346,1156.48C654.546,1166.56 602.318,1148.07 586.852,1121.79L541.514,1121.79C526.26,1148.07 473.611,1166.95 477.556,1156.72C482.486,1143.94 486.076,1135.14 486.076,1135.14C411.467,1151.43 359.293,1115.78 376.417,1108.71C409.457,1095.06 457.724,1072.42 486.411,1050.65C468.652,1053.91 408.314,1045.58 423.84,1040.32C484.089,1019.92 535.317,991.538 535.317,920.803L535.317,897.187L593.05,897.187L593.05,920.803Z" | ||
| 386 | style="fill:var(--site-footer--fg, #000);" | ||
| 387 | /> | ||
| 388 | <path | ||
| 389 | d="M587.292,682.02C587.292,752.353 644.509,792.408 701.696,801.537C719.04,804.306 672.861,827.527 654.263,822.487C682.209,841.498 751.199,865.652 803.056,875.722C822.258,879.451 756.167,906.114 683.383,899.654C683.383,899.654 722.173,921.528 754.53,932.986C786.887,944.445 671.61,969.481 585.539,922.273L544.438,922.273C468.813,969.016 344.383,944.666 373.557,934.956C415.076,921.138 444.366,900.735 444.366,900.735C379.817,902.833 315.054,880.093 333.974,875.722C381.892,864.652 447.238,840.626 475.347,820.896C457.946,823.85 410.72,803.995 426.28,801.537C484.009,792.418 541.954,752.756 541.954,682.02L541.954,658.404L587.292,658.404L587.292,682.02Z" | ||
| 390 | style="fill:var(--site-footer--fg, #000);" | ||
| 391 | /> | ||
| 392 | </g> | ||
| 393 | </g> | ||
| 394 | </symbol> | ||
| 395 | |||
| 396 | <symbol id="site-footer-bg" viewBox="0 0 100 1600" preserveAspectRatio="none"> | ||
| 397 | <rect | ||
| 398 | x="0" | ||
| 399 | y="1360" | ||
| 400 | width="100" | ||
| 401 | height="240" | ||
| 402 | style="fill:var(--site-footer--bg, rgb(41,39,45));" | ||
| 403 | /> | ||
| 404 | <rect | ||
| 405 | x="0" | ||
| 406 | y="1404.44" | ||
| 407 | width="100" | ||
| 408 | height="595.555" | ||
| 409 | style="fill:var(--site-footer--l1, rgb(24,22,29)); transform: translateY(var(--site-footer--l1-y, 0px));" | ||
| 410 | /> | ||
| 411 | <rect id="FG" x="-0" y="1477.48" width="100" height="522.519" /> | ||
| 412 | </symbol> | ||
| 413 | </svg> | ||
diff --git a/src/data/nav.ts b/src/data/nav.ts new file mode 100644 index 0000000..2f9efb9 --- /dev/null +++ b/src/data/nav.ts | |||
| @@ -0,0 +1,9 @@ | |||
| 1 | export const nav: { | ||
| 2 | label: string; | ||
| 3 | url: string; | ||
| 4 | icon: "house" | "package"; | ||
| 5 | hidden?: boolean; | ||
| 6 | }[] = [ | ||
| 7 | { icon: "house", label: "Home", url: "/" }, | ||
| 8 | { icon: "package", label: "Releases", url: "/releases/" }, | ||
| 9 | ]; | ||
diff --git a/src/deps/strapi.tsx b/src/deps/strapi.tsx new file mode 100644 index 0000000..de2e495 --- /dev/null +++ b/src/deps/strapi.tsx | |||
| @@ -0,0 +1,416 @@ | |||
| 1 | import type { Pipeline } from "corvos"; | ||
| 2 | import type { StandardSchemaV1 } from "corvos/deps/standard_schema.ts"; | ||
| 3 | |||
| 4 | import { type } from "arktype"; | ||
| 5 | import { Page, PipelineStageError } from "corvos"; | ||
| 6 | import { Severity } from "corvos/core/error.ts"; | ||
| 7 | import { replaceExtension } from "corvos/core/utils/path.ts"; | ||
| 8 | |||
| 9 | import { ContentImage } from "$/ui/objects/ContentImage.tsx"; | ||
| 10 | |||
| 11 | export enum HeadingBlockLevel { | ||
| 12 | L1 = 1, | ||
| 13 | L2 = 2, | ||
| 14 | L3 = 3, | ||
| 15 | L4 = 4, | ||
| 16 | L5 = 5, | ||
| 17 | L6 = 6, | ||
| 18 | } | ||
| 19 | |||
| 20 | export const fileAttributeSchema = type({ | ||
| 21 | "alternativeText?": "string | null", | ||
| 22 | "caption?": "string | null", | ||
| 23 | "createdAt": "string", | ||
| 24 | "documentId": "string", | ||
| 25 | "ext": "string", | ||
| 26 | "formats?": "Record<string, unknown> | null", | ||
| 27 | "hash": "string", | ||
| 28 | "height?": "number | null", | ||
| 29 | "mime": "string", | ||
| 30 | "name": "string", | ||
| 31 | "previewUrl?": "string | null", | ||
| 32 | "provider": "string", | ||
| 33 | "provider_metadata?": "unknown", | ||
| 34 | "size": "number", | ||
| 35 | "updatedAt": "string", | ||
| 36 | "url": "string", | ||
| 37 | "width?": "number | null", | ||
| 38 | }); | ||
| 39 | |||
| 40 | export type FileAttribute = typeof fileAttributeSchema.infer; | ||
| 41 | |||
| 42 | export const imageAttributeSchema = fileAttributeSchema.narrow( | ||
| 43 | (o): o is Omit<typeof o, "width" | "height"> & { width: number; height: number } => | ||
| 44 | !!o.width && !!o.height, | ||
| 45 | ); | ||
| 46 | |||
| 47 | export type ImageAttribute = typeof imageAttributeSchema.infer; | ||
| 48 | |||
| 49 | const blocksAttributeScope = type | ||
| 50 | .scope({ | ||
| 51 | codeBlockNode: { | ||
| 52 | "children": "defaultInlineNode[]", | ||
| 53 | "language?": "string", | ||
| 54 | "type": "'code'", | ||
| 55 | }, | ||
| 56 | |||
| 57 | defaultInlineNode: "textInlineNode | linkInlineNode", | ||
| 58 | |||
| 59 | headingBlockNode: { | ||
| 60 | children: "defaultInlineNode[]", | ||
| 61 | level: "1 | 2 | 3 | 4 | 5 | 6", | ||
| 62 | type: "'heading'", | ||
| 63 | }, | ||
| 64 | |||
| 65 | imageBlockNode: { | ||
| 66 | children: type({ | ||
| 67 | text: "string", | ||
| 68 | type: "'text'", | ||
| 69 | }).array(), | ||
| 70 | image: fileAttributeSchema.omit("documentId"), | ||
| 71 | type: "'image'", | ||
| 72 | }, | ||
| 73 | |||
| 74 | linkInlineNode: { | ||
| 75 | children: "textInlineNode[]", | ||
| 76 | type: "'link'", | ||
| 77 | url: "string", | ||
| 78 | }, | ||
| 79 | |||
| 80 | listBlockNode: { | ||
| 81 | children: "(listItemInlineNode | listBlockNode)[]", | ||
| 82 | format: "'ordered' | 'unordered'", | ||
| 83 | type: "'list'", | ||
| 84 | }, | ||
| 85 | |||
| 86 | listItemInlineNode: { | ||
| 87 | children: "defaultInlineNode[]", | ||
| 88 | type: "'list-item'", | ||
| 89 | }, | ||
| 90 | |||
| 91 | node: "rootNode | nonTextInlineNode", | ||
| 92 | |||
| 93 | nonTextInlineNode: "linkInlineNode | listItemInlineNode", | ||
| 94 | |||
| 95 | paragraphBlockNode: { | ||
| 96 | children: "defaultInlineNode[]", | ||
| 97 | type: "'paragraph'", | ||
| 98 | }, | ||
| 99 | |||
| 100 | quoteBlockNode: { | ||
| 101 | children: "defaultInlineNode[]", | ||
| 102 | type: "'quote'", | ||
| 103 | }, | ||
| 104 | |||
| 105 | rootNode: | ||
| 106 | "paragraphBlockNode | quoteBlockNode | codeBlockNode | headingBlockNode | listBlockNode | imageBlockNode", | ||
| 107 | |||
| 108 | textInlineNode: { | ||
| 109 | "bold?": "boolean", | ||
| 110 | "code?": "boolean", | ||
| 111 | "italic?": "boolean", | ||
| 112 | "strikethrough?": "boolean", | ||
| 113 | "text": "string", | ||
| 114 | "type": "'text'", | ||
| 115 | "underline?": "boolean", | ||
| 116 | }, | ||
| 117 | }) | ||
| 118 | .export(); | ||
| 119 | |||
| 120 | export const defaultInlineNodeSchema = blocksAttributeScope.defaultInlineNode; | ||
| 121 | |||
| 122 | export type DefaultInlineNode = typeof defaultInlineNodeSchema.infer; | ||
| 123 | |||
| 124 | export const nodeSchema = blocksAttributeScope.node; | ||
| 125 | |||
| 126 | export type Node = typeof nodeSchema.infer; | ||
| 127 | |||
| 128 | export const strapiItemSchema = type({ | ||
| 129 | "[string]": "unknown", | ||
| 130 | "documentId": "string", | ||
| 131 | "publishedAt": "string.date.parse | null", | ||
| 132 | "updatedAt": "string.date.parse", | ||
| 133 | }); | ||
| 134 | |||
| 135 | export type StrapiItem = typeof strapiItemSchema.infer; | ||
| 136 | |||
| 137 | export function walkBlocks( | ||
| 138 | content: (Node | DefaultInlineNode)[], | ||
| 139 | fn: (_: Node | DefaultInlineNode) => unknown, | ||
| 140 | ): void { | ||
| 141 | for (const node of content) { | ||
| 142 | fn(node); | ||
| 143 | |||
| 144 | switch (node.type) { | ||
| 145 | case "heading": | ||
| 146 | case "code": | ||
| 147 | case "quote": | ||
| 148 | case "paragraph": | ||
| 149 | case "list": | ||
| 150 | case "list-item": | ||
| 151 | case "link": { | ||
| 152 | return walkBlocks(node.children, fn); | ||
| 153 | } | ||
| 154 | } | ||
| 155 | } | ||
| 156 | } | ||
| 157 | |||
| 158 | function blocksNodeToTSX(node: Node | DefaultInlineNode) { | ||
| 159 | switch (true) { | ||
| 160 | case node.type === "heading": { | ||
| 161 | switch (node.level) { | ||
| 162 | case 1: | ||
| 163 | return <h1>{blocksToTSX(node.children)}</h1>; | ||
| 164 | case 2: | ||
| 165 | return <h2>{blocksToTSX(node.children)}</h2>; | ||
| 166 | case 3: | ||
| 167 | return <h3>{blocksToTSX(node.children)}</h3>; | ||
| 168 | case 4: | ||
| 169 | return <h4>{blocksToTSX(node.children)}</h4>; | ||
| 170 | case 5: | ||
| 171 | return <h5>{blocksToTSX(node.children)}</h5>; | ||
| 172 | default: | ||
| 173 | return <h6>{blocksToTSX(node.children)}</h6>; | ||
| 174 | } | ||
| 175 | } | ||
| 176 | |||
| 177 | case node.type === "image" && !!node.image.caption: { | ||
| 178 | return ( | ||
| 179 | <figure> | ||
| 180 | <ContentImage src={node.image.url} alt={node.image.alternativeText ?? undefined} /> | ||
| 181 | <figcaption>{node.image.caption}</figcaption> | ||
| 182 | </figure> | ||
| 183 | ); | ||
| 184 | } | ||
| 185 | |||
| 186 | case node.type === "image": { | ||
| 187 | return <ContentImage src={node.image.url} alt={node.image.alternativeText ?? undefined} />; | ||
| 188 | } | ||
| 189 | |||
| 190 | case node.type === "code": { | ||
| 191 | return ( | ||
| 192 | <pre> | ||
| 193 | <code>{blocksToTSX(node.children)}</code> | ||
| 194 | </pre> | ||
| 195 | ); | ||
| 196 | } | ||
| 197 | |||
| 198 | case node.type === "quote": { | ||
| 199 | return <blockquote>{blocksToTSX(node.children)}</blockquote>; | ||
| 200 | } | ||
| 201 | |||
| 202 | case node.type === "paragraph" && | ||
| 203 | node.children.length === 1 && | ||
| 204 | node.children[0]?.type === "text" && | ||
| 205 | node.children[0].text === "---": { | ||
| 206 | return <hr />; | ||
| 207 | } | ||
| 208 | |||
| 209 | case node.type === "paragraph": { | ||
| 210 | return <p>{blocksToTSX(node.children)}</p>; | ||
| 211 | } | ||
| 212 | |||
| 213 | case node.type === "list" && node.format === "ordered": { | ||
| 214 | return <ol>${blocksToTSX(node.children)}</ol>; | ||
| 215 | } | ||
| 216 | |||
| 217 | case node.type === "list": { | ||
| 218 | return <ul>${blocksToTSX(node.children)}</ul>; | ||
| 219 | } | ||
| 220 | |||
| 221 | case node.type === "list-item": { | ||
| 222 | return <li>${blocksToTSX(node.children)}</li>; | ||
| 223 | } | ||
| 224 | |||
| 225 | case node.type === "link": { | ||
| 226 | return <a href={node.url}>{blocksToTSX(node.children)}</a>; | ||
| 227 | } | ||
| 228 | |||
| 229 | case node.type === "text": { | ||
| 230 | return [ | ||
| 231 | node.bold ? "strong" : undefined, | ||
| 232 | node.code ? "code" : undefined, | ||
| 233 | node.italic ? "em" : undefined, | ||
| 234 | node.strikethrough ? "del" : undefined, | ||
| 235 | node.underline ? "u" : undefined, | ||
| 236 | ].reduce((res: JSX.Element, Tag) => (Tag ? <Tag>{res}</Tag> : res), node.text); | ||
| 237 | } | ||
| 238 | } | ||
| 239 | } | ||
| 240 | |||
| 241 | export function blocksToTSX( | ||
| 242 | content: (Node | DefaultInlineNode)[], | ||
| 243 | filter: (node: Node | DefaultInlineNode) => boolean = () => true, | ||
| 244 | ): JSX.Element { | ||
| 245 | return content | ||
| 246 | .filter(filter) | ||
| 247 | .map(blocksNodeToTSX) | ||
| 248 | .filter((s) => s !== undefined); | ||
| 249 | } | ||
| 250 | |||
| 251 | export const strapiCollectionResponseSchema = type({ | ||
| 252 | data: strapiItemSchema.array(), | ||
| 253 | meta: { | ||
| 254 | pagination: { | ||
| 255 | page: "number", | ||
| 256 | pageCount: "number", | ||
| 257 | pageSize: "number", | ||
| 258 | total: "number", | ||
| 259 | }, | ||
| 260 | }, | ||
| 261 | }); | ||
| 262 | |||
| 263 | export const strapiItemResponseSchema = type({ | ||
| 264 | data: strapiItemSchema, | ||
| 265 | }); | ||
| 266 | |||
| 267 | async function* loadPaginated(url: URL | string, init?: RequestInit) { | ||
| 268 | let pageSize: number | undefined; | ||
| 269 | let pages = 1; | ||
| 270 | |||
| 271 | for (let i = 0; i < pages; ++i) { | ||
| 272 | const pageUrl = new URL(url); | ||
| 273 | |||
| 274 | if (pageSize) { | ||
| 275 | pageUrl.searchParams.set("pagination[page]", `${i + 1}`); | ||
| 276 | pageUrl.searchParams.set("pagination[pageSize]", `${pageSize}`); | ||
| 277 | } | ||
| 278 | |||
| 279 | const res = await fetch(pageUrl, init); | ||
| 280 | const json = await res.json(); | ||
| 281 | |||
| 282 | const dec = strapiCollectionResponseSchema(json); | ||
| 283 | |||
| 284 | if (dec instanceof type.errors) { | ||
| 285 | throw new Error(dec.summary); | ||
| 286 | } | ||
| 287 | |||
| 288 | pages = dec.meta.pagination.pageCount; | ||
| 289 | pageSize = dec.meta.pagination.pageSize; | ||
| 290 | |||
| 291 | for (const item of dec.data) { | ||
| 292 | yield item; | ||
| 293 | } | ||
| 294 | } | ||
| 295 | } | ||
| 296 | |||
| 297 | export class StrapiLoader { | ||
| 298 | init: RequestInit; | ||
| 299 | |||
| 300 | constructor( | ||
| 301 | readonly host: string | URL, | ||
| 302 | token: string, | ||
| 303 | readonly publicHost = host, | ||
| 304 | ) { | ||
| 305 | this.init = { | ||
| 306 | headers: { | ||
| 307 | Authorization: `Bearer ${token}`, | ||
| 308 | }, | ||
| 309 | keepalive: true, | ||
| 310 | }; | ||
| 311 | } | ||
| 312 | |||
| 313 | async loadSingle<T>( | ||
| 314 | collection: string, | ||
| 315 | schema: StandardSchemaV1<unknown, T>, | ||
| 316 | query: Record<string, string> = {}, | ||
| 317 | ) { | ||
| 318 | const url = new URL(`/api/${collection}`, this.host); | ||
| 319 | Object.entries(query).forEach(([key, value]) => { | ||
| 320 | url.searchParams.append(key, value); | ||
| 321 | }); | ||
| 322 | |||
| 323 | const res = await fetch(url, this.init); | ||
| 324 | const json = await res.json(); | ||
| 325 | |||
| 326 | const dec = strapiItemResponseSchema(json); | ||
| 327 | |||
| 328 | if (dec instanceof type.errors) { | ||
| 329 | throw new Error(`Schema mismatch in ${url}:\n${dec.summary}`); | ||
| 330 | } | ||
| 331 | |||
| 332 | const dec2 = await schema["~standard"].validate(dec.data); | ||
| 333 | |||
| 334 | if (dec2.issues) { | ||
| 335 | throw new Error(`Schema mismatch in ${url}:\n${dec2.issues.join("\n")}`); | ||
| 336 | } | ||
| 337 | |||
| 338 | return { | ||
| 339 | documentId: dec.data.documentId, | ||
| 340 | publishedAt: dec.data.publishedAt, | ||
| 341 | updatedAt: dec.data.updatedAt, | ||
| 342 | ...dec2.value, | ||
| 343 | }; | ||
| 344 | } | ||
| 345 | |||
| 346 | async *loadCollection<T>( | ||
| 347 | collection: string, | ||
| 348 | schema: StandardSchemaV1<unknown, T>, | ||
| 349 | query: Record<string, string> = {}, | ||
| 350 | ) { | ||
| 351 | const url = new URL(`/api/${collection}`, this.host); | ||
| 352 | Object.entries(query).forEach(([key, value]) => { | ||
| 353 | url.searchParams.append(key, value); | ||
| 354 | }); | ||
| 355 | |||
| 356 | for await (const item of loadPaginated(url, this.init)) { | ||
| 357 | const dec = await schema["~standard"].validate(item); | ||
| 358 | |||
| 359 | if (dec.issues) { | ||
| 360 | yield new PipelineStageError( | ||
| 361 | Severity.ERROR, | ||
| 362 | `Schema mismatch in ${url}`, | ||
| 363 | dec.issues.join("\n"), | ||
| 364 | ); | ||
| 365 | } else { | ||
| 366 | yield { | ||
| 367 | documentId: item.documentId, | ||
| 368 | publishedAt: item.publishedAt, | ||
| 369 | updatedAt: item.updatedAt, | ||
| 370 | ...dec.value, | ||
| 371 | }; | ||
| 372 | } | ||
| 373 | } | ||
| 374 | } | ||
| 375 | |||
| 376 | readonly resolvedFileAttributeSchema = fileAttributeSchema.pipe(({ url, previewUrl, ...o }) => ({ | ||
| 377 | ...o, | ||
| 378 | url: new URL(url, this.publicHost).href, | ||
| 379 | ...(previewUrl ? { previewUrl: new URL(previewUrl, this.publicHost).href } : {}), | ||
| 380 | })); | ||
| 381 | |||
| 382 | readonly resolvedImageAttributeSchema = imageAttributeSchema.pipe((o) => ({ | ||
| 383 | ...o, | ||
| 384 | previewUrl: o.previewUrl ? new URL(o.previewUrl, this.publicHost).href : undefined, | ||
| 385 | url: new URL(o.url, this.publicHost).href, | ||
| 386 | })); | ||
| 387 | } | ||
| 388 | |||
| 389 | export function strapi() { | ||
| 390 | return <Loaders extends string, Out extends { ".strapi": [Node[], {}] }, Helpers extends {}>( | ||
| 391 | pipeline: Pipeline<Loaders, Out, Helpers>, | ||
| 392 | ) => | ||
| 393 | pipeline.process("strapi", ".strapi", function* (page) { | ||
| 394 | yield new Page( | ||
| 395 | ".jsxelement", | ||
| 396 | blocksToTSX(page.content), | ||
| 397 | page.data as Out[".strapi"][1], | ||
| 398 | replaceExtension(page.url, ".jsx"), | ||
| 399 | page, | ||
| 400 | ); | ||
| 401 | }); | ||
| 402 | } | ||
| 403 | |||
| 404 | export const Blocks = ({ | ||
| 405 | content, | ||
| 406 | filter = () => true, | ||
| 407 | }: { | ||
| 408 | content: (Node | DefaultInlineNode)[]; | ||
| 409 | filter?: (node: Node | DefaultInlineNode) => boolean; | ||
| 410 | }) => blocksToTSX(content, filter); | ||
| 411 | |||
| 412 | const strapiUrl = Deno.env.get("STRAPI_URL") ?? ""; | ||
| 413 | const strapiPublicUrl = Deno.env.get("STRAPI_PUBLIC_URL"); | ||
| 414 | const strapiToken = Deno.env.get("STRAPI_TOKEN") ?? ""; | ||
| 415 | |||
| 416 | export const strapiLoader = new StrapiLoader(strapiUrl, strapiToken, strapiPublicUrl); | ||
diff --git a/src/pages/index.mdx b/src/pages/index.mdx new file mode 100644 index 0000000..1d35a1d --- /dev/null +++ b/src/pages/index.mdx | |||
| @@ -0,0 +1,10 @@ | |||
| 1 | --- | ||
| 2 | type: "home" | ||
| 3 | title: "Corvos · static site generator" | ||
| 4 | description: "Project website of Corvos, a static site generator for Deno." | ||
| 5 | --- | ||
| 6 | |||
| 7 | import { template } from "./index.tsx"; | ||
| 8 | export { template }; | ||
| 9 | |||
| 10 | Corvos is a Static Site Generator with the goal to provide a customizeable, type-safe experience. | ||
diff --git a/src/pages/index.tsx b/src/pages/index.tsx new file mode 100644 index 0000000..b1581b7 --- /dev/null +++ b/src/pages/index.tsx | |||
| @@ -0,0 +1,64 @@ | |||
| 1 | import { BaseLayout } from "$/ui/base/BaseLayout.tsx"; | ||
| 2 | import { ReleaseCard } from "$/ui/components/ReleaseCard.tsx"; | ||
| 3 | import { SiteHeader } from "$/ui/components/SiteHeader.tsx"; | ||
| 4 | import { CardList } from "$/ui/layouts/CardList.tsx"; | ||
| 5 | import { Container } from "$/ui/layouts/Container.tsx"; | ||
| 6 | import { Button } from "$/ui/objects/Button.tsx"; | ||
| 7 | import { Divider } from "$/ui/objects/Divider.tsx"; | ||
| 8 | import { Heading } from "$/ui/objects/Heading.tsx"; | ||
| 9 | import { Icon } from "$/ui/objects/Icon.tsx"; | ||
| 10 | import site from "@/corvos.config.ts"; | ||
| 11 | |||
| 12 | export const template = site.$.template({ type: "home" }, (page, pages) => { | ||
| 13 | const query = site.$.query(pages); | ||
| 14 | const releasesIndex = query.type(".html").match({ type: "releases-index" }).one(); | ||
| 15 | const releases = query.type(".html").match({ type: "release" }).many().toArray(); | ||
| 16 | |||
| 17 | return ( | ||
| 18 | <BaseLayout page={page}> | ||
| 19 | <SiteHeader | ||
| 20 | url={page.url} | ||
| 21 | sm={ | ||
| 22 | <div class="l-media__block l-media__block--main"> | ||
| 23 | <Heading class="u-mbs-0 u-elp">{page.data.title}</Heading> | ||
| 24 | </div> | ||
| 25 | } | ||
| 26 | /> | ||
| 27 | |||
| 28 | <Container fixed="150" pad-inline pad-block class="c-home-banner"> | ||
| 29 | <Heading as="h1" class="c-home-banner__intro"> | ||
| 30 | <strong class="o-heading__highlight"> | ||
| 31 | <a class="p-name u-url u-uid" href={site.$.url("", true)}> | ||
| 32 | Corvos | ||
| 33 | </a> | ||
| 34 | </strong>{" "} | ||
| 35 | – A static site generator for Deno. | ||
| 36 | </Heading> | ||
| 37 | |||
| 38 | <div class="u-mbs-400 c-subtitle">{{ __html: page.content }}</div> | ||
| 39 | |||
| 40 | <div class="u-mbs-700"> | ||
| 41 | <Button | ||
| 42 | href={releasesIndex.url} | ||
| 43 | post={<Icon icon="arrow-right" variant="bold" />} | ||
| 44 | size="xl" | ||
| 45 | variant="primary" | ||
| 46 | wide | ||
| 47 | > | ||
| 48 | Releases | ||
| 49 | </Button> | ||
| 50 | </div> | ||
| 51 | </Container> | ||
| 52 | |||
| 53 | <Divider level="faint" /> | ||
| 54 | |||
| 55 | <Container class="u-mbs-neutralize" fixed="150" pad-block pad-inline> | ||
| 56 | <CardList keyboard-nav> | ||
| 57 | {releases.map((page) => ( | ||
| 58 | <ReleaseCard page={page} heading-level="h3" list /> | ||
| 59 | ))} | ||
| 60 | </CardList> | ||
| 61 | </Container> | ||
| 62 | </BaseLayout> | ||
| 63 | ); | ||
| 64 | }); | ||
diff --git a/src/pages/pkg/corvos@[version].ts b/src/pages/pkg/corvos@[version].ts new file mode 100644 index 0000000..6dcb65a --- /dev/null +++ b/src/pages/pkg/corvos@[version].ts | |||
| @@ -0,0 +1,35 @@ | |||
| 1 | import * as zip from "@zip-js/zip-js"; | ||
| 2 | import { Page, PipelineStageError } from "corvos"; | ||
| 3 | import { binaryLoader } from "corvos/core/loaders/binary.ts"; | ||
| 4 | |||
| 5 | import { strapiLoader } from "$/deps/strapi.tsx"; | ||
| 6 | import { releaseStrapiSchema } from "$/schemas/releases.ts"; | ||
| 7 | import site from "@/corvos.config.ts"; | ||
| 8 | |||
| 9 | export const pages = site.$.pageGenerator(async function* () { | ||
| 10 | for await (const item of strapiLoader.loadCollection("corvos-releases", releaseStrapiSchema, { | ||
| 11 | "populate[0]": "files", | ||
| 12 | "sort[0]": "date:desc", | ||
| 13 | })) { | ||
| 14 | if (item instanceof PipelineStageError) { | ||
| 15 | yield item; | ||
| 16 | continue; | ||
| 17 | } | ||
| 18 | |||
| 19 | const res = await binaryLoader(new URL(item.files.url)); | ||
| 20 | const reader = new zip.ZipReader(new zip.Uint8ArrayReader(res.content)); | ||
| 21 | |||
| 22 | for await (const entry of reader.getEntriesGenerator()) { | ||
| 23 | if (entry.directory) { | ||
| 24 | continue; | ||
| 25 | } | ||
| 26 | |||
| 27 | yield new Page( | ||
| 28 | ".", | ||
| 29 | new Uint8Array(await entry.arrayBuffer()), | ||
| 30 | { version: item.version }, | ||
| 31 | `/pkg/corvos@${item.version}/${entry.filename}`, | ||
| 32 | ); | ||
| 33 | } | ||
| 34 | } | ||
| 35 | }); | ||
diff --git a/src/pages/releases/[version].tsx b/src/pages/releases/[version].tsx new file mode 100644 index 0000000..84d807a --- /dev/null +++ b/src/pages/releases/[version].tsx | |||
| @@ -0,0 +1,100 @@ | |||
| 1 | import { Page, PipelineStageError } from "corvos"; | ||
| 2 | |||
| 3 | import { strapiLoader } from "$/deps/strapi.tsx"; | ||
| 4 | import { releaseStrapiSchema } from "$/schemas/releases.ts"; | ||
| 5 | import { BaseLayout } from "$/ui/base/BaseLayout.tsx"; | ||
| 6 | import { ReleaseCard } from "$/ui/components/ReleaseCard.tsx"; | ||
| 7 | import { SiteHeader } from "$/ui/components/SiteHeader.tsx"; | ||
| 8 | import { CardList } from "$/ui/layouts/CardList.tsx"; | ||
| 9 | import { Container } from "$/ui/layouts/Container.tsx"; | ||
| 10 | import { HList } from "$/ui/layouts/HList.tsx"; | ||
| 11 | import { ActionButton } from "$/ui/objects/ActionButton.tsx"; | ||
| 12 | import { Badge } from "$/ui/objects/Badge.tsx"; | ||
| 13 | import { Body } from "$/ui/objects/Body.tsx"; | ||
| 14 | import { Divider } from "$/ui/objects/Divider.tsx"; | ||
| 15 | import { Heading } from "$/ui/objects/Heading.tsx"; | ||
| 16 | import { Icon } from "$/ui/objects/Icon.tsx"; | ||
| 17 | import site from "@/corvos.config.ts"; | ||
| 18 | |||
| 19 | export const pages = site.$.pageGenerator(async function* () { | ||
| 20 | for await (const item of strapiLoader.loadCollection("corvos-releases", releaseStrapiSchema, { | ||
| 21 | "populate[0]": "files", | ||
| 22 | "sort[0]": "date:desc", | ||
| 23 | })) { | ||
| 24 | if (item instanceof PipelineStageError) { | ||
| 25 | yield item; | ||
| 26 | } else { | ||
| 27 | yield new Page(".strapi", item.notes ?? [], { | ||
| 28 | ...item, | ||
| 29 | title: `Corvos ${item.version}`, | ||
| 30 | type: "release" as const, | ||
| 31 | }); | ||
| 32 | } | ||
| 33 | } | ||
| 34 | }); | ||
| 35 | |||
| 36 | export const template = site.$.template({ type: "release" }, (page, pages) => { | ||
| 37 | const query = site.$.query(pages); | ||
| 38 | |||
| 39 | const feed = query.type(".rss").match({ type: "releases" }).one(); | ||
| 40 | |||
| 41 | const prev = query.type(".html").match({ type: "release" }).prev(page); | ||
| 42 | const next = query.type(".html").match({ type: "release" }).next(page); | ||
| 43 | |||
| 44 | return ( | ||
| 45 | <BaseLayout page={page}> | ||
| 46 | <SiteHeader | ||
| 47 | mode="compact" | ||
| 48 | url={page.url} | ||
| 49 | end={ | ||
| 50 | <div class="l-media__block"> | ||
| 51 | <ActionButton | ||
| 52 | align-block | ||
| 53 | class="u-mbs-0" | ||
| 54 | href={feed.url} | ||
| 55 | icon={<Icon icon="rss-simple" variant="bold" />} | ||
| 56 | level="quiet" | ||
| 57 | pill | ||
| 58 | title="RSS Feed" | ||
| 59 | /> | ||
| 60 | </div> | ||
| 61 | } | ||
| 62 | > | ||
| 63 | <div class="l-media__block l-media__block--main"> | ||
| 64 | <Heading class="u-mbs-0 u-elp">{page.data.title}</Heading> | ||
| 65 | </div> | ||
| 66 | </SiteHeader> | ||
| 67 | |||
| 68 | <Container as="main" class="u-mbs-neutralize h-entry" fixed="150" pad-block pad-inline> | ||
| 69 | <HList aria-label="Categories"> | ||
| 70 | <li> | ||
| 71 | <Badge as="strong" pill selected> | ||
| 72 | <time class="dt-published" datetime={page.data.date.toISOString()}> | ||
| 73 | {page.data.date.toLocaleDateString(undefined, { | ||
| 74 | day: "numeric", | ||
| 75 | month: "short", | ||
| 76 | year: "numeric", | ||
| 77 | })} | ||
| 78 | </time> | ||
| 79 | </Badge> | ||
| 80 | </li> | ||
| 81 | </HList> | ||
| 82 | |||
| 83 | <Heading as="h1" class="u-mbs-200 p-name" display highlight level="xxl"> | ||
| 84 | {page.data.title} | ||
| 85 | </Heading> | ||
| 86 | |||
| 87 | <Body class="e-content">{{ __html: page.content }}</Body> | ||
| 88 | </Container> | ||
| 89 | |||
| 90 | <Divider level="faint" /> | ||
| 91 | |||
| 92 | <Container as="footer" class="u-mbs-neutralize" fixed="150" pad-block pad-inline> | ||
| 93 | <CardList layout="grid"> | ||
| 94 | {prev && <ReleaseCard heading-level="h2" page={prev} list />} | ||
| 95 | {next && <ReleaseCard heading-level="h2" page={next} list />} | ||
| 96 | </CardList> | ||
| 97 | </Container> | ||
| 98 | </BaseLayout> | ||
| 99 | ); | ||
| 100 | }); | ||
diff --git a/src/pages/releases/feed.ts b/src/pages/releases/feed.ts new file mode 100644 index 0000000..b430ca5 --- /dev/null +++ b/src/pages/releases/feed.ts | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | import type { FeedItem } from "corvos/plugins/feeds.ts"; | ||
| 2 | |||
| 3 | import site from "@/corvos.config.ts"; | ||
| 4 | |||
| 5 | export const feeds = site.$.feedGenerator(function* (pages) { | ||
| 6 | const items = site.$.query(pages) | ||
| 7 | .match({ type: "release" }) | ||
| 8 | .many() | ||
| 9 | .take(20) | ||
| 10 | .map( | ||
| 11 | (page): FeedItem => ({ | ||
| 12 | title: page.data.title, | ||
| 13 | url: page.url, | ||
| 14 | content: page.content, | ||
| 15 | updated: page.data.date, | ||
| 16 | }), | ||
| 17 | ); | ||
| 18 | yield { type: "releases", title: `Releases`, url: "/releases/", items }; | ||
| 19 | }); | ||
diff --git a/src/pages/releases/index.mdx b/src/pages/releases/index.mdx new file mode 100644 index 0000000..6519c76 --- /dev/null +++ b/src/pages/releases/index.mdx | |||
| @@ -0,0 +1,8 @@ | |||
| 1 | --- | ||
| 2 | type: "releases-index" | ||
| 3 | title: "Releases" | ||
| 4 | description: "All releases of Corvos." | ||
| 5 | --- | ||
| 6 | |||
| 7 | import { template } from "./index.tsx"; | ||
| 8 | export { template }; | ||
diff --git a/src/pages/releases/index.tsx b/src/pages/releases/index.tsx new file mode 100644 index 0000000..132eee7 --- /dev/null +++ b/src/pages/releases/index.tsx | |||
| @@ -0,0 +1,51 @@ | |||
| 1 | import { BaseLayout } from "$/ui/base/BaseLayout.tsx"; | ||
| 2 | import { ReleaseCard } from "$/ui/components/ReleaseCard.tsx"; | ||
| 3 | import { SiteHeader } from "$/ui/components/SiteHeader.tsx"; | ||
| 4 | import { CardList } from "$/ui/layouts/CardList.tsx"; | ||
| 5 | import { Container } from "$/ui/layouts/Container.tsx"; | ||
| 6 | import { ActionButton } from "$/ui/objects/ActionButton.tsx"; | ||
| 7 | import { Heading } from "$/ui/objects/Heading.tsx"; | ||
| 8 | import { Icon } from "$/ui/objects/Icon.tsx"; | ||
| 9 | import { PlaceholderBox } from "$/ui/objects/PlaceholderBox.tsx"; | ||
| 10 | import site from "@/corvos.config.ts"; | ||
| 11 | |||
| 12 | export const template = site.$.template({ type: "releases-index" }, (page, pages) => { | ||
| 13 | const query = site.$.query(pages); | ||
| 14 | const items = query.type(".html").match({ type: "release" }).many().toArray(); | ||
| 15 | const feed = query.type(".rss").match({ type: "releases" }).one(); | ||
| 16 | |||
| 17 | return ( | ||
| 18 | <BaseLayout page={page}> | ||
| 19 | <SiteHeader | ||
| 20 | url={page.url} | ||
| 21 | end={ | ||
| 22 | <div class="l-media__block"> | ||
| 23 | <ActionButton | ||
| 24 | align-block | ||
| 25 | class="u-mbs-0" | ||
| 26 | href={feed.url} | ||
| 27 | icon={<Icon icon="rss-simple" variant="bold" />} | ||
| 28 | level="quiet" | ||
| 29 | pill | ||
| 30 | title="RSS Feed" | ||
| 31 | /> | ||
| 32 | </div> | ||
| 33 | } | ||
| 34 | sm={ | ||
| 35 | <div class="l-media__block l-media__block--main"> | ||
| 36 | <Heading class="u-mbs-0 u-elp">{page.data.title}</Heading> | ||
| 37 | </div> | ||
| 38 | } | ||
| 39 | ></SiteHeader> | ||
| 40 | |||
| 41 | <Container as="main" fixed pad-block pad-inline> | ||
| 42 | <CardList keyboard-nav> | ||
| 43 | {items.map((item) => ( | ||
| 44 | <ReleaseCard page={item} heading-level="h2" list /> | ||
| 45 | ))} | ||
| 46 | {!items.length && <PlaceholderBox as="em">Nothing there yet</PlaceholderBox>} | ||
| 47 | </CardList> | ||
| 48 | </Container> | ||
| 49 | </BaseLayout> | ||
| 50 | ); | ||
| 51 | }); | ||
diff --git a/src/pages/sitemap.ts b/src/pages/sitemap.ts new file mode 100644 index 0000000..378a9ed --- /dev/null +++ b/src/pages/sitemap.ts | |||
| @@ -0,0 +1,8 @@ | |||
| 1 | import site from "@/corvos.config.ts"; | ||
| 2 | |||
| 3 | export const sitemap = site.$.sitemap((pages) => ({ | ||
| 4 | items: pages.map((page) => ({ | ||
| 5 | loc: page.url, | ||
| 6 | lastmod: page.data.modifiedAt, | ||
| 7 | })), | ||
| 8 | })); | ||
diff --git a/src/schemas/misc.ts b/src/schemas/misc.ts new file mode 100644 index 0000000..e86df72 --- /dev/null +++ b/src/schemas/misc.ts | |||
| @@ -0,0 +1,16 @@ | |||
| 1 | import { type } from "arktype"; | ||
| 2 | |||
| 3 | export const miscSchema = type.or( | ||
| 4 | { | ||
| 5 | type: "'home'", | ||
| 6 | title: "string", | ||
| 7 | description: "string", | ||
| 8 | }, | ||
| 9 | { | ||
| 10 | type: "'releases-index'", | ||
| 11 | title: "string", | ||
| 12 | description: "string", | ||
| 13 | }, | ||
| 14 | ); | ||
| 15 | |||
| 16 | export type Misc = typeof miscSchema.infer; | ||
diff --git a/src/schemas/mod.ts b/src/schemas/mod.ts new file mode 100644 index 0000000..69d6c6f --- /dev/null +++ b/src/schemas/mod.ts | |||
| @@ -0,0 +1,8 @@ | |||
| 1 | import { type } from "arktype"; | ||
| 2 | |||
| 3 | import { miscSchema } from "$/schemas/misc.ts"; | ||
| 4 | import { releaseSchema } from "$/schemas/releases.ts"; | ||
| 5 | |||
| 6 | export const pageDataSchema = type.or(releaseSchema, miscSchema); | ||
| 7 | |||
| 8 | export type PageData = typeof pageDataSchema.infer; | ||
diff --git a/src/schemas/releases.ts b/src/schemas/releases.ts new file mode 100644 index 0000000..0ccb6a6 --- /dev/null +++ b/src/schemas/releases.ts | |||
| @@ -0,0 +1,18 @@ | |||
| 1 | import { type } from "arktype"; | ||
| 2 | |||
| 3 | import { nodeSchema } from "../deps/strapi.tsx"; | ||
| 4 | import { strapiLoader } from "../deps/strapi.tsx"; | ||
| 5 | |||
| 6 | export const releaseStrapiSchema = type({ | ||
| 7 | "version": "string", | ||
| 8 | "date": "string.date.parse", | ||
| 9 | "notes?": nodeSchema.array().or("null"), | ||
| 10 | "files": strapiLoader.resolvedFileAttributeSchema, | ||
| 11 | }); | ||
| 12 | |||
| 13 | export const releaseSchema = releaseStrapiSchema.merge({ | ||
| 14 | type: "'release'", | ||
| 15 | title: "string", | ||
| 16 | }); | ||
| 17 | |||
| 18 | export type Release = typeof releaseSchema.infer; | ||
diff --git a/src/scripts/lib/dom.ts b/src/scripts/lib/dom.ts new file mode 100644 index 0000000..a88686a --- /dev/null +++ b/src/scripts/lib/dom.ts | |||
| @@ -0,0 +1,59 @@ | |||
| 1 | function isAbove(a: HTMLElement, b: HTMLElement) { | ||
| 2 | return a.offsetTop + a.clientHeight < b.offsetTop; | ||
| 3 | } | ||
| 4 | |||
| 5 | export function getVerticalNeighbor( | ||
| 6 | dir: -1 | 1, | ||
| 7 | items: Iterable<HTMLElement>, | ||
| 8 | current: HTMLElement, | ||
| 9 | ) { | ||
| 10 | let candidates: HTMLElement[] = []; | ||
| 11 | |||
| 12 | if (dir === -1) { | ||
| 13 | let adjacentY = Number.MIN_SAFE_INTEGER; | ||
| 14 | |||
| 15 | for (const item of items) { | ||
| 16 | if (!isAbove(item, current)) { | ||
| 17 | continue; | ||
| 18 | } | ||
| 19 | |||
| 20 | if (item.offsetTop > adjacentY) { | ||
| 21 | adjacentY = item.offsetTop; | ||
| 22 | candidates = [item]; | ||
| 23 | } else if (item.offsetTop === adjacentY) { | ||
| 24 | candidates.push(item); | ||
| 25 | } | ||
| 26 | } | ||
| 27 | } else { | ||
| 28 | let adjacentY = Number.MAX_SAFE_INTEGER; | ||
| 29 | |||
| 30 | for (const item of items) { | ||
| 31 | if (!isAbove(current, item)) { | ||
| 32 | continue; | ||
| 33 | } | ||
| 34 | |||
| 35 | if (item.offsetTop < adjacentY) { | ||
| 36 | adjacentY = item.offsetTop; | ||
| 37 | candidates = [item]; | ||
| 38 | } else if (item.offsetTop === adjacentY) { | ||
| 39 | candidates.push(item); | ||
| 40 | } | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | const currentX = current.offsetLeft + 0.5 * current.clientWidth; | ||
| 45 | let match = candidates[0]; | ||
| 46 | let bestDist = Number.MAX_SAFE_INTEGER; | ||
| 47 | |||
| 48 | for (const candidate of candidates) { | ||
| 49 | const candidateX = candidate.offsetLeft + 0.5 * candidate.clientWidth; | ||
| 50 | const dist = Math.abs(currentX - candidateX); | ||
| 51 | |||
| 52 | if (dist < bestDist) { | ||
| 53 | bestDist = dist; | ||
| 54 | match = candidate; | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 58 | return match; | ||
| 59 | } | ||
diff --git a/src/scripts/lib/keyboardNav.ts b/src/scripts/lib/keyboardNav.ts new file mode 100644 index 0000000..4c79a08 --- /dev/null +++ b/src/scripts/lib/keyboardNav.ts | |||
| @@ -0,0 +1,272 @@ | |||
| 1 | import { getVerticalNeighbor } from "./dom.ts"; | ||
| 2 | |||
| 3 | function focusPrev( | ||
| 4 | owner: HTMLElement, | ||
| 5 | itemSelector: string, | ||
| 6 | wrap: boolean, | ||
| 7 | stateful: boolean, | ||
| 8 | current: HTMLElement, | ||
| 9 | ) { | ||
| 10 | const items = [...owner.querySelectorAll<HTMLElement>(itemSelector)]; | ||
| 11 | const i = items.indexOf(current); | ||
| 12 | |||
| 13 | const prev = i > 0 ? items[i - 1] : wrap ? items.at(-1) : undefined; | ||
| 14 | |||
| 15 | if (!prev || prev === current) { | ||
| 16 | return false; | ||
| 17 | } | ||
| 18 | |||
| 19 | if (stateful) { | ||
| 20 | current.setAttribute("tabindex", "-1"); | ||
| 21 | prev.removeAttribute("tabindex"); | ||
| 22 | } | ||
| 23 | prev.focus(); | ||
| 24 | return true; | ||
| 25 | } | ||
| 26 | |||
| 27 | function focusNext( | ||
| 28 | owner: HTMLElement, | ||
| 29 | itemSelector: string, | ||
| 30 | wrap: boolean, | ||
| 31 | stateful: boolean, | ||
| 32 | current: HTMLElement, | ||
| 33 | ) { | ||
| 34 | const items = [...owner.querySelectorAll<HTMLElement>(itemSelector)]; | ||
| 35 | const i = items.indexOf(current); | ||
| 36 | |||
| 37 | const next = i < items.length - 1 ? items[i + 1] : wrap ? items[0] : undefined; | ||
| 38 | |||
| 39 | if (!next || next === current) { | ||
| 40 | return false; | ||
| 41 | } | ||
| 42 | |||
| 43 | if (stateful) { | ||
| 44 | current.setAttribute("tabindex", "-1"); | ||
| 45 | next.removeAttribute("tabindex"); | ||
| 46 | } | ||
| 47 | next.focus(); | ||
| 48 | return true; | ||
| 49 | } | ||
| 50 | |||
| 51 | function focusUp( | ||
| 52 | owner: HTMLElement, | ||
| 53 | itemSelector: string, | ||
| 54 | wrap: boolean, | ||
| 55 | stateful: boolean, | ||
| 56 | current: HTMLElement, | ||
| 57 | ) { | ||
| 58 | const items = [...owner.querySelectorAll<HTMLElement>(itemSelector)]; | ||
| 59 | let neighbor: HTMLElement | undefined = getVerticalNeighbor(-1, items, current); | ||
| 60 | |||
| 61 | if (!neighbor && wrap) { | ||
| 62 | neighbor = items.at(-1); | ||
| 63 | } | ||
| 64 | |||
| 65 | if (!neighbor || neighbor === current) { | ||
| 66 | return false; | ||
| 67 | } | ||
| 68 | |||
| 69 | if (stateful) { | ||
| 70 | current.setAttribute("tabindex", "-1"); | ||
| 71 | neighbor.removeAttribute("tabindex"); | ||
| 72 | } | ||
| 73 | neighbor.focus(); | ||
| 74 | return true; | ||
| 75 | } | ||
| 76 | |||
| 77 | function focusDown( | ||
| 78 | owner: HTMLElement, | ||
| 79 | itemSelector: string, | ||
| 80 | wrap: boolean, | ||
| 81 | stateful: boolean, | ||
| 82 | current: HTMLElement, | ||
| 83 | ) { | ||
| 84 | const items = owner.querySelectorAll<HTMLElement>(itemSelector); | ||
| 85 | let neighbor = getVerticalNeighbor(1, items, current); | ||
| 86 | |||
| 87 | if (!neighbor && wrap) { | ||
| 88 | neighbor = items[0]; | ||
| 89 | } | ||
| 90 | |||
| 91 | if (!neighbor || neighbor === current) { | ||
| 92 | return false; | ||
| 93 | } | ||
| 94 | |||
| 95 | if (stateful) { | ||
| 96 | current.setAttribute("tabindex", "-1"); | ||
| 97 | neighbor.removeAttribute("tabindex"); | ||
| 98 | } | ||
| 99 | neighbor.focus(); | ||
| 100 | return true; | ||
| 101 | } | ||
| 102 | |||
| 103 | function focusFirst( | ||
| 104 | owner: HTMLElement, | ||
| 105 | itemSelector: string, | ||
| 106 | stateful: boolean, | ||
| 107 | current: HTMLElement, | ||
| 108 | ) { | ||
| 109 | const cards = [...owner.querySelectorAll<HTMLElement>(itemSelector)]; | ||
| 110 | const first = cards[0]; | ||
| 111 | |||
| 112 | if (!first || first === current) { | ||
| 113 | return false; | ||
| 114 | } | ||
| 115 | |||
| 116 | if (stateful) { | ||
| 117 | current.setAttribute("tabindex", "-1"); | ||
| 118 | first.removeAttribute("tabindex"); | ||
| 119 | } | ||
| 120 | first.focus(); | ||
| 121 | return true; | ||
| 122 | } | ||
| 123 | |||
| 124 | function focusLast( | ||
| 125 | owner: HTMLElement, | ||
| 126 | itemSelector: string, | ||
| 127 | stateful: boolean, | ||
| 128 | current: HTMLElement, | ||
| 129 | ) { | ||
| 130 | const cards = [...owner.querySelectorAll<HTMLElement>(itemSelector)]; | ||
| 131 | const last = cards.at(-1); | ||
| 132 | |||
| 133 | if (!last || last === current) { | ||
| 134 | return false; | ||
| 135 | } | ||
| 136 | |||
| 137 | if (stateful) { | ||
| 138 | current.setAttribute("tabindex", "-1"); | ||
| 139 | last.removeAttribute("tabindex"); | ||
| 140 | } | ||
| 141 | last.focus(); | ||
| 142 | return true; | ||
| 143 | } | ||
| 144 | |||
| 145 | function onKeyDown( | ||
| 146 | owner: HTMLElement, | ||
| 147 | itemSelector: string, | ||
| 148 | horizontal: boolean, | ||
| 149 | vertical: boolean, | ||
| 150 | wrap: boolean, | ||
| 151 | stateful: boolean, | ||
| 152 | e: KeyboardEvent, | ||
| 153 | ) { | ||
| 154 | let handled = false; | ||
| 155 | |||
| 156 | if (!e.currentTarget) { | ||
| 157 | return; | ||
| 158 | } | ||
| 159 | |||
| 160 | switch (e.key) { | ||
| 161 | case "ArrowLeft": { | ||
| 162 | if (horizontal) { | ||
| 163 | handled = focusPrev(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); | ||
| 164 | } | ||
| 165 | break; | ||
| 166 | } | ||
| 167 | |||
| 168 | case "ArrowRight": { | ||
| 169 | if (horizontal) { | ||
| 170 | handled = focusNext(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); | ||
| 171 | } | ||
| 172 | break; | ||
| 173 | } | ||
| 174 | |||
| 175 | case "ArrowUp": { | ||
| 176 | if (vertical) { | ||
| 177 | handled = focusUp(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); | ||
| 178 | } | ||
| 179 | break; | ||
| 180 | } | ||
| 181 | |||
| 182 | case "ArrowDown": { | ||
| 183 | if (vertical) { | ||
| 184 | handled = focusDown(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); | ||
| 185 | } | ||
| 186 | break; | ||
| 187 | } | ||
| 188 | |||
| 189 | case "Home": { | ||
| 190 | handled = focusFirst(owner, itemSelector, stateful, e.currentTarget as HTMLElement); | ||
| 191 | break; | ||
| 192 | } | ||
| 193 | |||
| 194 | case "End": { | ||
| 195 | handled = focusLast(owner, itemSelector, stateful, e.currentTarget as HTMLElement); | ||
| 196 | break; | ||
| 197 | } | ||
| 198 | } | ||
| 199 | |||
| 200 | if (handled) { | ||
| 201 | e.stopPropagation(); | ||
| 202 | e.preventDefault(); | ||
| 203 | } | ||
| 204 | } | ||
| 205 | |||
| 206 | export function mountKeyboardNav() { | ||
| 207 | const keyboardNavEls = document.querySelectorAll<HTMLElement>(".js-keyboard-nav"); | ||
| 208 | |||
| 209 | for (const keyboardNavEl of keyboardNavEls) { | ||
| 210 | const itemSelector = keyboardNavEl.dataset.keyboardNavItem ?? ""; | ||
| 211 | |||
| 212 | if (!itemSelector) { | ||
| 213 | console.error("Keyboard nav: No item class provided"); | ||
| 214 | continue; | ||
| 215 | } | ||
| 216 | |||
| 217 | let horizontal = false, | ||
| 218 | vertical = false, | ||
| 219 | wrap = false, | ||
| 220 | stateful = false; | ||
| 221 | |||
| 222 | switch (keyboardNavEl.dataset.keyboardNavMode) { | ||
| 223 | case "grid": { | ||
| 224 | horizontal = true; | ||
| 225 | vertical = true; | ||
| 226 | stateful = true; | ||
| 227 | break; | ||
| 228 | } | ||
| 229 | |||
| 230 | case "nav": { | ||
| 231 | horizontal = true; | ||
| 232 | wrap = true; | ||
| 233 | break; | ||
| 234 | } | ||
| 235 | |||
| 236 | case "menu": { | ||
| 237 | vertical = true; | ||
| 238 | wrap = true; | ||
| 239 | break; | ||
| 240 | } | ||
| 241 | |||
| 242 | case "list": { | ||
| 243 | vertical = true; | ||
| 244 | wrap = true; | ||
| 245 | stateful = true; | ||
| 246 | break; | ||
| 247 | } | ||
| 248 | } | ||
| 249 | |||
| 250 | const onKeyDown_ = (e: KeyboardEvent) => { | ||
| 251 | onKeyDown(keyboardNavEl, itemSelector, horizontal, vertical, wrap, stateful, e); | ||
| 252 | }; | ||
| 253 | |||
| 254 | const itemEls = [...keyboardNavEl.querySelectorAll<HTMLElement>(itemSelector)].filter((el) => | ||
| 255 | el.matches("a, button, input"), | ||
| 256 | ); | ||
| 257 | let focused = false; | ||
| 258 | |||
| 259 | for (const itemEl of itemEls) { | ||
| 260 | itemEl.addEventListener("keydown", onKeyDown_); | ||
| 261 | if (!itemEl.classList.contains("is-selected")) { | ||
| 262 | itemEl.setAttribute("tabindex", "-1"); | ||
| 263 | } else { | ||
| 264 | focused = true; | ||
| 265 | } | ||
| 266 | } | ||
| 267 | |||
| 268 | if (!focused) { | ||
| 269 | itemEls[0]?.removeAttribute("tabindex"); | ||
| 270 | } | ||
| 271 | } | ||
| 272 | } | ||
diff --git a/src/scripts/lib/lightbox.ts b/src/scripts/lib/lightbox.ts new file mode 100644 index 0000000..ac5b581 --- /dev/null +++ b/src/scripts/lib/lightbox.ts | |||
| @@ -0,0 +1,450 @@ | |||
| 1 | import Alpine from "alpinejs"; | ||
| 2 | |||
| 3 | export interface LightboxItem { | ||
| 4 | thumbnail?: string; | ||
| 5 | src: string; | ||
| 6 | srcset?: string; | ||
| 7 | alt: string; | ||
| 8 | } | ||
| 9 | |||
| 10 | function touchParams(e: TouchEvent) { | ||
| 11 | const [t0, t1] = e.touches; | ||
| 12 | |||
| 13 | if (e.touches.length > 2 || !t0) { | ||
| 14 | return; | ||
| 15 | } | ||
| 16 | |||
| 17 | let x = t0.clientX; | ||
| 18 | let y = t0.clientY; | ||
| 19 | let dist = 0; | ||
| 20 | |||
| 21 | if (t1) { | ||
| 22 | dist = Math.hypot(x - t1.clientX, y - t1.clientY); | ||
| 23 | x = (x + t1.clientX) / 2; | ||
| 24 | y = (y + t1.clientY) / 2; | ||
| 25 | } | ||
| 26 | |||
| 27 | return { x, y, dist }; | ||
| 28 | } | ||
| 29 | |||
| 30 | export function mountLightbox() { | ||
| 31 | const minZoom = 0.9; | ||
| 32 | |||
| 33 | Alpine.data("lightbox", () => ({ | ||
| 34 | _zoom: minZoom, | ||
| 35 | _offsetX: 0, | ||
| 36 | _offsetY: 0, | ||
| 37 | |||
| 38 | loading: false, | ||
| 39 | inertia: false, | ||
| 40 | items: <LightboxItem[]>[], | ||
| 41 | current: 0, | ||
| 42 | dragging: false, | ||
| 43 | touchStart: { | ||
| 44 | x: 0, | ||
| 45 | y: 0, | ||
| 46 | distance: 0, | ||
| 47 | transformOrigin: { x: 0, y: 0 }, | ||
| 48 | zoom: 1, | ||
| 49 | offsetX: 0, | ||
| 50 | offsetY: 0, | ||
| 51 | timestamp: 0, | ||
| 52 | }, | ||
| 53 | velocity: { | ||
| 54 | measure: false, | ||
| 55 | prevOffsetX: 0, | ||
| 56 | prevOffsetY: 0, | ||
| 57 | timestamp: 0, | ||
| 58 | x: 0, | ||
| 59 | y: 0, | ||
| 60 | }, | ||
| 61 | |||
| 62 | get zoom() { | ||
| 63 | return this._zoom; | ||
| 64 | }, | ||
| 65 | set zoom(value) { | ||
| 66 | this._zoom = Math.min(this.maxZoom, Math.max(minZoom, value)); | ||
| 67 | /* oxlint-disable-next-line eslint/no-self-assign */ // deno-lint-ignore no-self-assign | ||
| 68 | this.offsetX = this.offsetX; | ||
| 69 | /* oxlint-disable-next-line eslint/no-self-assign */ // deno-lint-ignore no-self-assign | ||
| 70 | this.offsetY = this.offsetY; | ||
| 71 | }, | ||
| 72 | |||
| 73 | get offsetX() { | ||
| 74 | return this._offsetX; | ||
| 75 | }, | ||
| 76 | set offsetX(value) { | ||
| 77 | const limit = this.offsetLimit; | ||
| 78 | this._offsetX = Math.min(limit.x, Math.max(-limit.x, value)); | ||
| 79 | }, | ||
| 80 | |||
| 81 | get offsetY() { | ||
| 82 | return this._offsetY; | ||
| 83 | }, | ||
| 84 | set offsetY(value) { | ||
| 85 | const limit = this.offsetLimit; | ||
| 86 | this._offsetY = Math.min(limit.y, Math.max(-limit.y, value)); | ||
| 87 | }, | ||
| 88 | |||
| 89 | get offsetLimit(): { x: number; y: number } { | ||
| 90 | if (!this.$refs.img) { | ||
| 91 | return { x: 0, y: 0 }; | ||
| 92 | } | ||
| 93 | |||
| 94 | const th = this.$refs.thumbnails?.clientHeight ?? 0; | ||
| 95 | const cw = this.$root.clientWidth; | ||
| 96 | const ch = this.$root.clientHeight - th; | ||
| 97 | const x = 0.5 * Math.max(0, this.$refs.img.clientWidth * this.zoom - cw); | ||
| 98 | const y = 0.5 * Math.max(0, this.$refs.img.clientHeight * this.zoom - ch); | ||
| 99 | |||
| 100 | return { x, y }; | ||
| 101 | }, | ||
| 102 | |||
| 103 | get open(): boolean { | ||
| 104 | return !!this.$root.hasAttribute("open"); | ||
| 105 | }, | ||
| 106 | |||
| 107 | get currentItem(): LightboxItem { | ||
| 108 | return this.items[this.current] ?? { src: "", alt: "" }; | ||
| 109 | }, | ||
| 110 | |||
| 111 | get transformOrigin(): { x: number; y: number } { | ||
| 112 | const img = this.$refs.img; | ||
| 113 | |||
| 114 | if (!img) { | ||
| 115 | return { x: this.offsetX, y: this.offsetY }; | ||
| 116 | } | ||
| 117 | |||
| 118 | return { | ||
| 119 | x: img.offsetLeft + img.clientWidth / 2 + this.offsetX, | ||
| 120 | y: img.offsetTop + img.clientHeight / 2 + this.offsetY, | ||
| 121 | }; | ||
| 122 | }, | ||
| 123 | |||
| 124 | get naturalZoom() { | ||
| 125 | const img = <HTMLImageElement>this.$refs.img; | ||
| 126 | return img ? img.naturalWidth / img.clientWidth : 1; | ||
| 127 | }, | ||
| 128 | |||
| 129 | get maxZoom() { | ||
| 130 | return Math.max(this.naturalZoom, 5); | ||
| 131 | }, | ||
| 132 | |||
| 133 | get zoomedFull() { | ||
| 134 | return Math.abs(this.zoom - this.naturalZoom) < 0.1; | ||
| 135 | }, | ||
| 136 | |||
| 137 | init() { | ||
| 138 | const triggerEls = document.querySelectorAll<HTMLAnchorElement>("a[data-lightbox]"); | ||
| 139 | const galleries = new Map<string, LightboxItem[]>(); | ||
| 140 | |||
| 141 | for (const triggerEl of triggerEls) { | ||
| 142 | const triggerImgEl = triggerEl.querySelector("img"); | ||
| 143 | |||
| 144 | const key = triggerEl.dataset.lightbox ?? ""; | ||
| 145 | const gallery = galleries.get(key); | ||
| 146 | const item: LightboxItem = { | ||
| 147 | thumbnail: triggerImgEl?.src, | ||
| 148 | src: triggerEl.href, | ||
| 149 | srcset: triggerEl.dataset.lightboxSrcset, | ||
| 150 | alt: "", | ||
| 151 | }; | ||
| 152 | |||
| 153 | if (gallery) { | ||
| 154 | gallery.push(item); | ||
| 155 | } else { | ||
| 156 | galleries.set(key, [item]); | ||
| 157 | } | ||
| 158 | |||
| 159 | triggerEl.style.cursor = "zoom-in"; | ||
| 160 | triggerEl.addEventListener("click", (e) => { | ||
| 161 | e.preventDefault(); | ||
| 162 | this.show(galleries.get(key) ?? [], item.src); | ||
| 163 | }); | ||
| 164 | } | ||
| 165 | |||
| 166 | this.$watch("currentItem.src", () => { | ||
| 167 | this.loading = true; | ||
| 168 | this.reset(); | ||
| 169 | }); | ||
| 170 | }, | ||
| 171 | |||
| 172 | show(items: LightboxItem[], src?: string) { | ||
| 173 | const current = items.findIndex((item) => item.src === src); | ||
| 174 | this.items = items; | ||
| 175 | this.current = current === -1 ? 0 : current; | ||
| 176 | this.reset(); | ||
| 177 | |||
| 178 | (<HTMLDialogElement>this.$root).showModal(); | ||
| 179 | }, | ||
| 180 | |||
| 181 | close() { | ||
| 182 | (<HTMLDialogElement>this.$root).close(); | ||
| 183 | }, | ||
| 184 | |||
| 185 | reset() { | ||
| 186 | this.stopVelocity(); | ||
| 187 | this.stopInertia(); | ||
| 188 | this.dragging = false; | ||
| 189 | this._zoom = minZoom; | ||
| 190 | this._offsetX = 0; | ||
| 191 | this._offsetY = 0; | ||
| 192 | }, | ||
| 193 | |||
| 194 | prev() { | ||
| 195 | this.current = this.current > 0 ? this.current - 1 : this.items.length - 1; | ||
| 196 | }, | ||
| 197 | |||
| 198 | next() { | ||
| 199 | this.current = this.current < this.items.length - 1 ? this.current + 1 : 0; | ||
| 200 | }, | ||
| 201 | |||
| 202 | toggleZoom(x?: number, y?: number) { | ||
| 203 | const zoom = this.zoomedFull ? minZoom : this.naturalZoom; | ||
| 204 | const deltaZoom = zoom / this.zoom - 1; | ||
| 205 | |||
| 206 | this.zoom = zoom; | ||
| 207 | |||
| 208 | if (x !== undefined && y !== undefined) { | ||
| 209 | const transformOrigin = this.transformOrigin; | ||
| 210 | const offsetX = transformOrigin.x - x; | ||
| 211 | const offsetY = transformOrigin.y - y; | ||
| 212 | |||
| 213 | this.offsetX += offsetX * deltaZoom; | ||
| 214 | this.offsetY += offsetY * deltaZoom; | ||
| 215 | } | ||
| 216 | }, | ||
| 217 | |||
| 218 | measureVelocity(loop?: boolean) { | ||
| 219 | if (!loop) { | ||
| 220 | this.inertia = false; | ||
| 221 | |||
| 222 | if (this.velocity.measure) { | ||
| 223 | return; | ||
| 224 | } else { | ||
| 225 | this.velocity.prevOffsetX = this.offsetX; | ||
| 226 | this.velocity.prevOffsetY = this.offsetY; | ||
| 227 | this.velocity.x = 0; | ||
| 228 | this.velocity.y = 0; | ||
| 229 | this.velocity.measure = true; | ||
| 230 | } | ||
| 231 | } | ||
| 232 | if (!this.velocity.measure) { | ||
| 233 | return; | ||
| 234 | } | ||
| 235 | |||
| 236 | const deltaX = this.offsetX - this.velocity.prevOffsetX; | ||
| 237 | const deltaY = this.offsetY - this.velocity.prevOffsetY; | ||
| 238 | let window = 1; | ||
| 239 | |||
| 240 | if ( | ||
| 241 | Math.sqrt(deltaX ** 2 + deltaY ** 2) < | ||
| 242 | Math.sqrt(this.velocity.x ** 2 + this.velocity.y ** 2) | ||
| 243 | ) { | ||
| 244 | window = 6; | ||
| 245 | } | ||
| 246 | |||
| 247 | this.velocity.x -= this.velocity.x / window; | ||
| 248 | this.velocity.x += deltaX / window; | ||
| 249 | this.velocity.y -= this.velocity.y / window; | ||
| 250 | this.velocity.y += deltaY / window; | ||
| 251 | |||
| 252 | this.velocity.prevOffsetX = this.offsetX; | ||
| 253 | this.velocity.prevOffsetY = this.offsetY; | ||
| 254 | |||
| 255 | requestAnimationFrame(this.measureVelocity.bind(this, true)); | ||
| 256 | }, | ||
| 257 | |||
| 258 | stopVelocity() { | ||
| 259 | this.velocity.measure = false; | ||
| 260 | }, | ||
| 261 | |||
| 262 | runInertia(loop?: boolean, timestamp: DOMHighResTimeStamp = performance.now()) { | ||
| 263 | const len = Math.sqrt(this.velocity.x ** 2 + this.velocity.y ** 2); | ||
| 264 | |||
| 265 | if (!loop) { | ||
| 266 | this.stopVelocity(); | ||
| 267 | |||
| 268 | if (this.inertia) { | ||
| 269 | return; | ||
| 270 | } else if (len > 5) { | ||
| 271 | this.inertia = true; | ||
| 272 | this.velocity.timestamp = timestamp; | ||
| 273 | } | ||
| 274 | } | ||
| 275 | |||
| 276 | if (!this.inertia) { | ||
| 277 | this.stopInertia(); | ||
| 278 | return; | ||
| 279 | } | ||
| 280 | |||
| 281 | if (loop) { | ||
| 282 | const norm = { x: this.velocity.x / len, y: this.velocity.y / len }; | ||
| 283 | const elapsed = timestamp - this.velocity.timestamp; | ||
| 284 | const friction = 30 * (elapsed / 1000); | ||
| 285 | const decel = { x: norm.x * friction, y: norm.y * friction }; | ||
| 286 | const decelLen = Math.sqrt(decel.x ** 2 + decel.y ** 2); | ||
| 287 | |||
| 288 | if (len < decelLen) { | ||
| 289 | this.stopInertia(); | ||
| 290 | return; | ||
| 291 | } | ||
| 292 | |||
| 293 | this.offsetX += this.velocity.x; | ||
| 294 | this.offsetY += this.velocity.y; | ||
| 295 | this.velocity.x -= decel.x; | ||
| 296 | this.velocity.y -= decel.y; | ||
| 297 | this.velocity.timestamp = timestamp; | ||
| 298 | } | ||
| 299 | |||
| 300 | requestAnimationFrame(this.runInertia.bind(this, true)); | ||
| 301 | }, | ||
| 302 | |||
| 303 | stopInertia() { | ||
| 304 | this.inertia = false; | ||
| 305 | }, | ||
| 306 | |||
| 307 | handleClose() { | ||
| 308 | this.stopVelocity(); | ||
| 309 | this.stopInertia(); | ||
| 310 | }, | ||
| 311 | |||
| 312 | handleScroll(e: WheelEvent) { | ||
| 313 | this.stopInertia(); | ||
| 314 | |||
| 315 | const direction = e.deltaY > 0 ? -1 : 1; | ||
| 316 | |||
| 317 | const transformOrigin = this.transformOrigin; | ||
| 318 | const offsetX = transformOrigin.x - e.clientX; | ||
| 319 | const offsetY = transformOrigin.y - e.clientY; | ||
| 320 | |||
| 321 | const zoom = Math.min(this.maxZoom, Math.max(minZoom, this.zoom * (1 + direction * 0.1))); | ||
| 322 | const deltaZoom = zoom / this.zoom - 1; | ||
| 323 | |||
| 324 | this.zoom = zoom; | ||
| 325 | this.offsetX += offsetX * deltaZoom; | ||
| 326 | this.offsetY += offsetY * deltaZoom; | ||
| 327 | }, | ||
| 328 | |||
| 329 | handleMouseDown(e: MouseEvent) { | ||
| 330 | this.measureVelocity(); | ||
| 331 | |||
| 332 | if (e.buttons === 1) { | ||
| 333 | this.dragging = true; | ||
| 334 | } | ||
| 335 | }, | ||
| 336 | |||
| 337 | handleMouseMove(e: MouseEvent) { | ||
| 338 | if (!this.dragging) { | ||
| 339 | return; | ||
| 340 | } | ||
| 341 | |||
| 342 | this.offsetX += e.movementX; | ||
| 343 | this.offsetY += e.movementY; | ||
| 344 | }, | ||
| 345 | |||
| 346 | handleMouseUp() { | ||
| 347 | this.dragging = false; | ||
| 348 | this.runInertia(); | ||
| 349 | }, | ||
| 350 | |||
| 351 | handleDoubleClick(e: MouseEvent) { | ||
| 352 | this.toggleZoom(e.clientX, e.clientY); | ||
| 353 | }, | ||
| 354 | |||
| 355 | handleTouchStart(e: TouchEvent) { | ||
| 356 | const params = touchParams(e); | ||
| 357 | |||
| 358 | if (!params || (e.touches.length === 1 && e.target !== this.$refs.img)) { | ||
| 359 | return; | ||
| 360 | } | ||
| 361 | |||
| 362 | e.preventDefault(); | ||
| 363 | |||
| 364 | const now = Date.now(); | ||
| 365 | |||
| 366 | if (e.touches.length === 1) { | ||
| 367 | if ( | ||
| 368 | now - this.touchStart.timestamp < 200 && | ||
| 369 | Math.sqrt((params.x - this.touchStart.x) ** 2 + (params.y - this.touchStart.y) ** 2) < 20 | ||
| 370 | ) { | ||
| 371 | this.toggleZoom(params.x, params.y); | ||
| 372 | } | ||
| 373 | } | ||
| 374 | |||
| 375 | this.touchStart.x = params.x; | ||
| 376 | this.touchStart.y = params.y; | ||
| 377 | this.touchStart.distance = params.dist; | ||
| 378 | this.touchStart.transformOrigin = this.transformOrigin; | ||
| 379 | this.touchStart.zoom = this.zoom; | ||
| 380 | this.touchStart.offsetX = this.offsetX; | ||
| 381 | this.touchStart.offsetY = this.offsetY; | ||
| 382 | this.touchStart.timestamp = now; | ||
| 383 | |||
| 384 | this.dragging = false; | ||
| 385 | |||
| 386 | if (e.touches.length === 1) { | ||
| 387 | this.measureVelocity(); | ||
| 388 | } else { | ||
| 389 | this.stopVelocity(); | ||
| 390 | } | ||
| 391 | }, | ||
| 392 | |||
| 393 | handleTouchMove(e: TouchEvent) { | ||
| 394 | const params = touchParams(e); | ||
| 395 | |||
| 396 | if (!params || (e.touches.length === 1 && e.target !== this.$refs.img)) { | ||
| 397 | return; | ||
| 398 | } | ||
| 399 | |||
| 400 | e.preventDefault(); | ||
| 401 | |||
| 402 | const deltaX = params.x - this.touchStart.x; | ||
| 403 | const deltaY = params.y - this.touchStart.y; | ||
| 404 | const deltaDist = this.touchStart.distance ? params.dist / this.touchStart.distance : 1; | ||
| 405 | |||
| 406 | const offsetX = this.touchStart.transformOrigin.x - this.touchStart.x; | ||
| 407 | const offsetY = this.touchStart.transformOrigin.y - this.touchStart.y; | ||
| 408 | |||
| 409 | const zoom = Math.min(this.maxZoom, Math.max(minZoom, this.touchStart.zoom * deltaDist)); | ||
| 410 | const deltaZoom = zoom / this.touchStart.zoom - 1; | ||
| 411 | |||
| 412 | this.zoom = zoom; | ||
| 413 | this.offsetX = this.touchStart.offsetX + deltaX + offsetX * deltaZoom; | ||
| 414 | this.offsetY = this.touchStart.offsetY + deltaY + offsetY * deltaZoom; | ||
| 415 | }, | ||
| 416 | |||
| 417 | handleTouchEnd(e: TouchEvent) { | ||
| 418 | this.handleTouchStart(e); | ||
| 419 | |||
| 420 | if (!e.touches.length) { | ||
| 421 | this.runInertia(); | ||
| 422 | } | ||
| 423 | }, | ||
| 424 | |||
| 425 | handleKeyDown(e: KeyboardEvent) { | ||
| 426 | if (!this.open || e.defaultPrevented) { | ||
| 427 | return; | ||
| 428 | } | ||
| 429 | |||
| 430 | this.stopInertia(); | ||
| 431 | |||
| 432 | switch (e.key) { | ||
| 433 | case "Left": | ||
| 434 | case "ArrowLeft": | ||
| 435 | this.prev(); | ||
| 436 | break; | ||
| 437 | |||
| 438 | case "Right": | ||
| 439 | case "ArrowRight": | ||
| 440 | this.next(); | ||
| 441 | break; | ||
| 442 | |||
| 443 | default: | ||
| 444 | return; | ||
| 445 | } | ||
| 446 | |||
| 447 | e.preventDefault(); | ||
| 448 | }, | ||
| 449 | })); | ||
| 450 | } | ||
diff --git a/src/scripts/lib/siteHeader.ts b/src/scripts/lib/siteHeader.ts new file mode 100644 index 0000000..0d9ec43 --- /dev/null +++ b/src/scripts/lib/siteHeader.ts | |||
| @@ -0,0 +1,37 @@ | |||
| 1 | export function mountSiteHeader() { | ||
| 2 | const headerEl = document.querySelector<HTMLElement>(".c-site-header"); | ||
| 3 | |||
| 4 | if (headerEl) { | ||
| 5 | const navbarEl = headerEl?.querySelector(".o-navbar--static-white"); | ||
| 6 | const logoEl = headerEl?.querySelector(".c-logo--static-white"); | ||
| 7 | const buttonEls = headerEl?.querySelectorAll(".o-button--static-white"); | ||
| 8 | |||
| 9 | let settings: IntersectionObserverInit; | ||
| 10 | |||
| 11 | const boundingRect = headerEl.getBoundingClientRect(); | ||
| 12 | |||
| 13 | if (boundingRect.top > 0) { | ||
| 14 | settings = { | ||
| 15 | rootMargin: "1px 0px -100% 0px", | ||
| 16 | }; | ||
| 17 | } else { | ||
| 18 | settings = { | ||
| 19 | root: document.documentElement, | ||
| 20 | rootMargin: "-1px 0px 0px 0px", | ||
| 21 | threshold: [1], | ||
| 22 | }; | ||
| 23 | } | ||
| 24 | |||
| 25 | const observer = new IntersectionObserver(([e]) => { | ||
| 26 | const scroll = e?.isIntersecting; | ||
| 27 | headerEl.classList.toggle("is-scroll", scroll); | ||
| 28 | navbarEl?.classList.toggle("o-navbar--static-white", !scroll); | ||
| 29 | logoEl?.classList.toggle("c-logo--static-white", !scroll); | ||
| 30 | for (const buttonEl of buttonEls) { | ||
| 31 | buttonEl.classList.toggle("o-button--static-white", !scroll); | ||
| 32 | } | ||
| 33 | }, settings); | ||
| 34 | |||
| 35 | observer.observe(headerEl); | ||
| 36 | } | ||
| 37 | } | ||
diff --git a/src/scripts/lib/tabbar.ts b/src/scripts/lib/tabbar.ts new file mode 100644 index 0000000..6a3d3d3 --- /dev/null +++ b/src/scripts/lib/tabbar.ts | |||
| @@ -0,0 +1,12 @@ | |||
| 1 | export function mountTabbar() { | ||
| 2 | const tabbarEls = document.querySelectorAll<HTMLElement>(".o-tabbar__tabs"); | ||
| 3 | |||
| 4 | for (const tabbarEl of tabbarEls) { | ||
| 5 | const selectedItemEl = tabbarEl.querySelector<HTMLElement>(".o-tabbar__tab.is-selected"); | ||
| 6 | |||
| 7 | if (selectedItemEl) { | ||
| 8 | tabbarEl.scrollLeft = | ||
| 9 | selectedItemEl.offsetLeft - 0.5 * tabbarEl.clientWidth + 0.5 * selectedItemEl.clientWidth; | ||
| 10 | } | ||
| 11 | } | ||
| 12 | } | ||
diff --git a/src/scripts/lib/toc.ts b/src/scripts/lib/toc.ts new file mode 100644 index 0000000..958f209 --- /dev/null +++ b/src/scripts/lib/toc.ts | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | export function mountToc() { | ||
| 2 | const observer = new IntersectionObserver((entries) => { | ||
| 3 | for (const entry of entries) { | ||
| 4 | const id = entry.target.getAttribute("id"); | ||
| 5 | document | ||
| 6 | .querySelector(`.o-toc__item[href="#${id}"]`) | ||
| 7 | ?.classList.toggle("is-selected", entry.intersectionRatio > 0); | ||
| 8 | } | ||
| 9 | }); | ||
| 10 | |||
| 11 | document.querySelectorAll("section[id]").forEach((section) => { | ||
| 12 | observer.observe(section); | ||
| 13 | }); | ||
| 14 | } | ||
diff --git a/src/scripts/script.ts b/src/scripts/script.ts new file mode 100644 index 0000000..02fb7ff --- /dev/null +++ b/src/scripts/script.ts | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | /// <reference lib="dom" /> | ||
| 2 | /// <reference lib="dom.iterable" /> | ||
| 3 | |||
| 4 | import { mountKeyboardNav } from "./lib/keyboardNav.ts"; | ||
| 5 | import { mountLightbox } from "./lib/lightbox.ts"; | ||
| 6 | import { mountSiteHeader } from "./lib/siteHeader.ts"; | ||
| 7 | import { mountTabbar } from "./lib/tabbar.ts"; | ||
| 8 | import { mountToc } from "./lib/toc.ts"; | ||
| 9 | |||
| 10 | document.body.classList.remove("t-no-js"); | ||
| 11 | document.body.classList.add("t-js"); | ||
| 12 | |||
| 13 | mountKeyboardNav(); | ||
| 14 | mountLightbox(); | ||
| 15 | mountTabbar(); | ||
| 16 | mountSiteHeader(); | ||
| 17 | mountToc(); | ||
diff --git a/src/styles/_common.scss b/src/styles/_common.scss new file mode 100644 index 0000000..2b1f6c2 --- /dev/null +++ b/src/styles/_common.scss | |||
| @@ -0,0 +1,87 @@ | |||
| 1 | @use "sass:map"; | ||
| 2 | @use "sass:math"; | ||
| 3 | @use "pkg:iro-sass/props"; | ||
| 4 | @use "pkg:iro-sass/functions" as fn; | ||
| 5 | @use "pkg:iro-sass/easing" as easing; | ||
| 6 | @use "pkg:iro-design/config.defaults" as defaults; | ||
| 7 | |||
| 8 | $font--standard--family: props.def( | ||
| 9 | --font--standard--family, | ||
| 10 | ( | ||
| 11 | "Inter", | ||
| 12 | "Open Sans", | ||
| 13 | "Segoe UI", | ||
| 14 | "Droid Sans", | ||
| 15 | Roboto, | ||
| 16 | Oxygen, | ||
| 17 | "Helvetica Neue", | ||
| 18 | Helvetica, | ||
| 19 | Tahoma, | ||
| 20 | Arial, | ||
| 21 | sans-serif | ||
| 22 | ) | ||
| 23 | ); | ||
| 24 | |||
| 25 | @use "pkg:iro-design/core.vars" as core; | ||
| 26 | |||
| 27 | @use "./components/site-header.vars" as site-header; | ||
| 28 | |||
| 29 | @use "pkg:iro-design/layouts/split-view.vars" with ( | ||
| 30 | $panel--sticky-offset: props.def( | ||
| 31 | --l-split-view--panel--sticky-offset, | ||
| 32 | calc(props.get(site-header.$block-size) + props.get(core.$size--400)) | ||
| 33 | ) | ||
| 34 | ); | ||
| 35 | |||
| 36 | @forward "pkg:iro-design"; | ||
| 37 | |||
| 38 | @forward "objects/deflist" as o-deflist--*; | ||
| 39 | @forward "objects/message" as o-message--*; | ||
| 40 | @forward "objects/placeholder-box" as o-placeholder-box--*; | ||
| 41 | @forward "objects/toc" as o-toc--*; | ||
| 42 | |||
| 43 | @forward "components/home-banner" as c-home-banner--*; | ||
| 44 | @forward "components/logo" as c-logo--*; | ||
| 45 | @forward "components/site-header" as c-site-header--*; | ||
| 46 | @forward "components/site-nav" as c-site-nav--*; | ||
| 47 | @forward "components/site-footer" as c-site-footer--*; | ||
| 48 | @forward "components/subtitle" as c-subtitle--*; | ||
| 49 | @forward "components/visited-indicator" as c-visited-indicator--*; | ||
| 50 | |||
| 51 | @use "pkg:iro-design" as iro; | ||
| 52 | |||
| 53 | @mixin styles { | ||
| 54 | @font-face { | ||
| 55 | font-family: "Inter"; | ||
| 56 | font-style: normal; | ||
| 57 | font-weight: 50 950; | ||
| 58 | font-display: swap; | ||
| 59 | src: url(load("/assets/InterVariable.woff2")) format("woff2-variations"); | ||
| 60 | font-feature-settings: "ss01"; | ||
| 61 | } | ||
| 62 | |||
| 63 | @font-face { | ||
| 64 | font-family: "Inter"; | ||
| 65 | font-style: italic; | ||
| 66 | font-weight: 50 950; | ||
| 67 | font-display: swap; | ||
| 68 | src: url(load("/assets/InterVariable-Italic.woff2")) format("woff2-variations"); | ||
| 69 | font-feature-settings: "ss01"; | ||
| 70 | } | ||
| 71 | |||
| 72 | html { | ||
| 73 | scroll-behavior: smooth; | ||
| 74 | scroll-padding-block-start: props.get(site-header.$block-size); | ||
| 75 | scrollbar-gutter: stable; | ||
| 76 | min-block-size: 100%; | ||
| 77 | display: flex; | ||
| 78 | } | ||
| 79 | |||
| 80 | body { | ||
| 81 | inline-size: 100%; | ||
| 82 | } | ||
| 83 | |||
| 84 | [x-cloak] { | ||
| 85 | display: none; | ||
| 86 | } | ||
| 87 | } | ||
diff --git a/src/styles/components/_home-banner.scss b/src/styles/components/_home-banner.scss new file mode 100644 index 0000000..660cd9b --- /dev/null +++ b/src/styles/components/_home-banner.scss | |||
| @@ -0,0 +1,39 @@ | |||
| 1 | @use "sass:map"; | ||
| 2 | @use "sass:string"; | ||
| 3 | @use "sass:meta"; | ||
| 4 | |||
| 5 | @use "pkg:iro-sass/bem"; | ||
| 6 | @use "pkg:iro-sass/props"; | ||
| 7 | @use "pkg:iro-design" as iro; | ||
| 8 | |||
| 9 | @use "site-header.vars" as site-header; | ||
| 10 | |||
| 11 | @mixin styles { | ||
| 12 | @include bem.component("home-banner") { | ||
| 13 | @include bem.elem("intro") { | ||
| 14 | margin-block-start: 0; | ||
| 15 | font-size: clamp(props.get(iro.$core--font-size--500), 5.3vw, 5rem); | ||
| 16 | font-weight: 300; | ||
| 17 | color: props.get(iro.$core--theme, --text); | ||
| 18 | line-height: 1.2; | ||
| 19 | |||
| 20 | strong { | ||
| 21 | font-family: props.get(iro.$core--font--headline--family); | ||
| 22 | font-weight: 900; | ||
| 23 | text-transform: uppercase; | ||
| 24 | letter-spacing: 2px; | ||
| 25 | color: props.get(iro.$core--theme, --heading); | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | @include iro.media--media("<=md") { | ||
| 30 | @include bem.elem("intro") { | ||
| 31 | font-size: props.get(iro.$core--font-size--500); | ||
| 32 | } | ||
| 33 | |||
| 34 | @include bem.elem("buttons") { | ||
| 35 | justify-content: center; | ||
| 36 | } | ||
| 37 | } | ||
| 38 | } | ||
| 39 | } | ||
diff --git a/src/styles/components/_logo.scss b/src/styles/components/_logo.scss new file mode 100644 index 0000000..3450817 --- /dev/null +++ b/src/styles/components/_logo.scss | |||
| @@ -0,0 +1,81 @@ | |||
| 1 | @use "sass:map"; | ||
| 2 | @use "sass:string"; | ||
| 3 | |||
| 4 | @use "pkg:iro-sass/bem"; | ||
| 5 | @use "pkg:iro-sass/props"; | ||
| 6 | @use "pkg:iro-design" as iro; | ||
| 7 | |||
| 8 | @mixin styles { | ||
| 9 | @include bem.component("logo") { | ||
| 10 | display: block; | ||
| 11 | padding: props.get(iro.$core--size--50); | ||
| 12 | margin-inline: calc(-1 * props.get(iro.$core--size--50)); | ||
| 13 | border-radius: props.get(iro.$s-links--rounding); | ||
| 14 | color: props.get(iro.$core--theme, --heading); | ||
| 15 | |||
| 16 | &:link, | ||
| 17 | &:visited { | ||
| 18 | &:hover { | ||
| 19 | text-decoration: none; | ||
| 20 | color: props.get(iro.$core--theme, --text-mute); | ||
| 21 | } | ||
| 22 | |||
| 23 | &:focus-visible { | ||
| 24 | color: props.get(iro.$s-links--key-focus--text-color); | ||
| 25 | text-decoration: none; | ||
| 26 | outline: props.get(iro.$s-links--key-focus--border-color) solid | ||
| 27 | props.get(iro.$s-links--key-focus--border-width); | ||
| 28 | box-shadow: 0 0 0 | ||
| 29 | calc( | ||
| 30 | props.get(iro.$s-links--key-focus--border-width) + | ||
| 31 | props.get(iro.$s-links--key-focus--outline-width) | ||
| 32 | ) | ||
| 33 | props.get(iro.$s-links--key-focus--outline-color); | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | @include bem.elem("img") { | ||
| 38 | display: block; | ||
| 39 | block-size: 1.7em; | ||
| 40 | aspect-ratio: 2700 / 600; | ||
| 41 | } | ||
| 42 | |||
| 43 | @include bem.modifier("sm") { | ||
| 44 | @include bem.elem("img") { | ||
| 45 | aspect-ratio: 600 / 600; | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | @include bem.modifier("lg") { | ||
| 50 | @include bem.elem("img") { | ||
| 51 | block-size: 2em; | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | @each $theme in map.keys(props.get(iro.$core--transparent-colors)) { | ||
| 56 | $logo-theme: static-#{string.slice($theme, 3)}; | ||
| 57 | |||
| 58 | @include bem.modifier($logo-theme) { | ||
| 59 | color: props.get(iro.$core--transparent-colors, $theme, --900); | ||
| 60 | |||
| 61 | &:link, | ||
| 62 | &:visited { | ||
| 63 | &:hover { | ||
| 64 | color: props.get(iro.$core--transparent-colors, $theme, --800); | ||
| 65 | } | ||
| 66 | |||
| 67 | &:focus-visible { | ||
| 68 | color: props.get(iro.$core--transparent-colors, $theme, --900); | ||
| 69 | outline-color: props.get(iro.$core--transparent-colors, $theme, --900); | ||
| 70 | box-shadow: 0 0 0 | ||
| 71 | calc( | ||
| 72 | props.get(iro.$s-links--key-focus--border-width) + | ||
| 73 | props.get(iro.$s-links--key-focus--outline-width) | ||
| 74 | ) | ||
| 75 | props.get(iro.$core--transparent-colors, $theme, --300); | ||
| 76 | } | ||
| 77 | } | ||
| 78 | } | ||
| 79 | } | ||
| 80 | } | ||
| 81 | } | ||
diff --git a/src/styles/components/_site-footer.scss b/src/styles/components/_site-footer.scss new file mode 100644 index 0000000..33608ff --- /dev/null +++ b/src/styles/components/_site-footer.scss | |||
| @@ -0,0 +1,157 @@ | |||
| 1 | @use "sass:math"; | ||
| 2 | @use "sass:meta"; | ||
| 3 | |||
| 4 | @use "pkg:iro-design" as iro; | ||
| 5 | @use "pkg:iro-sass/bem"; | ||
| 6 | @use "pkg:iro-sass/props"; | ||
| 7 | |||
| 8 | @forward "site-footer.vars"; | ||
| 9 | @use "site-footer.vars" as vars; | ||
| 10 | |||
| 11 | @mixin styles { | ||
| 12 | @include iro.props--materialize-at-root(meta.module-variables("vars")); | ||
| 13 | |||
| 14 | @property --site-footer--l1-y { | ||
| 15 | initial-value: 0px; | ||
| 16 | inherits: true; | ||
| 17 | syntax: "<length>"; | ||
| 18 | } | ||
| 19 | |||
| 20 | @property --site-footer--cloud-y { | ||
| 21 | initial-value: 0px; | ||
| 22 | inherits: true; | ||
| 23 | syntax: "<length>"; | ||
| 24 | } | ||
| 25 | |||
| 26 | @property --site-footer--moon-y { | ||
| 27 | initial-value: 0px; | ||
| 28 | inherits: true; | ||
| 29 | syntax: "<length>"; | ||
| 30 | } | ||
| 31 | |||
| 32 | @property --site-footer--star-y { | ||
| 33 | initial-value: 0px; | ||
| 34 | inherits: true; | ||
| 35 | syntax: "<length>"; | ||
| 36 | } | ||
| 37 | |||
| 38 | @keyframes footer-l1-ani { | ||
| 39 | from { | ||
| 40 | --site-footer--l1-y: -300px; | ||
| 41 | --site-footer--cloud-y: -400px; | ||
| 42 | --site-footer--moon-y: -500px; | ||
| 43 | --site-footer--star-y: -500px; | ||
| 44 | } | ||
| 45 | to { | ||
| 46 | --site-footer--l1-y: 300px; | ||
| 47 | --site-footer--cloud-y: 400px; | ||
| 48 | --site-footer--moon-y: 500px; | ||
| 49 | --site-footer--star-y: 500px; | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | @include bem.component("site-footer") { | ||
| 54 | position: sticky; | ||
| 55 | inset-block-start: 100%; | ||
| 56 | |||
| 57 | @include bem.elem("banner") { | ||
| 58 | position: relative; | ||
| 59 | display: flex; | ||
| 60 | block-size: 33rem; | ||
| 61 | justify-content: end; | ||
| 62 | overflow: hidden; | ||
| 63 | background: linear-gradient( | ||
| 64 | to bottom, | ||
| 65 | props.get(vars.$colors, --bg-from) 35%, | ||
| 66 | props.get(vars.$colors, --bg) 85% | ||
| 67 | ); | ||
| 68 | |||
| 69 | animation-name: footer-l1-ani; | ||
| 70 | //animation-fill-mode: both; | ||
| 71 | animation-duration: 1ms; | ||
| 72 | animation-timeline: view(block 0% 0%); | ||
| 73 | animation-timing-function: linear; | ||
| 74 | } | ||
| 75 | |||
| 76 | @include bem.elem("banner-img") { | ||
| 77 | position: relative; | ||
| 78 | z-index: 2; | ||
| 79 | flex: 0 0 auto; | ||
| 80 | display: block; | ||
| 81 | block-size: 100%; | ||
| 82 | margin-inline: auto; | ||
| 83 | aspect-ratio: 3840 / 1600; | ||
| 84 | } | ||
| 85 | |||
| 86 | @include bem.elem("banner-bg") { | ||
| 87 | position: absolute; | ||
| 88 | inset-inline-start: 50%; | ||
| 89 | inset-block-end: 0; | ||
| 90 | z-index: 1; | ||
| 91 | display: block; | ||
| 92 | inline-size: 100%; | ||
| 93 | block-size: 100%; | ||
| 94 | transform: translateX(-50%); | ||
| 95 | } | ||
| 96 | |||
| 97 | @include bem.elem("content-wrap") { | ||
| 98 | padding-block-end: props.get(iro.$core--size--400); | ||
| 99 | background-color: props.get(iro.$core--transparent-colors, --black, --900); | ||
| 100 | } | ||
| 101 | |||
| 102 | @include bem.elem("content") { | ||
| 103 | display: flex; | ||
| 104 | flex-wrap: wrap; | ||
| 105 | gap: props.get(iro.$core--size--600); | ||
| 106 | } | ||
| 107 | |||
| 108 | @include bem.elem("sections") { | ||
| 109 | display: flex; | ||
| 110 | flex-wrap: wrap; | ||
| 111 | gap: props.get(iro.$core--size--400) props.get(iro.$core--size--800); | ||
| 112 | } | ||
| 113 | |||
| 114 | @include bem.elem("table") { | ||
| 115 | display: grid; | ||
| 116 | grid-template-columns: auto auto auto auto; | ||
| 117 | column-gap: props.get(iro.$core--size--200); | ||
| 118 | } | ||
| 119 | |||
| 120 | @include bem.elem("table-row") { | ||
| 121 | display: contents; | ||
| 122 | } | ||
| 123 | |||
| 124 | @include bem.elem("divider") { | ||
| 125 | display: none; | ||
| 126 | } | ||
| 127 | |||
| 128 | @include bem.elem("logo") { | ||
| 129 | margin-block: auto; | ||
| 130 | margin-inline-start: auto; | ||
| 131 | } | ||
| 132 | |||
| 133 | @include iro.media--media("<=md") { | ||
| 134 | @include bem.elem("content") { | ||
| 135 | flex-direction: column; | ||
| 136 | align-items: center; | ||
| 137 | } | ||
| 138 | |||
| 139 | @include bem.elem("divider") { | ||
| 140 | display: flex; | ||
| 141 | inline-size: 100%; | ||
| 142 | } | ||
| 143 | |||
| 144 | @include bem.elem("logo") { | ||
| 145 | margin-inline-start: 0; | ||
| 146 | } | ||
| 147 | } | ||
| 148 | |||
| 149 | @include iro.media--media("<=sm") { | ||
| 150 | transform: translateY(calc(-1 * props.get(iro.$o-navbar--block-size))); | ||
| 151 | |||
| 152 | @include bem.elem("banner-img") { | ||
| 153 | transform: translateX(max(0rem, 19rem - 50vw)); | ||
| 154 | } | ||
| 155 | } | ||
| 156 | } | ||
| 157 | } | ||
diff --git a/src/styles/components/_site-footer.vars.scss b/src/styles/components/_site-footer.vars.scss new file mode 100644 index 0000000..0b2f968 --- /dev/null +++ b/src/styles/components/_site-footer.vars.scss | |||
| @@ -0,0 +1,34 @@ | |||
| 1 | @use "pkg:iro-design/core.vars" as core; | ||
| 2 | @use "pkg:iro-design/objects/backdrop.vars" as backdrop; | ||
| 3 | @use "pkg:iro-sass/props"; | ||
| 4 | |||
| 5 | $colors: props.def( | ||
| 6 | --site-footer, | ||
| 7 | ( | ||
| 8 | --bg-from: props.get(core.$theme, --bg-l1), | ||
| 9 | --bg: props.get(core.$theme, --bg-l1), | ||
| 10 | --star: transparent, | ||
| 11 | --cloud: props.get(core.$theme, --base, --50), | ||
| 12 | --l1: props.get(core.$theme, --bg-base), | ||
| 13 | --dragon-awake: props.get(core.$transparent-colors, --black, --900), | ||
| 14 | --dragon-asleep--eye: transparent, | ||
| 15 | --dragon-asleep: transparent, | ||
| 16 | --fg: props.get(core.$transparent-colors, --black, --900), | ||
| 17 | ), | ||
| 18 | "color" | ||
| 19 | ); | ||
| 20 | |||
| 21 | $colors--dark: props.def( | ||
| 22 | --site-footer, | ||
| 23 | ( | ||
| 24 | --bg: props.get(core.$theme, --bg-l2), | ||
| 25 | --star: #fff, | ||
| 26 | --cloud: props.get(core.$theme, --base, --100), | ||
| 27 | --l1: props.get(core.$theme, --bg-l1), | ||
| 28 | --dragon-awake: transparent, | ||
| 29 | --dragon-asleep--eye: #fff, | ||
| 30 | --dragon-asleep: #000, | ||
| 31 | --fg: #000, | ||
| 32 | ), | ||
| 33 | "dark" | ||
| 34 | ); | ||
diff --git a/src/styles/components/_site-header.scss b/src/styles/components/_site-header.scss new file mode 100644 index 0000000..d23fa7c --- /dev/null +++ b/src/styles/components/_site-header.scss | |||
| @@ -0,0 +1,136 @@ | |||
| 1 | @use "sass:math"; | ||
| 2 | @use "sass:meta"; | ||
| 3 | |||
| 4 | @use "pkg:iro-design" as iro; | ||
| 5 | @use "pkg:iro-sass/bem"; | ||
| 6 | @use "pkg:iro-sass/props"; | ||
| 7 | |||
| 8 | @forward "site-header.vars"; | ||
| 9 | @use "site-header.vars" as vars; | ||
| 10 | |||
| 11 | @mixin styles { | ||
| 12 | @include iro.props--materialize-at-root(meta.module-variables("vars")); | ||
| 13 | |||
| 14 | @include bem.component("site-header") { | ||
| 15 | position: sticky; | ||
| 16 | inset-block-start: 0; | ||
| 17 | z-index: props.get(vars.$z-index); | ||
| 18 | display: flex; | ||
| 19 | gap: props.get(vars.$spacing); | ||
| 20 | align-items: center; | ||
| 21 | line-height: 1.4; | ||
| 22 | block-size: props.get(vars.$block-size); | ||
| 23 | padding-inline: props.get(iro.$l-container--pad-i); | ||
| 24 | background-color: props.get(iro.$core--theme, --bg-l1); | ||
| 25 | overflow: hidden; | ||
| 26 | transition: | ||
| 27 | background-color 0.2s ease, | ||
| 28 | opacity 0.2s ease, | ||
| 29 | transform 0.2s ease, | ||
| 30 | visibility 0s linear 0.2s; | ||
| 31 | |||
| 32 | @include bem.at-theme("js") { | ||
| 33 | background-color: transparent; | ||
| 34 | } | ||
| 35 | |||
| 36 | @include bem.modifier("hidden") { | ||
| 37 | margin-block-start: calc(-1 * props.get(vars.$block-size)); | ||
| 38 | opacity: 0; | ||
| 39 | visibility: hidden; | ||
| 40 | transform: translateY(-0.2em); | ||
| 41 | |||
| 42 | @include bem.is("scroll") { | ||
| 43 | transition: | ||
| 44 | background-color 0.2s ease, | ||
| 45 | opacity 0.2s ease, | ||
| 46 | transform 0.2s ease, | ||
| 47 | visibility 0s linear; | ||
| 48 | visibility: visible; | ||
| 49 | opacity: 1; | ||
| 50 | transform: none; | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | @include bem.modifier("end") { | ||
| 55 | background-color: props.get(iro.$core--transparent-colors, --black, --900); | ||
| 56 | } | ||
| 57 | |||
| 58 | @include bem.is("scroll") { | ||
| 59 | background-color: props.get(iro.$core--theme, --base, --50); | ||
| 60 | } | ||
| 61 | |||
| 62 | @include bem.elem("logo") { | ||
| 63 | @include bem.modifier("sm") { | ||
| 64 | display: none; | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | @include bem.elem("nav", "back") { | ||
| 69 | margin-inline-end: calc(-1 * props.get(vars.$nav-pull)); | ||
| 70 | } | ||
| 71 | |||
| 72 | @include bem.elem("back") { | ||
| 73 | display: none; | ||
| 74 | } | ||
| 75 | |||
| 76 | @include bem.elem("spacer") { | ||
| 77 | margin-inline-start: auto; | ||
| 78 | } | ||
| 79 | |||
| 80 | @include bem.elem("slot") { | ||
| 81 | display: contents; | ||
| 82 | |||
| 83 | @include bem.modifier("sm") { | ||
| 84 | display: none; | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | @include iro.media--media("<=sm") { | ||
| 89 | @include bem.elem("logo") { | ||
| 90 | display: none; | ||
| 91 | |||
| 92 | @include bem.modifier("sm") { | ||
| 93 | display: block; | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | @include bem.elem("nav") { | ||
| 98 | display: none; | ||
| 99 | } | ||
| 100 | |||
| 101 | @include bem.elem("back") { | ||
| 102 | display: block; | ||
| 103 | } | ||
| 104 | |||
| 105 | @include bem.elem("slot") { | ||
| 106 | @include bem.modifier("end") { | ||
| 107 | display: none; | ||
| 108 | } | ||
| 109 | |||
| 110 | @include bem.modifier("sm") { | ||
| 111 | display: contents; | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | @include bem.modifier("end") { | ||
| 116 | @include bem.elem("logo") { | ||
| 117 | display: block; | ||
| 118 | |||
| 119 | @include bem.modifier("sm") { | ||
| 120 | display: none; | ||
| 121 | } | ||
| 122 | } | ||
| 123 | |||
| 124 | @include bem.elem("slot") { | ||
| 125 | @include bem.modifier("sm") { | ||
| 126 | display: none; | ||
| 127 | } | ||
| 128 | } | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | @include iro.media--media("height<=500px") { | ||
| 133 | position: relative; | ||
| 134 | } | ||
| 135 | } | ||
| 136 | } | ||
diff --git a/src/styles/components/_site-header.vars.scss b/src/styles/components/_site-header.vars.scss new file mode 100644 index 0000000..282e944 --- /dev/null +++ b/src/styles/components/_site-header.vars.scss | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | @use "pkg:iro-design/core.vars" as core; | ||
| 2 | @use "pkg:iro-design/objects/backdrop.vars" as backdrop; | ||
| 3 | @use "pkg:iro-sass/props"; | ||
| 4 | |||
| 5 | $z-index: props.def(--c-site-header--z-index, calc(props.get(backdrop.$z-index) - 10)); | ||
| 6 | $block-size: props.def(--c-site-header--block-size, props.get(core.$size--800)); | ||
| 7 | $spacing: props.def(--c-site-header--spacing, props.get(core.$size--500)); | ||
| 8 | $nav-pull: props.def(--c-site-header--nav-pull, props.get(core.$size--300)); | ||
| 9 | |||
| 10 | $spacing--md: props.def(--c-site-header--spacing, props.get(core.$size--400), "md"); | ||
| 11 | $nav-pull--md: props.def(--c-site-header--nav-pull, props.get(core.$size--200), "md"); | ||
| 12 | |||
| 13 | $block-size--sm: props.def(--c-site-header--block-size, props.get(core.$size--700), "sm"); | ||
| 14 | $spacing--sm: props.def(--c-site-header--spacing, props.get(core.$size--300), "sm"); | ||
diff --git a/src/styles/components/_site-nav.scss b/src/styles/components/_site-nav.scss new file mode 100644 index 0000000..99a8afc --- /dev/null +++ b/src/styles/components/_site-nav.scss | |||
| @@ -0,0 +1,30 @@ | |||
| 1 | @use "sass:math"; | ||
| 2 | @use "sass:meta"; | ||
| 3 | |||
| 4 | @use "pkg:iro-design" as iro; | ||
| 5 | @use "pkg:iro-sass/bem"; | ||
| 6 | @use "pkg:iro-sass/props"; | ||
| 7 | |||
| 8 | @mixin styles { | ||
| 9 | @include bem.component("site-nav-placeholder") { | ||
| 10 | block-size: props.get(iro.$o-navbar--block-size); | ||
| 11 | |||
| 12 | @include iro.media--media(">sm") { | ||
| 13 | display: none; | ||
| 14 | } | ||
| 15 | } | ||
| 16 | |||
| 17 | @include bem.component("site-nav") { | ||
| 18 | position: fixed; | ||
| 19 | inset-block-end: 0; | ||
| 20 | inset-inline: 0; | ||
| 21 | z-index: calc(props.get(iro.$o-backdrop--z-index) - 10); | ||
| 22 | background-color: props.get(iro.$core--transparent-colors, --black, --900); | ||
| 23 | //border-block-start: 1px solid props.get(iro.$core--theme, --border-mute); | ||
| 24 | overflow-x: auto; | ||
| 25 | |||
| 26 | @include iro.media--media(">sm") { | ||
| 27 | display: none; | ||
| 28 | } | ||
| 29 | } | ||
| 30 | } | ||
diff --git a/src/styles/components/_subtitle.scss b/src/styles/components/_subtitle.scss new file mode 100644 index 0000000..e9a40bb --- /dev/null +++ b/src/styles/components/_subtitle.scss | |||
| @@ -0,0 +1,11 @@ | |||
| 1 | @use "pkg:iro-sass/bem"; | ||
| 2 | @use "pkg:iro-sass/props"; | ||
| 3 | @use "pkg:iro-design" as iro; | ||
| 4 | |||
| 5 | @mixin styles { | ||
| 6 | @include bem.component("subtitle") { | ||
| 7 | margin-block-start: props.get(iro.$s-implicit--paragraph--margin-bs); | ||
| 8 | color: props.get(iro.$core--theme, --text-mute-more); | ||
| 9 | font-size: props.get(iro.$core--font-size--300); | ||
| 10 | } | ||
| 11 | } | ||
diff --git a/src/styles/components/_visited-indicator.scss b/src/styles/components/_visited-indicator.scss new file mode 100644 index 0000000..7aa1f38 --- /dev/null +++ b/src/styles/components/_visited-indicator.scss | |||
| @@ -0,0 +1,27 @@ | |||
| 1 | @use "pkg:iro-sass/bem"; | ||
| 2 | @use "pkg:iro-sass/props"; | ||
| 3 | @use "pkg:iro-design" as iro; | ||
| 4 | |||
| 5 | @mixin styles { | ||
| 6 | @include bem.component("with-visited-indicator") { | ||
| 7 | @include bem.elem("indicator") { | ||
| 8 | block-size: 1.4em; | ||
| 9 | color: props.get(iro.$core--theme, --text); | ||
| 10 | |||
| 11 | &::after { | ||
| 12 | content: ""; | ||
| 13 | display: block; | ||
| 14 | block-size: 100%; | ||
| 15 | inline-size: props.get(iro.$core--border-width--thick); | ||
| 16 | border-radius: 10em; | ||
| 17 | background-color: currentColor; | ||
| 18 | } | ||
| 19 | } | ||
| 20 | |||
| 21 | &:visited { | ||
| 22 | @include bem.elem("indicator") { | ||
| 23 | color: props.get(iro.$core--theme, --border-mute); | ||
| 24 | } | ||
| 25 | } | ||
| 26 | } | ||
| 27 | } | ||
diff --git a/src/styles/objects/_deflist.scss b/src/styles/objects/_deflist.scss new file mode 100644 index 0000000..63ba1b9 --- /dev/null +++ b/src/styles/objects/_deflist.scss | |||
| @@ -0,0 +1,25 @@ | |||
| 1 | @use "sass:list"; | ||
| 2 | |||
| 3 | @use "pkg:iro-sass/bem"; | ||
| 4 | @use "pkg:iro-sass/props"; | ||
| 5 | @use "pkg:iro-design" as iro; | ||
| 6 | |||
| 7 | @mixin styles { | ||
| 8 | @include bem.object("deflist") { | ||
| 9 | display: grid; | ||
| 10 | grid-template-columns: auto 1fr; | ||
| 11 | gap: props.get(iro.$core--size--50) props.get(iro.$core--size--400); | ||
| 12 | padding: 0; | ||
| 13 | margin-block: props.get(iro.$s-implicit--paragraph--margin-bs) 0; | ||
| 14 | margin-inline: 0; | ||
| 15 | |||
| 16 | dt { | ||
| 17 | font-weight: bold; | ||
| 18 | color: props.get(iro.$core--theme, --heading); | ||
| 19 | } | ||
| 20 | |||
| 21 | dd { | ||
| 22 | margin: 0; | ||
| 23 | } | ||
| 24 | } | ||
| 25 | } | ||
diff --git a/src/styles/objects/_message.scss b/src/styles/objects/_message.scss new file mode 100644 index 0000000..a7c9996 --- /dev/null +++ b/src/styles/objects/_message.scss | |||
| @@ -0,0 +1,216 @@ | |||
| 1 | @use "sass:meta"; | ||
| 2 | |||
| 3 | @use "pkg:iro-design" as iro; | ||
| 4 | @use "pkg:iro-sass/bem"; | ||
| 5 | @use "pkg:iro-sass/props"; | ||
| 6 | |||
| 7 | @forward "message.vars"; | ||
| 8 | @use "message.vars" as vars; | ||
| 9 | |||
| 10 | @mixin styles { | ||
| 11 | @include iro.props--materialize-at-root(meta.module-variables("vars")); | ||
| 12 | |||
| 13 | @include bem.object("message") { | ||
| 14 | display: grid; | ||
| 15 | position: relative; | ||
| 16 | grid-template-rows: auto 1fr auto; | ||
| 17 | grid-template-columns: | ||
| 18 | auto calc(props.get(vars.$avatar-size) + props.get(vars.$spacing-x)) | ||
| 19 | minmax(0, 1fr); | ||
| 20 | grid-template-areas: | ||
| 21 | "line-from avatar main" | ||
| 22 | "line-from line-to main" | ||
| 23 | "line-from replies replies"; | ||
| 24 | |||
| 25 | & + & { | ||
| 26 | padding-block-start: props.get(vars.$spacing-outer); | ||
| 27 | } | ||
| 28 | |||
| 29 | @include bem.elem("line-from") { | ||
| 30 | grid-area: line-from; | ||
| 31 | position: relative; | ||
| 32 | inline-size: calc(props.get(vars.$avatar-size) + props.get(vars.$spacing-x)); | ||
| 33 | |||
| 34 | &::before, | ||
| 35 | &::after { | ||
| 36 | content: ""; | ||
| 37 | display: block; | ||
| 38 | position: absolute; | ||
| 39 | inset-block-start: 0; | ||
| 40 | inset-inline-start: props.get(vars.$line-offset); | ||
| 41 | } | ||
| 42 | |||
| 43 | &::before { | ||
| 44 | inline-size: props.get(vars.$line-width); | ||
| 45 | block-size: calc(100% + props.get(vars.$spacing-outer)); | ||
| 46 | background-color: props.get(vars.$colors, --line); | ||
| 47 | } | ||
| 48 | |||
| 49 | &::after { | ||
| 50 | inline-size: calc( | ||
| 51 | props.get(vars.$avatar-size) - props.get(vars.$line-offset) + props.get(vars.$spacing-x) | ||
| 52 | ); | ||
| 53 | block-size: calc(0.5 * (props.get(vars.$avatar-size) - props.get(vars.$line-width))); | ||
| 54 | border-block-end: props.get(vars.$line-width) solid props.get(vars.$colors, --line); | ||
| 55 | border-inline-start: props.get(vars.$line-width) solid props.get(vars.$colors, --line); | ||
| 56 | border-radius: 0 0 0 props.get(vars.$line-curve); | ||
| 57 | } | ||
| 58 | |||
| 59 | @include bem.modifier("last") { | ||
| 60 | &::before { | ||
| 61 | block-size: 0; | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | @include bem.sibling-elem("avatar") { | ||
| 66 | &::before { | ||
| 67 | inset-block-start: 50%; | ||
| 68 | inset-block-end: auto; | ||
| 69 | inset-inline-start: 0; | ||
| 70 | inline-size: 50%; | ||
| 71 | block-size: props.get(vars.$line-width); | ||
| 72 | transform: translateY(-50%); | ||
| 73 | } | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | @include bem.elem("line-to") { | ||
| 78 | grid-area: line-to; | ||
| 79 | display: block; | ||
| 80 | inline-size: props.get(vars.$line-width); | ||
| 81 | margin-inline-start: props.get(vars.$line-offset); | ||
| 82 | background-color: props.get(vars.$colors, --line); | ||
| 83 | } | ||
| 84 | |||
| 85 | @include bem.elem("avatar") { | ||
| 86 | position: relative; | ||
| 87 | grid-area: avatar; | ||
| 88 | inline-size: props.get(vars.$avatar-size); | ||
| 89 | block-size: props.get(vars.$avatar-size); | ||
| 90 | margin-inline-end: props.get(vars.$spacing-x); | ||
| 91 | |||
| 92 | &::before, | ||
| 93 | &::after { | ||
| 94 | content: ""; | ||
| 95 | display: block; | ||
| 96 | position: absolute; | ||
| 97 | z-index: -1; | ||
| 98 | inset-inline-start: props.get(vars.$line-offset); | ||
| 99 | inline-size: props.get(vars.$line-width); | ||
| 100 | block-size: 50%; | ||
| 101 | background-color: props.get(vars.$colors, --line); | ||
| 102 | } | ||
| 103 | |||
| 104 | &::before { | ||
| 105 | inset-block-start: 0; | ||
| 106 | } | ||
| 107 | |||
| 108 | &::after { | ||
| 109 | inset-block-end: 0; | ||
| 110 | } | ||
| 111 | |||
| 112 | @include bem.modifier("last") { | ||
| 113 | &::after { | ||
| 114 | display: none; | ||
| 115 | } | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | @include bem.elem("avatar-dot") { | ||
| 120 | grid-area: avatar; | ||
| 121 | position: absolute; | ||
| 122 | inset-block-start: 50%; | ||
| 123 | inset-inline-start: 50%; | ||
| 124 | inline-size: props.get(vars.$dot-radius); | ||
| 125 | block-size: props.get(vars.$dot-radius); | ||
| 126 | transform: translate(-50%, -50%); | ||
| 127 | border: props.get(vars.$dot-width) solid props.get(vars.$colors, --dot, --border); | ||
| 128 | border-radius: 1em; | ||
| 129 | background-color: props.get(vars.$colors, --dot, --bg); | ||
| 130 | } | ||
| 131 | |||
| 132 | @include bem.elem("meta") { | ||
| 133 | white-space: nowrap; | ||
| 134 | color: props.get(vars.$colors, --meta); | ||
| 135 | } | ||
| 136 | |||
| 137 | @include bem.elem("main") { | ||
| 138 | position: relative; | ||
| 139 | grid-area: main; | ||
| 140 | } | ||
| 141 | |||
| 142 | @include bem.elem("replies") { | ||
| 143 | grid-area: replies; | ||
| 144 | |||
| 145 | &::before { | ||
| 146 | content: ""; | ||
| 147 | grid-area: line; | ||
| 148 | display: block; | ||
| 149 | inline-size: props.get(vars.$line-width); | ||
| 150 | block-size: props.get(vars.$spacing-inner); | ||
| 151 | margin-inline-start: props.get(vars.$line-offset); | ||
| 152 | background-color: props.get(vars.$colors, --line); | ||
| 153 | } | ||
| 154 | } | ||
| 155 | |||
| 156 | @include bem.elem("header") { | ||
| 157 | line-height: 1.4; | ||
| 158 | |||
| 159 | @include bem.next-elem("body") { | ||
| 160 | padding-block-start: props.get(vars.$spacing-y); | ||
| 161 | } | ||
| 162 | } | ||
| 163 | |||
| 164 | @include bem.elem("body") { | ||
| 165 | word-wrap: break-word; | ||
| 166 | |||
| 167 | &::before { | ||
| 168 | content: ""; | ||
| 169 | display: block; | ||
| 170 | margin-block: -100em 100em; | ||
| 171 | } | ||
| 172 | } | ||
| 173 | |||
| 174 | @include bem.elem("attachments") { | ||
| 175 | display: grid; | ||
| 176 | grid-template-columns: repeat(auto-fill, minmax(props.get(vars.$attachment-width), 1fr)); | ||
| 177 | grid-auto-rows: auto; | ||
| 178 | gap: props.get(vars.$spacing-x); | ||
| 179 | margin-block-start: props.get(vars.$spacing-attachments); | ||
| 180 | } | ||
| 181 | |||
| 182 | @include bem.elem("attachment") { | ||
| 183 | aspect-ratio: 3 / 2; | ||
| 184 | background-color: props.get(vars.$colors, --attachment, --bg); | ||
| 185 | } | ||
| 186 | |||
| 187 | @include bem.modifier("expanded") { | ||
| 188 | grid-template-rows: auto 1fr auto; | ||
| 189 | grid-template-columns: calc(props.get(vars.$avatar-size) + props.get(vars.$spacing-x)) minmax( | ||
| 190 | 0, | ||
| 191 | 1fr | ||
| 192 | ); | ||
| 193 | grid-template-areas: | ||
| 194 | "avatar header" | ||
| 195 | "body body" | ||
| 196 | "attachments attachments"; | ||
| 197 | |||
| 198 | @include bem.elem("header") { | ||
| 199 | grid-area: header; | ||
| 200 | align-self: center; | ||
| 201 | } | ||
| 202 | |||
| 203 | @include bem.elem("main") { | ||
| 204 | display: contents; | ||
| 205 | } | ||
| 206 | |||
| 207 | @include bem.elem("body") { | ||
| 208 | grid-area: body; | ||
| 209 | } | ||
| 210 | |||
| 211 | @include bem.elem("attachments") { | ||
| 212 | grid-area: attachments; | ||
| 213 | } | ||
| 214 | } | ||
| 215 | } | ||
| 216 | } | ||
diff --git a/src/styles/objects/_message.vars.scss b/src/styles/objects/_message.vars.scss new file mode 100644 index 0000000..f394894 --- /dev/null +++ b/src/styles/objects/_message.vars.scss | |||
| @@ -0,0 +1,39 @@ | |||
| 1 | @use "pkg:iro-design/core.vars" as core; | ||
| 2 | @use "pkg:iro-design/objects/avatar.vars" as avatar; | ||
| 3 | @use "pkg:iro-sass/props"; | ||
| 4 | |||
| 5 | $avatar-size: props.def(--o-message--avatar-size, props.get(avatar.$size)); | ||
| 6 | |||
| 7 | $dot-radius: props.def(--o-message--dot-radius, 6px); | ||
| 8 | $dot-width: props.def(--o-message--dot-width, 4px); | ||
| 9 | |||
| 10 | $spacing-x: props.def(--o-message--spacing-x, props.get(core.$size--200)); | ||
| 11 | $spacing-y: props.def(--o-message--spacing-y, props.get(core.$size--150)); | ||
| 12 | $spacing-inner: props.def(--o-message--spacing-inner, props.get(core.$size--500)); | ||
| 13 | $spacing-outer: props.def(--o-message--spacing-outer, props.get(core.$size--700)); | ||
| 14 | $spacing-attachments: props.def(--o-message--spacing-attachments, props.get(core.$size--200)); | ||
| 15 | |||
| 16 | $line-width: props.def(--o-message--line-width, props.get(core.$border-width--medium)); | ||
| 17 | $line-offset: props.def( | ||
| 18 | --o-message--line-offset, | ||
| 19 | calc(0.5 * props.get($avatar-size) - props.get($line-width)) | ||
| 20 | ); | ||
| 21 | $line-curve: props.def(--o-message--line-curve, props.get(core.$size--250)); | ||
| 22 | |||
| 23 | $attachment-width: props.def(--o-message--attachment-width, props.get(core.$size--2000)); | ||
| 24 | |||
| 25 | $colors: props.def( | ||
| 26 | --o-message, | ||
| 27 | ( | ||
| 28 | --meta: props.get(core.$theme, --text-mute-more), | ||
| 29 | --line: props.get(core.$theme, --border), | ||
| 30 | --dot: ( | ||
| 31 | --border: props.get(core.$theme, --text-mute-more), | ||
| 32 | --bg: props.get(core.$theme, --bg-base), | ||
| 33 | ), | ||
| 34 | --attachment: ( | ||
| 35 | --bg: props.get(core.$theme, --border-mute), | ||
| 36 | ), | ||
| 37 | ), | ||
| 38 | "color" | ||
| 39 | ); | ||
diff --git a/src/styles/objects/_placeholder-box.scss b/src/styles/objects/_placeholder-box.scss new file mode 100644 index 0000000..9be3dc8 --- /dev/null +++ b/src/styles/objects/_placeholder-box.scss | |||
| @@ -0,0 +1,12 @@ | |||
| 1 | @use "pkg:iro-sass/bem"; | ||
| 2 | @use "pkg:iro-sass/props"; | ||
| 3 | @use "pkg:iro-design" as iro; | ||
| 4 | |||
| 5 | @mixin styles { | ||
| 6 | @include bem.object("placeholder-box") { | ||
| 7 | padding: props.get(iro.$core--size--250) props.get(iro.$core--size--300); | ||
| 8 | border: props.get(iro.$core--border-width--thin) dashed | ||
| 9 | props.get(iro.$core--theme, --border, --mute); | ||
| 10 | border-radius: props.get(iro.$core--rounding); | ||
| 11 | } | ||
| 12 | } | ||
diff --git a/src/styles/objects/_toc.scss b/src/styles/objects/_toc.scss new file mode 100644 index 0000000..aefb0c5 --- /dev/null +++ b/src/styles/objects/_toc.scss | |||
| @@ -0,0 +1,27 @@ | |||
| 1 | @use "sass:list"; | ||
| 2 | |||
| 3 | @use "pkg:iro-sass/bem"; | ||
| 4 | @use "pkg:iro-sass/props"; | ||
| 5 | @use "pkg:iro-design" as iro; | ||
| 6 | |||
| 7 | @mixin styles { | ||
| 8 | @include bem.object("toc") { | ||
| 9 | @include bem.elem("item") { | ||
| 10 | @include bem.next-twin-elem { | ||
| 11 | margin-block-start: props.get(iro.$core--size--50); | ||
| 12 | } | ||
| 13 | |||
| 14 | @include bem.is("active") { | ||
| 15 | @include bem.elem("item-link") { | ||
| 16 | font-weight: bold; | ||
| 17 | text-decoration-line: none; | ||
| 18 | color: props.get(iro.$core--theme, --heading); | ||
| 19 | |||
| 20 | &:hover { | ||
| 21 | text-decoration-line: underline; | ||
| 22 | } | ||
| 23 | } | ||
| 24 | } | ||
| 25 | } | ||
| 26 | } | ||
| 27 | } | ||
diff --git a/src/styles/style.scss b/src/styles/style.scss new file mode 100644 index 0000000..75d123f --- /dev/null +++ b/src/styles/style.scss | |||
| @@ -0,0 +1,53 @@ | |||
| 1 | @use "./common" as iro; | ||
| 2 | |||
| 3 | @include iro.layers--styles; | ||
| 4 | @include iro.core--styles; | ||
| 5 | @include iro.styles; | ||
| 6 | |||
| 7 | @include iro.s-implicit--styles; | ||
| 8 | @include iro.s-blockquotes--styles; | ||
| 9 | @include iro.s-body--styles; | ||
| 10 | @include iro.s-code--styles; | ||
| 11 | @include iro.s-figures--styles; | ||
| 12 | @include iro.s-headings--styles; | ||
| 13 | @include iro.s-lists--styles; | ||
| 14 | @include iro.s-links--styles; | ||
| 15 | |||
| 16 | @include iro.l-card-list--styles; | ||
| 17 | @include iro.l-container--styles; | ||
| 18 | @include iro.l-flex--styles; | ||
| 19 | @include iro.l-hlist--styles; | ||
| 20 | @include iro.l-media--styles; | ||
| 21 | @include iro.l-split-view--styles; | ||
| 22 | |||
| 23 | @include iro.o-alert--styles; | ||
| 24 | @include iro.o-avatar--styles; | ||
| 25 | @include iro.o-backdrop--styles; | ||
| 26 | @include iro.o-button--styles; | ||
| 27 | @include iro.o-card--styles; | ||
| 28 | @include iro.o-deflist--styles; | ||
| 29 | @include iro.o-dialog--styles; | ||
| 30 | @include iro.o-divider--styles; | ||
| 31 | @include iro.o-emoji--styles; | ||
| 32 | @include iro.o-figure--styles; | ||
| 33 | @include iro.o-heading--styles; | ||
| 34 | @include iro.o-icon--styles; | ||
| 35 | @include iro.o-thumbnail--styles; | ||
| 36 | @include iro.o-lightbox--styles; | ||
| 37 | @include iro.o-navbar--styles; | ||
| 38 | @include iro.o-placeholder-box--styles; | ||
| 39 | @include iro.o-side-nav--styles; | ||
| 40 | @include iro.o-tabbar--styles; | ||
| 41 | @include iro.o-text-field--styles; | ||
| 42 | @include iro.o-toc--styles; | ||
| 43 | |||
| 44 | @include iro.c-home-banner--styles; | ||
| 45 | @include iro.c-logo--styles; | ||
| 46 | @include iro.c-site-header--styles; | ||
| 47 | @include iro.c-site-nav--styles; | ||
| 48 | @include iro.c-site-footer--styles; | ||
| 49 | @include iro.c-subtitle--styles; | ||
| 50 | @include iro.c-visited-indicator--styles; | ||
| 51 | |||
| 52 | @include iro.utils--styles; | ||
| 53 | @include iro.themes--styles; | ||
diff --git a/src/ui/base/BaseLayout.tsx b/src/ui/base/BaseLayout.tsx new file mode 100644 index 0000000..c57d41e --- /dev/null +++ b/src/ui/base/BaseLayout.tsx | |||
| @@ -0,0 +1,134 @@ | |||
| 1 | // @ts-types="corvos/mod.ts" | ||
| 2 | import { getGenerator, type Select } from "corvos/mod.ts"; | ||
| 3 | |||
| 4 | import site from "@/corvos.config.ts"; | ||
| 5 | |||
| 6 | import { SiteFooter } from "../components/SiteFooter.tsx"; | ||
| 7 | import { SiteNav } from "../components/SiteNav.tsx"; | ||
| 8 | import { Lightbox } from "../objects/Lightbox.tsx"; | ||
| 9 | |||
| 10 | export interface Props { | ||
| 11 | page: Select<typeof site, ".html">; | ||
| 12 | image?: string; | ||
| 13 | theme?: "l1" | "l2" | null; | ||
| 14 | children?: JSX.Element; | ||
| 15 | class?: unknown; | ||
| 16 | } | ||
| 17 | |||
| 18 | export const BaseLayout = site.$.component( | ||
| 19 | async ({ page, theme = "l1", image: imageUrl, children, class: cls }: Props, pages) => { | ||
| 20 | const query = site.$.query(pages); | ||
| 21 | |||
| 22 | const stylesheet = await site.load("styles/style.scss"); | ||
| 23 | const script = await site.load("scripts/script.ts"); | ||
| 24 | const fonts = await site.glob("assets/**.woff2"); | ||
| 25 | const favicon = await site.load("assets/favicon.svg"); | ||
| 26 | const image = await site.load( | ||
| 27 | imageUrl ?? "https://strapi.volpeon.ink/uploads/icon_7999cf625e.png", | ||
| 28 | ); | ||
| 29 | |||
| 30 | const feeds = query.type(".rss").many(); | ||
| 31 | |||
| 32 | return ( | ||
| 33 | <> | ||
| 34 | {{ __html: "<!DOCTYPE html>" }} | ||
| 35 | <html lang="en"> | ||
| 36 | <head> | ||
| 37 | <meta charset="utf-8" /> | ||
| 38 | <meta content="light dark" name="color-scheme" /> | ||
| 39 | <meta content="width=device-width, initial-scale=1" name="viewport" /> | ||
| 40 | <meta content="scale" name="text-scale" /> | ||
| 41 | <link href={site.$.url("sitemap.xml")} rel="sitemap" /> | ||
| 42 | <link href={favicon.url} rel="icon" type="image/svg+xml" /> | ||
| 43 | {fonts.map((font) => ( | ||
| 44 | <link as="font" href={font.url} rel="preload" type="font/woff2" /> | ||
| 45 | ))} | ||
| 46 | |||
| 47 | <style>@layer scope, theme, object, layout, component, utility;</style> | ||
| 48 | <link rel="stylesheet" href={stylesheet.url} /> | ||
| 49 | <script src={script.url} defer /> | ||
| 50 | {feeds.map((feed) => ( | ||
| 51 | <link | ||
| 52 | rel="alternate" | ||
| 53 | href={feed.url} | ||
| 54 | title={feed.data.title} | ||
| 55 | type="application/rss+xml" | ||
| 56 | /> | ||
| 57 | ))} | ||
| 58 | <title> | ||
| 59 | {page.data.title} | ||
| 60 | {page.data.type === "home" ? "" : " · Corvos"} | ||
| 61 | </title> | ||
| 62 | |||
| 63 | <meta property="og:type" content="website" /> | ||
| 64 | <meta property="og:site_name" content="Corvos" /> | ||
| 65 | <meta property="og:locale" content="en" /> | ||
| 66 | {"description" in page.data && page.data.description && ( | ||
| 67 | <> | ||
| 68 | <meta property="og:description" content={page.data.description} /> | ||
| 69 | <meta name="description" content={page.data.description} /> | ||
| 70 | </> | ||
| 71 | )} | ||
| 72 | <meta property="og:url" content={site.$.url("", true)} /> | ||
| 73 | <meta property="og:image" content={image.url} /> | ||
| 74 | <meta name="twitter:card" content={imageUrl ? "summary_large_image" : "summary"} /> | ||
| 75 | <meta name="generator" content={getGenerator()} /> | ||
| 76 | </head> | ||
| 77 | <body class:list={["t-no-js", { [`t-${theme}`]: theme }, cls]}> | ||
| 78 | {children} | ||
| 79 | |||
| 80 | <SiteFooter /> | ||
| 81 | <SiteNav url={page.url} /> | ||
| 82 | <Lightbox /> | ||
| 83 | <svg | ||
| 84 | version="1.1" | ||
| 85 | xmlns="http://www.w3.org/2000/svg" | ||
| 86 | xmlns:xlink="http://www.w3.org/1999/xlink" | ||
| 87 | class="u-d-none" | ||
| 88 | > | ||
| 89 | <title>Loading indicator</title> | ||
| 90 | <symbol id="loading-indicator" viewBox="0 0 50 50"> | ||
| 91 | <circle | ||
| 92 | cx="25" | ||
| 93 | cy="25" | ||
| 94 | r="20" | ||
| 95 | fill="none" | ||
| 96 | stroke="currentColor" | ||
| 97 | stroke-width=".4em" | ||
| 98 | stroke-linecap="round" | ||
| 99 | stroke-dasharray="1 200" | ||
| 100 | stroke-dashoffset="0" | ||
| 101 | stroke-miterlimit="10" | ||
| 102 | transform-origin="center center" | ||
| 103 | > | ||
| 104 | <animate | ||
| 105 | attributeName="stroke-dasharray" | ||
| 106 | keyTimes="0;0.5;1" | ||
| 107 | values="1 200;89 200;89 200" | ||
| 108 | dur="2s" | ||
| 109 | repeatCount="indefinite" | ||
| 110 | /> | ||
| 111 | <animate | ||
| 112 | attributeName="stroke-dashoffset" | ||
| 113 | keyTimes="0;0.5;1" | ||
| 114 | values="0;-35;-124" | ||
| 115 | dur="2s" | ||
| 116 | repeatCount="indefinite" | ||
| 117 | /> | ||
| 118 | <animateTransform | ||
| 119 | attributeName="transform" | ||
| 120 | type="rotate" | ||
| 121 | from="0" | ||
| 122 | to="360" | ||
| 123 | dur="1.3s" | ||
| 124 | repeatCount="indefinite" | ||
| 125 | /> | ||
| 126 | </circle> | ||
| 127 | </symbol> | ||
| 128 | </svg> | ||
| 129 | </body> | ||
| 130 | </html> | ||
| 131 | </> | ||
| 132 | ); | ||
| 133 | }, | ||
| 134 | ); | ||
diff --git a/src/ui/components/Logo.tsx b/src/ui/components/Logo.tsx new file mode 100644 index 0000000..a9ec9be --- /dev/null +++ b/src/ui/components/Logo.tsx | |||
| @@ -0,0 +1,34 @@ | |||
| 1 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | import site from "@/corvos.config.ts"; | ||
| 4 | |||
| 5 | export type Props = HTMLAttributes<"a"> & { | ||
| 6 | size?: "sm" | "lg"; | ||
| 7 | variant?: "static-black" | "static-white"; | ||
| 8 | }; | ||
| 9 | |||
| 10 | export const Logo = async ({ size, variant, class: cls, ...props }: Props) => { | ||
| 11 | const symbols = await site.load("assets/symbols.svg"); | ||
| 12 | |||
| 13 | return ( | ||
| 14 | <a | ||
| 15 | aria-label="Home" | ||
| 16 | class:list={[ | ||
| 17 | "c-logo", | ||
| 18 | { | ||
| 19 | [`c-logo--${size}`]: size, | ||
| 20 | [`c-logo--${variant}`]: variant, | ||
| 21 | }, | ||
| 22 | cls, | ||
| 23 | ]} | ||
| 24 | href={site.$.url()} | ||
| 25 | rel="home" | ||
| 26 | {...props} | ||
| 27 | > | ||
| 28 | <svg class="c-logo__img"> | ||
| 29 | <title>Logo</title> | ||
| 30 | <use href={`${symbols.url}#logo${size === "sm" ? "-sm" : ""}`} /> | ||
| 31 | </svg> | ||
| 32 | </a> | ||
| 33 | ); | ||
| 34 | }; | ||
diff --git a/src/ui/components/ReleaseCard.tsx b/src/ui/components/ReleaseCard.tsx new file mode 100644 index 0000000..bffe256 --- /dev/null +++ b/src/ui/components/ReleaseCard.tsx | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | import type { Select } from "corvos"; | ||
| 2 | |||
| 3 | import type site from "@/corvos.config.ts"; | ||
| 4 | |||
| 5 | import { CardBody } from "$/ui/objects/CardBody.tsx"; | ||
| 6 | import { Heading } from "$/ui/objects/Heading.tsx"; | ||
| 7 | |||
| 8 | import { Card, type Props as CardProps } from "../objects/Card.tsx"; | ||
| 9 | |||
| 10 | export type Props = CardProps<"a"> & { | ||
| 11 | "heading-level": "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; | ||
| 12 | "page": Select<typeof site, ".html", { type: "release" }>; | ||
| 13 | }; | ||
| 14 | |||
| 15 | export const ReleaseCard = ({ "heading-level": headingLevel, page, ...props }: Props) => { | ||
| 16 | return ( | ||
| 17 | <Card | ||
| 18 | as="a" | ||
| 19 | class="c-with-visited-indicator h-entry u-url" | ||
| 20 | horizontal | ||
| 21 | href={page.url} | ||
| 22 | {...props} | ||
| 23 | > | ||
| 24 | <CardBody class="c-with-visited-indicator__indicator u-pie-0" /> | ||
| 25 | |||
| 26 | <CardBody main> | ||
| 27 | <Heading as={headingLevel} class="p-name"> | ||
| 28 | <span class="u-mie-100">{page.data.title}</span> | ||
| 29 | </Heading> | ||
| 30 | </CardBody> | ||
| 31 | |||
| 32 | <CardBody as="small" class="u-c-mute u-d-none@sm-lo"> | ||
| 33 | <ul class="l-hlist l-hlist--separated"> | ||
| 34 | <li class="l-hlist__item"> | ||
| 35 | <time class="dt-published" datetime={page.data.date.toISOString()}> | ||
| 36 | {page.data.date.toLocaleDateString(undefined, { | ||
| 37 | day: "numeric", | ||
| 38 | month: "short", | ||
| 39 | year: "numeric", | ||
| 40 | })} | ||
| 41 | </time> | ||
| 42 | </li> | ||
| 43 | </ul> | ||
| 44 | </CardBody> | ||
| 45 | </Card> | ||
| 46 | ); | ||
| 47 | }; | ||
diff --git a/src/ui/components/SiteFooter.tsx b/src/ui/components/SiteFooter.tsx new file mode 100644 index 0000000..8bada2e --- /dev/null +++ b/src/ui/components/SiteFooter.tsx | |||
| @@ -0,0 +1,39 @@ | |||
| 1 | import site from "@/corvos.config.ts"; | ||
| 2 | |||
| 3 | import { Container } from "../layouts/Container.tsx"; | ||
| 4 | import { Divider } from "../objects/Divider.tsx"; | ||
| 5 | import { Logo } from "./Logo.tsx"; | ||
| 6 | |||
| 7 | export const SiteFooter = async () => { | ||
| 8 | const symbols = await site.load("assets/symbols.svg"); | ||
| 9 | |||
| 10 | return ( | ||
| 11 | <footer class="c-site-footer"> | ||
| 12 | <div class="c-site-footer__banner"> | ||
| 13 | <svg class="c-site-footer__banner-img"> | ||
| 14 | <use href={`${symbols.url}#site-footer`} /> | ||
| 15 | </svg> | ||
| 16 | |||
| 17 | <svg class="c-site-footer__banner-bg"> | ||
| 18 | <use href={`${symbols.url}#site-footer-bg`} /> | ||
| 19 | </svg> | ||
| 20 | </div> | ||
| 21 | |||
| 22 | <div class="c-site-footer__content-wrap t-static-white"> | ||
| 23 | <Container class="c-site-footer__content" fixed="150" pad-block pad-inline> | ||
| 24 | <div class="c-site-footer__sections"> | ||
| 25 | <section class="c-site-footer__section s-links s-links--mark-external s-links--static-white"> | ||
| 26 | Developed by <a href="https://volpeon.ink/">Volpeon</a> | ||
| 27 | </section> | ||
| 28 | </div> | ||
| 29 | |||
| 30 | <Divider class="c-site-footer__divider" level="quiet" variant="static-white" /> | ||
| 31 | |||
| 32 | <section class="c-site-footer__logo"> | ||
| 33 | <Logo size="lg" variant="static-white" /> | ||
| 34 | </section> | ||
| 35 | </Container> | ||
| 36 | </div> | ||
| 37 | </footer> | ||
| 38 | ); | ||
| 39 | }; | ||
diff --git a/src/ui/components/SiteHeader.tsx b/src/ui/components/SiteHeader.tsx new file mode 100644 index 0000000..17831a1 --- /dev/null +++ b/src/ui/components/SiteHeader.tsx | |||
| @@ -0,0 +1,121 @@ | |||
| 1 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | import { Icon } from "$/ui/objects/Icon.tsx"; | ||
| 4 | |||
| 5 | import { nav } from "../../data/nav.ts"; | ||
| 6 | import { ActionButton } from "../objects/ActionButton.tsx"; | ||
| 7 | import { Navbar } from "../objects/Navbar.tsx"; | ||
| 8 | import { NavbarItem } from "../objects/NavbarItem.tsx"; | ||
| 9 | import { Logo } from "./Logo.tsx"; | ||
| 10 | |||
| 11 | export type Props = HTMLAttributes<"header"> & { | ||
| 12 | "url": string; | ||
| 13 | "mode"?: "compact" | "none"; | ||
| 14 | "at-end"?: boolean; | ||
| 15 | "hidden"?: boolean; | ||
| 16 | "sm"?: JSX.Element; | ||
| 17 | "end"?: JSX.Element; | ||
| 18 | "children"?: JSX.Element; | ||
| 19 | }; | ||
| 20 | |||
| 21 | export const SiteHeader = ({ | ||
| 22 | children, | ||
| 23 | sm, | ||
| 24 | url, | ||
| 25 | mode, | ||
| 26 | "at-end": atEnd, | ||
| 27 | hidden, | ||
| 28 | end, | ||
| 29 | class: cls, | ||
| 30 | ...props | ||
| 31 | }: Props) => { | ||
| 32 | const navItems = nav.map((item) => ({ | ||
| 33 | ...item, | ||
| 34 | selected: new RegExp(`^${item.url.replace("/#", "/")}`).test(url), | ||
| 35 | })); | ||
| 36 | |||
| 37 | const parent = navItems | ||
| 38 | .filter((item) => item.url !== url) | ||
| 39 | .reduce( | ||
| 40 | (parent: (typeof navItems)[number] | undefined, item) => | ||
| 41 | item.selected && item.url.length > (parent?.url.length ?? 0) ? item : parent, | ||
| 42 | undefined, | ||
| 43 | ); | ||
| 44 | const selected = navItems.reduce( | ||
| 45 | (sel, item) => (item.selected && item.url.length > sel.url.length ? item : sel), | ||
| 46 | { | ||
| 47 | icon: "", | ||
| 48 | label: "Back", | ||
| 49 | selected: true, | ||
| 50 | url: "/", | ||
| 51 | }, | ||
| 52 | ); | ||
| 53 | |||
| 54 | const variant = atEnd ? "static-white" : undefined; | ||
| 55 | |||
| 56 | return ( | ||
| 57 | <header | ||
| 58 | class:list={[ | ||
| 59 | "c-site-header", | ||
| 60 | "l-media", | ||
| 61 | { "c-site-header--end": atEnd, "c-site-header--hidden": hidden }, | ||
| 62 | cls, | ||
| 63 | ]} | ||
| 64 | {...props} | ||
| 65 | > | ||
| 66 | <Logo class="c-site-header__logo l-media__block" variant={variant} /> | ||
| 67 | |||
| 68 | {mode !== "none" && ( | ||
| 69 | <> | ||
| 70 | <Navbar aria-label="Mainmenu" class="c-site-header__nav l-media__block" variant={variant}> | ||
| 71 | {mode === "compact" ? ( | ||
| 72 | <NavbarItem | ||
| 73 | href={selected.url} | ||
| 74 | pre={<Icon icon="arrow-left" variant="bold" />} | ||
| 75 | selected | ||
| 76 | > | ||
| 77 | {selected.label} | ||
| 78 | </NavbarItem> | ||
| 79 | ) : ( | ||
| 80 | navItems | ||
| 81 | .filter((item) => item.url !== "/" && !item.hidden) | ||
| 82 | .map((item) => ( | ||
| 83 | <NavbarItem as="a" href={item.url} selected={item.selected}> | ||
| 84 | {item.label} | ||
| 85 | </NavbarItem> | ||
| 86 | )) | ||
| 87 | )} | ||
| 88 | </Navbar> | ||
| 89 | |||
| 90 | {parent && ( | ||
| 91 | <ActionButton | ||
| 92 | align-block | ||
| 93 | class="c-site-header__back l-media__block" | ||
| 94 | href={parent.url} | ||
| 95 | icon={<Icon icon="arrow-left" variant="bold" />} | ||
| 96 | level="quiet" | ||
| 97 | pill | ||
| 98 | /> | ||
| 99 | )} | ||
| 100 | </> | ||
| 101 | )} | ||
| 102 | |||
| 103 | {children} | ||
| 104 | |||
| 105 | {sm && <div class="c-site-header__slot c-site-header__slot--sm">{sm}</div>} | ||
| 106 | |||
| 107 | {end && ( | ||
| 108 | <> | ||
| 109 | <div class="c-site-header__spacer" /> | ||
| 110 | <div class="c-site-header__slot c-site-header__slot--end">{end}</div> | ||
| 111 | </> | ||
| 112 | )} | ||
| 113 | |||
| 114 | <Logo | ||
| 115 | class="c-site-header__logo c-site-header__logo--sm l-media__block" | ||
| 116 | size="sm" | ||
| 117 | variant={variant} | ||
| 118 | /> | ||
| 119 | </header> | ||
| 120 | ); | ||
| 121 | }; | ||
diff --git a/src/ui/components/SiteNav.tsx b/src/ui/components/SiteNav.tsx new file mode 100644 index 0000000..7518e3c --- /dev/null +++ b/src/ui/components/SiteNav.tsx | |||
| @@ -0,0 +1,38 @@ | |||
| 1 | import { Icon } from "$/ui/objects/Icon.tsx"; | ||
| 2 | |||
| 3 | import { nav } from "../../data/nav.ts"; | ||
| 4 | import { Navbar } from "../objects/Navbar.tsx"; | ||
| 5 | import { NavbarItem } from "../objects/NavbarItem.tsx"; | ||
| 6 | |||
| 7 | export interface Props { | ||
| 8 | url: string; | ||
| 9 | } | ||
| 10 | |||
| 11 | export const SiteNav = ({ url }: Props) => { | ||
| 12 | const navItems = nav | ||
| 13 | .filter((item) => !item.hidden) | ||
| 14 | .map((item) => ({ | ||
| 15 | ...item, | ||
| 16 | selected: item.url === "/" ? item.url === url : new RegExp(`^${item.url}`).test(url), | ||
| 17 | })); | ||
| 18 | |||
| 19 | return ( | ||
| 20 | <> | ||
| 21 | <div class="c-site-nav-placeholder"></div> | ||
| 22 | |||
| 23 | <footer class="c-site-nav"> | ||
| 24 | <Navbar aria-label="Mainmenu" quiet variant="static-white"> | ||
| 25 | {navItems.map((item) => ( | ||
| 26 | <NavbarItem | ||
| 27 | href={item.url} | ||
| 28 | icon={<Icon icon={item.icon} variant={item.selected ? "fill" : "regular"} />} | ||
| 29 | selected={item.selected} | ||
| 30 | > | ||
| 31 | {item.label} | ||
| 32 | </NavbarItem> | ||
| 33 | ))} | ||
| 34 | </Navbar> | ||
| 35 | </footer> | ||
| 36 | </> | ||
| 37 | ); | ||
| 38 | }; | ||
diff --git a/src/ui/layouts/CardList.tsx b/src/ui/layouts/CardList.tsx new file mode 100644 index 0000000..96ae642 --- /dev/null +++ b/src/ui/layouts/CardList.tsx | |||
| @@ -0,0 +1,52 @@ | |||
| 1 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props = HTMLAttributes<"ul"> & { | ||
| 4 | "layout"?: | ||
| 5 | | "merge" | ||
| 6 | | "grid" | ||
| 7 | | "grid-sm" | ||
| 8 | | "grid-xs" | ||
| 9 | | "flex" | ||
| 10 | | "flex-sm" | ||
| 11 | | "flex-xs" | ||
| 12 | | "masonry" | ||
| 13 | | "masonry-h" | ||
| 14 | | "masonry-h-sm"; | ||
| 15 | "quiet"?: boolean; | ||
| 16 | "border"?: boolean; | ||
| 17 | "interactive"?: boolean; | ||
| 18 | "no-shadow"?: boolean; | ||
| 19 | "no-flush"?: boolean; | ||
| 20 | "keyboard-nav"?: boolean; | ||
| 21 | }; | ||
| 22 | |||
| 23 | export const CardList = ({ | ||
| 24 | layout, | ||
| 25 | quiet, | ||
| 26 | border, | ||
| 27 | interactive, | ||
| 28 | "no-shadow": noShadow, | ||
| 29 | "no-flush": noFlush, | ||
| 30 | "keyboard-nav": keyboardNav, | ||
| 31 | class: cls, | ||
| 32 | ...props | ||
| 33 | }: Props) => ( | ||
| 34 | <div | ||
| 35 | class:list={[ | ||
| 36 | "l-card-list", | ||
| 37 | { | ||
| 38 | [`l-card-list--${layout}`]: layout, | ||
| 39 | "l-card-list--interactive": interactive, | ||
| 40 | "l-card-list--no-flush": noFlush, | ||
| 41 | "l-card-list--quiet": quiet, | ||
| 42 | "l-card-list--borderless": !border, | ||
| 43 | "l-card-list--shadow": layout === "merge" && !noShadow, | ||
| 44 | "js-keyboard-nav": keyboardNav, | ||
| 45 | }, | ||
| 46 | cls, | ||
| 47 | ]} | ||
| 48 | data-keyboard-nav-item=".l-card-list__card" | ||
| 49 | data-keyboard-nav-mode="grid" | ||
| 50 | {...props} | ||
| 51 | /> | ||
| 52 | ); | ||
diff --git a/src/ui/layouts/Container.tsx b/src/ui/layouts/Container.tsx new file mode 100644 index 0000000..7ce7ed5 --- /dev/null +++ b/src/ui/layouts/Container.tsx | |||
| @@ -0,0 +1,33 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 4 | "as": Tag; | ||
| 5 | "pad-inline"?: boolean; | ||
| 6 | "pad-block"?: boolean; | ||
| 7 | "size"?: "sm"; | ||
| 8 | "fixed"?: boolean | "150" | "200"; | ||
| 9 | }>; | ||
| 10 | |||
| 11 | export const Container = <T extends HTMLTag = "div">({ | ||
| 12 | as: Tag = "div" as T, | ||
| 13 | class: cls, | ||
| 14 | "pad-inline": padInline, | ||
| 15 | "pad-block": padBlock, | ||
| 16 | fixed, | ||
| 17 | size, | ||
| 18 | ...props | ||
| 19 | }: Props<T>) => ( | ||
| 20 | <Tag | ||
| 21 | class:list={[ | ||
| 22 | "l-container", | ||
| 23 | { | ||
| 24 | "l-container--pad-i": padInline, | ||
| 25 | "l-container--pad-b": padBlock, | ||
| 26 | [`l-container--${size}`]: size, | ||
| 27 | [`l-container--fixed${typeof fixed === "boolean" ? "" : `-${fixed}`}`]: fixed, | ||
| 28 | }, | ||
| 29 | cls, | ||
| 30 | ]} | ||
| 31 | {...props} | ||
| 32 | /> | ||
| 33 | ); | ||
diff --git a/src/ui/layouts/EmojiGrid.tsx b/src/ui/layouts/EmojiGrid.tsx new file mode 100644 index 0000000..5dc9266 --- /dev/null +++ b/src/ui/layouts/EmojiGrid.tsx | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props = HTMLAttributes<"ul"> & { | ||
| 4 | id: string; | ||
| 5 | sprite: { | ||
| 6 | variants: { size: number; space: number }[]; | ||
| 7 | wrap: number; | ||
| 8 | }; | ||
| 9 | count: number; | ||
| 10 | }; | ||
| 11 | |||
| 12 | export const EmojiGrid = ({ id, sprite, count, class: cls, ...props }: Props) => { | ||
| 13 | const size = sprite.variants[0]?.size ?? 48; | ||
| 14 | const space = sprite.variants[0]?.space ?? 2; | ||
| 15 | const { wrap } = sprite; | ||
| 16 | |||
| 17 | const src = `/emojis/${id}/sprite48.avif`; | ||
| 18 | const srcset = `/emojis/${id}/sprite48.avif 1x, /emojis/${id}/sprite96.avif 2x`; | ||
| 19 | |||
| 20 | const offsetX = (i: number) => -1 * (size + space) * (i % wrap); | ||
| 21 | const offsetY = (i: number) => -1 * (size + space) * Math.floor(i / wrap); | ||
| 22 | |||
| 23 | return ( | ||
| 24 | <ul class:list={["l-emoji-grid", cls]} style={{ "--emoji-size": `${size}px` }} {...props}> | ||
| 25 | {[...Array(count).keys()].map((i) => ( | ||
| 26 | <li> | ||
| 27 | <img | ||
| 28 | alt="" | ||
| 29 | class="l-emoji-grid__emoji" | ||
| 30 | src={src} | ||
| 31 | srcset={srcset} | ||
| 32 | width={size} | ||
| 33 | height={size} | ||
| 34 | style={{ "object-position": `${offsetX(i)}px ${offsetY(i)}px` }} | ||
| 35 | /> | ||
| 36 | </li> | ||
| 37 | ))} | ||
| 38 | </ul> | ||
| 39 | ); | ||
| 40 | }; | ||
diff --git a/src/ui/layouts/HList.tsx b/src/ui/layouts/HList.tsx new file mode 100644 index 0000000..c1d870a --- /dev/null +++ b/src/ui/layouts/HList.tsx | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | import type { Props as ActionButtonProps } from "../objects/ActionButton.tsx"; | ||
| 4 | |||
| 5 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 6 | "as"?: Tag; | ||
| 7 | "pill"?: boolean; | ||
| 8 | "icon"?: boolean; | ||
| 9 | "separated"?: boolean; | ||
| 10 | "size"?: "gapless" | "sm" | "lg" | "xl"; | ||
| 11 | "buttons-size"?: ActionButtonProps<Tag>["size"]; | ||
| 12 | "buttons"?: boolean; | ||
| 13 | }>; | ||
| 14 | |||
| 15 | export const HList = <T extends HTMLTag = "ul">({ | ||
| 16 | as: Tag = "ul" as T, | ||
| 17 | pill, | ||
| 18 | icon, | ||
| 19 | separated, | ||
| 20 | size, | ||
| 21 | "buttons-size": buttonsSize, | ||
| 22 | buttons, | ||
| 23 | class: cls, | ||
| 24 | ...props | ||
| 25 | }: Props<T>) => ( | ||
| 26 | <Tag | ||
| 27 | class:list={[ | ||
| 28 | "l-hlist", | ||
| 29 | { | ||
| 30 | "l-hlist--pill": pill, | ||
| 31 | "l-hlist--icon": icon, | ||
| 32 | "l-hlist--separated": separated, | ||
| 33 | [`l-hlist--${size}`]: size, | ||
| 34 | [`l-hlist--buttons-${buttonsSize}`]: buttonsSize, | ||
| 35 | "l-hlist--buttons": buttons, | ||
| 36 | }, | ||
| 37 | "js-keyboard-nav", | ||
| 38 | cls, | ||
| 39 | ]} | ||
| 40 | data-keyboard-nav-item="li > :first-child" | ||
| 41 | data-keyboard-nav-mode="nav" | ||
| 42 | {...props} | ||
| 43 | /> | ||
| 44 | ); | ||
diff --git a/src/ui/layouts/SplitView.tsx b/src/ui/layouts/SplitView.tsx new file mode 100644 index 0000000..81a55d1 --- /dev/null +++ b/src/ui/layouts/SplitView.tsx | |||
| @@ -0,0 +1,27 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 4 | "as": Tag; | ||
| 5 | "gapless"?: boolean; | ||
| 6 | "wrap-reverse"?: boolean; | ||
| 7 | }>; | ||
| 8 | |||
| 9 | export const SplitView = <T extends HTMLTag = "div">({ | ||
| 10 | as: Tag = "div" as T, | ||
| 11 | gapless, | ||
| 12 | "wrap-reverse": wrapReverse, | ||
| 13 | class: cls, | ||
| 14 | ...props | ||
| 15 | }: Props<T>) => ( | ||
| 16 | <Tag | ||
| 17 | class:list={[ | ||
| 18 | "l-split-view", | ||
| 19 | { | ||
| 20 | "l-split-view--gapless": gapless, | ||
| 21 | "l-split-view--wrap-reverse": wrapReverse, | ||
| 22 | }, | ||
| 23 | cls, | ||
| 24 | ]} | ||
| 25 | {...props} | ||
| 26 | /> | ||
| 27 | ); | ||
diff --git a/src/ui/layouts/SplitViewPanel.tsx b/src/ui/layouts/SplitViewPanel.tsx new file mode 100644 index 0000000..7f04b44 --- /dev/null +++ b/src/ui/layouts/SplitViewPanel.tsx | |||
| @@ -0,0 +1,33 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 4 | as: Tag; | ||
| 5 | sticky?: boolean; | ||
| 6 | side?: "25"; | ||
| 7 | min?: "50" | "100" | "200"; | ||
| 8 | fill?: boolean; | ||
| 9 | }>; | ||
| 10 | |||
| 11 | export const SplitViewPanel = <T extends HTMLTag = "div">({ | ||
| 12 | as: Tag = "div" as T, | ||
| 13 | sticky, | ||
| 14 | side, | ||
| 15 | min, | ||
| 16 | fill, | ||
| 17 | class: cls, | ||
| 18 | ...props | ||
| 19 | }: Props<T>) => ( | ||
| 20 | <Tag | ||
| 21 | class:list={[ | ||
| 22 | "l-split-view__panel", | ||
| 23 | { | ||
| 24 | "l-split-view__panel--sticky": sticky, | ||
| 25 | [`l-split-view__panel--side-${side}`]: side, | ||
| 26 | [`l-split-view__panel--min-${min}`]: min, | ||
| 27 | "l-split-view__panel--fill": fill, | ||
| 28 | }, | ||
| 29 | cls, | ||
| 30 | ]} | ||
| 31 | {...props} | ||
| 32 | /> | ||
| 33 | ); | ||
diff --git a/src/ui/objects/ActionButton.tsx b/src/ui/objects/ActionButton.tsx new file mode 100644 index 0000000..76f859f --- /dev/null +++ b/src/ui/objects/ActionButton.tsx | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 4 | "as"?: Tag; | ||
| 5 | "label-as"?: string; | ||
| 6 | "pill"?: boolean; | ||
| 7 | "icon"?: JSX.Element; | ||
| 8 | "pre"?: JSX.Element; | ||
| 9 | "post"?: JSX.Element; | ||
| 10 | "highlighted"?: boolean; | ||
| 11 | "selected"?: boolean; | ||
| 12 | "wide"?: boolean; | ||
| 13 | "justify"?: boolean; | ||
| 14 | "align-block"?: boolean; | ||
| 15 | "level"?: "button" | "badge" | "quiet"; | ||
| 16 | "variant"?: "accent" | "positive" | "negative" | "warning" | "static-black" | "static-white"; | ||
| 17 | "size"?: "xs" | "sm" | "lg" | "xl"; | ||
| 18 | "children"?: JSX.Element; | ||
| 19 | }>; | ||
| 20 | |||
| 21 | export const ActionButton = <T extends HTMLTag = "a">({ | ||
| 22 | children, | ||
| 23 | as: Tag = "a" as T, | ||
| 24 | "label-as": Label = "span", | ||
| 25 | pill, | ||
| 26 | pre, | ||
| 27 | post, | ||
| 28 | wide, | ||
| 29 | justify, | ||
| 30 | icon, | ||
| 31 | "align-block": alignBlock, | ||
| 32 | highlighted, | ||
| 33 | selected, | ||
| 34 | level, | ||
| 35 | variant, | ||
| 36 | size, | ||
| 37 | class: cls, | ||
| 38 | ...props | ||
| 39 | }: Props<T>) => ( | ||
| 40 | <Tag | ||
| 41 | class:list={[ | ||
| 42 | "o-button", | ||
| 43 | { | ||
| 44 | "o-button--pill": pill, | ||
| 45 | "o-button--wide": wide, | ||
| 46 | "o-button--justify": justify, | ||
| 47 | "o-button--icon": icon, | ||
| 48 | "o-button--align-block": alignBlock, | ||
| 49 | "is-highlighted": highlighted, | ||
| 50 | "is-selected": selected, | ||
| 51 | [`o-button--${level}`]: level && level !== "button", | ||
| 52 | [`o-button--${variant}`]: variant, | ||
| 53 | [`o-button--${size}`]: size, | ||
| 54 | }, | ||
| 55 | cls, | ||
| 56 | ]} | ||
| 57 | {...props} | ||
| 58 | > | ||
| 59 | {pre} {icon ?? <Label class="o-button__label">{children}</Label>} {post} | ||
| 60 | </Tag> | ||
| 61 | ); | ||
diff --git a/src/ui/objects/Alert.tsx b/src/ui/objects/Alert.tsx new file mode 100644 index 0000000..f590e18 --- /dev/null +++ b/src/ui/objects/Alert.tsx | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 4 | as?: Tag; | ||
| 5 | icon?: JSX.Element; | ||
| 6 | variant?: "accent" | "positive" | "negative" | "warning"; | ||
| 7 | }>; | ||
| 8 | |||
| 9 | export const Alert = <T extends HTMLTag = "div">({ | ||
| 10 | children, | ||
| 11 | as: Tag = "div" as T, | ||
| 12 | variant, | ||
| 13 | icon, | ||
| 14 | class: cls, | ||
| 15 | ...props | ||
| 16 | }: Props<T>) => ( | ||
| 17 | <Tag class:list={["o-alert", { [`o-alert--${variant}`]: variant }, cls]} {...props}> | ||
| 18 | {icon && <span class="u-mie-100">{icon}</span>} | ||
| 19 | {children} | ||
| 20 | </Tag> | ||
| 21 | ); | ||
diff --git a/src/ui/objects/Avatar.tsx b/src/ui/objects/Avatar.tsx new file mode 100644 index 0000000..459af12 --- /dev/null +++ b/src/ui/objects/Avatar.tsx | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | import { StatusIndicator } from "$/ui/objects/StatusIndicator.tsx"; | ||
| 4 | |||
| 5 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 6 | "as"?: Tag; | ||
| 7 | "src"?: string; | ||
| 8 | "icon"?: JSX.Element; | ||
| 9 | "children"?: JSX.Element; | ||
| 10 | "class"?: string; | ||
| 11 | "circle"?: boolean; | ||
| 12 | "placeholder"?: boolean; | ||
| 13 | "color"?: `hsl(${number}, ${number}%, ${number}%)`; | ||
| 14 | "color2"?: `hsl(${number}, ${number}%, ${number}%)`; | ||
| 15 | "color-angle"?: number | `${number}`; | ||
| 16 | "size"?: "xs" | "sm" | "lg" | "xl" | "xxl" | "xxxl"; | ||
| 17 | "status"?: "accent" | "positive" | "negative" | "warning"; | ||
| 18 | }>; | ||
| 19 | |||
| 20 | const hslRegex = /^hsl\(([\d.]+), ([\d.]+%), ([\d.]+%)\)$/; | ||
| 21 | |||
| 22 | export const Avatar = <T extends HTMLTag = "div">({ | ||
| 23 | children, | ||
| 24 | as: Tag = "div" as T, | ||
| 25 | src, | ||
| 26 | icon, | ||
| 27 | circle, | ||
| 28 | placeholder, | ||
| 29 | color, | ||
| 30 | color2, | ||
| 31 | "color-angle": colorAngle, | ||
| 32 | size, | ||
| 33 | status, | ||
| 34 | class: cls, | ||
| 35 | ...props | ||
| 36 | }: Props<T>) => ( | ||
| 37 | <Tag | ||
| 38 | class:list={[ | ||
| 39 | "o-avatar", | ||
| 40 | { | ||
| 41 | "o-avatar--circle": circle, | ||
| 42 | "o-avatar--placeholder": placeholder, | ||
| 43 | "o-avatar--colored": color && !color2, | ||
| 44 | "o-avatar--colored-gradient": color && !!color2, | ||
| 45 | [`o-avatar--${size}`]: !!size, | ||
| 46 | }, | ||
| 47 | cls, | ||
| 48 | ]} | ||
| 49 | style={{ | ||
| 50 | "--o-avatar--bg-angle": colorAngle, | ||
| 51 | "--o-avatar--bg-color--h": color?.match(hslRegex)?.[1], | ||
| 52 | "--o-avatar--bg-color--l": color?.match(hslRegex)?.[3], | ||
| 53 | "--o-avatar--bg-color--s": color?.match(hslRegex)?.[2], | ||
| 54 | "--o-avatar--bg-color-2--h": color2?.match(hslRegex)?.[1], | ||
| 55 | "--o-avatar--bg-color-2--l": color2?.match(hslRegex)?.[3], | ||
| 56 | "--o-avatar--bg-color-2--s": color2?.match(hslRegex)?.[2], | ||
| 57 | }} | ||
| 58 | {...props} | ||
| 59 | > | ||
| 60 | {status && <StatusIndicator class="o-avatar__status" status={status} />} | ||
| 61 | {icon && <div class="o-avatar__icon u-ai-end u-jc-end">{icon}</div>} | ||
| 62 | |||
| 63 | {src ? ( | ||
| 64 | <img alt="" class="o-avatar__content" src={src} /> | ||
| 65 | ) : ( | ||
| 66 | <span class="o-avatar__content">{children}</span> | ||
| 67 | )} | ||
| 68 | </Tag> | ||
| 69 | ); | ||
diff --git a/src/ui/objects/Badge.tsx b/src/ui/objects/Badge.tsx new file mode 100644 index 0000000..e98ef0d --- /dev/null +++ b/src/ui/objects/Badge.tsx | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | import type { Props as ActionButtonProps } from "./ActionButton.tsx"; | ||
| 4 | |||
| 5 | import { ActionButton } from "./ActionButton.tsx"; | ||
| 6 | |||
| 7 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 8 | as?: Tag; | ||
| 9 | variant?: ActionButtonProps<Tag>["variant"]; | ||
| 10 | pill?: boolean; | ||
| 11 | selected?: boolean; | ||
| 12 | size?: ActionButtonProps<Tag>["size"]; | ||
| 13 | children?: JSX.Element; | ||
| 14 | }>; | ||
| 15 | |||
| 16 | export const Badge = <T extends HTMLTag = "span">({ as = "span" as T, ...props }: Props<T>) => ( | ||
| 17 | // deno-lint-ignore no-explicit-any | ||
| 18 | <ActionButton as={as} level="badge" {...(props as any)} /> | ||
| 19 | ); | ||
diff --git a/src/ui/objects/Body.tsx b/src/ui/objects/Body.tsx new file mode 100644 index 0000000..149662c --- /dev/null +++ b/src/ui/objects/Body.tsx | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props = HTMLAttributes<"div">; | ||
| 4 | |||
| 5 | export const Body = ({ class: cls, ...props }: Props) => ( | ||
| 6 | <div | ||
| 7 | class:list={[ | ||
| 8 | "s-body", | ||
| 9 | "s-headings", | ||
| 10 | "s-headings--display", | ||
| 11 | "s-links", | ||
| 12 | "s-links--colored", | ||
| 13 | "s-code", | ||
| 14 | "s-figures", | ||
| 15 | "s-blockquotes", | ||
| 16 | "s-lists", | ||
| 17 | cls, | ||
| 18 | ]} | ||
| 19 | {...props} | ||
| 20 | /> | ||
| 21 | ); | ||
diff --git a/src/ui/objects/Button.tsx b/src/ui/objects/Button.tsx new file mode 100644 index 0000000..68e1776 --- /dev/null +++ b/src/ui/objects/Button.tsx | |||
| @@ -0,0 +1,26 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | import type { Props as ActionButtonProps } from "./ActionButton.tsx"; | ||
| 4 | |||
| 5 | import { ActionButton } from "./ActionButton.tsx"; | ||
| 6 | |||
| 7 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 8 | as?: Tag; | ||
| 9 | variant?: "primary" | ActionButtonProps<Tag>["variant"]; | ||
| 10 | size?: ActionButtonProps<Tag>["size"]; | ||
| 11 | wide?: boolean; | ||
| 12 | justify?: boolean; | ||
| 13 | children?: JSX.Element; | ||
| 14 | }>; | ||
| 15 | |||
| 16 | export const Button = <T extends HTMLTag = "a">({ as = "a" as T, variant, ...props }: Props<T>) => ( | ||
| 17 | <ActionButton | ||
| 18 | as={as} | ||
| 19 | label-as={variant ? "strong" : "span"} | ||
| 20 | pill | ||
| 21 | selected={!!variant} | ||
| 22 | variant={variant === "primary" ? undefined : variant} | ||
| 23 | // deno-lint-ignore no-explicit-any | ||
| 24 | {...(props as any)} | ||
| 25 | /> | ||
| 26 | ); | ||
diff --git a/src/ui/objects/Card.tsx b/src/ui/objects/Card.tsx new file mode 100644 index 0000000..fd565e1 --- /dev/null +++ b/src/ui/objects/Card.tsx | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 4 | "as"?: Tag; | ||
| 5 | "list"?: boolean; | ||
| 6 | "quiet"?: boolean; | ||
| 7 | "thumbnail"?: boolean; | ||
| 8 | "border"?: boolean; | ||
| 9 | "no-shadow"?: boolean; | ||
| 10 | "horizontal"?: boolean; | ||
| 11 | "interactive"?: boolean; | ||
| 12 | "highlight"?: boolean; | ||
| 13 | "children"?: JSX.Element; | ||
| 14 | }>; | ||
| 15 | |||
| 16 | export const Card = <T extends HTMLTag = "article">({ | ||
| 17 | as: Tag = "article" as T, | ||
| 18 | list, | ||
| 19 | quiet, | ||
| 20 | thumbnail, | ||
| 21 | border, | ||
| 22 | "no-shadow": noShadow, | ||
| 23 | horizontal, | ||
| 24 | interactive, | ||
| 25 | highlight, | ||
| 26 | class: cls, | ||
| 27 | ...props | ||
| 28 | }: Props<T>) => ( | ||
| 29 | <Tag | ||
| 30 | class:list={[ | ||
| 31 | "o-card", | ||
| 32 | { | ||
| 33 | "l-card-list__card": list, | ||
| 34 | "o-card--borderless": !border, | ||
| 35 | "o-card--horizontal": horizontal, | ||
| 36 | "o-card--interactive": interactive, | ||
| 37 | "o-card--highlight": highlight, | ||
| 38 | "o-card--quiet": quiet, | ||
| 39 | "o-card--collapsible": Tag === "details", | ||
| 40 | "o-card--shadow": !noShadow, | ||
| 41 | "o-card--thumbnail": thumbnail, | ||
| 42 | }, | ||
| 43 | cls, | ||
| 44 | ]} | ||
| 45 | {...props} | ||
| 46 | /> | ||
| 47 | ); | ||
diff --git a/src/ui/objects/CardBody.tsx b/src/ui/objects/CardBody.tsx new file mode 100644 index 0000000..aa5df3d --- /dev/null +++ b/src/ui/objects/CardBody.tsx | |||
| @@ -0,0 +1,32 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 4 | "as"?: Tag; | ||
| 5 | "list"?: boolean; | ||
| 6 | "main"?: boolean; | ||
| 7 | "no-mbs-neutralize"?: boolean; | ||
| 8 | "children"?: JSX.Element; | ||
| 9 | }>; | ||
| 10 | |||
| 11 | export const CardBody = <T extends HTMLTag = "div">({ | ||
| 12 | as: Tag = "div" as T, | ||
| 13 | list, | ||
| 14 | main, | ||
| 15 | "no-mbs-neutralize": noMbsNeutralize, | ||
| 16 | class: cls, | ||
| 17 | ...props | ||
| 18 | }: Props<T>) => ( | ||
| 19 | <Tag | ||
| 20 | class:list={[ | ||
| 21 | "o-card__body", | ||
| 22 | { | ||
| 23 | "o-card__body--main": main, | ||
| 24 | "o-card__header": Tag === "summary", | ||
| 25 | "l-card-list__card-body": list, | ||
| 26 | "o-card__body--no-mbs-neutralize": noMbsNeutralize, | ||
| 27 | }, | ||
| 28 | cls, | ||
| 29 | ]} | ||
| 30 | {...props} | ||
| 31 | /> | ||
| 32 | ); | ||
diff --git a/src/ui/objects/CardFooter.tsx b/src/ui/objects/CardFooter.tsx new file mode 100644 index 0000000..63d74d0 --- /dev/null +++ b/src/ui/objects/CardFooter.tsx | |||
| @@ -0,0 +1,11 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 4 | as?: Tag; | ||
| 5 | }>; | ||
| 6 | |||
| 7 | export const CardFooter = <T extends HTMLTag = "div">({ | ||
| 8 | as: Tag = "div" as T, | ||
| 9 | class: cls, | ||
| 10 | ...props | ||
| 11 | }: Props<T>) => <Tag class:list={["o-card__footer", cls]} {...props} />; | ||
diff --git a/src/ui/objects/CardImage.tsx b/src/ui/objects/CardImage.tsx new file mode 100644 index 0000000..12b9735 --- /dev/null +++ b/src/ui/objects/CardImage.tsx | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | import type { Props as ImageProps } from "corvos/plugins/images.ts"; | ||
| 2 | import type { CSSProperties } from "https://cdn.jsdelivr.net/gh/oscarotero/ssx@0.1.14/css.ts"; | ||
| 3 | |||
| 4 | import { Image } from "corvos/plugins/images.ts"; | ||
| 5 | |||
| 6 | export type Props = ImageProps & { | ||
| 7 | "list"?: boolean; | ||
| 8 | "img-class"?: unknown; | ||
| 9 | "img-style"?: CSSProperties; | ||
| 10 | "aspect"?: `${number} / ${number}`; | ||
| 11 | "content-class"?: unknown; | ||
| 12 | "children"?: JSX.Element; | ||
| 13 | }; | ||
| 14 | |||
| 15 | export const CardImage = ({ | ||
| 16 | children, | ||
| 17 | list, | ||
| 18 | "img-class": imgClass, | ||
| 19 | aspect, | ||
| 20 | "content-class": contentClass, | ||
| 21 | alt, | ||
| 22 | class: cls, | ||
| 23 | style, | ||
| 24 | "img-style": imgStyle, | ||
| 25 | ...props | ||
| 26 | }: Props) => ( | ||
| 27 | <div class:list={["o-card__image", cls]} style={style}> | ||
| 28 | <Image | ||
| 29 | class:list={["o-card__image-img", { "l-card-list__card-image": list }, imgClass]} | ||
| 30 | style={{ | ||
| 31 | "aspect-ratio": aspect, | ||
| 32 | "block-size": aspect ? "auto" : undefined, | ||
| 33 | ...imgStyle, | ||
| 34 | }} | ||
| 35 | alt={alt} | ||
| 36 | {...props} | ||
| 37 | /> | ||
| 38 | {children && <div class:list={["o-card__image-overlay", contentClass]}>{children}</div>} | ||
| 39 | </div> | ||
| 40 | ); | ||
diff --git a/src/ui/objects/CollapseCard.tsx b/src/ui/objects/CollapseCard.tsx new file mode 100644 index 0000000..27e1ce2 --- /dev/null +++ b/src/ui/objects/CollapseCard.tsx | |||
| @@ -0,0 +1,27 @@ | |||
| 1 | import { Card, type Props as CardProps } from "./Card.tsx"; | ||
| 2 | import { CardBody } from "./CardBody.tsx"; | ||
| 3 | import { Heading } from "./Heading.tsx"; | ||
| 4 | |||
| 5 | export type Props = CardProps<"details"> & { | ||
| 6 | "heading-level": "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; | ||
| 7 | "title"?: JSX.Element; | ||
| 8 | "header"?: JSX.Element; | ||
| 9 | }; | ||
| 10 | |||
| 11 | export const CollapseCard = ({ | ||
| 12 | "heading-level": headingLevel, | ||
| 13 | children, | ||
| 14 | title, | ||
| 15 | header, | ||
| 16 | ...props | ||
| 17 | }: Props) => ( | ||
| 18 | <Card as="details" {...props}> | ||
| 19 | <CardBody as="summary"> | ||
| 20 | <Heading as={headingLevel} class="u-mbs-0" level="lg"> | ||
| 21 | {title} | ||
| 22 | </Heading> | ||
| 23 | {header} | ||
| 24 | </CardBody> | ||
| 25 | <CardBody>{children}</CardBody> | ||
| 26 | </Card> | ||
| 27 | ); | ||
diff --git a/src/ui/objects/ContentImage.tsx b/src/ui/objects/ContentImage.tsx new file mode 100644 index 0000000..ef45f89 --- /dev/null +++ b/src/ui/objects/ContentImage.tsx | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | import type { ClassValue } from "corvos/deps/clsx.ts"; | ||
| 2 | import type { Props as ImageProps } from "corvos/plugins/images.ts"; | ||
| 3 | |||
| 4 | import { Image } from "corvos/plugins/images.ts"; | ||
| 5 | |||
| 6 | export type Props = Omit<ImageProps, "src"> & { | ||
| 7 | "src"?: string; | ||
| 8 | "img-class"?: ClassValue; | ||
| 9 | }; | ||
| 10 | |||
| 11 | export const ContentImage = ({ src, "img-class": imgClass, ...props }: Props) => ( | ||
| 12 | <a href={src} data-lightbox {...props}> | ||
| 13 | <Image src={src} width="1400" class:list={imgClass} /> | ||
| 14 | </a> | ||
| 15 | ); | ||
diff --git a/src/ui/objects/Deflist.tsx b/src/ui/objects/Deflist.tsx new file mode 100644 index 0000000..53d60d6 --- /dev/null +++ b/src/ui/objects/Deflist.tsx | |||
| @@ -0,0 +1,7 @@ | |||
| 1 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props = HTMLAttributes<"dl">; | ||
| 4 | |||
| 5 | export const Deflist = ({ class: cls, ...props }: Props) => ( | ||
| 6 | <dl class={`o-deflist ${cls ?? ""}`} {...props} /> | ||
| 7 | ); | ||
diff --git a/src/ui/objects/Dialog.tsx b/src/ui/objects/Dialog.tsx new file mode 100644 index 0000000..8ab6c4f --- /dev/null +++ b/src/ui/objects/Dialog.tsx | |||
| @@ -0,0 +1,41 @@ | |||
| 1 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | import { Icon } from "$/ui/objects/Icon.tsx"; | ||
| 4 | |||
| 5 | import { Container } from "../layouts/Container.tsx"; | ||
| 6 | import { ActionButton } from "./ActionButton.tsx"; | ||
| 7 | import { Divider } from "./Divider.tsx"; | ||
| 8 | import { Heading } from "./Heading.tsx"; | ||
| 9 | |||
| 10 | export type Props = HTMLAttributes<"dialog"> & { | ||
| 11 | title: string; | ||
| 12 | divider?: boolean; | ||
| 13 | children?: JSX.Element; | ||
| 14 | }; | ||
| 15 | |||
| 16 | export const Dialog = ({ children, title, divider, class: cls, ...props }: Props) => ( | ||
| 17 | <dialog popover="auto" class:list={["o-dialog is-modal", cls]} closedby="any" {...props}> | ||
| 18 | <Container fixed class="o-dialog__content"> | ||
| 19 | <header class="o-dialog__header"> | ||
| 20 | <Heading as="h1" class="u-mbs-0" level="xxl"> | ||
| 21 | {title} | ||
| 22 | </Heading> | ||
| 23 | |||
| 24 | <ActionButton | ||
| 25 | command="close" | ||
| 26 | commandfor={props.id} | ||
| 27 | as="button" | ||
| 28 | pill | ||
| 29 | size="lg" | ||
| 30 | level="quiet" | ||
| 31 | icon={<Icon icon="x" variant="bold" />} | ||
| 32 | class="u-mis-auto" | ||
| 33 | /> | ||
| 34 | </header> | ||
| 35 | |||
| 36 | {divider && <Divider level="faint" class="o-dialog__divider" />} | ||
| 37 | |||
| 38 | <div class="o-dialog__body">{children}</div> | ||
| 39 | </Container> | ||
| 40 | </dialog> | ||
| 41 | ); | ||
diff --git a/src/ui/objects/Divider.tsx b/src/ui/objects/Divider.tsx new file mode 100644 index 0000000..8f7efcd --- /dev/null +++ b/src/ui/objects/Divider.tsx | |||
| @@ -0,0 +1,51 @@ | |||
| 1 | import type { HTMLAttributes, HTMLTag } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props = HTMLAttributes<"div" | "hr"> & { | ||
| 4 | "level"?: "strong" | "medium" | "quiet" | "faint"; | ||
| 5 | "variant"?: "static-black" | "static-white"; | ||
| 6 | "vertical"?: boolean | "dot"; | ||
| 7 | "label-as"?: HTMLTag; | ||
| 8 | "children"?: JSX.Element; | ||
| 9 | }; | ||
| 10 | |||
| 11 | export const Divider = ({ | ||
| 12 | children, | ||
| 13 | level = "strong", | ||
| 14 | variant, | ||
| 15 | vertical, | ||
| 16 | "label-as": Label = "span", | ||
| 17 | class: cls, | ||
| 18 | ...props | ||
| 19 | }: Props) => | ||
| 20 | children ? ( | ||
| 21 | <div | ||
| 22 | class:list={[ | ||
| 23 | "o-divider", | ||
| 24 | "o-divider--labelled", | ||
| 25 | `o-divider--${level}`, | ||
| 26 | { | ||
| 27 | [`o-divider--${variant}`]: variant, | ||
| 28 | "o-divider--vertical": vertical === true, | ||
| 29 | "o-divider--dot": vertical === "dot", | ||
| 30 | }, | ||
| 31 | cls, | ||
| 32 | ]} | ||
| 33 | {...props} | ||
| 34 | > | ||
| 35 | <Label class="o-divider__label u-mbs-0">{children}</Label> | ||
| 36 | </div> | ||
| 37 | ) : ( | ||
| 38 | <hr | ||
| 39 | class:list={[ | ||
| 40 | "o-divider", | ||
| 41 | `o-divider--${level}`, | ||
| 42 | { | ||
| 43 | [`o-divider--${variant}`]: variant, | ||
| 44 | "o-divider--vertical": vertical === true, | ||
| 45 | "o-divider--dot": vertical === "dot", | ||
| 46 | }, | ||
| 47 | cls, | ||
| 48 | ]} | ||
| 49 | {...props} | ||
| 50 | /> | ||
| 51 | ); | ||
diff --git a/src/ui/objects/Emoji.tsx b/src/ui/objects/Emoji.tsx new file mode 100644 index 0000000..f04f776 --- /dev/null +++ b/src/ui/objects/Emoji.tsx | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | import site from "@/corvos.config.ts"; | ||
| 2 | |||
| 3 | export interface Props { | ||
| 4 | pack: string; | ||
| 5 | emoji: string; | ||
| 6 | size?: "125" | "150" | "200"; | ||
| 7 | class?: string; | ||
| 8 | } | ||
| 9 | |||
| 10 | export const Emoji = site.$.component( | ||
| 11 | ({ pack, emoji, size, class: cls, ...props }: Props, pages) => { | ||
| 12 | const query = site.$.query(pages); | ||
| 13 | const sprite = query.type(".svg").match({ emojiPack: pack }).one(); | ||
| 14 | |||
| 15 | return ( | ||
| 16 | <svg class:list={["o-emoji o-emoji--sprite", { [`o-emoji--${size}`]: size }, cls]} {...props}> | ||
| 17 | <use href={`${sprite.url}#${emoji}`} /> | ||
| 18 | </svg> | ||
| 19 | ); | ||
| 20 | }, | ||
| 21 | ); | ||
diff --git a/src/ui/objects/Figure.tsx b/src/ui/objects/Figure.tsx new file mode 100644 index 0000000..7f5e20a --- /dev/null +++ b/src/ui/objects/Figure.tsx | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props = HTMLAttributes<"figure"> & { | ||
| 4 | children?: JSX.Element; | ||
| 5 | caption?: JSX.Element; | ||
| 6 | }; | ||
| 7 | |||
| 8 | export const Figure = ({ children, caption, class: cls, ...props }: Props) => ( | ||
| 9 | <figure class:list={["o-figure", cls]} {...props}> | ||
| 10 | {children} | ||
| 11 | {caption && <figcaption class="o-figure__caption">{caption}</figcaption>} | ||
| 12 | </figure> | ||
| 13 | ); | ||
diff --git a/src/ui/objects/FigureCaption.tsx b/src/ui/objects/FigureCaption.tsx new file mode 100644 index 0000000..4112170 --- /dev/null +++ b/src/ui/objects/FigureCaption.tsx | |||
| @@ -0,0 +1,11 @@ | |||
| 1 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props = HTMLAttributes<"figcaption"> & { | ||
| 4 | children?: JSX.Element; | ||
| 5 | }; | ||
| 6 | |||
| 7 | export const FigureCaption = ({ children, class: cls, ...props }: Props) => ( | ||
| 8 | <figcaption class:list={["o-figure__caption", cls]} {...props}> | ||
| 9 | {children} | ||
| 10 | </figcaption> | ||
| 11 | ); | ||
diff --git a/src/ui/objects/Heading.tsx b/src/ui/objects/Heading.tsx new file mode 100644 index 0000000..089de3a --- /dev/null +++ b/src/ui/objects/Heading.tsx | |||
| @@ -0,0 +1,49 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 4 | as: Tag; | ||
| 5 | level?: "xxl" | "xl" | "lg" | "md" | "sm" | "xs"; | ||
| 6 | variant?: "static-black" | "static-white"; | ||
| 7 | display?: boolean; | ||
| 8 | highlight?: boolean; | ||
| 9 | children?: JSX.Element; | ||
| 10 | }>; | ||
| 11 | |||
| 12 | const levelTagMap = { | ||
| 13 | lg: "h3", | ||
| 14 | md: "h4", | ||
| 15 | sm: "h5", | ||
| 16 | xl: "h2", | ||
| 17 | xs: "h6", | ||
| 18 | xxl: "h1", | ||
| 19 | } as const; | ||
| 20 | |||
| 21 | export const Heading = <T extends HTMLTag = "div">({ | ||
| 22 | as, | ||
| 23 | children, | ||
| 24 | display, | ||
| 25 | level, | ||
| 26 | variant, | ||
| 27 | highlight, | ||
| 28 | class: cls, | ||
| 29 | ...props | ||
| 30 | }: Props<T>) => { | ||
| 31 | const Tag = as ?? (level ? levelTagMap[level] : "strong"); | ||
| 32 | return ( | ||
| 33 | <Tag | ||
| 34 | class:list={[ | ||
| 35 | "o-heading", | ||
| 36 | { | ||
| 37 | [`o-heading--${level}`]: level, | ||
| 38 | [`o-heading--${variant}`]: variant, | ||
| 39 | "o-heading--display": display, | ||
| 40 | "u-c-heading": Tag === "strong", | ||
| 41 | }, | ||
| 42 | cls, | ||
| 43 | ]} | ||
| 44 | {...props} | ||
| 45 | > | ||
| 46 | {highlight ? <span class="o-heading__highlight">{children}</span> : children} | ||
| 47 | </Tag> | ||
| 48 | ); | ||
| 49 | }; | ||
diff --git a/src/ui/objects/Icon.tsx b/src/ui/objects/Icon.tsx new file mode 100644 index 0000000..e8af718 --- /dev/null +++ b/src/ui/objects/Icon.tsx | |||
| @@ -0,0 +1,20 @@ | |||
| 1 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | import site from "@/corvos.config.ts"; | ||
| 4 | |||
| 5 | export type Props = HTMLAttributes<"svg"> & { | ||
| 6 | icon: Parameters<typeof site.$.icon>[0]; | ||
| 7 | variant?: Parameters<typeof site.$.icon>[1]; | ||
| 8 | }; | ||
| 9 | |||
| 10 | export const Icon = ({ icon, variant, class: cls, ...props }: Props) => ( | ||
| 11 | <svg | ||
| 12 | aria-hidden="true" | ||
| 13 | class:list={["o-icon", cls]} | ||
| 14 | height="1.142857143em" | ||
| 15 | width="1.142857143em" | ||
| 16 | {...props} | ||
| 17 | > | ||
| 18 | <use href={site.$.icon(icon, variant)} /> | ||
| 19 | </svg> | ||
| 20 | ); | ||
diff --git a/src/ui/objects/Lightbox.tsx b/src/ui/objects/Lightbox.tsx new file mode 100644 index 0000000..00268cc --- /dev/null +++ b/src/ui/objects/Lightbox.tsx | |||
| @@ -0,0 +1,122 @@ | |||
| 1 | /** Biome-ignore-all lint/a11y/useValidAnchor: <explanation> */ | ||
| 2 | /** Biome-ignore-all lint/a11y/useAltText: <explanation> */ | ||
| 3 | |||
| 4 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 5 | |||
| 6 | import { Icon } from "$/ui/objects/Icon.tsx"; | ||
| 7 | |||
| 8 | import { ActionButton } from "./ActionButton.tsx"; | ||
| 9 | import { LoadingIndicator } from "./LoadingIndicator.tsx"; | ||
| 10 | import { Thumbnail } from "./Thumbnail.tsx"; | ||
| 11 | |||
| 12 | export type Props = HTMLAttributes<"dialog">; | ||
| 13 | |||
| 14 | export const Lightbox = ({ class: cls, ...props }: Props) => ( | ||
| 15 | <dialog | ||
| 16 | class:list={["o-lightbox o-lightbox--static-black is-modal t-static-white", cls]} | ||
| 17 | closedby="any" | ||
| 18 | x-id="['lightbox']" | ||
| 19 | x-bind:id="$id('lightbox')" | ||
| 20 | x-data="lightbox" | ||
| 21 | x-on:close="handleClose" | ||
| 22 | x-on:touchstart="handleTouchStart" | ||
| 23 | x-on:touchend="handleTouchEnd" | ||
| 24 | {...{ | ||
| 25 | "x-on:keydown.document.capture": "handleKeyDown", | ||
| 26 | }} | ||
| 27 | {...props} | ||
| 28 | > | ||
| 29 | <div x-show="items.length > 1" class="o-lightbox__info"> | ||
| 30 | <small class="u-mis-100 u-mie-200 u-d-none@sm-lo"> | ||
| 31 | <strong x-text="current + 1"></strong> <span class="u-c-mute-more u-mi-50">⁄</span>{" "} | ||
| 32 | <strong x-text="items.length"></strong> | ||
| 33 | </small> | ||
| 34 | <ActionButton | ||
| 35 | x-on:click="prev" | ||
| 36 | as="button" | ||
| 37 | pill | ||
| 38 | level="quiet" | ||
| 39 | icon={<Icon icon="caret-left" variant="bold" />} | ||
| 40 | variant="static-white" | ||
| 41 | /> | ||
| 42 | <ActionButton | ||
| 43 | x-on:click="next" | ||
| 44 | as="button" | ||
| 45 | pill | ||
| 46 | level="quiet" | ||
| 47 | icon={<Icon icon="caret-right" variant="bold" />} | ||
| 48 | variant="static-white" | ||
| 49 | /> | ||
| 50 | </div> | ||
| 51 | |||
| 52 | <div class="o-lightbox__controls"> | ||
| 53 | <ActionButton | ||
| 54 | x-show="zoomedFull" | ||
| 55 | x-on:click="toggleZoom" | ||
| 56 | as="button" | ||
| 57 | class="u-mie-100" | ||
| 58 | pill | ||
| 59 | level="quiet" | ||
| 60 | icon={<Icon icon="magnifying-glass-minus" variant="bold" />} | ||
| 61 | variant="static-white" | ||
| 62 | /> | ||
| 63 | <ActionButton | ||
| 64 | x-show="!zoomedFull" | ||
| 65 | x-on:click="toggleZoom" | ||
| 66 | as="button" | ||
| 67 | class="u-mie-100" | ||
| 68 | pill | ||
| 69 | level="quiet" | ||
| 70 | icon={<Icon icon="magnifying-glass-plus" variant="bold" />} | ||
| 71 | variant="static-white" | ||
| 72 | /> | ||
| 73 | <ActionButton | ||
| 74 | x-bind:commandfor="$id('lightbox')" | ||
| 75 | command="close" | ||
| 76 | as="button" | ||
| 77 | pill | ||
| 78 | level="quiet" | ||
| 79 | icon={<Icon icon="x" variant="bold" />} | ||
| 80 | variant="static-white" | ||
| 81 | /> | ||
| 82 | </div> | ||
| 83 | |||
| 84 | <img | ||
| 85 | class="o-lightbox__img" | ||
| 86 | x-ref="img" | ||
| 87 | x-bind:src="currentItem?.src ?? ''" | ||
| 88 | x-bind:srcset="currentItem?.srcset ?? ''" | ||
| 89 | x-bind:alt="currentItem?.alt ?? ''" | ||
| 90 | x-on:load="loading = false" | ||
| 91 | x-on:mousemove="handleMouseMove" | ||
| 92 | x-on:touchmove="handleTouchMove" | ||
| 93 | x-on:dblclick="handleDoubleClick" | ||
| 94 | x-bind:class="{ 'is-hidden': loading }" | ||
| 95 | x-bind:style="{ transform: `translate(${offsetX}px, ${offsetY}px) scale(${zoom})` }" | ||
| 96 | {...{ | ||
| 97 | "x-on:mousedown.prevent": "handleMouseDown", | ||
| 98 | "x-on:mouseup.window": "handleMouseUp", | ||
| 99 | "x-on:wheel.prevent": "handleScroll", | ||
| 100 | }} | ||
| 101 | /> | ||
| 102 | |||
| 103 | <div class="o-lightbox__placeholder"> | ||
| 104 | <LoadingIndicator /> | ||
| 105 | </div> | ||
| 106 | |||
| 107 | <div class="o-lightbox__thumbnails" x-show="items.length > 1" x-ref="thumbnails"> | ||
| 108 | <template x-for="(item, i) in items"> | ||
| 109 | <Thumbnail | ||
| 110 | class="o-lightbox__thumbnail" | ||
| 111 | variant="static-white" | ||
| 112 | x-bind:src="item.thumbnail ?? item.src" | ||
| 113 | x-bind:srcset="item.srcset" | ||
| 114 | x-bind:alt="item.alt" | ||
| 115 | x-bind:href="item.src" | ||
| 116 | x-bind:class="{ 'is-selected': i === current }" | ||
| 117 | {...{ "x-on:click.prevent": "current = i" }} | ||
| 118 | /> | ||
| 119 | </template> | ||
| 120 | </div> | ||
| 121 | </dialog> | ||
| 122 | ); | ||
diff --git a/src/ui/objects/LoadingIndicator.tsx b/src/ui/objects/LoadingIndicator.tsx new file mode 100644 index 0000000..325cbb3 --- /dev/null +++ b/src/ui/objects/LoadingIndicator.tsx | |||
| @@ -0,0 +1,10 @@ | |||
| 1 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props = HTMLAttributes<"svg">; | ||
| 4 | |||
| 5 | export const LoadingIndicator = (props: Props) => ( | ||
| 6 | <svg height="50" width="50" {...props}> | ||
| 7 | <title>Loading indicator</title> | ||
| 8 | <use href="#loading-indicator" /> | ||
| 9 | </svg> | ||
| 10 | ); | ||
diff --git a/src/ui/objects/Navbar.tsx b/src/ui/objects/Navbar.tsx new file mode 100644 index 0000000..4439e58 --- /dev/null +++ b/src/ui/objects/Navbar.tsx | |||
| @@ -0,0 +1,37 @@ | |||
| 1 | /** Biome-ignore-all lint/a11y/noNoninteractiveElementToInteractiveRole: <explanation> */ | ||
| 2 | |||
| 3 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 4 | |||
| 5 | export type Props = HTMLAttributes<"nav"> & { | ||
| 6 | "adapt"?: boolean; | ||
| 7 | "quiet"?: boolean; | ||
| 8 | "align-block"?: boolean; | ||
| 9 | "variant"?: "static-black" | "static-white"; | ||
| 10 | }; | ||
| 11 | |||
| 12 | export const Navbar = ({ | ||
| 13 | adapt, | ||
| 14 | quiet, | ||
| 15 | "align-block": alignBlock, | ||
| 16 | variant, | ||
| 17 | class: cls, | ||
| 18 | ...props | ||
| 19 | }: Props) => ( | ||
| 20 | <nav | ||
| 21 | class:list={[ | ||
| 22 | "o-navbar", | ||
| 23 | { | ||
| 24 | "o-navbar--adapt": adapt, | ||
| 25 | "o-navbar--quiet": quiet, | ||
| 26 | "o-navbar--align-block": alignBlock, | ||
| 27 | [`o-navbar--${variant}`]: variant, | ||
| 28 | }, | ||
| 29 | "js-keyboard-nav", | ||
| 30 | cls, | ||
| 31 | ]} | ||
| 32 | data-keyboard-nav-item=".o-navbar__item" | ||
| 33 | data-keyboard-nav-mode="nav" | ||
| 34 | role="menubar" | ||
| 35 | {...props} | ||
| 36 | /> | ||
| 37 | ); | ||
diff --git a/src/ui/objects/NavbarItem.tsx b/src/ui/objects/NavbarItem.tsx new file mode 100644 index 0000000..c0e347f --- /dev/null +++ b/src/ui/objects/NavbarItem.tsx | |||
| @@ -0,0 +1,33 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 4 | as: Tag; | ||
| 5 | icon?: JSX.Element; | ||
| 6 | pre?: JSX.Element; | ||
| 7 | post?: JSX.Element; | ||
| 8 | selected?: boolean; | ||
| 9 | }>; | ||
| 10 | |||
| 11 | export const NavbarItem = <T extends HTMLTag = "a">({ | ||
| 12 | children, | ||
| 13 | as: Tag = "a" as T, | ||
| 14 | icon, | ||
| 15 | pre, | ||
| 16 | post, | ||
| 17 | selected, | ||
| 18 | class: cls, | ||
| 19 | ...props | ||
| 20 | }: Props<T>) => ( | ||
| 21 | <Tag | ||
| 22 | aria-current={selected ? "page" : undefined} | ||
| 23 | class:list={["o-navbar__item", { "is-selected": selected }, cls]} | ||
| 24 | role="menuitem" | ||
| 25 | {...props} | ||
| 26 | > | ||
| 27 | {icon && <div class="o-navbar__item-label">{children}</div>} | ||
| 28 | |||
| 29 | <div class="o-navbar__item-content"> | ||
| 30 | {pre} {icon ?? <span class="o-navbar__item-content-text">{children}</span>} {post} | ||
| 31 | </div> | ||
| 32 | </Tag> | ||
| 33 | ); | ||
diff --git a/src/ui/objects/PlaceholderBox.tsx b/src/ui/objects/PlaceholderBox.tsx new file mode 100644 index 0000000..d64866f --- /dev/null +++ b/src/ui/objects/PlaceholderBox.tsx | |||
| @@ -0,0 +1,11 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 4 | as: Tag; | ||
| 5 | }>; | ||
| 6 | |||
| 7 | export const PlaceholderBox = <T extends HTMLTag = "div">({ | ||
| 8 | as: Tag = "div" as T, | ||
| 9 | class: cls, | ||
| 10 | ...props | ||
| 11 | }: Props<T>) => <Tag class:list={["o-placeholder-box u-ta-center u-c-mute", cls]} {...props} />; | ||
diff --git a/src/ui/objects/SideNav.tsx b/src/ui/objects/SideNav.tsx new file mode 100644 index 0000000..f96ce7a --- /dev/null +++ b/src/ui/objects/SideNav.tsx | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | /** Biome-ignore-all lint/a11y/noNoninteractiveElementToInteractiveRole: <explanation> */ | ||
| 2 | |||
| 3 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 4 | |||
| 5 | export type Props = HTMLAttributes<"nav">; | ||
| 6 | |||
| 7 | export const SideNav = ({ class: cls, ...props }: Props) => ( | ||
| 8 | <nav | ||
| 9 | class:list={["o-side-nav", "js-keyboard-nav", cls]} | ||
| 10 | data-keyboard-nav-item=".o-side-nav__item" | ||
| 11 | data-keyboard-nav-mode="list" | ||
| 12 | {...props} | ||
| 13 | /> | ||
| 14 | ); | ||
diff --git a/src/ui/objects/SideNavItem.tsx b/src/ui/objects/SideNavItem.tsx new file mode 100644 index 0000000..fb8f523 --- /dev/null +++ b/src/ui/objects/SideNavItem.tsx | |||
| @@ -0,0 +1,33 @@ | |||
| 1 | import type { HTMLTag, Polymorphic } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props<Tag extends HTMLTag> = Polymorphic<{ | ||
| 4 | as: Tag; | ||
| 5 | icon?: JSX.Element; | ||
| 6 | pre?: JSX.Element; | ||
| 7 | post?: JSX.Element; | ||
| 8 | selected?: boolean; | ||
| 9 | }>; | ||
| 10 | |||
| 11 | export const SideNavItem = <T extends HTMLTag = "a">({ | ||
| 12 | children, | ||
| 13 | as: Tag = "a" as T, | ||
| 14 | icon, | ||
| 15 | pre, | ||
| 16 | post, | ||
| 17 | selected, | ||
| 18 | class: cls, | ||
| 19 | ...props | ||
| 20 | }: Props<T>) => ( | ||
| 21 | <Tag | ||
| 22 | aria-current={selected ? "location" : undefined} | ||
| 23 | class:list={["o-side-nav__item", { "is-selected": selected }, cls]} | ||
| 24 | role="menuitem" | ||
| 25 | {...props} | ||
| 26 | > | ||
| 27 | {icon && <div class="l-media__block o-side-nav__icon-slot">{icon}</div>} | ||
| 28 | |||
| 29 | <div class="l-media__block l-media__block--main"> | ||
| 30 | {pre} {children} {post} | ||
| 31 | </div> | ||
| 32 | </Tag> | ||
| 33 | ); | ||
diff --git a/src/ui/objects/StatusIndicator.tsx b/src/ui/objects/StatusIndicator.tsx new file mode 100644 index 0000000..06b6481 --- /dev/null +++ b/src/ui/objects/StatusIndicator.tsx | |||
| @@ -0,0 +1,18 @@ | |||
| 1 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props = HTMLAttributes<"div"> & { | ||
| 4 | status?: "accent" | "positive" | "negative" | "warning"; | ||
| 5 | }; | ||
| 6 | |||
| 7 | export const StatusIndicator = ({ status, class: cls, ...props }: Props) => ( | ||
| 8 | <div | ||
| 9 | class:list={[ | ||
| 10 | "o-status-indicator", | ||
| 11 | { | ||
| 12 | [`is-${status}`]: status, | ||
| 13 | }, | ||
| 14 | cls, | ||
| 15 | ]} | ||
| 16 | {...props} | ||
| 17 | /> | ||
| 18 | ); | ||
diff --git a/src/ui/objects/Tab.tsx b/src/ui/objects/Tab.tsx new file mode 100644 index 0000000..15c1e5d --- /dev/null +++ b/src/ui/objects/Tab.tsx | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props = HTMLAttributes<"a"> & { | ||
| 4 | selected?: boolean; | ||
| 5 | }; | ||
| 6 | |||
| 7 | export const Tab = ({ selected, class: cls, ...props }: Props) => ( | ||
| 8 | <a | ||
| 9 | aria-selected={`${selected}`} | ||
| 10 | class:list={["o-tabbar__tab", { "is-selected": selected }, cls]} | ||
| 11 | role="tab" | ||
| 12 | {...props} | ||
| 13 | /> | ||
| 14 | ); | ||
diff --git a/src/ui/objects/Tabbar.tsx b/src/ui/objects/Tabbar.tsx new file mode 100644 index 0000000..58c58fa --- /dev/null +++ b/src/ui/objects/Tabbar.tsx | |||
| @@ -0,0 +1,43 @@ | |||
| 1 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props = HTMLAttributes<"nav"> & { | ||
| 4 | "variant"?: "accent"; | ||
| 5 | "quiet"?: boolean; | ||
| 6 | "adapt"?: boolean; | ||
| 7 | "tabs-class"?: unknown; | ||
| 8 | }; | ||
| 9 | |||
| 10 | export const Tabbar = ({ | ||
| 11 | children, | ||
| 12 | variant, | ||
| 13 | quiet, | ||
| 14 | adapt, | ||
| 15 | "tabs-class": tabsClass, | ||
| 16 | class: cls, | ||
| 17 | ...props | ||
| 18 | }: Props) => ( | ||
| 19 | <nav | ||
| 20 | class:list={[ | ||
| 21 | "o-tabbar", | ||
| 22 | { | ||
| 23 | [`o-tabbar--${variant}`]: variant, | ||
| 24 | "o-tabbar--quiet": quiet, | ||
| 25 | "o-tabbar--adapt": adapt, | ||
| 26 | }, | ||
| 27 | "js-keyboard-nav", | ||
| 28 | cls, | ||
| 29 | ]} | ||
| 30 | data-keyboard-nav-item=".o-tabbar__tab" | ||
| 31 | data-keyboard-nav-mode="nav" | ||
| 32 | {...props} | ||
| 33 | > | ||
| 34 | <div | ||
| 35 | aria-label={props["aria-label"]} | ||
| 36 | aria-orientation="horizontal" | ||
| 37 | class:list={["o-tabbar__tabs", tabsClass]} | ||
| 38 | role="tablist" | ||
| 39 | > | ||
| 40 | {children} | ||
| 41 | </div> | ||
| 42 | </nav> | ||
| 43 | ); | ||
diff --git a/src/ui/objects/TextField.tsx b/src/ui/objects/TextField.tsx new file mode 100644 index 0000000..dc7893a --- /dev/null +++ b/src/ui/objects/TextField.tsx | |||
| @@ -0,0 +1,62 @@ | |||
| 1 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 2 | |||
| 3 | export type Props = HTMLAttributes<"input"> & { | ||
| 4 | "pre"?: JSX.Element; | ||
| 5 | "post"?: JSX.Element; | ||
| 6 | "invalid"?: boolean; | ||
| 7 | "pill"?: boolean; | ||
| 8 | "x-model"?: string; | ||
| 9 | }; | ||
| 10 | |||
| 11 | export const TextField = ({ | ||
| 12 | pre, | ||
| 13 | post, | ||
| 14 | disabled, | ||
| 15 | invalid, | ||
| 16 | pill, | ||
| 17 | placeholder, | ||
| 18 | readonly, | ||
| 19 | value, | ||
| 20 | pattern, | ||
| 21 | required, | ||
| 22 | "x-model": xModel, | ||
| 23 | class: cls, | ||
| 24 | ...props | ||
| 25 | }: Props) => { | ||
| 26 | const extended = !!pre || !!post; | ||
| 27 | |||
| 28 | return ( | ||
| 29 | <div | ||
| 30 | class:list={[ | ||
| 31 | "o-text-field", | ||
| 32 | { | ||
| 33 | "is-disabled": disabled, | ||
| 34 | "is-invalid": invalid, | ||
| 35 | "l-media": extended, | ||
| 36 | "l-media--gapless": extended, | ||
| 37 | "o-text-field--extended": extended, | ||
| 38 | "o-text-field--pill": pill, | ||
| 39 | }, | ||
| 40 | cls, | ||
| 41 | ]} | ||
| 42 | {...props} | ||
| 43 | > | ||
| 44 | {pre}{" "} | ||
| 45 | <input | ||
| 46 | class="o-text-field__native" | ||
| 47 | class:list={{ | ||
| 48 | "l-media__block": extended, | ||
| 49 | "l-media__block--main": extended, | ||
| 50 | }} | ||
| 51 | disabled={disabled} | ||
| 52 | pattern={pattern} | ||
| 53 | placeholder={placeholder} | ||
| 54 | readonly={readonly} | ||
| 55 | required={required} | ||
| 56 | value={value} | ||
| 57 | x-model={xModel} | ||
| 58 | />{" "} | ||
| 59 | {post} <div class="o-text-field__bg"></div> | ||
| 60 | </div> | ||
| 61 | ); | ||
| 62 | }; | ||
diff --git a/src/ui/objects/Thumbnail.tsx b/src/ui/objects/Thumbnail.tsx new file mode 100644 index 0000000..4ef76d3 --- /dev/null +++ b/src/ui/objects/Thumbnail.tsx | |||
| @@ -0,0 +1,46 @@ | |||
| 1 | import type { Props as ImageProps } from "corvos/plugins/images.ts"; | ||
| 2 | import type { HTMLAttributes } from "corvos/plugins/jsx.ts"; | ||
| 3 | |||
| 4 | import { Image } from "corvos/plugins/images.ts"; | ||
| 5 | |||
| 6 | export type Props = HTMLAttributes<"a"> & | ||
| 7 | ImageProps & { | ||
| 8 | "x-bind:src"?: string; | ||
| 9 | "x-bind:srcset"?: string; | ||
| 10 | "x-bind:alt"?: string; | ||
| 11 | "variant"?: "static-black" | "static-white"; | ||
| 12 | }; | ||
| 13 | |||
| 14 | export const Thumbnail = ({ | ||
| 15 | src, | ||
| 16 | srcset, | ||
| 17 | alt, | ||
| 18 | "x-bind:src": xbindSrc, | ||
| 19 | "x-bind:srcset": xbindSrcset, | ||
| 20 | "x-bind:alt": xbindAlt, | ||
| 21 | variant, | ||
| 22 | class: cls, | ||
| 23 | widths, | ||
| 24 | width, | ||
| 25 | height, | ||
| 26 | densities, | ||
| 27 | "no-transform": noTransform, | ||
| 28 | ...props | ||
| 29 | }: Props) => ( | ||
| 30 | <a class:list={["o-thumbnail", { [`o-thumbnail--${variant}`]: variant }, cls]} {...props}> | ||
| 31 | <Image | ||
| 32 | class="o-thumbnail__image" | ||
| 33 | src={src} | ||
| 34 | srcset={srcset} | ||
| 35 | widths={widths as undefined} | ||
| 36 | width={width} | ||
| 37 | height={height} | ||
| 38 | densities={densities} | ||
| 39 | no-transform={noTransform} | ||
| 40 | alt={alt} | ||
| 41 | x-bind:src={xbindSrc} | ||
| 42 | x-bind:srcset={xbindSrcset} | ||
| 43 | x-bind:alt={xbindAlt} | ||
| 44 | /> | ||
| 45 | </a> | ||
| 46 | ); | ||
