add projects :bangbang:
vi did:web:vt3e.cat
Sat, 25 Jul 2026 02:52:20 +0100
16 files changed,
366 insertions(+),
4 deletions(-)
jump to
A
src/components/Project.astro
@@ -0,0 +1,151 @@
+--- +import { LANGUAGES } from "@/constants"; +import type { Project } from "@/types"; + +interface Props { + project: Project; + page: string; +} + +const { + project: { name, description, languages, links, stub: isStub }, + page, +} = Astro.props; +--- + +<div class="project"> + <div class="meta"> + <h3>{name}</h3> + <p>{description}</p> + </div> + + <div class="footer"> + <div class="links"> + { + links.length > 0 && ( + <div class="link-section left"> + {links.map((link) => ( + <a + href={link.href} + target="_blank" + rel="noreferrer" + data-text={link.label} + > + {link.label} + </a> + ))} + </div> + ) + } + + { + !isStub && ( + <div class="link-section right"> + <a href={`/projects/${page}`} data-text="more"> + more + </a> + </div> + ) + } + </div> + <div class="languages"> + { + languages.map((key) => { + const lang = LANGUAGES[key]; + return ( + <span + class="language" + title={lang.name} + style={{ "--colour": `var(--${lang.colour})` }} + > + {lang.name} + </span> + ); + }) + } + </div> + </div> +</div> + +<style> + .project { + display: flex; + flex-direction: column; + padding: 0.5rem; + + background: hsla(var(--surface0) / 1); + border-radius: var(--inner-radii); + } + + .meta { + display: flex; + flex-direction: column; + + h3 { + font-weight: 700; + font-size: 1.2rem; + } + + p { + font-size: 0.9rem; + color: hsl(var(--subtext1)); + margin-bottom: 0.5rem; + } + } + + .languages { + display: flex; + flex-direction: row; + gap: 0.2rem; + + .language { + font-size: 0.75rem; + padding: 0.1rem 0.5rem; + border-radius: 1rem; + font-weight: 550; + background-color: hsla(var(--colour) / 0.25); + color: hsl(var(--subtext1)); + /*border: 2px solid hsla(var(--colour) / 0.5);*/ + user-select: none; + } + } + + .links { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.25rem; + margin-top: 1ch; + + .link-section { + display: flex; + flex-direction: row; + gap: 0.2rem; + border-radius: 5rem; + padding: 0.2rem; + background-color: hsla(var(--mantle) / 1); + } + + a { + --interior-radii: 1rem; + outline: none; + + &:first-child { + border-top-left-radius: 2rem; + border-bottom-left-radius: 2rem; + } + &:last-child { + border-top-right-radius: 2rem; + border-bottom-right-radius: 2rem; + } + } + } + + .footer { + display: flex; + flex-direction: row; + gap: 1ch; + align-items: flex-end; + justify-content: space-between; + } +</style>
A
src/constants.ts
@@ -0,0 +1,33 @@
+export type Language<T extends string = string> = { + name: T; + short: string; + colour: string; +}; + +export const LANGUAGES = { + TypeScript: { + name: "TypeScript", + short: "TS", + colour: "blue", + }, + Vue: { + name: "Vue", + short: "Vue", + colour: "green", + }, + Astro: { + name: "Astro", + short: "Astro", + colour: "green", + }, + Nix: { + name: "Nix", + short: "Nix", + colour: "blue", + }, +} as const satisfies Record<string, Language>; + +export const LANGUAGE_KEYS = Object.keys(LANGUAGES) as [ + keyof typeof LANGUAGES, + ...Array<keyof typeof LANGUAGES>, +];
A
src/content.config.ts
@@ -0,0 +1,11 @@
+import { defineCollection } from "astro:content"; +import { glob } from "astro/loaders"; + +import { ProjectSchema } from "./types"; + +const projects = defineCollection({ + loader: glob({ pattern: "**/*.mdx", base: "./src/content/projects" }), + schema: ProjectSchema, +}); + +export const collections = { projects };
A
src/content/projects/bluebell/index.mdx
@@ -0,0 +1,13 @@
+--- +name: "bluebell" +description: "a pretty client for bluesky on the web." + +languages: ["Vue", "TypeScript"] +stub: true +links: + [ + { type: "website", label: "website", href: "https://next.bbell.vt3e.cat" }, + { type: "source", label: "source", href: "https://tangled.org/bbell.vt3e.cat/bluebell" }, + ] +testimonials: [{ text: "bluebell is a great client for bluesky on the web.", author: "John Doe" }] +---
A
src/content/projects/cmd.mdx
@@ -0,0 +1,9 @@
+--- +name: "cmd" +description: "a fully typesafe, lighweight cli framework" + +languages: ["TypeScript"] +stub: true + +links: [{ type: "source", label: "source", href: "https://git.vt3e.cat/cmd.git" }] +---
A
src/content/projects/flake.mdx
@@ -0,0 +1,14 @@
+--- +name: "flake" +description: "my nix flake used on my server & desktop." + +languages: ["Nix"] +stub: true + +links: + [{ type: "source", label: "source", href: "https://git.vt3e.cat/flake.git" }] +--- + +<section> + <h2>meoww</h2> +</section>
A
src/content/projects/purrkit.mdx
@@ -0,0 +1,17 @@
+--- +name: "purrkit" +description: "a meowing, fully-typesafe set of packages for interacting with the discord API" + +languages: ["TypeScript"] +stub: true + +links: + [ + { type: "source", label: "source", href: "https://tangled.org/vt3e.cat/purrkit.git" }, + { type: "website", label: "npm", href: "https://www.npmjs.com/package/@purrkit" }, + ] +--- + +<section> + <h2>meoww</h2> +</section>
A
src/content/projects/website.mdx
@@ -0,0 +1,21 @@
+--- +name: "website" +description: "this website!!" + +languages: ["Astro", "TypeScript"] +stub: true + +links: + [ + { type: "website", label: "live", href: "https://vt3e.cat/" }, + { + type: "source", + label: "source", + href: "https://tangled.org/vt3e.cat/catsite", + }, + ] +--- + +<section> + <h2>meoww</h2> +</section>
M
src/layouts/Layout.astro
→
src/layouts/Layout.astro
@@ -46,7 +46,6 @@ </html>
<style is:global> body { - height: 100%; } .viewport {@@ -81,7 +80,6 @@ flex-direction: column;
gap: 1rem; border-radius: var(--inner-radii); overflow-y: auto; - border: 4px solid hsl(var(--accent)); } }@@ -89,13 +87,18 @@ .left {
flex: 1 1 auto; max-width: 400px; position: sticky; + top: 1rem; img { - border-radius: calc(var(--inner-radii) - 0.25rem); + --radius: calc(var(--inner-radii) - 0.25rem); object-fit: cover; width: calc(100% + 0.5rem); margin: -0.25rem; } + } + + .right { + width: 100%; } section header::before,@@ -198,7 +201,7 @@ }
} .content { - padding: calc(var(--padding) * 2); + padding: calc(var(--padding) * 2) calc(var(--padding) / 2); } } </style>
M
src/pages/index.astro
→
src/pages/index.astro
@@ -1,5 +1,7 @@
--- +import { getCollection } from "astro:content"; import { Layout } from "@/layouts"; +import { ProjectCard } from "@/components"; const links = [ { label: "on bluesky", href: "https://bsky.app/profile/did:web:vt3e.cat" },@@ -13,6 +15,8 @@ text: "you bring a very SSRI type of vibe to the function",
author: "khls", }, ]; + +const projects = await getCollection("projects"); --- <Layout>@@ -52,6 +56,19 @@ </section>
<section> <header> + <h2>projects</h2> + </header> + <div class="content projects"> + { + projects.map((project) => ( + <ProjectCard page={project.id} project={project.data} /> + )) + } + </div> + </section> + + <section> + <header> <h2>testimonials</h2> </header> <div class="content testimonials">@@ -101,5 +118,11 @@ content: "— ";
color: hsl(var(--overlay1)); } } + } + + .projects { + display: flex; + flex-direction: column; + gap: 0.5rem; } </style>
A
src/pages/projects/[slug].astro
@@ -0,0 +1,38 @@
+--- +import { getCollection, render } from "astro:content"; +import Layout from "../../layouts/Layout.astro"; + +export async function getStaticPaths() { + const projects = await getCollection("projects"); + return projects.map((project) => ({ + params: { slug: project.id }, + props: { project }, + })); +} + +const { project } = Astro.props; +const { Content } = await render(project); +--- + +<Layout> + <Fragment slot="left"> + <header class="page-head"> + <img + src="/kittens.jpg" + alt="two small orange & white kittens huddled together on a white surface. there is white text overlay over each of the kittens, the first reads 'you are made of stardust', the last reads 'and i am made of dirt'. there is a date stamp in the bottom right corner, it says '04.04.2008'." + /> + <div class="title"> + <h1 class="name">{project.data.name}</h1> + </div> + <p class="subtitle"> + {project.data.description} + </p> + </header> + </Fragment> + + <Fragment slot="right"> + <article> + <Content /> + </article> + </Fragment> +</Layout>
A
src/types.ts
@@ -0,0 +1,28 @@
+import { z } from "astro/zod"; +import { LANGUAGE_KEYS } from "./constants"; + +export const LinkSchema = z + .object({ + type: z.custom<"source" | "website" | (string & {})>( + (val) => typeof val === "string", + ), + label: z.string(), + href: z.url(), + }) + .transform((link) => ({ + ...link, + type: link.type as "source" | "website" | (string & {}), + })); +export type Link = z.infer<typeof LinkSchema>; + +export const ProjectSchema = z.object({ + name: z.string(), + description: z.string(), + /** if true, link will not be rendered to its page */ + stub: z.boolean(), + + links: z.array(LinkSchema), + languages: z.array(z.enum(LANGUAGE_KEYS)), +}); + +export type Project = z.infer<typeof ProjectSchema>;