adding a lot to admin, mess around with dev-only routes
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -22,3 +22,6 @@ pnpm-debug.log* | ||||
| 
 | ||||
| # jetbrains setting folder | ||||
| .idea/ | ||||
| 
 | ||||
| # dont grab database files you dont need that | ||||
| local.db | ||||
							
								
								
									
										11
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -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" | ||||
|     } | ||||
|   ] | ||||
| } | ||||
| @ -3,8 +3,8 @@ import { defineConfig } from 'astro/config'; | ||||
| import { modifiedTime } from './src/utils/lastModified.mjs'; | ||||
| import mdx from "@astrojs/mdx"; | ||||
| import db from "@astrojs/db"; | ||||
| 
 | ||||
| import node from "@astrojs/node"; | ||||
| import devOnlyRoutes from '@fujocoded/astro-dev-only'; | ||||
| 
 | ||||
| // https://astro.build/config
 | ||||
| export default defineConfig({ | ||||
| @ -13,7 +13,14 @@ export default defineConfig({ | ||||
|     remarkPlugins: [modifiedTime], | ||||
|     smartypants: false, | ||||
|   }, | ||||
|   integrations: [mdx(), db()], | ||||
|   integrations: [ | ||||
|     mdx(), | ||||
|     db(), | ||||
|     devOnlyRoutes({ | ||||
|       // dryRun: true,
 | ||||
|       routePatterns: ["/guestbook/admin"] | ||||
|     }), | ||||
|   ], | ||||
|   adapter: node({ | ||||
|     mode: "standalone", | ||||
|   }), | ||||
|  | ||||
							
								
								
									
										18
									
								
								bun.lock
									
									
									
									
									
								
							
							
						
						| @ -6,11 +6,11 @@ | ||||
|       "dependencies": { | ||||
|         "@astrojs/db": "^0.16.1", | ||||
|         "@astrojs/mdx": "^4.3.3", | ||||
|         "@astrojs/node": "^9.3.3", | ||||
|         "@astrojs/node": "^9.4.0", | ||||
|         "@astrojs/rss": "4.0.12", | ||||
|         "@fujocoded/astro-dev-only": "0.0.3", | ||||
|         "astro": "5.12.3", | ||||
|         "astro-breadcrumbs": "^3.3.1", | ||||
|         "bcryptjs": "^3.0.2", | ||||
|         "dayjs": "^1.11.13", | ||||
|         "markdown-it": "^14.1.0", | ||||
|         "node-html-parser": "^7.0.1", | ||||
| @ -18,7 +18,7 @@ | ||||
|       }, | ||||
|       "devDependencies": { | ||||
|         "@types/markdown-it": "^14.1.2", | ||||
|         "@types/node": "^22.17.0", | ||||
|         "@types/node": "^22.17.1", | ||||
|         "@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/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=="], | ||||
| 
 | ||||
| @ -106,6 +106,8 @@ | ||||
| 
 | ||||
|     "@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-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/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=="], | ||||
| 
 | ||||
| @ -304,8 +306,6 @@ | ||||
| 
 | ||||
|     "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=="], | ||||
| 
 | ||||
|     "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=="], | ||||
| 
 | ||||
|     "@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=="], | ||||
| 
 | ||||
|     "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], | ||||
|  | ||||
| @ -4,18 +4,18 @@ | ||||
|   "version": "0.0.1", | ||||
|   "scripts": { | ||||
|     "dev": "astro dev", | ||||
|     "build": "astro build", | ||||
|     "build": "astro build --remote", | ||||
|     "preview": "astro preview", | ||||
|     "astro": "astro" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@astrojs/db": "^0.16.1", | ||||
|     "@astrojs/mdx": "^4.3.3", | ||||
|     "@astrojs/node": "^9.3.3", | ||||
|     "@astrojs/node": "^9.4.0", | ||||
|     "@astrojs/rss": "4.0.12", | ||||
|     "@fujocoded/astro-dev-only": "0.0.3", | ||||
|     "astro": "5.12.3", | ||||
|     "astro-breadcrumbs": "^3.3.1", | ||||
|     "bcryptjs": "^3.0.2", | ||||
|     "dayjs": "^1.11.13", | ||||
|     "markdown-it": "^14.1.0", | ||||
|     "node-html-parser": "^7.0.1", | ||||
| @ -23,7 +23,7 @@ | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/markdown-it": "^14.1.2", | ||||
|     "@types/node": "^22.17.0", | ||||
|     "@types/node": "^22.17.1", | ||||
|     "@types/sanitize-html": "^2.16.0" | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import { ActionError, defineAction } from "astro:actions"; | ||||
| 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"; | ||||
| 
 | ||||
| export const guestbook = { | ||||
| @ -12,27 +12,34 @@ export const guestbook = { | ||||
|       message: z.string().min(1, "Can't be that short..."), | ||||
|     }), | ||||
|     handler: async ({ username, website, message }) => { | ||||
|       // figure out how to add line breaks and THEN sanitize message
 | ||||
|       const addLine = message.replaceAll("/n", "<br/>"); | ||||
|       sanitize(addLine); | ||||
|       const addLine = message.replaceAll(/\r?\n/g, "<br />"); | ||||
|       const sanitized = sanitize(addLine, { allowedTags: ["br"] }); | ||||
| 
 | ||||
|       try { | ||||
|         const entry = await db.insert(Guestbook).values({ | ||||
|           username, | ||||
|           website, | ||||
|         message, | ||||
|           message: sanitized, | ||||
|         }).returning(); | ||||
|          | ||||
|         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 }); | ||||
|       } | ||||
|     }, | ||||
|   }), | ||||
|   ...import.meta.env.DEV && { | ||||
|     reply: defineAction({ | ||||
|       accept: "form", | ||||
|       input: z.object({ | ||||
|       id: z.number(), | ||||
|         id: z.coerce.number(), | ||||
|         reply: z.string(), | ||||
|       }), | ||||
|     handler: async ({ id, reply }, context) => { | ||||
|       if (context.url.hostname !== "127.0.0.1" || "localhost") { | ||||
|       handler: async ({ id, reply }) => { | ||||
|         if (!import.meta.env.DEV) { | ||||
|           throw new ActionError({ code: "UNAUTHORIZED" }); | ||||
|         } | ||||
|          | ||||
| @ -44,10 +51,53 @@ export const guestbook = { | ||||
|           }); | ||||
|         } | ||||
|          | ||||
|       // sanitize reply here
 | ||||
|         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(); | ||||
| 
 | ||||
|       const update = await db.update(Guestbook).set({ reply }).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 }); | ||||
|         } | ||||
|       }, | ||||
|     }), | ||||
|     deleteEntry: defineAction({ | ||||
|       accept: "form", | ||||
|       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!" | ||||
|           }); | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|           const entry = await db.delete(Guestbook).where(eq(Guestbook.id, id)).returning(); | ||||
|            | ||||
|           return entry[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 }); | ||||
|         } | ||||
|       }, | ||||
|     }), | ||||
|   }, | ||||
| }; | ||||
							
								
								
									
										
											BIN
										
									
								
								src/assets/acnl-bulletin.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 31 KiB | 
| Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/moon-bullet.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 61 B | 
| Before Width: | Height: | Size: 411 B After Width: | Height: | Size: 411 B | 
| Before Width: | Height: | Size: 689 B After Width: | Height: | Size: 689 B | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/star-bullet.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 62 B | 
							
								
								
									
										90
									
								
								src/components/Dialog.astro
									
									
									
									
									
										Normal 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> | ||||
| @ -1,6 +1,7 @@ | ||||
| --- | ||||
| import { db, desc, Guestbook } from "astro:db"; | ||||
| import formatDate from "@/utils/formatDate"; | ||||
| import pikachu from "$/images/portrait-0025.png"; | ||||
| 
 | ||||
| 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> | ||||
|           </header> | ||||
| 
 | ||||
|           {entry.message} | ||||
|           <div class="content"> | ||||
|             <Fragment set:html={entry.message} /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </article> | ||||
| 
 | ||||
|       {entry.reply && <img src="" alt="help" /> | ||||
|       <article class="reply" id={`reply-${entry.username}-${entry.id}`}> | ||||
|         <img src="" alt="" /> | ||||
|       {entry.reply && <article class="reply" id={`reply-${entry.username}-${entry.id}`}> | ||||
|         <img src={pikachu.src} width="80" height="80" alt="a portrait of pikachu" /> | ||||
|         <div class="entry"> | ||||
|           <header> | ||||
|             <h1>Reply to {entry.username}</h1> | ||||
| @ -35,16 +37,24 @@ const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.publish | ||||
|             </time> | ||||
|           </header> | ||||
| 
 | ||||
|           {entry.reply} | ||||
|           <div class="content"> | ||||
|             <Fragment set:html={entry.reply} /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </article>} | ||||
|     </> | ||||
|   ))} | ||||
|   {entries.length === 0 && ( | ||||
|     <article> | ||||
|       <h1>Huh...</h1> | ||||
|       <p>There's nothing here! Want to be the first to comment?</p> | ||||
|     </article> | ||||
|   )} | ||||
| </section> | ||||
| 
 | ||||
| <style> | ||||
|   #entries { | ||||
|     margin: 2rem 0 4rem; | ||||
|     margin: 2rem 0 3rem; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 2rem; | ||||
| @ -73,7 +83,7 @@ const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.publish | ||||
| 
 | ||||
|     header { | ||||
|       display: flex; | ||||
|       align-items: start; | ||||
|       align-items: center; | ||||
|       justify-content: space-between; | ||||
|       margin-bottom: 1rem; | ||||
|     } | ||||
| @ -85,6 +95,8 @@ const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.publish | ||||
|       text-align: center; | ||||
|     } | ||||
| 
 | ||||
|     .content { line-height: 1; } | ||||
| 
 | ||||
|     @media screen and (width < 1000px) { | ||||
|       max-width: 100%; | ||||
|       padding: 1rem 1.5rem; | ||||
| @ -92,6 +104,8 @@ const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.publish | ||||
|   } | ||||
| 
 | ||||
|   .reply { | ||||
| 
 | ||||
|     flex-direction: row-reverse; | ||||
|     align-self: end; | ||||
|     min-width: min(100%, 45ch); | ||||
|   } | ||||
| </style> | ||||
| @ -5,8 +5,8 @@ import Layout from "./Layout.astro"; | ||||
| import Navbar from "~/Navbar.astro"; | ||||
| import Figure from "~/Figure.astro"; | ||||
| 
 | ||||
| import border from "$/border.png"; | ||||
| import frame from "$/frame.png"; | ||||
| import border from "$/pmd-border.png"; | ||||
| import frame from "$/pmd-frame.png"; | ||||
| 
 | ||||
| type Props = MarkdownLayoutProps<{ | ||||
|   avatar?: string; | ||||
| @ -30,6 +30,10 @@ const { frontmatter } = Astro.props; | ||||
|       <slot /> | ||||
|     </article> | ||||
|   </main> | ||||
| 
 | ||||
|   <footer> | ||||
|     hi | ||||
|   </footer> | ||||
| </Layout> | ||||
| 
 | ||||
| <style define:vars={{ borderImage: `url(${border.src})`, frameImage: `url(${frame.src})` }}> | ||||
| @ -67,6 +71,8 @@ const { frontmatter } = Astro.props; | ||||
|         text-shadow: none; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     li::marker { content: " "; } | ||||
|   } | ||||
| 
 | ||||
|   :global(.avatar) { | ||||
|  | ||||
| @ -3,12 +3,12 @@ import { Font, Image } from "astro:assets"; | ||||
| import { getCollection } from "astro:content"; | ||||
| import Layout from "./Layout.astro"; | ||||
| import Navbar from "~/Navbar.astro"; | ||||
| import formatDate from "@/utils/formatDate"; | ||||
| import moods from "@/utils/moods"; | ||||
| 
 | ||||
| import outerBBS from "$/guild-bbs.png"; | ||||
| import innerBBS from "$/guild-bbs-content.png"; | ||||
| import sideBBS from "$/guild-bbs-list.png"; | ||||
| import formatDate from "@/utils/formatDate"; | ||||
| 
 | ||||
| interface Props { | ||||
|   id?: string; | ||||
| @ -159,6 +159,7 @@ blog.sort((a, b) => b.data.pubDate!.valueOf() - a.data.pubDate!.valueOf()); | ||||
|     --active-border: #442266; | ||||
|     color: #000; | ||||
|   } | ||||
|    | ||||
|   h1 { font: bold 1rem var(--arial-font); } | ||||
|   h2 { | ||||
|     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); | ||||
|       flex-flow: column wrap; | ||||
|       background-color: var(--bg-3); | ||||
|       padding: 2px; | ||||
|       margin: 18px 6px; | ||||
|       padding: 16px 6px; | ||||
|       margin: 18px 20px 16px; | ||||
|       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); | ||||
| 
 | ||||
|       .title { | ||||
|         display: inline-block; | ||||
|         display: block; | ||||
|         background-color: var(--bg-8); | ||||
|         color: var(--bg-0); | ||||
|         font-weight: normal; | ||||
|         text-transform: uppercase; | ||||
|         padding: 5px 2px; | ||||
|         text-align: center; | ||||
|         width: 100%; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
| @ -223,6 +223,7 @@ blog.sort((a, b) => b.data.pubDate!.valueOf() - a.data.pubDate!.valueOf()); | ||||
|       li { | ||||
|         padding: 4px 2px; | ||||
|         border-bottom: 2px solid var(--bg-4); | ||||
|         max-width: 100%; | ||||
| 
 | ||||
|         .item { | ||||
|           position: relative; | ||||
| @ -244,7 +245,7 @@ blog.sort((a, b) => b.data.pubDate!.valueOf() - a.data.pubDate!.valueOf()); | ||||
|                 white-space: nowrap; | ||||
|                 overflow: hidden; | ||||
|                 text-overflow: ellipsis; | ||||
|                 /* max-width: 19ch; */ | ||||
|                 max-width: 19ch; | ||||
| 
 | ||||
|                 @media screen and (max-width: 76em) { | ||||
|                   max-width: unset; | ||||
| @ -276,18 +277,15 @@ blog.sort((a, b) => b.data.pubDate!.valueOf() - a.data.pubDate!.valueOf()); | ||||
|     } | ||||
| 
 | ||||
|     .nav-section { | ||||
|       margin: 0 4px; | ||||
|       background-color: var(--bg-0); | ||||
|       max-width: 100%; | ||||
| 
 | ||||
|       &:first-child { margin-top: 4px; } | ||||
|       &:last-child {  | ||||
|         border-bottom: 2px solid var(--border-2); | ||||
|         margin-bottom: 4px; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     @media screen and (max-width: 76em) { | ||||
|       margin-right: 0; | ||||
|       margin-bottom: -2px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| --- | ||||
| import { Font } from "astro:assets"; | ||||
| import "$/styles/base.css"; | ||||
| import "$/styles/themes.css"; | ||||
| import { Font } from "astro:assets"; | ||||
| 
 | ||||
| interface Props { title?: string; } | ||||
| 
 | ||||
|  | ||||
| @ -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 | ||||
| - 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 | ||||
| - [bullet point images](https://foollovers.com) by foollovers | ||||
| 
 | ||||
| ## 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) | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
| import Blog from '@/layouts/Blog.astro'; | ||||
| import type { GetStaticPaths } from 'astro'; | ||||
| import { getCollection, render } from 'astro:content'; | ||||
| import buttons from "$/buttons.png"; | ||||
| import buttons from "$/guild-bbs-buttons.png"; | ||||
| 
 | ||||
| export const getStaticPaths = (async () => { | ||||
|   const blog = await getCollection("blog"); | ||||
|  | ||||
| @ -1,19 +1,24 @@ | ||||
| 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 MarkdownIt from "markdown-it"; | ||||
| 
 | ||||
| import { parse as htmlParser } from "node-html-parser"; | ||||
| import sanitize from "sanitize-html"; | ||||
| import fixRssImages from "@/utils/fixRssImages"; | ||||
| 
 | ||||
| const parser = new MarkdownIt(); | ||||
| 
 | ||||
| export const GET: APIRoute = async (context) => { | ||||
|   const renderers = await loadRenderers([getMDXRenderer()]); | ||||
|   const container = await AstroContainer.create({ renderers }); | ||||
|    | ||||
|   const blog = await getCollection("blog"); | ||||
|   const feed: RSSFeedItem[] = []; | ||||
| 
 | ||||
|   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 images = html.querySelectorAll("img"); | ||||
| 
 | ||||
|  | ||||
| @ -1,10 +1,13 @@ | ||||
| --- | ||||
| export const prerender = false; | ||||
| 
 | ||||
| import { Font } from "astro:assets"; | ||||
| import { actions } from "astro:actions"; | ||||
| import { db, desc, Guestbook } from "astro:db"; | ||||
| 
 | ||||
| import Layout from "@/layouts/Layout.astro"; | ||||
| import formatDate from "@/utils/formatDate"; | ||||
| import Dialog from "~/Dialog.astro"; | ||||
| 
 | ||||
| if (!import.meta.env.DEV) { | ||||
|   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)); | ||||
| --- | ||||
| <Layout title="guestbook admin"> | ||||
|   <Fragment slot="head"> | ||||
|     <Font cssVariable="--mono" preload /> | ||||
|   </Fragment> | ||||
|    | ||||
|   <header> | ||||
|     <h1>entries</h1> | ||||
|     <a href="/guestbook">*spooky voice* tuuuurn baaack</a> | ||||
|   </header> | ||||
| 
 | ||||
|   <section> | ||||
|     <table> | ||||
| @ -36,42 +46,107 @@ const entries = await db.select().from(Guestbook).orderBy(desc(Guestbook.publish | ||||
|             <td>{entry.message}</td> | ||||
|             <td>{formatDate(entry.published, false, 'MMMM D, YYYY')}</td> | ||||
|             <td>{entry.reply}</td> | ||||
|             <td><button class="edit">edit</button></td> | ||||
|             <td><button class="edit">edit entry #{entry.id}</button></td> | ||||
|           </tr> | ||||
|         ))} | ||||
|       </tbody> | ||||
|     </table> | ||||
| 
 | ||||
|     <dialog id="edit-entry"> | ||||
|     <Dialog id="edit-entry" title="entry"> | ||||
|       <form id="edit-entry-form" action={actions.guestbook.reply} method="post"> | ||||
|         <input type="hidden" name="id" id="entryId" value="" /> | ||||
|         <p id="entry-username"></p> | ||||
|         <p id="entry-website"></p> | ||||
|         <p><strong>Name:</strong> <span id="entry-username"></span></p> | ||||
|         <p><strong>Website:</strong> <span id="entry-website"></span></p> | ||||
|         <p><strong>Message:</strong></p> | ||||
|         <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> | ||||
|         <textarea name="reply" id="reply"></textarea> | ||||
| 
 | ||||
|         <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> | ||||
|     </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> | ||||
| </Layout> | ||||
| 
 | ||||
| <style> | ||||
|   header { text-align: center; } | ||||
| 
 | ||||
|   table { | ||||
|     border-collapse: collapse; | ||||
|     margin: 1rem auto; | ||||
| 
 | ||||
|     td { | ||||
|       max-width: 30ch; | ||||
|       white-space: nowrap; | ||||
|       overflow: hidden; | ||||
|       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> | ||||
| 
 | ||||
| <script> | ||||
|   const modal = document.getElementById("edit-entry") as HTMLDialogElement; | ||||
|   const confirm = document.getElementById("confirm-delete") as HTMLDialogElement; | ||||
|   const buttons = document.querySelectorAll("button.edit"); | ||||
|   const trigger = document.getElementById("try-delete") as HTMLButtonElement; | ||||
| 
 | ||||
|   buttons.forEach(button => { | ||||
|     button.addEventListener("click", e => { | ||||
|       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(); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   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> | ||||
| @ -5,6 +5,7 @@ import { Font } from "astro:assets"; | ||||
| import Layout from "@/layouts/Layout.astro"; | ||||
| import speech from "$/speech.png"; | ||||
| import Entries from "~/Entries.astro"; | ||||
| import Dialog from "~/Dialog.astro"; | ||||
| 
 | ||||
| const result = Astro.getActionResult(actions.guestbook.addEntry); | ||||
| const inputErrors = isInputError(result?.error) ? result.error.fields : {}; | ||||
| @ -19,6 +20,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}; | ||||
|     <h1>Guestbook</h1> | ||||
| 
 | ||||
|     <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"> | ||||
|         <label for="username">Nickname</label> | ||||
|         <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> | ||||
|     </Entries> | ||||
|      | ||||
|     <dialog id="notification"> | ||||
|       <form method="dialog"> | ||||
|         <menu> | ||||
|           <button type="submit" aria-label="Close"> | ||||
|             <span>x</span> | ||||
|           </button> | ||||
|         </menu> | ||||
|     <Dialog id="notification"> | ||||
|       Successfully posted! Refreshing in <span id="seconds">5</span> seconds. | ||||
|       </form> | ||||
|     </dialog> | ||||
| 
 | ||||
|     <footer> | ||||
|       hi | ||||
|     </footer> | ||||
|     </Dialog> | ||||
|   </main> | ||||
| </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)); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   #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> | ||||
| 
 | ||||
| <script> | ||||
| @ -177,6 +104,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}; | ||||
|         if (time <= 0) { | ||||
|           clearInterval(timer); | ||||
|           notification.close(); | ||||
|           location.reload(); | ||||
|           navigate("#entries"); | ||||
|         } else { | ||||
|           seconds.innerText = time.toString(); | ||||
|  | ||||
| @ -1,22 +1,28 @@ | ||||
| --- | ||||
| import Navbar from '~/Navbar.astro'; | ||||
| import Layout from '@/layouts/Layout.astro'; | ||||
| import Navbar from '~/Navbar.astro'; | ||||
| 
 | ||||
| import bulletin from "$/acnl-bulletin.png"; | ||||
| --- | ||||
| <Layout> | ||||
|   <Navbar /> | ||||
|   <main> | ||||
| 		<section id="welcome"> | ||||
|     <section id="welcome" class="board"> | ||||
|       <div class="card"> | ||||
|         <h1>welcome!</h1> | ||||
|         <article> | ||||
|           this is  | ||||
|         </article> | ||||
|       </div> | ||||
|     </section> | ||||
| 
 | ||||
| 		<section id="updates"> | ||||
| 			<article class="update-card"> | ||||
|     <section id="updates" class="board"> | ||||
|       <article class="update card"> | ||||
|         <h1>update title</h1> | ||||
| 				<time datetime="">timestamp</time> | ||||
|         <time datetime="">05/01/25</time> | ||||
|         <div class="content"> | ||||
|           <p>some stuff happened</p> | ||||
|         </div> | ||||
|       </article> | ||||
|     </section> | ||||
| 
 | ||||
| @ -30,24 +36,76 @@ import Layout from '@/layouts/Layout.astro'; | ||||
|   </main> | ||||
| </Layout> | ||||
| 
 | ||||
| <style> | ||||
| <style define:vars={{ bulletin: `url(${bulletin.src})` }}> | ||||
|   body { | ||||
|     --acnl-border: #ac6835; | ||||
|     --acnl-bg: #8c5321; | ||||
|     --card-border-1: #ffd53c; | ||||
|     --card-border-2: #5acbbd; | ||||
|     --card-bg: white; | ||||
|   } | ||||
| 
 | ||||
|   main { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(2, 1fr); | ||||
|     justify-content: center; | ||||
|     gap: 1rem; | ||||
|     max-width: 50em; | ||||
|     margin: 0 auto; | ||||
| 		display: grid; | ||||
| 		grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); | ||||
| 		grid-template-rows: 1.5fr 0.5fr; | ||||
| 		gap: 1rem; | ||||
| 		grid-template-areas: | ||||
| 			"welcome updates" | ||||
| 			"wall wall"; | ||||
| 
 | ||||
| 		section { | ||||
| 			border: 2px solid #ccc; | ||||
|     @media screen and (max-width: 1360px) { | ||||
|       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-area: wall; | ||||
|     grid-column: 1 / -1; | ||||
|   } | ||||
| </style> | ||||