From c1e28224aa2e1450031669d9a7e359b63e04687d Mon Sep 17 00:00:00 2001 From: Volpeon Date: Wed, 22 Jul 2026 19:40:59 +0200 Subject: Init --- src/assets/InterVariable-Italic.woff2 | Bin 0 -> 387976 bytes src/assets/InterVariable.woff2 | Bin 0 -> 352240 bytes src/assets/favicon.svg | 15 + src/assets/symbols.svg | 413 +++++++++++++++++++++++ src/data/nav.ts | 9 + src/deps/strapi.tsx | 416 ++++++++++++++++++++++++ src/pages/index.mdx | 10 + src/pages/index.tsx | 64 ++++ src/pages/pkg/corvos@[version].ts | 35 ++ src/pages/releases/[version].tsx | 100 ++++++ src/pages/releases/feed.ts | 19 ++ src/pages/releases/index.mdx | 8 + src/pages/releases/index.tsx | 51 +++ src/pages/sitemap.ts | 8 + src/schemas/misc.ts | 16 + src/schemas/mod.ts | 8 + src/schemas/releases.ts | 18 ++ src/scripts/lib/dom.ts | 59 ++++ src/scripts/lib/keyboardNav.ts | 272 ++++++++++++++++ src/scripts/lib/lightbox.ts | 450 ++++++++++++++++++++++++++ src/scripts/lib/siteHeader.ts | 37 +++ src/scripts/lib/tabbar.ts | 12 + src/scripts/lib/toc.ts | 14 + src/scripts/script.ts | 17 + src/styles/_common.scss | 87 +++++ src/styles/components/_home-banner.scss | 39 +++ src/styles/components/_logo.scss | 81 +++++ src/styles/components/_site-footer.scss | 157 +++++++++ src/styles/components/_site-footer.vars.scss | 34 ++ src/styles/components/_site-header.scss | 136 ++++++++ src/styles/components/_site-header.vars.scss | 14 + src/styles/components/_site-nav.scss | 30 ++ src/styles/components/_subtitle.scss | 11 + src/styles/components/_visited-indicator.scss | 27 ++ src/styles/objects/_deflist.scss | 25 ++ src/styles/objects/_message.scss | 216 +++++++++++++ src/styles/objects/_message.vars.scss | 39 +++ src/styles/objects/_placeholder-box.scss | 12 + src/styles/objects/_toc.scss | 27 ++ src/styles/style.scss | 53 +++ src/ui/base/BaseLayout.tsx | 134 ++++++++ src/ui/components/Logo.tsx | 34 ++ src/ui/components/ReleaseCard.tsx | 47 +++ src/ui/components/SiteFooter.tsx | 39 +++ src/ui/components/SiteHeader.tsx | 121 +++++++ src/ui/components/SiteNav.tsx | 38 +++ src/ui/layouts/CardList.tsx | 52 +++ src/ui/layouts/Container.tsx | 33 ++ src/ui/layouts/EmojiGrid.tsx | 40 +++ src/ui/layouts/HList.tsx | 44 +++ src/ui/layouts/SplitView.tsx | 27 ++ src/ui/layouts/SplitViewPanel.tsx | 33 ++ src/ui/objects/ActionButton.tsx | 61 ++++ src/ui/objects/Alert.tsx | 21 ++ src/ui/objects/Avatar.tsx | 69 ++++ src/ui/objects/Badge.tsx | 19 ++ src/ui/objects/Body.tsx | 21 ++ src/ui/objects/Button.tsx | 26 ++ src/ui/objects/Card.tsx | 47 +++ src/ui/objects/CardBody.tsx | 32 ++ src/ui/objects/CardFooter.tsx | 11 + src/ui/objects/CardImage.tsx | 40 +++ src/ui/objects/CollapseCard.tsx | 27 ++ src/ui/objects/ContentImage.tsx | 15 + src/ui/objects/Deflist.tsx | 7 + src/ui/objects/Dialog.tsx | 41 +++ src/ui/objects/Divider.tsx | 51 +++ src/ui/objects/Emoji.tsx | 21 ++ src/ui/objects/Figure.tsx | 13 + src/ui/objects/FigureCaption.tsx | 11 + src/ui/objects/Heading.tsx | 49 +++ src/ui/objects/Icon.tsx | 20 ++ src/ui/objects/Lightbox.tsx | 122 +++++++ src/ui/objects/LoadingIndicator.tsx | 10 + src/ui/objects/Navbar.tsx | 37 +++ src/ui/objects/NavbarItem.tsx | 33 ++ src/ui/objects/PlaceholderBox.tsx | 11 + src/ui/objects/SideNav.tsx | 14 + src/ui/objects/SideNavItem.tsx | 33 ++ src/ui/objects/StatusIndicator.tsx | 18 ++ src/ui/objects/Tab.tsx | 14 + src/ui/objects/Tabbar.tsx | 43 +++ src/ui/objects/TextField.tsx | 62 ++++ src/ui/objects/Thumbnail.tsx | 46 +++ 84 files changed, 4726 insertions(+) create mode 100644 src/assets/InterVariable-Italic.woff2 create mode 100644 src/assets/InterVariable.woff2 create mode 100644 src/assets/favicon.svg create mode 100644 src/assets/symbols.svg create mode 100644 src/data/nav.ts create mode 100644 src/deps/strapi.tsx create mode 100644 src/pages/index.mdx create mode 100644 src/pages/index.tsx create mode 100644 src/pages/pkg/corvos@[version].ts create mode 100644 src/pages/releases/[version].tsx create mode 100644 src/pages/releases/feed.ts create mode 100644 src/pages/releases/index.mdx create mode 100644 src/pages/releases/index.tsx create mode 100644 src/pages/sitemap.ts create mode 100644 src/schemas/misc.ts create mode 100644 src/schemas/mod.ts create mode 100644 src/schemas/releases.ts create mode 100644 src/scripts/lib/dom.ts create mode 100644 src/scripts/lib/keyboardNav.ts create mode 100644 src/scripts/lib/lightbox.ts create mode 100644 src/scripts/lib/siteHeader.ts create mode 100644 src/scripts/lib/tabbar.ts create mode 100644 src/scripts/lib/toc.ts create mode 100644 src/scripts/script.ts create mode 100644 src/styles/_common.scss create mode 100644 src/styles/components/_home-banner.scss create mode 100644 src/styles/components/_logo.scss create mode 100644 src/styles/components/_site-footer.scss create mode 100644 src/styles/components/_site-footer.vars.scss create mode 100644 src/styles/components/_site-header.scss create mode 100644 src/styles/components/_site-header.vars.scss create mode 100644 src/styles/components/_site-nav.scss create mode 100644 src/styles/components/_subtitle.scss create mode 100644 src/styles/components/_visited-indicator.scss create mode 100644 src/styles/objects/_deflist.scss create mode 100644 src/styles/objects/_message.scss create mode 100644 src/styles/objects/_message.vars.scss create mode 100644 src/styles/objects/_placeholder-box.scss create mode 100644 src/styles/objects/_toc.scss create mode 100644 src/styles/style.scss create mode 100644 src/ui/base/BaseLayout.tsx create mode 100644 src/ui/components/Logo.tsx create mode 100644 src/ui/components/ReleaseCard.tsx create mode 100644 src/ui/components/SiteFooter.tsx create mode 100644 src/ui/components/SiteHeader.tsx create mode 100644 src/ui/components/SiteNav.tsx create mode 100644 src/ui/layouts/CardList.tsx create mode 100644 src/ui/layouts/Container.tsx create mode 100644 src/ui/layouts/EmojiGrid.tsx create mode 100644 src/ui/layouts/HList.tsx create mode 100644 src/ui/layouts/SplitView.tsx create mode 100644 src/ui/layouts/SplitViewPanel.tsx create mode 100644 src/ui/objects/ActionButton.tsx create mode 100644 src/ui/objects/Alert.tsx create mode 100644 src/ui/objects/Avatar.tsx create mode 100644 src/ui/objects/Badge.tsx create mode 100644 src/ui/objects/Body.tsx create mode 100644 src/ui/objects/Button.tsx create mode 100644 src/ui/objects/Card.tsx create mode 100644 src/ui/objects/CardBody.tsx create mode 100644 src/ui/objects/CardFooter.tsx create mode 100644 src/ui/objects/CardImage.tsx create mode 100644 src/ui/objects/CollapseCard.tsx create mode 100644 src/ui/objects/ContentImage.tsx create mode 100644 src/ui/objects/Deflist.tsx create mode 100644 src/ui/objects/Dialog.tsx create mode 100644 src/ui/objects/Divider.tsx create mode 100644 src/ui/objects/Emoji.tsx create mode 100644 src/ui/objects/Figure.tsx create mode 100644 src/ui/objects/FigureCaption.tsx create mode 100644 src/ui/objects/Heading.tsx create mode 100644 src/ui/objects/Icon.tsx create mode 100644 src/ui/objects/Lightbox.tsx create mode 100644 src/ui/objects/LoadingIndicator.tsx create mode 100644 src/ui/objects/Navbar.tsx create mode 100644 src/ui/objects/NavbarItem.tsx create mode 100644 src/ui/objects/PlaceholderBox.tsx create mode 100644 src/ui/objects/SideNav.tsx create mode 100644 src/ui/objects/SideNavItem.tsx create mode 100644 src/ui/objects/StatusIndicator.tsx create mode 100644 src/ui/objects/Tab.tsx create mode 100644 src/ui/objects/Tabbar.tsx create mode 100644 src/ui/objects/TextField.tsx create mode 100644 src/ui/objects/Thumbnail.tsx (limited to 'src') diff --git a/src/assets/InterVariable-Italic.woff2 b/src/assets/InterVariable-Italic.woff2 new file mode 100644 index 0000000..b3530f3 Binary files /dev/null and b/src/assets/InterVariable-Italic.woff2 differ diff --git a/src/assets/InterVariable.woff2 b/src/assets/InterVariable.woff2 new file mode 100644 index 0000000..5a8d3e7 Binary files /dev/null and b/src/assets/InterVariable.woff2 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 @@ + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ +export const nav: { + label: string; + url: string; + icon: "house" | "package"; + hidden?: boolean; +}[] = [ + { icon: "house", label: "Home", url: "/" }, + { icon: "package", label: "Releases", url: "/releases/" }, +]; 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 @@ +import type { Pipeline } from "corvos"; +import type { StandardSchemaV1 } from "corvos/deps/standard_schema.ts"; + +import { type } from "arktype"; +import { Page, PipelineStageError } from "corvos"; +import { Severity } from "corvos/core/error.ts"; +import { replaceExtension } from "corvos/core/utils/path.ts"; + +import { ContentImage } from "$/ui/objects/ContentImage.tsx"; + +export enum HeadingBlockLevel { + L1 = 1, + L2 = 2, + L3 = 3, + L4 = 4, + L5 = 5, + L6 = 6, +} + +export const fileAttributeSchema = type({ + "alternativeText?": "string | null", + "caption?": "string | null", + "createdAt": "string", + "documentId": "string", + "ext": "string", + "formats?": "Record | null", + "hash": "string", + "height?": "number | null", + "mime": "string", + "name": "string", + "previewUrl?": "string | null", + "provider": "string", + "provider_metadata?": "unknown", + "size": "number", + "updatedAt": "string", + "url": "string", + "width?": "number | null", +}); + +export type FileAttribute = typeof fileAttributeSchema.infer; + +export const imageAttributeSchema = fileAttributeSchema.narrow( + (o): o is Omit & { width: number; height: number } => + !!o.width && !!o.height, +); + +export type ImageAttribute = typeof imageAttributeSchema.infer; + +const blocksAttributeScope = type + .scope({ + codeBlockNode: { + "children": "defaultInlineNode[]", + "language?": "string", + "type": "'code'", + }, + + defaultInlineNode: "textInlineNode | linkInlineNode", + + headingBlockNode: { + children: "defaultInlineNode[]", + level: "1 | 2 | 3 | 4 | 5 | 6", + type: "'heading'", + }, + + imageBlockNode: { + children: type({ + text: "string", + type: "'text'", + }).array(), + image: fileAttributeSchema.omit("documentId"), + type: "'image'", + }, + + linkInlineNode: { + children: "textInlineNode[]", + type: "'link'", + url: "string", + }, + + listBlockNode: { + children: "(listItemInlineNode | listBlockNode)[]", + format: "'ordered' | 'unordered'", + type: "'list'", + }, + + listItemInlineNode: { + children: "defaultInlineNode[]", + type: "'list-item'", + }, + + node: "rootNode | nonTextInlineNode", + + nonTextInlineNode: "linkInlineNode | listItemInlineNode", + + paragraphBlockNode: { + children: "defaultInlineNode[]", + type: "'paragraph'", + }, + + quoteBlockNode: { + children: "defaultInlineNode[]", + type: "'quote'", + }, + + rootNode: + "paragraphBlockNode | quoteBlockNode | codeBlockNode | headingBlockNode | listBlockNode | imageBlockNode", + + textInlineNode: { + "bold?": "boolean", + "code?": "boolean", + "italic?": "boolean", + "strikethrough?": "boolean", + "text": "string", + "type": "'text'", + "underline?": "boolean", + }, + }) + .export(); + +export const defaultInlineNodeSchema = blocksAttributeScope.defaultInlineNode; + +export type DefaultInlineNode = typeof defaultInlineNodeSchema.infer; + +export const nodeSchema = blocksAttributeScope.node; + +export type Node = typeof nodeSchema.infer; + +export const strapiItemSchema = type({ + "[string]": "unknown", + "documentId": "string", + "publishedAt": "string.date.parse | null", + "updatedAt": "string.date.parse", +}); + +export type StrapiItem = typeof strapiItemSchema.infer; + +export function walkBlocks( + content: (Node | DefaultInlineNode)[], + fn: (_: Node | DefaultInlineNode) => unknown, +): void { + for (const node of content) { + fn(node); + + switch (node.type) { + case "heading": + case "code": + case "quote": + case "paragraph": + case "list": + case "list-item": + case "link": { + return walkBlocks(node.children, fn); + } + } + } +} + +function blocksNodeToTSX(node: Node | DefaultInlineNode) { + switch (true) { + case node.type === "heading": { + switch (node.level) { + case 1: + return

{blocksToTSX(node.children)}

; + case 2: + return

{blocksToTSX(node.children)}

; + case 3: + return

{blocksToTSX(node.children)}

; + case 4: + return

{blocksToTSX(node.children)}

; + case 5: + return
{blocksToTSX(node.children)}
; + default: + return
{blocksToTSX(node.children)}
; + } + } + + case node.type === "image" && !!node.image.caption: { + return ( +
+ +
{node.image.caption}
+
+ ); + } + + case node.type === "image": { + return ; + } + + case node.type === "code": { + return ( +
+					{blocksToTSX(node.children)}
+				
+ ); + } + + case node.type === "quote": { + return
{blocksToTSX(node.children)}
; + } + + case node.type === "paragraph" && + node.children.length === 1 && + node.children[0]?.type === "text" && + node.children[0].text === "---": { + return
; + } + + case node.type === "paragraph": { + return

{blocksToTSX(node.children)}

; + } + + case node.type === "list" && node.format === "ordered": { + return
    ${blocksToTSX(node.children)}
; + } + + case node.type === "list": { + return
    ${blocksToTSX(node.children)}
; + } + + case node.type === "list-item": { + return
  • ${blocksToTSX(node.children)}
  • ; + } + + case node.type === "link": { + return {blocksToTSX(node.children)}; + } + + case node.type === "text": { + return [ + node.bold ? "strong" : undefined, + node.code ? "code" : undefined, + node.italic ? "em" : undefined, + node.strikethrough ? "del" : undefined, + node.underline ? "u" : undefined, + ].reduce((res: JSX.Element, Tag) => (Tag ? {res} : res), node.text); + } + } +} + +export function blocksToTSX( + content: (Node | DefaultInlineNode)[], + filter: (node: Node | DefaultInlineNode) => boolean = () => true, +): JSX.Element { + return content + .filter(filter) + .map(blocksNodeToTSX) + .filter((s) => s !== undefined); +} + +export const strapiCollectionResponseSchema = type({ + data: strapiItemSchema.array(), + meta: { + pagination: { + page: "number", + pageCount: "number", + pageSize: "number", + total: "number", + }, + }, +}); + +export const strapiItemResponseSchema = type({ + data: strapiItemSchema, +}); + +async function* loadPaginated(url: URL | string, init?: RequestInit) { + let pageSize: number | undefined; + let pages = 1; + + for (let i = 0; i < pages; ++i) { + const pageUrl = new URL(url); + + if (pageSize) { + pageUrl.searchParams.set("pagination[page]", `${i + 1}`); + pageUrl.searchParams.set("pagination[pageSize]", `${pageSize}`); + } + + const res = await fetch(pageUrl, init); + const json = await res.json(); + + const dec = strapiCollectionResponseSchema(json); + + if (dec instanceof type.errors) { + throw new Error(dec.summary); + } + + pages = dec.meta.pagination.pageCount; + pageSize = dec.meta.pagination.pageSize; + + for (const item of dec.data) { + yield item; + } + } +} + +export class StrapiLoader { + init: RequestInit; + + constructor( + readonly host: string | URL, + token: string, + readonly publicHost = host, + ) { + this.init = { + headers: { + Authorization: `Bearer ${token}`, + }, + keepalive: true, + }; + } + + async loadSingle( + collection: string, + schema: StandardSchemaV1, + query: Record = {}, + ) { + const url = new URL(`/api/${collection}`, this.host); + Object.entries(query).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + + const res = await fetch(url, this.init); + const json = await res.json(); + + const dec = strapiItemResponseSchema(json); + + if (dec instanceof type.errors) { + throw new Error(`Schema mismatch in ${url}:\n${dec.summary}`); + } + + const dec2 = await schema["~standard"].validate(dec.data); + + if (dec2.issues) { + throw new Error(`Schema mismatch in ${url}:\n${dec2.issues.join("\n")}`); + } + + return { + documentId: dec.data.documentId, + publishedAt: dec.data.publishedAt, + updatedAt: dec.data.updatedAt, + ...dec2.value, + }; + } + + async *loadCollection( + collection: string, + schema: StandardSchemaV1, + query: Record = {}, + ) { + const url = new URL(`/api/${collection}`, this.host); + Object.entries(query).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + + for await (const item of loadPaginated(url, this.init)) { + const dec = await schema["~standard"].validate(item); + + if (dec.issues) { + yield new PipelineStageError( + Severity.ERROR, + `Schema mismatch in ${url}`, + dec.issues.join("\n"), + ); + } else { + yield { + documentId: item.documentId, + publishedAt: item.publishedAt, + updatedAt: item.updatedAt, + ...dec.value, + }; + } + } + } + + readonly resolvedFileAttributeSchema = fileAttributeSchema.pipe(({ url, previewUrl, ...o }) => ({ + ...o, + url: new URL(url, this.publicHost).href, + ...(previewUrl ? { previewUrl: new URL(previewUrl, this.publicHost).href } : {}), + })); + + readonly resolvedImageAttributeSchema = imageAttributeSchema.pipe((o) => ({ + ...o, + previewUrl: o.previewUrl ? new URL(o.previewUrl, this.publicHost).href : undefined, + url: new URL(o.url, this.publicHost).href, + })); +} + +export function strapi() { + return ( + pipeline: Pipeline, + ) => + pipeline.process("strapi", ".strapi", function* (page) { + yield new Page( + ".jsxelement", + blocksToTSX(page.content), + page.data as Out[".strapi"][1], + replaceExtension(page.url, ".jsx"), + page, + ); + }); +} + +export const Blocks = ({ + content, + filter = () => true, +}: { + content: (Node | DefaultInlineNode)[]; + filter?: (node: Node | DefaultInlineNode) => boolean; +}) => blocksToTSX(content, filter); + +const strapiUrl = Deno.env.get("STRAPI_URL") ?? ""; +const strapiPublicUrl = Deno.env.get("STRAPI_PUBLIC_URL"); +const strapiToken = Deno.env.get("STRAPI_TOKEN") ?? ""; + +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 @@ +--- +type: "home" +title: "Corvos · static site generator" +description: "Project website of Corvos, a static site generator for Deno." +--- + +import { template } from "./index.tsx"; +export { template }; + +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 @@ +import { BaseLayout } from "$/ui/base/BaseLayout.tsx"; +import { ReleaseCard } from "$/ui/components/ReleaseCard.tsx"; +import { SiteHeader } from "$/ui/components/SiteHeader.tsx"; +import { CardList } from "$/ui/layouts/CardList.tsx"; +import { Container } from "$/ui/layouts/Container.tsx"; +import { Button } from "$/ui/objects/Button.tsx"; +import { Divider } from "$/ui/objects/Divider.tsx"; +import { Heading } from "$/ui/objects/Heading.tsx"; +import { Icon } from "$/ui/objects/Icon.tsx"; +import site from "@/corvos.config.ts"; + +export const template = site.$.template({ type: "home" }, (page, pages) => { + const query = site.$.query(pages); + const releasesIndex = query.type(".html").match({ type: "releases-index" }).one(); + const releases = query.type(".html").match({ type: "release" }).many().toArray(); + + return ( + + + {page.data.title} + + } + /> + + + + + + Corvos + + {" "} + – A static site generator for Deno. + + +
    {{ __html: page.content }}
    + +
    + +
    +
    + + + + + + {releases.map((page) => ( + + ))} + + +
    + ); +}); 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 @@ +import * as zip from "@zip-js/zip-js"; +import { Page, PipelineStageError } from "corvos"; +import { binaryLoader } from "corvos/core/loaders/binary.ts"; + +import { strapiLoader } from "$/deps/strapi.tsx"; +import { releaseStrapiSchema } from "$/schemas/releases.ts"; +import site from "@/corvos.config.ts"; + +export const pages = site.$.pageGenerator(async function* () { + for await (const item of strapiLoader.loadCollection("corvos-releases", releaseStrapiSchema, { + "populate[0]": "files", + "sort[0]": "date:desc", + })) { + if (item instanceof PipelineStageError) { + yield item; + continue; + } + + const res = await binaryLoader(new URL(item.files.url)); + const reader = new zip.ZipReader(new zip.Uint8ArrayReader(res.content)); + + for await (const entry of reader.getEntriesGenerator()) { + if (entry.directory) { + continue; + } + + yield new Page( + ".", + new Uint8Array(await entry.arrayBuffer()), + { version: item.version }, + `/pkg/corvos@${item.version}/${entry.filename}`, + ); + } + } +}); 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 @@ +import { Page, PipelineStageError } from "corvos"; + +import { strapiLoader } from "$/deps/strapi.tsx"; +import { releaseStrapiSchema } from "$/schemas/releases.ts"; +import { BaseLayout } from "$/ui/base/BaseLayout.tsx"; +import { ReleaseCard } from "$/ui/components/ReleaseCard.tsx"; +import { SiteHeader } from "$/ui/components/SiteHeader.tsx"; +import { CardList } from "$/ui/layouts/CardList.tsx"; +import { Container } from "$/ui/layouts/Container.tsx"; +import { HList } from "$/ui/layouts/HList.tsx"; +import { ActionButton } from "$/ui/objects/ActionButton.tsx"; +import { Badge } from "$/ui/objects/Badge.tsx"; +import { Body } from "$/ui/objects/Body.tsx"; +import { Divider } from "$/ui/objects/Divider.tsx"; +import { Heading } from "$/ui/objects/Heading.tsx"; +import { Icon } from "$/ui/objects/Icon.tsx"; +import site from "@/corvos.config.ts"; + +export const pages = site.$.pageGenerator(async function* () { + for await (const item of strapiLoader.loadCollection("corvos-releases", releaseStrapiSchema, { + "populate[0]": "files", + "sort[0]": "date:desc", + })) { + if (item instanceof PipelineStageError) { + yield item; + } else { + yield new Page(".strapi", item.notes ?? [], { + ...item, + title: `Corvos ${item.version}`, + type: "release" as const, + }); + } + } +}); + +export const template = site.$.template({ type: "release" }, (page, pages) => { + const query = site.$.query(pages); + + const feed = query.type(".rss").match({ type: "releases" }).one(); + + const prev = query.type(".html").match({ type: "release" }).prev(page); + const next = query.type(".html").match({ type: "release" }).next(page); + + return ( + + + } + level="quiet" + pill + title="RSS Feed" + /> + + } + > +
    + {page.data.title} +
    +
    + + + +
  • + + + +
  • +
    + + + {page.data.title} + + + {{ __html: page.content }} +
    + + + + + + {prev && } + {next && } + + +
    + ); +}); 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 @@ +import type { FeedItem } from "corvos/plugins/feeds.ts"; + +import site from "@/corvos.config.ts"; + +export const feeds = site.$.feedGenerator(function* (pages) { + const items = site.$.query(pages) + .match({ type: "release" }) + .many() + .take(20) + .map( + (page): FeedItem => ({ + title: page.data.title, + url: page.url, + content: page.content, + updated: page.data.date, + }), + ); + yield { type: "releases", title: `Releases`, url: "/releases/", items }; +}); 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 @@ +--- +type: "releases-index" +title: "Releases" +description: "All releases of Corvos." +--- + +import { template } from "./index.tsx"; +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 @@ +import { BaseLayout } from "$/ui/base/BaseLayout.tsx"; +import { ReleaseCard } from "$/ui/components/ReleaseCard.tsx"; +import { SiteHeader } from "$/ui/components/SiteHeader.tsx"; +import { CardList } from "$/ui/layouts/CardList.tsx"; +import { Container } from "$/ui/layouts/Container.tsx"; +import { ActionButton } from "$/ui/objects/ActionButton.tsx"; +import { Heading } from "$/ui/objects/Heading.tsx"; +import { Icon } from "$/ui/objects/Icon.tsx"; +import { PlaceholderBox } from "$/ui/objects/PlaceholderBox.tsx"; +import site from "@/corvos.config.ts"; + +export const template = site.$.template({ type: "releases-index" }, (page, pages) => { + const query = site.$.query(pages); + const items = query.type(".html").match({ type: "release" }).many().toArray(); + const feed = query.type(".rss").match({ type: "releases" }).one(); + + return ( + + + } + level="quiet" + pill + title="RSS Feed" + /> + + } + sm={ +
    + {page.data.title} +
    + } + >
    + + + + {items.map((item) => ( + + ))} + {!items.length && Nothing there yet} + + +
    + ); +}); 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 @@ +import site from "@/corvos.config.ts"; + +export const sitemap = site.$.sitemap((pages) => ({ + items: pages.map((page) => ({ + loc: page.url, + lastmod: page.data.modifiedAt, + })), +})); 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 @@ +import { type } from "arktype"; + +export const miscSchema = type.or( + { + type: "'home'", + title: "string", + description: "string", + }, + { + type: "'releases-index'", + title: "string", + description: "string", + }, +); + +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 @@ +import { type } from "arktype"; + +import { miscSchema } from "$/schemas/misc.ts"; +import { releaseSchema } from "$/schemas/releases.ts"; + +export const pageDataSchema = type.or(releaseSchema, miscSchema); + +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 @@ +import { type } from "arktype"; + +import { nodeSchema } from "../deps/strapi.tsx"; +import { strapiLoader } from "../deps/strapi.tsx"; + +export const releaseStrapiSchema = type({ + "version": "string", + "date": "string.date.parse", + "notes?": nodeSchema.array().or("null"), + "files": strapiLoader.resolvedFileAttributeSchema, +}); + +export const releaseSchema = releaseStrapiSchema.merge({ + type: "'release'", + title: "string", +}); + +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 @@ +function isAbove(a: HTMLElement, b: HTMLElement) { + return a.offsetTop + a.clientHeight < b.offsetTop; +} + +export function getVerticalNeighbor( + dir: -1 | 1, + items: Iterable, + current: HTMLElement, +) { + let candidates: HTMLElement[] = []; + + if (dir === -1) { + let adjacentY = Number.MIN_SAFE_INTEGER; + + for (const item of items) { + if (!isAbove(item, current)) { + continue; + } + + if (item.offsetTop > adjacentY) { + adjacentY = item.offsetTop; + candidates = [item]; + } else if (item.offsetTop === adjacentY) { + candidates.push(item); + } + } + } else { + let adjacentY = Number.MAX_SAFE_INTEGER; + + for (const item of items) { + if (!isAbove(current, item)) { + continue; + } + + if (item.offsetTop < adjacentY) { + adjacentY = item.offsetTop; + candidates = [item]; + } else if (item.offsetTop === adjacentY) { + candidates.push(item); + } + } + } + + const currentX = current.offsetLeft + 0.5 * current.clientWidth; + let match = candidates[0]; + let bestDist = Number.MAX_SAFE_INTEGER; + + for (const candidate of candidates) { + const candidateX = candidate.offsetLeft + 0.5 * candidate.clientWidth; + const dist = Math.abs(currentX - candidateX); + + if (dist < bestDist) { + bestDist = dist; + match = candidate; + } + } + + return match; +} 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 @@ +import { getVerticalNeighbor } from "./dom.ts"; + +function focusPrev( + owner: HTMLElement, + itemSelector: string, + wrap: boolean, + stateful: boolean, + current: HTMLElement, +) { + const items = [...owner.querySelectorAll(itemSelector)]; + const i = items.indexOf(current); + + const prev = i > 0 ? items[i - 1] : wrap ? items.at(-1) : undefined; + + if (!prev || prev === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + prev.removeAttribute("tabindex"); + } + prev.focus(); + return true; +} + +function focusNext( + owner: HTMLElement, + itemSelector: string, + wrap: boolean, + stateful: boolean, + current: HTMLElement, +) { + const items = [...owner.querySelectorAll(itemSelector)]; + const i = items.indexOf(current); + + const next = i < items.length - 1 ? items[i + 1] : wrap ? items[0] : undefined; + + if (!next || next === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + next.removeAttribute("tabindex"); + } + next.focus(); + return true; +} + +function focusUp( + owner: HTMLElement, + itemSelector: string, + wrap: boolean, + stateful: boolean, + current: HTMLElement, +) { + const items = [...owner.querySelectorAll(itemSelector)]; + let neighbor: HTMLElement | undefined = getVerticalNeighbor(-1, items, current); + + if (!neighbor && wrap) { + neighbor = items.at(-1); + } + + if (!neighbor || neighbor === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + neighbor.removeAttribute("tabindex"); + } + neighbor.focus(); + return true; +} + +function focusDown( + owner: HTMLElement, + itemSelector: string, + wrap: boolean, + stateful: boolean, + current: HTMLElement, +) { + const items = owner.querySelectorAll(itemSelector); + let neighbor = getVerticalNeighbor(1, items, current); + + if (!neighbor && wrap) { + neighbor = items[0]; + } + + if (!neighbor || neighbor === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + neighbor.removeAttribute("tabindex"); + } + neighbor.focus(); + return true; +} + +function focusFirst( + owner: HTMLElement, + itemSelector: string, + stateful: boolean, + current: HTMLElement, +) { + const cards = [...owner.querySelectorAll(itemSelector)]; + const first = cards[0]; + + if (!first || first === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + first.removeAttribute("tabindex"); + } + first.focus(); + return true; +} + +function focusLast( + owner: HTMLElement, + itemSelector: string, + stateful: boolean, + current: HTMLElement, +) { + const cards = [...owner.querySelectorAll(itemSelector)]; + const last = cards.at(-1); + + if (!last || last === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + last.removeAttribute("tabindex"); + } + last.focus(); + return true; +} + +function onKeyDown( + owner: HTMLElement, + itemSelector: string, + horizontal: boolean, + vertical: boolean, + wrap: boolean, + stateful: boolean, + e: KeyboardEvent, +) { + let handled = false; + + if (!e.currentTarget) { + return; + } + + switch (e.key) { + case "ArrowLeft": { + if (horizontal) { + handled = focusPrev(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); + } + break; + } + + case "ArrowRight": { + if (horizontal) { + handled = focusNext(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); + } + break; + } + + case "ArrowUp": { + if (vertical) { + handled = focusUp(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); + } + break; + } + + case "ArrowDown": { + if (vertical) { + handled = focusDown(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); + } + break; + } + + case "Home": { + handled = focusFirst(owner, itemSelector, stateful, e.currentTarget as HTMLElement); + break; + } + + case "End": { + handled = focusLast(owner, itemSelector, stateful, e.currentTarget as HTMLElement); + break; + } + } + + if (handled) { + e.stopPropagation(); + e.preventDefault(); + } +} + +export function mountKeyboardNav() { + const keyboardNavEls = document.querySelectorAll(".js-keyboard-nav"); + + for (const keyboardNavEl of keyboardNavEls) { + const itemSelector = keyboardNavEl.dataset.keyboardNavItem ?? ""; + + if (!itemSelector) { + console.error("Keyboard nav: No item class provided"); + continue; + } + + let horizontal = false, + vertical = false, + wrap = false, + stateful = false; + + switch (keyboardNavEl.dataset.keyboardNavMode) { + case "grid": { + horizontal = true; + vertical = true; + stateful = true; + break; + } + + case "nav": { + horizontal = true; + wrap = true; + break; + } + + case "menu": { + vertical = true; + wrap = true; + break; + } + + case "list": { + vertical = true; + wrap = true; + stateful = true; + break; + } + } + + const onKeyDown_ = (e: KeyboardEvent) => { + onKeyDown(keyboardNavEl, itemSelector, horizontal, vertical, wrap, stateful, e); + }; + + const itemEls = [...keyboardNavEl.querySelectorAll(itemSelector)].filter((el) => + el.matches("a, button, input"), + ); + let focused = false; + + for (const itemEl of itemEls) { + itemEl.addEventListener("keydown", onKeyDown_); + if (!itemEl.classList.contains("is-selected")) { + itemEl.setAttribute("tabindex", "-1"); + } else { + focused = true; + } + } + + if (!focused) { + itemEls[0]?.removeAttribute("tabindex"); + } + } +} 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 @@ +import Alpine from "alpinejs"; + +export interface LightboxItem { + thumbnail?: string; + src: string; + srcset?: string; + alt: string; +} + +function touchParams(e: TouchEvent) { + const [t0, t1] = e.touches; + + if (e.touches.length > 2 || !t0) { + return; + } + + let x = t0.clientX; + let y = t0.clientY; + let dist = 0; + + if (t1) { + dist = Math.hypot(x - t1.clientX, y - t1.clientY); + x = (x + t1.clientX) / 2; + y = (y + t1.clientY) / 2; + } + + return { x, y, dist }; +} + +export function mountLightbox() { + const minZoom = 0.9; + + Alpine.data("lightbox", () => ({ + _zoom: minZoom, + _offsetX: 0, + _offsetY: 0, + + loading: false, + inertia: false, + items: [], + current: 0, + dragging: false, + touchStart: { + x: 0, + y: 0, + distance: 0, + transformOrigin: { x: 0, y: 0 }, + zoom: 1, + offsetX: 0, + offsetY: 0, + timestamp: 0, + }, + velocity: { + measure: false, + prevOffsetX: 0, + prevOffsetY: 0, + timestamp: 0, + x: 0, + y: 0, + }, + + get zoom() { + return this._zoom; + }, + set zoom(value) { + this._zoom = Math.min(this.maxZoom, Math.max(minZoom, value)); + /* oxlint-disable-next-line eslint/no-self-assign */ // deno-lint-ignore no-self-assign + this.offsetX = this.offsetX; + /* oxlint-disable-next-line eslint/no-self-assign */ // deno-lint-ignore no-self-assign + this.offsetY = this.offsetY; + }, + + get offsetX() { + return this._offsetX; + }, + set offsetX(value) { + const limit = this.offsetLimit; + this._offsetX = Math.min(limit.x, Math.max(-limit.x, value)); + }, + + get offsetY() { + return this._offsetY; + }, + set offsetY(value) { + const limit = this.offsetLimit; + this._offsetY = Math.min(limit.y, Math.max(-limit.y, value)); + }, + + get offsetLimit(): { x: number; y: number } { + if (!this.$refs.img) { + return { x: 0, y: 0 }; + } + + const th = this.$refs.thumbnails?.clientHeight ?? 0; + const cw = this.$root.clientWidth; + const ch = this.$root.clientHeight - th; + const x = 0.5 * Math.max(0, this.$refs.img.clientWidth * this.zoom - cw); + const y = 0.5 * Math.max(0, this.$refs.img.clientHeight * this.zoom - ch); + + return { x, y }; + }, + + get open(): boolean { + return !!this.$root.hasAttribute("open"); + }, + + get currentItem(): LightboxItem { + return this.items[this.current] ?? { src: "", alt: "" }; + }, + + get transformOrigin(): { x: number; y: number } { + const img = this.$refs.img; + + if (!img) { + return { x: this.offsetX, y: this.offsetY }; + } + + return { + x: img.offsetLeft + img.clientWidth / 2 + this.offsetX, + y: img.offsetTop + img.clientHeight / 2 + this.offsetY, + }; + }, + + get naturalZoom() { + const img = this.$refs.img; + return img ? img.naturalWidth / img.clientWidth : 1; + }, + + get maxZoom() { + return Math.max(this.naturalZoom, 5); + }, + + get zoomedFull() { + return Math.abs(this.zoom - this.naturalZoom) < 0.1; + }, + + init() { + const triggerEls = document.querySelectorAll("a[data-lightbox]"); + const galleries = new Map(); + + for (const triggerEl of triggerEls) { + const triggerImgEl = triggerEl.querySelector("img"); + + const key = triggerEl.dataset.lightbox ?? ""; + const gallery = galleries.get(key); + const item: LightboxItem = { + thumbnail: triggerImgEl?.src, + src: triggerEl.href, + srcset: triggerEl.dataset.lightboxSrcset, + alt: "", + }; + + if (gallery) { + gallery.push(item); + } else { + galleries.set(key, [item]); + } + + triggerEl.style.cursor = "zoom-in"; + triggerEl.addEventListener("click", (e) => { + e.preventDefault(); + this.show(galleries.get(key) ?? [], item.src); + }); + } + + this.$watch("currentItem.src", () => { + this.loading = true; + this.reset(); + }); + }, + + show(items: LightboxItem[], src?: string) { + const current = items.findIndex((item) => item.src === src); + this.items = items; + this.current = current === -1 ? 0 : current; + this.reset(); + + (this.$root).showModal(); + }, + + close() { + (this.$root).close(); + }, + + reset() { + this.stopVelocity(); + this.stopInertia(); + this.dragging = false; + this._zoom = minZoom; + this._offsetX = 0; + this._offsetY = 0; + }, + + prev() { + this.current = this.current > 0 ? this.current - 1 : this.items.length - 1; + }, + + next() { + this.current = this.current < this.items.length - 1 ? this.current + 1 : 0; + }, + + toggleZoom(x?: number, y?: number) { + const zoom = this.zoomedFull ? minZoom : this.naturalZoom; + const deltaZoom = zoom / this.zoom - 1; + + this.zoom = zoom; + + if (x !== undefined && y !== undefined) { + const transformOrigin = this.transformOrigin; + const offsetX = transformOrigin.x - x; + const offsetY = transformOrigin.y - y; + + this.offsetX += offsetX * deltaZoom; + this.offsetY += offsetY * deltaZoom; + } + }, + + measureVelocity(loop?: boolean) { + if (!loop) { + this.inertia = false; + + if (this.velocity.measure) { + return; + } else { + this.velocity.prevOffsetX = this.offsetX; + this.velocity.prevOffsetY = this.offsetY; + this.velocity.x = 0; + this.velocity.y = 0; + this.velocity.measure = true; + } + } + if (!this.velocity.measure) { + return; + } + + const deltaX = this.offsetX - this.velocity.prevOffsetX; + const deltaY = this.offsetY - this.velocity.prevOffsetY; + let window = 1; + + if ( + Math.sqrt(deltaX ** 2 + deltaY ** 2) < + Math.sqrt(this.velocity.x ** 2 + this.velocity.y ** 2) + ) { + window = 6; + } + + this.velocity.x -= this.velocity.x / window; + this.velocity.x += deltaX / window; + this.velocity.y -= this.velocity.y / window; + this.velocity.y += deltaY / window; + + this.velocity.prevOffsetX = this.offsetX; + this.velocity.prevOffsetY = this.offsetY; + + requestAnimationFrame(this.measureVelocity.bind(this, true)); + }, + + stopVelocity() { + this.velocity.measure = false; + }, + + runInertia(loop?: boolean, timestamp: DOMHighResTimeStamp = performance.now()) { + const len = Math.sqrt(this.velocity.x ** 2 + this.velocity.y ** 2); + + if (!loop) { + this.stopVelocity(); + + if (this.inertia) { + return; + } else if (len > 5) { + this.inertia = true; + this.velocity.timestamp = timestamp; + } + } + + if (!this.inertia) { + this.stopInertia(); + return; + } + + if (loop) { + const norm = { x: this.velocity.x / len, y: this.velocity.y / len }; + const elapsed = timestamp - this.velocity.timestamp; + const friction = 30 * (elapsed / 1000); + const decel = { x: norm.x * friction, y: norm.y * friction }; + const decelLen = Math.sqrt(decel.x ** 2 + decel.y ** 2); + + if (len < decelLen) { + this.stopInertia(); + return; + } + + this.offsetX += this.velocity.x; + this.offsetY += this.velocity.y; + this.velocity.x -= decel.x; + this.velocity.y -= decel.y; + this.velocity.timestamp = timestamp; + } + + requestAnimationFrame(this.runInertia.bind(this, true)); + }, + + stopInertia() { + this.inertia = false; + }, + + handleClose() { + this.stopVelocity(); + this.stopInertia(); + }, + + handleScroll(e: WheelEvent) { + this.stopInertia(); + + const direction = e.deltaY > 0 ? -1 : 1; + + const transformOrigin = this.transformOrigin; + const offsetX = transformOrigin.x - e.clientX; + const offsetY = transformOrigin.y - e.clientY; + + const zoom = Math.min(this.maxZoom, Math.max(minZoom, this.zoom * (1 + direction * 0.1))); + const deltaZoom = zoom / this.zoom - 1; + + this.zoom = zoom; + this.offsetX += offsetX * deltaZoom; + this.offsetY += offsetY * deltaZoom; + }, + + handleMouseDown(e: MouseEvent) { + this.measureVelocity(); + + if (e.buttons === 1) { + this.dragging = true; + } + }, + + handleMouseMove(e: MouseEvent) { + if (!this.dragging) { + return; + } + + this.offsetX += e.movementX; + this.offsetY += e.movementY; + }, + + handleMouseUp() { + this.dragging = false; + this.runInertia(); + }, + + handleDoubleClick(e: MouseEvent) { + this.toggleZoom(e.clientX, e.clientY); + }, + + handleTouchStart(e: TouchEvent) { + const params = touchParams(e); + + if (!params || (e.touches.length === 1 && e.target !== this.$refs.img)) { + return; + } + + e.preventDefault(); + + const now = Date.now(); + + if (e.touches.length === 1) { + if ( + now - this.touchStart.timestamp < 200 && + Math.sqrt((params.x - this.touchStart.x) ** 2 + (params.y - this.touchStart.y) ** 2) < 20 + ) { + this.toggleZoom(params.x, params.y); + } + } + + this.touchStart.x = params.x; + this.touchStart.y = params.y; + this.touchStart.distance = params.dist; + this.touchStart.transformOrigin = this.transformOrigin; + this.touchStart.zoom = this.zoom; + this.touchStart.offsetX = this.offsetX; + this.touchStart.offsetY = this.offsetY; + this.touchStart.timestamp = now; + + this.dragging = false; + + if (e.touches.length === 1) { + this.measureVelocity(); + } else { + this.stopVelocity(); + } + }, + + handleTouchMove(e: TouchEvent) { + const params = touchParams(e); + + if (!params || (e.touches.length === 1 && e.target !== this.$refs.img)) { + return; + } + + e.preventDefault(); + + const deltaX = params.x - this.touchStart.x; + const deltaY = params.y - this.touchStart.y; + const deltaDist = this.touchStart.distance ? params.dist / this.touchStart.distance : 1; + + const offsetX = this.touchStart.transformOrigin.x - this.touchStart.x; + const offsetY = this.touchStart.transformOrigin.y - this.touchStart.y; + + const zoom = Math.min(this.maxZoom, Math.max(minZoom, this.touchStart.zoom * deltaDist)); + const deltaZoom = zoom / this.touchStart.zoom - 1; + + this.zoom = zoom; + this.offsetX = this.touchStart.offsetX + deltaX + offsetX * deltaZoom; + this.offsetY = this.touchStart.offsetY + deltaY + offsetY * deltaZoom; + }, + + handleTouchEnd(e: TouchEvent) { + this.handleTouchStart(e); + + if (!e.touches.length) { + this.runInertia(); + } + }, + + handleKeyDown(e: KeyboardEvent) { + if (!this.open || e.defaultPrevented) { + return; + } + + this.stopInertia(); + + switch (e.key) { + case "Left": + case "ArrowLeft": + this.prev(); + break; + + case "Right": + case "ArrowRight": + this.next(); + break; + + default: + return; + } + + e.preventDefault(); + }, + })); +} 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 @@ +export function mountSiteHeader() { + const headerEl = document.querySelector(".c-site-header"); + + if (headerEl) { + const navbarEl = headerEl?.querySelector(".o-navbar--static-white"); + const logoEl = headerEl?.querySelector(".c-logo--static-white"); + const buttonEls = headerEl?.querySelectorAll(".o-button--static-white"); + + let settings: IntersectionObserverInit; + + const boundingRect = headerEl.getBoundingClientRect(); + + if (boundingRect.top > 0) { + settings = { + rootMargin: "1px 0px -100% 0px", + }; + } else { + settings = { + root: document.documentElement, + rootMargin: "-1px 0px 0px 0px", + threshold: [1], + }; + } + + const observer = new IntersectionObserver(([e]) => { + const scroll = e?.isIntersecting; + headerEl.classList.toggle("is-scroll", scroll); + navbarEl?.classList.toggle("o-navbar--static-white", !scroll); + logoEl?.classList.toggle("c-logo--static-white", !scroll); + for (const buttonEl of buttonEls) { + buttonEl.classList.toggle("o-button--static-white", !scroll); + } + }, settings); + + observer.observe(headerEl); + } +} 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 @@ +export function mountTabbar() { + const tabbarEls = document.querySelectorAll(".o-tabbar__tabs"); + + for (const tabbarEl of tabbarEls) { + const selectedItemEl = tabbarEl.querySelector(".o-tabbar__tab.is-selected"); + + if (selectedItemEl) { + tabbarEl.scrollLeft = + selectedItemEl.offsetLeft - 0.5 * tabbarEl.clientWidth + 0.5 * selectedItemEl.clientWidth; + } + } +} 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 @@ +export function mountToc() { + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + const id = entry.target.getAttribute("id"); + document + .querySelector(`.o-toc__item[href="#${id}"]`) + ?.classList.toggle("is-selected", entry.intersectionRatio > 0); + } + }); + + document.querySelectorAll("section[id]").forEach((section) => { + observer.observe(section); + }); +} 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 @@ +/// +/// + +import { mountKeyboardNav } from "./lib/keyboardNav.ts"; +import { mountLightbox } from "./lib/lightbox.ts"; +import { mountSiteHeader } from "./lib/siteHeader.ts"; +import { mountTabbar } from "./lib/tabbar.ts"; +import { mountToc } from "./lib/toc.ts"; + +document.body.classList.remove("t-no-js"); +document.body.classList.add("t-js"); + +mountKeyboardNav(); +mountLightbox(); +mountTabbar(); +mountSiteHeader(); +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 @@ +@use "sass:map"; +@use "sass:math"; +@use "pkg:iro-sass/props"; +@use "pkg:iro-sass/functions" as fn; +@use "pkg:iro-sass/easing" as easing; +@use "pkg:iro-design/config.defaults" as defaults; + +$font--standard--family: props.def( + --font--standard--family, + ( + "Inter", + "Open Sans", + "Segoe UI", + "Droid Sans", + Roboto, + Oxygen, + "Helvetica Neue", + Helvetica, + Tahoma, + Arial, + sans-serif + ) +); + +@use "pkg:iro-design/core.vars" as core; + +@use "./components/site-header.vars" as site-header; + +@use "pkg:iro-design/layouts/split-view.vars" with ( + $panel--sticky-offset: props.def( + --l-split-view--panel--sticky-offset, + calc(props.get(site-header.$block-size) + props.get(core.$size--400)) + ) +); + +@forward "pkg:iro-design"; + +@forward "objects/deflist" as o-deflist--*; +@forward "objects/message" as o-message--*; +@forward "objects/placeholder-box" as o-placeholder-box--*; +@forward "objects/toc" as o-toc--*; + +@forward "components/home-banner" as c-home-banner--*; +@forward "components/logo" as c-logo--*; +@forward "components/site-header" as c-site-header--*; +@forward "components/site-nav" as c-site-nav--*; +@forward "components/site-footer" as c-site-footer--*; +@forward "components/subtitle" as c-subtitle--*; +@forward "components/visited-indicator" as c-visited-indicator--*; + +@use "pkg:iro-design" as iro; + +@mixin styles { + @font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 50 950; + font-display: swap; + src: url(load("/assets/InterVariable.woff2")) format("woff2-variations"); + font-feature-settings: "ss01"; + } + + @font-face { + font-family: "Inter"; + font-style: italic; + font-weight: 50 950; + font-display: swap; + src: url(load("/assets/InterVariable-Italic.woff2")) format("woff2-variations"); + font-feature-settings: "ss01"; + } + + html { + scroll-behavior: smooth; + scroll-padding-block-start: props.get(site-header.$block-size); + scrollbar-gutter: stable; + min-block-size: 100%; + display: flex; + } + + body { + inline-size: 100%; + } + + [x-cloak] { + display: none; + } +} 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 @@ +@use "sass:map"; +@use "sass:string"; +@use "sass:meta"; + +@use "pkg:iro-sass/bem"; +@use "pkg:iro-sass/props"; +@use "pkg:iro-design" as iro; + +@use "site-header.vars" as site-header; + +@mixin styles { + @include bem.component("home-banner") { + @include bem.elem("intro") { + margin-block-start: 0; + font-size: clamp(props.get(iro.$core--font-size--500), 5.3vw, 5rem); + font-weight: 300; + color: props.get(iro.$core--theme, --text); + line-height: 1.2; + + strong { + font-family: props.get(iro.$core--font--headline--family); + font-weight: 900; + text-transform: uppercase; + letter-spacing: 2px; + color: props.get(iro.$core--theme, --heading); + } + } + + @include iro.media--media("<=md") { + @include bem.elem("intro") { + font-size: props.get(iro.$core--font-size--500); + } + + @include bem.elem("buttons") { + justify-content: center; + } + } + } +} 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 @@ +@use "sass:map"; +@use "sass:string"; + +@use "pkg:iro-sass/bem"; +@use "pkg:iro-sass/props"; +@use "pkg:iro-design" as iro; + +@mixin styles { + @include bem.component("logo") { + display: block; + padding: props.get(iro.$core--size--50); + margin-inline: calc(-1 * props.get(iro.$core--size--50)); + border-radius: props.get(iro.$s-links--rounding); + color: props.get(iro.$core--theme, --heading); + + &:link, + &:visited { + &:hover { + text-decoration: none; + color: props.get(iro.$core--theme, --text-mute); + } + + &:focus-visible { + color: props.get(iro.$s-links--key-focus--text-color); + text-decoration: none; + outline: props.get(iro.$s-links--key-focus--border-color) solid + props.get(iro.$s-links--key-focus--border-width); + box-shadow: 0 0 0 + calc( + props.get(iro.$s-links--key-focus--border-width) + + props.get(iro.$s-links--key-focus--outline-width) + ) + props.get(iro.$s-links--key-focus--outline-color); + } + } + + @include bem.elem("img") { + display: block; + block-size: 1.7em; + aspect-ratio: 2700 / 600; + } + + @include bem.modifier("sm") { + @include bem.elem("img") { + aspect-ratio: 600 / 600; + } + } + + @include bem.modifier("lg") { + @include bem.elem("img") { + block-size: 2em; + } + } + + @each $theme in map.keys(props.get(iro.$core--transparent-colors)) { + $logo-theme: static-#{string.slice($theme, 3)}; + + @include bem.modifier($logo-theme) { + color: props.get(iro.$core--transparent-colors, $theme, --900); + + &:link, + &:visited { + &:hover { + color: props.get(iro.$core--transparent-colors, $theme, --800); + } + + &:focus-visible { + color: props.get(iro.$core--transparent-colors, $theme, --900); + outline-color: props.get(iro.$core--transparent-colors, $theme, --900); + box-shadow: 0 0 0 + calc( + props.get(iro.$s-links--key-focus--border-width) + + props.get(iro.$s-links--key-focus--outline-width) + ) + props.get(iro.$core--transparent-colors, $theme, --300); + } + } + } + } + } +} 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 @@ +@use "sass:math"; +@use "sass:meta"; + +@use "pkg:iro-design" as iro; +@use "pkg:iro-sass/bem"; +@use "pkg:iro-sass/props"; + +@forward "site-footer.vars"; +@use "site-footer.vars" as vars; + +@mixin styles { + @include iro.props--materialize-at-root(meta.module-variables("vars")); + + @property --site-footer--l1-y { + initial-value: 0px; + inherits: true; + syntax: ""; + } + + @property --site-footer--cloud-y { + initial-value: 0px; + inherits: true; + syntax: ""; + } + + @property --site-footer--moon-y { + initial-value: 0px; + inherits: true; + syntax: ""; + } + + @property --site-footer--star-y { + initial-value: 0px; + inherits: true; + syntax: ""; + } + + @keyframes footer-l1-ani { + from { + --site-footer--l1-y: -300px; + --site-footer--cloud-y: -400px; + --site-footer--moon-y: -500px; + --site-footer--star-y: -500px; + } + to { + --site-footer--l1-y: 300px; + --site-footer--cloud-y: 400px; + --site-footer--moon-y: 500px; + --site-footer--star-y: 500px; + } + } + + @include bem.component("site-footer") { + position: sticky; + inset-block-start: 100%; + + @include bem.elem("banner") { + position: relative; + display: flex; + block-size: 33rem; + justify-content: end; + overflow: hidden; + background: linear-gradient( + to bottom, + props.get(vars.$colors, --bg-from) 35%, + props.get(vars.$colors, --bg) 85% + ); + + animation-name: footer-l1-ani; + //animation-fill-mode: both; + animation-duration: 1ms; + animation-timeline: view(block 0% 0%); + animation-timing-function: linear; + } + + @include bem.elem("banner-img") { + position: relative; + z-index: 2; + flex: 0 0 auto; + display: block; + block-size: 100%; + margin-inline: auto; + aspect-ratio: 3840 / 1600; + } + + @include bem.elem("banner-bg") { + position: absolute; + inset-inline-start: 50%; + inset-block-end: 0; + z-index: 1; + display: block; + inline-size: 100%; + block-size: 100%; + transform: translateX(-50%); + } + + @include bem.elem("content-wrap") { + padding-block-end: props.get(iro.$core--size--400); + background-color: props.get(iro.$core--transparent-colors, --black, --900); + } + + @include bem.elem("content") { + display: flex; + flex-wrap: wrap; + gap: props.get(iro.$core--size--600); + } + + @include bem.elem("sections") { + display: flex; + flex-wrap: wrap; + gap: props.get(iro.$core--size--400) props.get(iro.$core--size--800); + } + + @include bem.elem("table") { + display: grid; + grid-template-columns: auto auto auto auto; + column-gap: props.get(iro.$core--size--200); + } + + @include bem.elem("table-row") { + display: contents; + } + + @include bem.elem("divider") { + display: none; + } + + @include bem.elem("logo") { + margin-block: auto; + margin-inline-start: auto; + } + + @include iro.media--media("<=md") { + @include bem.elem("content") { + flex-direction: column; + align-items: center; + } + + @include bem.elem("divider") { + display: flex; + inline-size: 100%; + } + + @include bem.elem("logo") { + margin-inline-start: 0; + } + } + + @include iro.media--media("<=sm") { + transform: translateY(calc(-1 * props.get(iro.$o-navbar--block-size))); + + @include bem.elem("banner-img") { + transform: translateX(max(0rem, 19rem - 50vw)); + } + } + } +} 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 @@ +@use "pkg:iro-design/core.vars" as core; +@use "pkg:iro-design/objects/backdrop.vars" as backdrop; +@use "pkg:iro-sass/props"; + +$colors: props.def( + --site-footer, + ( + --bg-from: props.get(core.$theme, --bg-l1), + --bg: props.get(core.$theme, --bg-l1), + --star: transparent, + --cloud: props.get(core.$theme, --base, --50), + --l1: props.get(core.$theme, --bg-base), + --dragon-awake: props.get(core.$transparent-colors, --black, --900), + --dragon-asleep--eye: transparent, + --dragon-asleep: transparent, + --fg: props.get(core.$transparent-colors, --black, --900), + ), + "color" +); + +$colors--dark: props.def( + --site-footer, + ( + --bg: props.get(core.$theme, --bg-l2), + --star: #fff, + --cloud: props.get(core.$theme, --base, --100), + --l1: props.get(core.$theme, --bg-l1), + --dragon-awake: transparent, + --dragon-asleep--eye: #fff, + --dragon-asleep: #000, + --fg: #000, + ), + "dark" +); 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 @@ +@use "sass:math"; +@use "sass:meta"; + +@use "pkg:iro-design" as iro; +@use "pkg:iro-sass/bem"; +@use "pkg:iro-sass/props"; + +@forward "site-header.vars"; +@use "site-header.vars" as vars; + +@mixin styles { + @include iro.props--materialize-at-root(meta.module-variables("vars")); + + @include bem.component("site-header") { + position: sticky; + inset-block-start: 0; + z-index: props.get(vars.$z-index); + display: flex; + gap: props.get(vars.$spacing); + align-items: center; + line-height: 1.4; + block-size: props.get(vars.$block-size); + padding-inline: props.get(iro.$l-container--pad-i); + background-color: props.get(iro.$core--theme, --bg-l1); + overflow: hidden; + transition: + background-color 0.2s ease, + opacity 0.2s ease, + transform 0.2s ease, + visibility 0s linear 0.2s; + + @include bem.at-theme("js") { + background-color: transparent; + } + + @include bem.modifier("hidden") { + margin-block-start: calc(-1 * props.get(vars.$block-size)); + opacity: 0; + visibility: hidden; + transform: translateY(-0.2em); + + @include bem.is("scroll") { + transition: + background-color 0.2s ease, + opacity 0.2s ease, + transform 0.2s ease, + visibility 0s linear; + visibility: visible; + opacity: 1; + transform: none; + } + } + + @include bem.modifier("end") { + background-color: props.get(iro.$core--transparent-colors, --black, --900); + } + + @include bem.is("scroll") { + background-color: props.get(iro.$core--theme, --base, --50); + } + + @include bem.elem("logo") { + @include bem.modifier("sm") { + display: none; + } + } + + @include bem.elem("nav", "back") { + margin-inline-end: calc(-1 * props.get(vars.$nav-pull)); + } + + @include bem.elem("back") { + display: none; + } + + @include bem.elem("spacer") { + margin-inline-start: auto; + } + + @include bem.elem("slot") { + display: contents; + + @include bem.modifier("sm") { + display: none; + } + } + + @include iro.media--media("<=sm") { + @include bem.elem("logo") { + display: none; + + @include bem.modifier("sm") { + display: block; + } + } + + @include bem.elem("nav") { + display: none; + } + + @include bem.elem("back") { + display: block; + } + + @include bem.elem("slot") { + @include bem.modifier("end") { + display: none; + } + + @include bem.modifier("sm") { + display: contents; + } + } + + @include bem.modifier("end") { + @include bem.elem("logo") { + display: block; + + @include bem.modifier("sm") { + display: none; + } + } + + @include bem.elem("slot") { + @include bem.modifier("sm") { + display: none; + } + } + } + } + + @include iro.media--media("height<=500px") { + position: relative; + } + } +} 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 @@ +@use "pkg:iro-design/core.vars" as core; +@use "pkg:iro-design/objects/backdrop.vars" as backdrop; +@use "pkg:iro-sass/props"; + +$z-index: props.def(--c-site-header--z-index, calc(props.get(backdrop.$z-index) - 10)); +$block-size: props.def(--c-site-header--block-size, props.get(core.$size--800)); +$spacing: props.def(--c-site-header--spacing, props.get(core.$size--500)); +$nav-pull: props.def(--c-site-header--nav-pull, props.get(core.$size--300)); + +$spacing--md: props.def(--c-site-header--spacing, props.get(core.$size--400), "md"); +$nav-pull--md: props.def(--c-site-header--nav-pull, props.get(core.$size--200), "md"); + +$block-size--sm: props.def(--c-site-header--block-size, props.get(core.$size--700), "sm"); +$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 @@ +@use "sass:math"; +@use "sass:meta"; + +@use "pkg:iro-design" as iro; +@use "pkg:iro-sass/bem"; +@use "pkg:iro-sass/props"; + +@mixin styles { + @include bem.component("site-nav-placeholder") { + block-size: props.get(iro.$o-navbar--block-size); + + @include iro.media--media(">sm") { + display: none; + } + } + + @include bem.component("site-nav") { + position: fixed; + inset-block-end: 0; + inset-inline: 0; + z-index: calc(props.get(iro.$o-backdrop--z-index) - 10); + background-color: props.get(iro.$core--transparent-colors, --black, --900); + //border-block-start: 1px solid props.get(iro.$core--theme, --border-mute); + overflow-x: auto; + + @include iro.media--media(">sm") { + display: none; + } + } +} 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 @@ +@use "pkg:iro-sass/bem"; +@use "pkg:iro-sass/props"; +@use "pkg:iro-design" as iro; + +@mixin styles { + @include bem.component("subtitle") { + margin-block-start: props.get(iro.$s-implicit--paragraph--margin-bs); + color: props.get(iro.$core--theme, --text-mute-more); + font-size: props.get(iro.$core--font-size--300); + } +} 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 @@ +@use "pkg:iro-sass/bem"; +@use "pkg:iro-sass/props"; +@use "pkg:iro-design" as iro; + +@mixin styles { + @include bem.component("with-visited-indicator") { + @include bem.elem("indicator") { + block-size: 1.4em; + color: props.get(iro.$core--theme, --text); + + &::after { + content: ""; + display: block; + block-size: 100%; + inline-size: props.get(iro.$core--border-width--thick); + border-radius: 10em; + background-color: currentColor; + } + } + + &:visited { + @include bem.elem("indicator") { + color: props.get(iro.$core--theme, --border-mute); + } + } + } +} 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 @@ +@use "sass:list"; + +@use "pkg:iro-sass/bem"; +@use "pkg:iro-sass/props"; +@use "pkg:iro-design" as iro; + +@mixin styles { + @include bem.object("deflist") { + display: grid; + grid-template-columns: auto 1fr; + gap: props.get(iro.$core--size--50) props.get(iro.$core--size--400); + padding: 0; + margin-block: props.get(iro.$s-implicit--paragraph--margin-bs) 0; + margin-inline: 0; + + dt { + font-weight: bold; + color: props.get(iro.$core--theme, --heading); + } + + dd { + margin: 0; + } + } +} 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 @@ +@use "sass:meta"; + +@use "pkg:iro-design" as iro; +@use "pkg:iro-sass/bem"; +@use "pkg:iro-sass/props"; + +@forward "message.vars"; +@use "message.vars" as vars; + +@mixin styles { + @include iro.props--materialize-at-root(meta.module-variables("vars")); + + @include bem.object("message") { + display: grid; + position: relative; + grid-template-rows: auto 1fr auto; + grid-template-columns: + auto calc(props.get(vars.$avatar-size) + props.get(vars.$spacing-x)) + minmax(0, 1fr); + grid-template-areas: + "line-from avatar main" + "line-from line-to main" + "line-from replies replies"; + + & + & { + padding-block-start: props.get(vars.$spacing-outer); + } + + @include bem.elem("line-from") { + grid-area: line-from; + position: relative; + inline-size: calc(props.get(vars.$avatar-size) + props.get(vars.$spacing-x)); + + &::before, + &::after { + content: ""; + display: block; + position: absolute; + inset-block-start: 0; + inset-inline-start: props.get(vars.$line-offset); + } + + &::before { + inline-size: props.get(vars.$line-width); + block-size: calc(100% + props.get(vars.$spacing-outer)); + background-color: props.get(vars.$colors, --line); + } + + &::after { + inline-size: calc( + props.get(vars.$avatar-size) - props.get(vars.$line-offset) + props.get(vars.$spacing-x) + ); + block-size: calc(0.5 * (props.get(vars.$avatar-size) - props.get(vars.$line-width))); + border-block-end: props.get(vars.$line-width) solid props.get(vars.$colors, --line); + border-inline-start: props.get(vars.$line-width) solid props.get(vars.$colors, --line); + border-radius: 0 0 0 props.get(vars.$line-curve); + } + + @include bem.modifier("last") { + &::before { + block-size: 0; + } + } + + @include bem.sibling-elem("avatar") { + &::before { + inset-block-start: 50%; + inset-block-end: auto; + inset-inline-start: 0; + inline-size: 50%; + block-size: props.get(vars.$line-width); + transform: translateY(-50%); + } + } + } + + @include bem.elem("line-to") { + grid-area: line-to; + display: block; + inline-size: props.get(vars.$line-width); + margin-inline-start: props.get(vars.$line-offset); + background-color: props.get(vars.$colors, --line); + } + + @include bem.elem("avatar") { + position: relative; + grid-area: avatar; + inline-size: props.get(vars.$avatar-size); + block-size: props.get(vars.$avatar-size); + margin-inline-end: props.get(vars.$spacing-x); + + &::before, + &::after { + content: ""; + display: block; + position: absolute; + z-index: -1; + inset-inline-start: props.get(vars.$line-offset); + inline-size: props.get(vars.$line-width); + block-size: 50%; + background-color: props.get(vars.$colors, --line); + } + + &::before { + inset-block-start: 0; + } + + &::after { + inset-block-end: 0; + } + + @include bem.modifier("last") { + &::after { + display: none; + } + } + } + + @include bem.elem("avatar-dot") { + grid-area: avatar; + position: absolute; + inset-block-start: 50%; + inset-inline-start: 50%; + inline-size: props.get(vars.$dot-radius); + block-size: props.get(vars.$dot-radius); + transform: translate(-50%, -50%); + border: props.get(vars.$dot-width) solid props.get(vars.$colors, --dot, --border); + border-radius: 1em; + background-color: props.get(vars.$colors, --dot, --bg); + } + + @include bem.elem("meta") { + white-space: nowrap; + color: props.get(vars.$colors, --meta); + } + + @include bem.elem("main") { + position: relative; + grid-area: main; + } + + @include bem.elem("replies") { + grid-area: replies; + + &::before { + content: ""; + grid-area: line; + display: block; + inline-size: props.get(vars.$line-width); + block-size: props.get(vars.$spacing-inner); + margin-inline-start: props.get(vars.$line-offset); + background-color: props.get(vars.$colors, --line); + } + } + + @include bem.elem("header") { + line-height: 1.4; + + @include bem.next-elem("body") { + padding-block-start: props.get(vars.$spacing-y); + } + } + + @include bem.elem("body") { + word-wrap: break-word; + + &::before { + content: ""; + display: block; + margin-block: -100em 100em; + } + } + + @include bem.elem("attachments") { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(props.get(vars.$attachment-width), 1fr)); + grid-auto-rows: auto; + gap: props.get(vars.$spacing-x); + margin-block-start: props.get(vars.$spacing-attachments); + } + + @include bem.elem("attachment") { + aspect-ratio: 3 / 2; + background-color: props.get(vars.$colors, --attachment, --bg); + } + + @include bem.modifier("expanded") { + grid-template-rows: auto 1fr auto; + grid-template-columns: calc(props.get(vars.$avatar-size) + props.get(vars.$spacing-x)) minmax( + 0, + 1fr + ); + grid-template-areas: + "avatar header" + "body body" + "attachments attachments"; + + @include bem.elem("header") { + grid-area: header; + align-self: center; + } + + @include bem.elem("main") { + display: contents; + } + + @include bem.elem("body") { + grid-area: body; + } + + @include bem.elem("attachments") { + grid-area: attachments; + } + } + } +} 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 @@ +@use "pkg:iro-design/core.vars" as core; +@use "pkg:iro-design/objects/avatar.vars" as avatar; +@use "pkg:iro-sass/props"; + +$avatar-size: props.def(--o-message--avatar-size, props.get(avatar.$size)); + +$dot-radius: props.def(--o-message--dot-radius, 6px); +$dot-width: props.def(--o-message--dot-width, 4px); + +$spacing-x: props.def(--o-message--spacing-x, props.get(core.$size--200)); +$spacing-y: props.def(--o-message--spacing-y, props.get(core.$size--150)); +$spacing-inner: props.def(--o-message--spacing-inner, props.get(core.$size--500)); +$spacing-outer: props.def(--o-message--spacing-outer, props.get(core.$size--700)); +$spacing-attachments: props.def(--o-message--spacing-attachments, props.get(core.$size--200)); + +$line-width: props.def(--o-message--line-width, props.get(core.$border-width--medium)); +$line-offset: props.def( + --o-message--line-offset, + calc(0.5 * props.get($avatar-size) - props.get($line-width)) +); +$line-curve: props.def(--o-message--line-curve, props.get(core.$size--250)); + +$attachment-width: props.def(--o-message--attachment-width, props.get(core.$size--2000)); + +$colors: props.def( + --o-message, + ( + --meta: props.get(core.$theme, --text-mute-more), + --line: props.get(core.$theme, --border), + --dot: ( + --border: props.get(core.$theme, --text-mute-more), + --bg: props.get(core.$theme, --bg-base), + ), + --attachment: ( + --bg: props.get(core.$theme, --border-mute), + ), + ), + "color" +); 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 @@ +@use "pkg:iro-sass/bem"; +@use "pkg:iro-sass/props"; +@use "pkg:iro-design" as iro; + +@mixin styles { + @include bem.object("placeholder-box") { + padding: props.get(iro.$core--size--250) props.get(iro.$core--size--300); + border: props.get(iro.$core--border-width--thin) dashed + props.get(iro.$core--theme, --border, --mute); + border-radius: props.get(iro.$core--rounding); + } +} 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 @@ +@use "sass:list"; + +@use "pkg:iro-sass/bem"; +@use "pkg:iro-sass/props"; +@use "pkg:iro-design" as iro; + +@mixin styles { + @include bem.object("toc") { + @include bem.elem("item") { + @include bem.next-twin-elem { + margin-block-start: props.get(iro.$core--size--50); + } + + @include bem.is("active") { + @include bem.elem("item-link") { + font-weight: bold; + text-decoration-line: none; + color: props.get(iro.$core--theme, --heading); + + &:hover { + text-decoration-line: underline; + } + } + } + } + } +} 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 @@ +@use "./common" as iro; + +@include iro.layers--styles; +@include iro.core--styles; +@include iro.styles; + +@include iro.s-implicit--styles; +@include iro.s-blockquotes--styles; +@include iro.s-body--styles; +@include iro.s-code--styles; +@include iro.s-figures--styles; +@include iro.s-headings--styles; +@include iro.s-lists--styles; +@include iro.s-links--styles; + +@include iro.l-card-list--styles; +@include iro.l-container--styles; +@include iro.l-flex--styles; +@include iro.l-hlist--styles; +@include iro.l-media--styles; +@include iro.l-split-view--styles; + +@include iro.o-alert--styles; +@include iro.o-avatar--styles; +@include iro.o-backdrop--styles; +@include iro.o-button--styles; +@include iro.o-card--styles; +@include iro.o-deflist--styles; +@include iro.o-dialog--styles; +@include iro.o-divider--styles; +@include iro.o-emoji--styles; +@include iro.o-figure--styles; +@include iro.o-heading--styles; +@include iro.o-icon--styles; +@include iro.o-thumbnail--styles; +@include iro.o-lightbox--styles; +@include iro.o-navbar--styles; +@include iro.o-placeholder-box--styles; +@include iro.o-side-nav--styles; +@include iro.o-tabbar--styles; +@include iro.o-text-field--styles; +@include iro.o-toc--styles; + +@include iro.c-home-banner--styles; +@include iro.c-logo--styles; +@include iro.c-site-header--styles; +@include iro.c-site-nav--styles; +@include iro.c-site-footer--styles; +@include iro.c-subtitle--styles; +@include iro.c-visited-indicator--styles; + +@include iro.utils--styles; +@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 @@ +// @ts-types="corvos/mod.ts" +import { getGenerator, type Select } from "corvos/mod.ts"; + +import site from "@/corvos.config.ts"; + +import { SiteFooter } from "../components/SiteFooter.tsx"; +import { SiteNav } from "../components/SiteNav.tsx"; +import { Lightbox } from "../objects/Lightbox.tsx"; + +export interface Props { + page: Select; + image?: string; + theme?: "l1" | "l2" | null; + children?: JSX.Element; + class?: unknown; +} + +export const BaseLayout = site.$.component( + async ({ page, theme = "l1", image: imageUrl, children, class: cls }: Props, pages) => { + const query = site.$.query(pages); + + const stylesheet = await site.load("styles/style.scss"); + const script = await site.load("scripts/script.ts"); + const fonts = await site.glob("assets/**.woff2"); + const favicon = await site.load("assets/favicon.svg"); + const image = await site.load( + imageUrl ?? "https://strapi.volpeon.ink/uploads/icon_7999cf625e.png", + ); + + const feeds = query.type(".rss").many(); + + return ( + <> + {{ __html: "" }} + + + + + + + + + {fonts.map((font) => ( + + ))} + + + +