adding a lot to admin, mess around with dev-only routes

This commit is contained in:
haetae 2025-08-13 04:16:34 -04:00
parent 776fc1b38e
commit 496ba125d0
23 changed files with 478 additions and 222 deletions

3
.gitignore vendored
View File

@ -22,3 +22,6 @@ pnpm-debug.log*
# jetbrains setting folder # jetbrains setting folder
.idea/ .idea/
# dont grab database files you dont need that
local.db

11
.vscode/settings.json vendored
View File

@ -1,3 +1,12 @@
{ {
"typescript.tsdk": "node_modules/typescript/lib" "typescript.tsdk": "node_modules/typescript/lib",
"sqltools.useNodeRuntime": true,
"sqltools.connections": [
{
"previewLimit": 50,
"driver": "SQLite",
"database": "./guestbook.db",
"name": "test"
}
]
} }

View File

@ -3,8 +3,8 @@ import { defineConfig } from 'astro/config';
import { modifiedTime } from './src/utils/lastModified.mjs'; import { modifiedTime } from './src/utils/lastModified.mjs';
import mdx from "@astrojs/mdx"; import mdx from "@astrojs/mdx";
import db from "@astrojs/db"; import db from "@astrojs/db";
import node from "@astrojs/node"; import node from "@astrojs/node";
import devOnlyRoutes from '@fujocoded/astro-dev-only';
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
@ -13,7 +13,14 @@ export default defineConfig({
remarkPlugins: [modifiedTime], remarkPlugins: [modifiedTime],
smartypants: false, smartypants: false,
}, },
integrations: [mdx(), db()], integrations: [
mdx(),
db(),
devOnlyRoutes({
// dryRun: true,
routePatterns: ["/guestbook/admin"]
}),
],
adapter: node({ adapter: node({
mode: "standalone", mode: "standalone",
}), }),

View File

@ -6,11 +6,11 @@
"dependencies": { "dependencies": {
"@astrojs/db": "^0.16.1", "@astrojs/db": "^0.16.1",
"@astrojs/mdx": "^4.3.3", "@astrojs/mdx": "^4.3.3",
"@astrojs/node": "^9.3.3", "@astrojs/node": "^9.4.0",
"@astrojs/rss": "4.0.12", "@astrojs/rss": "4.0.12",
"@fujocoded/astro-dev-only": "0.0.3",
"astro": "5.12.3", "astro": "5.12.3",
"astro-breadcrumbs": "^3.3.1", "astro-breadcrumbs": "^3.3.1",
"bcryptjs": "^3.0.2",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"node-html-parser": "^7.0.1", "node-html-parser": "^7.0.1",
@ -18,7 +18,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^22.17.0", "@types/node": "^22.17.1",
"@types/sanitize-html": "^2.16.0", "@types/sanitize-html": "^2.16.0",
}, },
}, },
@ -34,7 +34,7 @@
"@astrojs/mdx": ["@astrojs/mdx@4.3.3", "", { "dependencies": { "@astrojs/markdown-remark": "6.3.5", "@mdx-js/mdx": "^3.1.0", "acorn": "^8.14.1", "es-module-lexer": "^1.6.0", "estree-util-visit": "^2.0.0", "hast-util-to-html": "^9.0.5", "kleur": "^4.1.5", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", "source-map": "^0.7.4", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-+9+xGP2TBXxcm84cpiq4S9JbuHOHM1fcvREfqW7VHxlUyfUQPByoJ9YYliqHkLS6BMzG+O/+o7n8nguVhuEv4w=="], "@astrojs/mdx": ["@astrojs/mdx@4.3.3", "", { "dependencies": { "@astrojs/markdown-remark": "6.3.5", "@mdx-js/mdx": "^3.1.0", "acorn": "^8.14.1", "es-module-lexer": "^1.6.0", "estree-util-visit": "^2.0.0", "hast-util-to-html": "^9.0.5", "kleur": "^4.1.5", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", "source-map": "^0.7.4", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-+9+xGP2TBXxcm84cpiq4S9JbuHOHM1fcvREfqW7VHxlUyfUQPByoJ9YYliqHkLS6BMzG+O/+o7n8nguVhuEv4w=="],
"@astrojs/node": ["@astrojs/node@9.3.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "send": "^1.2.0", "server-destroy": "^1.0.1" }, "peerDependencies": { "astro": "^5.3.0" } }, "sha512-5jVuDbSxrY7rH7H+6QoRiN78AITLobYXWu+t1A2wRaFPKywaXNr8YHSXfOE4i2YN4c+VqMCv83SjZLWjTK6f9w=="], "@astrojs/node": ["@astrojs/node@9.4.0", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "send": "^1.2.0", "server-destroy": "^1.0.1" }, "peerDependencies": { "astro": "^5.3.0" } }, "sha512-Gxs0iVUvOmQmK+H1DBoabcgvdSDg277SwbujRv2cUBlnpcOTJQDFRhRvyJ7G+Zkd06/jhRphsTTmmrBY0PqI4g=="],
"@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], "@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
@ -106,6 +106,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="],
"@fujocoded/astro-dev-only": ["@fujocoded/astro-dev-only@0.0.3", "", {}, "sha512-BOLYZcivrJVUA60d4R2+yEGwDJZ+3Z/zndLACxk6YpClu5ETl0adghwCCt9yIHsq79KDx/Or8LH2aqS6fVlQbg=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
@ -262,7 +264,7 @@
"@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="],
"@types/node": ["@types/node@22.17.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ=="], "@types/node": ["@types/node@22.17.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA=="],
"@types/sanitize-html": ["@types/sanitize-html@2.16.0", "", { "dependencies": { "htmlparser2": "^8.0.0" } }, "sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw=="], "@types/sanitize-html": ["@types/sanitize-html@2.16.0", "", { "dependencies": { "htmlparser2": "^8.0.0" } }, "sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw=="],
@ -304,8 +306,6 @@
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bcryptjs": ["bcryptjs@3.0.2", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="],
"blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="], "blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@ -952,6 +952,10 @@
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@types/fontkit/@types/node": ["@types/node@22.17.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ=="],
"@types/ws/@types/node": ["@types/node@22.17.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],

View File

@ -4,18 +4,18 @@
"version": "0.0.1", "version": "0.0.1",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build --remote",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"@astrojs/db": "^0.16.1", "@astrojs/db": "^0.16.1",
"@astrojs/mdx": "^4.3.3", "@astrojs/mdx": "^4.3.3",
"@astrojs/node": "^9.3.3", "@astrojs/node": "^9.4.0",
"@astrojs/rss": "4.0.12", "@astrojs/rss": "4.0.12",
"@fujocoded/astro-dev-only": "0.0.3",
"astro": "5.12.3", "astro": "5.12.3",
"astro-breadcrumbs": "^3.3.1", "astro-breadcrumbs": "^3.3.1",
"bcryptjs": "^3.0.2",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"node-html-parser": "^7.0.1", "node-html-parser": "^7.0.1",
@ -23,7 +23,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^22.17.0", "@types/node": "^22.17.1",
"@types/sanitize-html": "^2.16.0" "@types/sanitize-html": "^2.16.0"
} }
} }

View File

@ -1,6 +1,6 @@
import { ActionError, defineAction } from "astro:actions"; import { ActionError, defineAction } from "astro:actions";
import { z } from "astro:content"; import { z } from "astro:content";
import { db, eq, Guestbook } from "astro:db"; import { db, eq, Guestbook, isDbError } from "astro:db";
import sanitize from "sanitize-html"; import sanitize from "sanitize-html";
export const guestbook = { export const guestbook = {
@ -12,42 +12,92 @@ export const guestbook = {
message: z.string().min(1, "Can't be that short..."), message: z.string().min(1, "Can't be that short..."),
}), }),
handler: async ({ username, website, message }) => { handler: async ({ username, website, message }) => {
// figure out how to add line breaks and THEN sanitize message const addLine = message.replaceAll(/\r?\n/g, "<br />");
const addLine = message.replaceAll("/n", "<br/>"); const sanitized = sanitize(addLine, { allowedTags: ["br"] });
sanitize(addLine);
const entry = await db.insert(Guestbook).values({ try {
username, const entry = await db.insert(Guestbook).values({
website, username,
message, website,
}).returning(); message: sanitized,
}).returning();
return entry[0];
return entry[0];
} catch (e) {
if (isDbError(e)) {
return new Response(`Cannot insert entry\n\n${e.message}`, { status: 400 });
}
return new Response('An unexpected error occurred', { status: 500 });
}
}, },
}), }),
reply: defineAction({ ...import.meta.env.DEV && {
accept: "form", reply: defineAction({
input: z.object({ accept: "form",
id: z.number(), input: z.object({
reply: z.string(), id: z.coerce.number(),
reply: z.string(),
}),
handler: async ({ id, reply }) => {
if (!import.meta.env.DEV) {
throw new ActionError({ code: "UNAUTHORIZED" });
}
const entry = await db.select().from(Guestbook).where(eq(Guestbook.id, id));
if (!entry) {
throw new ActionError({
code: "NOT_FOUND",
message: "That entry doesn't exist!"
});
}
const addLine = reply.replaceAll(/\r?\n/g, "<br />");
const sanitized = sanitize(addLine, { allowedTags: ["br"] });
try {
const update = await db.update(Guestbook).set({
reply: sanitized,
updated: new Date(),
}).where(eq(Guestbook.id, id)).returning();
return update[0];
} catch (e) {
if (isDbError(e)) {
return new Response(`Cannot update entry\n\n${e.message}`, { status: 400 });
}
return new Response('An unexpected error occurred', { status: 500 });
}
},
}), }),
handler: async ({ id, reply }, context) => { deleteEntry: defineAction({
if (context.url.hostname !== "127.0.0.1" || "localhost") { accept: "form",
throw new ActionError({ code: "UNAUTHORIZED" }); input: z.object({
} id: z.coerce.number()
}),
handler: async ({ id }) => {
if (!import.meta.env.DEV) {
throw new ActionError({ code: "UNAUTHORIZED" });
}
const entry = await db.select().from(Guestbook).where(eq(Guestbook.id, id));
if (!entry) {
throw new ActionError({
code: "NOT_FOUND",
message: "That entry doesn't exist!"
});
}
const entry = await db.select().from(Guestbook).where(eq(Guestbook.id, id)); try {
if (!entry) { const entry = await db.delete(Guestbook).where(eq(Guestbook.id, id)).returning();
throw new ActionError({
code: "NOT_FOUND", return entry[0];
message: "That entry doesn't exist!" } catch (e) {
}); if (isDbError(e)) {
} return new Response(`Cannot update entry\n\n${e.message}`, { status: 400 });
}
// sanitize reply here return new Response('An unexpected error occurred', { status: 500 });
}
const update = await db.update(Guestbook).set({ reply }).where(eq(Guestbook.id, id)).returning(); },
return update[0]; }),
}, },
}),
}; };

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
src/assets/moon-bullet.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 B

View File

Before

Width:  |  Height:  |  Size: 411 B

After

Width:  |  Height:  |  Size: 411 B

View File

Before

Width:  |  Height:  |  Size: 689 B

After

Width:  |  Height:  |  Size: 689 B

BIN
src/assets/star-bullet.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 B

View File

@ -0,0 +1,90 @@
---
interface Props {
id?: string;
title?: string;
class?: string;
}
const { id, title, class: className, ...rest } = Astro.props;
---
<dialog {id} class:list={className} {...rest}>
<form method="dialog">
<menu>
{title && <div class="title">{title}</div>}
<button type="submit" aria-label="Close">
<span>x</span>
</button>
</menu>
</form>
<div class="content">
<slot />
</div>
</dialog>
<style>
dialog {
margin: auto;
color: var(--fg-color);
background: var(--bg-color);
transition:
display 1s allow-discrete,
overlay 1s allow-discrete;
animation: fadeOut 1s forwards;
menu {
position: absolute;
top: 0;
right: 0;
width: calc(100% - 4px);
display: flex;
justify-content: end;
align-items: center;
padding: 2px;
margin: 2px 2px 0;
background-color: color-mix(in oklab, var(--bg-color) 95%, var(--fg-color));
border: 2px solid var(--fg-color);
line-height: 1;
.title {
flex: 1;
font-size: inherit;
}
button {
padding: 0;
box-shadow: none;
line-height: 1;
transform: none;
display: grid;
place-content: center;
width: 44px;
height: 44px;
span { transform: translateY(-2px); }
&:focus {
border: 4px inset var(--secondary-color);
outline: 2px solid var(--fg-color);
box-shadow: none;
span { transform: translateY(0); }
}
}
}
.content { margin-top: calc(44px + 2px); }
&[open] { animation: fadeIn 1.0s forwards; }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
</style>

View File

@ -1,6 +1,7 @@
--- ---
import { db, desc, Guestbook } from "astro:db"; import { db, desc, Guestbook } from "astro:db";
import formatDate from "@/utils/formatDate"; import formatDate from "@/utils/formatDate";
import pikachu from "$/images/portrait-0025.png";
const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.published)); const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.published));
--- ---
@ -20,13 +21,14 @@ const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.publish
</time> </time>
</header> </header>
{entry.message} <div class="content">
<Fragment set:html={entry.message} />
</div>
</div> </div>
</article> </article>
{entry.reply && <img src="" alt="help" /> {entry.reply && <article class="reply" id={`reply-${entry.username}-${entry.id}`}>
<article class="reply" id={`reply-${entry.username}-${entry.id}`}> <img src={pikachu.src} width="80" height="80" alt="a portrait of pikachu" />
<img src="" alt="" />
<div class="entry"> <div class="entry">
<header> <header>
<h1>Reply to {entry.username}</h1> <h1>Reply to {entry.username}</h1>
@ -35,16 +37,24 @@ const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.publish
</time> </time>
</header> </header>
{entry.reply} <div class="content">
<Fragment set:html={entry.reply} />
</div>
</div> </div>
</article>} </article>}
</> </>
))} ))}
{entries.length === 0 && (
<article>
<h1>Huh...</h1>
<p>There's nothing here! Want to be the first to comment?</p>
</article>
)}
</section> </section>
<style> <style>
#entries { #entries {
margin: 2rem 0 4rem; margin: 2rem 0 3rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 2rem;
@ -73,7 +83,7 @@ const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.publish
header { header {
display: flex; display: flex;
align-items: start; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -85,6 +95,8 @@ const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.publish
text-align: center; text-align: center;
} }
.content { line-height: 1; }
@media screen and (width < 1000px) { @media screen and (width < 1000px) {
max-width: 100%; max-width: 100%;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
@ -92,6 +104,8 @@ const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.publish
} }
.reply { .reply {
flex-direction: row-reverse;
align-self: end;
min-width: min(100%, 45ch);
} }
</style> </style>

View File

@ -5,8 +5,8 @@ import Layout from "./Layout.astro";
import Navbar from "~/Navbar.astro"; import Navbar from "~/Navbar.astro";
import Figure from "~/Figure.astro"; import Figure from "~/Figure.astro";
import border from "$/border.png"; import border from "$/pmd-border.png";
import frame from "$/frame.png"; import frame from "$/pmd-frame.png";
type Props = MarkdownLayoutProps<{ type Props = MarkdownLayoutProps<{
avatar?: string; avatar?: string;
@ -30,6 +30,10 @@ const { frontmatter } = Astro.props;
<slot /> <slot />
</article> </article>
</main> </main>
<footer>
hi
</footer>
</Layout> </Layout>
<style define:vars={{ borderImage: `url(${border.src})`, frameImage: `url(${frame.src})` }}> <style define:vars={{ borderImage: `url(${border.src})`, frameImage: `url(${frame.src})` }}>
@ -67,6 +71,8 @@ const { frontmatter } = Astro.props;
text-shadow: none; text-shadow: none;
} }
} }
li::marker { content: " "; }
} }
:global(.avatar) { :global(.avatar) {

View File

@ -3,12 +3,12 @@ import { Font, Image } from "astro:assets";
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
import Layout from "./Layout.astro"; import Layout from "./Layout.astro";
import Navbar from "~/Navbar.astro"; import Navbar from "~/Navbar.astro";
import formatDate from "@/utils/formatDate";
import moods from "@/utils/moods"; import moods from "@/utils/moods";
import outerBBS from "$/guild-bbs.png"; import outerBBS from "$/guild-bbs.png";
import innerBBS from "$/guild-bbs-content.png"; import innerBBS from "$/guild-bbs-content.png";
import sideBBS from "$/guild-bbs-list.png"; import sideBBS from "$/guild-bbs-list.png";
import formatDate from "@/utils/formatDate";
interface Props { interface Props {
id?: string; id?: string;
@ -159,6 +159,7 @@ blog.sort((a, b) => b.data.pubDate!.valueOf() - a.data.pubDate!.valueOf());
--active-border: #442266; --active-border: #442266;
color: #000; color: #000;
} }
h1 { font: bold 1rem var(--arial-font); } h1 { font: bold 1rem var(--arial-font); }
h2 { h2 {
font: normal 1.5rem var(--dotum-12-font); font: normal 1.5rem var(--dotum-12-font);
@ -193,8 +194,8 @@ blog.sort((a, b) => b.data.pubDate!.valueOf() - a.data.pubDate!.valueOf());
height: calc(100% - 34px); height: calc(100% - 34px);
flex-flow: column wrap; flex-flow: column wrap;
background-color: var(--bg-3); background-color: var(--bg-3);
padding: 2px; padding: 16px 6px;
margin: 18px 6px; margin: 18px 20px 16px;
border: 2px solid var(--border-6); border: 2px solid var(--border-6);
} }
@ -203,14 +204,13 @@ blog.sort((a, b) => b.data.pubDate!.valueOf() - a.data.pubDate!.valueOf());
border-block: 2px solid var(--border-3); border-block: 2px solid var(--border-3);
.title { .title {
display: inline-block; display: block;
background-color: var(--bg-8); background-color: var(--bg-8);
color: var(--bg-0); color: var(--bg-0);
font-weight: normal; font-weight: normal;
text-transform: uppercase; text-transform: uppercase;
padding: 5px 2px; padding: 5px 2px;
text-align: center; text-align: center;
width: 100%;
} }
} }
@ -223,6 +223,7 @@ blog.sort((a, b) => b.data.pubDate!.valueOf() - a.data.pubDate!.valueOf());
li { li {
padding: 4px 2px; padding: 4px 2px;
border-bottom: 2px solid var(--bg-4); border-bottom: 2px solid var(--bg-4);
max-width: 100%;
.item { .item {
position: relative; position: relative;
@ -244,7 +245,7 @@ blog.sort((a, b) => b.data.pubDate!.valueOf() - a.data.pubDate!.valueOf());
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
/* max-width: 19ch; */ max-width: 19ch;
@media screen and (max-width: 76em) { @media screen and (max-width: 76em) {
max-width: unset; max-width: unset;
@ -276,18 +277,15 @@ blog.sort((a, b) => b.data.pubDate!.valueOf() - a.data.pubDate!.valueOf());
} }
.nav-section { .nav-section {
margin: 0 4px;
background-color: var(--bg-0); background-color: var(--bg-0);
max-width: 100%;
&:first-child { margin-top: 4px; }
&:last-child { &:last-child {
border-bottom: 2px solid var(--border-2); border-bottom: 2px solid var(--border-2);
margin-bottom: 4px;
} }
} }
@media screen and (max-width: 76em) { @media screen and (max-width: 76em) {
margin-right: 0;
margin-bottom: -2px; margin-bottom: -2px;
} }
} }

View File

@ -1,7 +1,7 @@
--- ---
import { Font } from "astro:assets";
import "$/styles/base.css"; import "$/styles/base.css";
import "$/styles/themes.css"; import "$/styles/themes.css";
import { Font } from "astro:assets";
interface Props { title?: string; } interface Props { title?: string; }
@ -9,22 +9,22 @@ const { title = "haetae" }: Props = Astro.props;
--- ---
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<meta name="pinterest" content="nopin nohover" /> <meta name="pinterest" content="nopin nohover" />
<meta name="robots" content="noai, noimageai" /> <meta name="robots" content="noai, noimageai" />
<Font cssVariable="--sq" preload /> <Font cssVariable="--sq" preload />
<Font cssVariable="--kiwi" preload /> <Font cssVariable="--kiwi" preload />
<slot name="head" /> <slot name="head" />
<title>{title}</title> <title>{title}</title>
<script is:inline> <script is:inline>
localStorage.getItem("theme") localStorage.getItem("theme")
? document.documentElement.className = localStorage.getItem("theme") ? document.documentElement.className = localStorage.getItem("theme")
: document.documentElement.removeAttribute("class"); : document.documentElement.removeAttribute("class");
</script> </script>
</head> </head>
<body> <body>
<slot /> <slot />
</body> </body>
</html> </html>

View File

@ -17,6 +17,7 @@ i used a bunch of assets that require attribution but i also wanted to link peop
- [this about page's frame images](https://www.spriters-resource.com/ds_dsi/pokemonmysterydungeonexplorersoftimedarkness/sheet/5986/) by redblueyellow - [this about page's frame images](https://www.spriters-resource.com/ds_dsi/pokemonmysterydungeonexplorersoftimedarkness/sheet/5986/) by redblueyellow
- the blog's frame images and additional assets (such as the mood emoticons) were graciously provided by AymShade on the MapleLegends forums! i can't directly link because i'm scared of a Certain Company™ being evil - the blog's frame images and additional assets (such as the mood emoticons) were graciously provided by AymShade on the MapleLegends forums! i can't directly link because i'm scared of a Certain Company™ being evil
- [the guestbook's speech bubble images](https://www.spriters-resource.com/game_boy_advance/mlss/sheet/7573/) by MajinPiccolo - [the guestbook's speech bubble images](https://www.spriters-resource.com/game_boy_advance/mlss/sheet/7573/) by MajinPiccolo
- [bullet point images](https://foollovers.com) by foollovers
## fonts ## fonts
- [Departure Mono](https://departuremono.com/) by [Helena Zhang](https://www.helenazhang.com/) is licensed under [OFL 1.1](https://www.tldrlegal.com/license/open-font-license-ofl-explained) - [Departure Mono](https://departuremono.com/) by [Helena Zhang](https://www.helenazhang.com/) is licensed under [OFL 1.1](https://www.tldrlegal.com/license/open-font-license-ofl-explained)

View File

@ -2,7 +2,7 @@
import Blog from '@/layouts/Blog.astro'; import Blog from '@/layouts/Blog.astro';
import type { GetStaticPaths } from 'astro'; import type { GetStaticPaths } from 'astro';
import { getCollection, render } from 'astro:content'; import { getCollection, render } from 'astro:content';
import buttons from "$/buttons.png"; import buttons from "$/guild-bbs-buttons.png";
export const getStaticPaths = (async () => { export const getStaticPaths = (async () => {
const blog = await getCollection("blog"); const blog = await getCollection("blog");

View File

@ -1,19 +1,24 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { getCollection } from "astro:content"; import { experimental_AstroContainer as AstroContainer } from "astro/container";
import { getContainerRenderer as getMDXRenderer } from "@astrojs/mdx";
import { loadRenderers } from "astro:container";
import { getCollection, render } from "astro:content";
import rss, { type RSSFeedItem } from "@astrojs/rss"; import rss, { type RSSFeedItem } from "@astrojs/rss";
import MarkdownIt from "markdown-it";
import { parse as htmlParser } from "node-html-parser"; import { parse as htmlParser } from "node-html-parser";
import sanitize from "sanitize-html"; import sanitize from "sanitize-html";
import fixRssImages from "@/utils/fixRssImages"; import fixRssImages from "@/utils/fixRssImages";
const parser = new MarkdownIt();
export const GET: APIRoute = async (context) => { export const GET: APIRoute = async (context) => {
const renderers = await loadRenderers([getMDXRenderer()]);
const container = await AstroContainer.create({ renderers });
const blog = await getCollection("blog"); const blog = await getCollection("blog");
const feed: RSSFeedItem[] = []; const feed: RSSFeedItem[] = [];
for (const entry of blog) { for (const entry of blog) {
const content = parser.render(entry.body!); const { Content } = await render(entry);
const content = await container.renderToString(Content);
const html = htmlParser.parse(content); const html = htmlParser.parse(content);
const images = html.querySelectorAll("img"); const images = html.querySelectorAll("img");

View File

@ -1,10 +1,13 @@
--- ---
export const prerender = false; export const prerender = false;
import { Font } from "astro:assets";
import { actions } from "astro:actions"; import { actions } from "astro:actions";
import { db, desc, Guestbook } from "astro:db"; import { db, desc, Guestbook } from "astro:db";
import Layout from "@/layouts/Layout.astro"; import Layout from "@/layouts/Layout.astro";
import formatDate from "@/utils/formatDate"; import formatDate from "@/utils/formatDate";
import Dialog from "~/Dialog.astro";
if (!import.meta.env.DEV) { if (!import.meta.env.DEV) {
console.error("you shouldn't be here..."); console.error("you shouldn't be here...");
@ -14,7 +17,14 @@ if (!import.meta.env.DEV) {
const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.published)); const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.published));
--- ---
<Layout title="guestbook admin"> <Layout title="guestbook admin">
<h1>entries</h1> <Fragment slot="head">
<Font cssVariable="--mono" preload />
</Fragment>
<header>
<h1>entries</h1>
<a href="/guestbook">*spooky voice* tuuuurn baaack</a>
</header>
<section> <section>
<table> <table>
@ -36,42 +46,107 @@ const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.publish
<td>{entry.message}</td> <td>{entry.message}</td>
<td>{formatDate(entry.published, false, 'MMMM D, YYYY')}</td> <td>{formatDate(entry.published, false, 'MMMM D, YYYY')}</td>
<td>{entry.reply}</td> <td>{entry.reply}</td>
<td><button class="edit">edit</button></td> <td><button class="edit">edit entry #{entry.id}</button></td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
<dialog id="edit-entry"> <Dialog id="edit-entry" title="entry">
<form id="edit-entry-form" action={actions.guestbook.reply} method="post"> <form id="edit-entry-form" action={actions.guestbook.reply} method="post">
<input type="hidden" name="id" id="entryId" value="" /> <input type="hidden" name="id" id="entryId" value="" />
<p id="entry-username"></p> <p><strong>Name:</strong> <span id="entry-username"></span></p>
<p id="entry-website"></p> <p><strong>Website:</strong> <span id="entry-website"></span></p>
<p><strong>Message:</strong></p>
<div id="entry-message"></div> <div id="entry-message"></div>
<p id="entry-published"></p> <p><strong>Published:</strong> <span id="entry-published"></span></p>
<label for="reply">Reply</label> <label for="reply">Reply</label>
<textarea name="reply" id="reply"></textarea> <textarea name="reply" id="reply"></textarea>
<button type="submit">Reply</button> <div class="actions" style="justify-content: space-between;">
<button type="button" id="try-delete" class="cancel">Delete</button>
<button type="submit">Reply</button>
</div>
</form> </form>
</dialog> </Dialog>
<Dialog id="confirm-delete" title="Are you sure?">
Do you want to delete this entry?
<div class="actions">
<form id="delete-entry" action={actions.guestbook.deleteEntry} method="post">
<input type="hidden" name="id" id="delete-id" value="" />
<button type="submit">Delete</button>
</form>
<form method="dialog">
<button class="cancel" type="submit">Cancel</button>
</form>
</div>
</Dialog>
</section> </section>
</Layout> </Layout>
<style> <style>
header { text-align: center; }
table { table {
border-collapse: collapse; border-collapse: collapse;
margin: 1rem auto;
td { td {
max-width: 30ch; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 20ch;
&:not(:has(button)) {
border: 2px solid var(--fg-color);
padding-inline: 0.5em;
}
&:nth-of-type(6n + 1) { font-weight: bold; }
&:has(button) {
padding-left: 0.15em;
button {
padding: 0.15em 0.25em;
line-height: 1;
}
}
}
}
#edit-entry { width: clamp(35ch, 60ch, 100%); }
#edit-entry-form {
display: flex;
flex-flow: column wrap;
}
.actions {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1rem;
.cancel {
background-color: color-mix(in oklab, var(--fg-color) 70%, var(--bg-color));
&:hover {
background-color: color-mix(in oklab, var(--fg-color) 60%, var(--bg-color));
color: var(--bg-color);
}
} }
} }
</style> </style>
<script> <script>
const modal = document.getElementById("edit-entry") as HTMLDialogElement; const modal = document.getElementById("edit-entry") as HTMLDialogElement;
const confirm = document.getElementById("confirm-delete") as HTMLDialogElement;
const buttons = document.querySelectorAll("button.edit"); const buttons = document.querySelectorAll("button.edit");
const trigger = document.getElementById("try-delete") as HTMLButtonElement;
buttons.forEach(button => { buttons.forEach(button => {
button.addEventListener("click", e => { button.addEventListener("click", e => {
const row = (e.target as HTMLElement).parentElement?.parentElement as HTMLTableRowElement; const row = (e.target as HTMLElement).parentElement?.parentElement as HTMLTableRowElement;
@ -87,4 +162,12 @@ const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.publish
modal.showModal(); modal.showModal();
}); });
}); });
trigger.addEventListener("click", e => {
const form = (e.target as HTMLElement).parentElement?.parentElement as HTMLFormElement;
const id = (form.elements.namedItem("entryId") as HTMLInputElement).value;
(document.getElementById("delete-id") as HTMLInputElement).value = id;
confirm.showModal();
});
</script> </script>

View File

@ -5,6 +5,7 @@ import { Font } from "astro:assets";
import Layout from "@/layouts/Layout.astro"; import Layout from "@/layouts/Layout.astro";
import speech from "$/speech.png"; import speech from "$/speech.png";
import Entries from "~/Entries.astro"; import Entries from "~/Entries.astro";
import Dialog from "~/Dialog.astro";
const result = Astro.getActionResult(actions.guestbook.addEntry); const result = Astro.getActionResult(actions.guestbook.addEntry);
const inputErrors = isInputError(result?.error) ? result.error.fields : {}; const inputErrors = isInputError(result?.error) ? result.error.fields : {};
@ -19,6 +20,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {};
<h1>Guestbook</h1> <h1>Guestbook</h1>
<section id="add-comment"> <section id="add-comment">
{import.meta.env.DEV && <p><a href="/guestbook/admin">for your eyes only...</a></p>}
<form action={actions.guestbook.addEntry} method="post"> <form action={actions.guestbook.addEntry} method="post">
<label for="username">Nickname</label> <label for="username">Nickname</label>
<input type="text" id="username" name="username" required aria-describedby="username-error" /> <input type="text" id="username" name="username" required aria-describedby="username-error" />
@ -43,20 +45,9 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {};
<p slot="fallback">Loading...</p> <p slot="fallback">Loading...</p>
</Entries> </Entries>
<dialog id="notification"> <Dialog id="notification">
<form method="dialog"> Successfully posted! Refreshing in <span id="seconds">5</span> seconds.
<menu> </Dialog>
<button type="submit" aria-label="Close">
<span>x</span>
</button>
</menu>
Successfully posted! Refreshing in <span id="seconds">5</span> seconds.
</form>
</dialog>
<footer>
hi
</footer>
</main> </main>
</Layout> </Layout>
@ -91,70 +82,6 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {};
color: color-mix(in oklab, var(--fg-color) 80%, var(--bg-color)); color: color-mix(in oklab, var(--fg-color) 80%, var(--bg-color));
} }
} }
#notification {
margin: auto;
max-width: 35ch;
color: var(--fg-color);
background: var(--bg-color);
transition:
display 1s allow-discrete,
overlay 1s allow-discrete;
animation: fadeOut 1s forwards;
menu {
position: absolute;
top: 0;
right: 0;
width: calc(100% - 4px);
display: flex;
justify-content: end;
padding: 2px;
margin: 2px 2px 0;
background-color: color-mix(in oklab, var(--bg-color) 95%, var(--fg-color));
border: 2px solid var(--fg-color);
line-height: 1;
button {
padding: 0;
box-shadow: none;
line-height: 1;
transform: none;
display: grid;
place-content: center;
width: 44px;
height: 44px;
span { transform: translateY(-2px); }
&:focus {
border: 4px inset var(--secondary-color);
outline: 2px solid var(--fg-color);
box-shadow: none;
span { transform: translateY(0); }
}
}
}
form {
margin-top: calc(44px + 2px);
}
&[open] {
animation: fadeIn 1.0s forwards;
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
</style> </style>
<script> <script>
@ -177,6 +104,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {};
if (time <= 0) { if (time <= 0) {
clearInterval(timer); clearInterval(timer);
notification.close(); notification.close();
location.reload();
navigate("#entries"); navigate("#entries");
} else { } else {
seconds.innerText = time.toString(); seconds.innerText = time.toString();

View File

@ -1,53 +1,111 @@
--- ---
import Navbar from '~/Navbar.astro';
import Layout from '@/layouts/Layout.astro'; import Layout from '@/layouts/Layout.astro';
import Navbar from '~/Navbar.astro';
import bulletin from "$/acnl-bulletin.png";
--- ---
<Layout> <Layout>
<Navbar /> <Navbar />
<main> <main>
<section id="welcome"> <section id="welcome" class="board">
<h1>welcome!</h1> <div class="card">
<article> <h1>welcome!</h1>
this is <article>
</article> this is
</section> </article>
</div>
</section>
<section id="updates"> <section id="updates" class="board">
<article class="update-card"> <article class="update card">
<h1>update title</h1> <h1>update title</h1>
<time datetime="">timestamp</time> <time datetime="">05/01/25</time>
<p>some stuff happened</p> <div class="content">
</article> <p>some stuff happened</p>
</section> </div>
</article>
</section>
<section id="wall"> <section id="wall">
<h1>stuff!</h1> <h1>stuff!</h1>
<p>i keep a bunch of links here, including the webrings i've joined and some badges i like</p> <p>i keep a bunch of links here, including the webrings i've joined and some badges i like</p>
<div class="web"> <div class="web">
</div> </div>
</section> </section>
</main> </main>
</Layout> </Layout>
<style> <style define:vars={{ bulletin: `url(${bulletin.src})` }}>
main { body {
max-width: 50em; --acnl-border: #ac6835;
margin: 0 auto; --acnl-bg: #8c5321;
display: grid; --card-border-1: #ffd53c;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); --card-border-2: #5acbbd;
grid-template-rows: 1.5fr 0.5fr; --card-bg: white;
gap: 1rem; }
grid-template-areas:
"welcome updates"
"wall wall";
section { main {
border: 2px solid #ccc; display: grid;
} grid-template-columns: repeat(2, 1fr);
} justify-content: center;
gap: 1rem;
max-width: 50em;
margin: 0 auto;
#wall { @media screen and (max-width: 1360px) {
grid-area: wall; margin: 0 1rem;
} }
@media screen and (max-width: 76em) {
grid-template-columns: 1fr;
}
}
h1 { font-size: 2rem; }
.board {
image-rendering: pixelated;
border: 2px solid var(--acnl-border);
background-color: transparent;
border-image: var(--bulletin) 42 30 30 fill / 42px 30px 30px;
padding: 42px 30px 30px;
}
.card {
border: 6px solid black;
background-color: var(--card-bg);
box-shadow: inset 0 0 0 4px var(--card-bg), inset 0 0 0 6px black;
padding: calc(1rem + 4px);
h1 {
padding-bottom: 2px;
border-bottom: 2px solid black;
text-align: center;
margin-bottom: 1rem;
}
}
#welcome {
.card {
border-color: var(--card-border-1);
box-shadow: inset 0 0 0 4px var(--card-bg), inset 0 0 0 6px var(--card-border-1);
height: 100%;
h1 { border-bottom-color: var(--card-border-1); }
}
}
#updates {
.card {
border-color: var(--card-border-2);
box-shadow: inset 0 0 0 4px var(--card-bg), inset 0 0 0 6px var(--card-border-2);
h1 { border-bottom-color: var(--card-border-2); }
}
}
#wall {
grid-column: 1 / -1;
}
</style> </style>