Merge pull request 'Migrate to Astro' (#1) from fresh-2.0-migrate into main

Reviewed-on: atridad/atri.dad#1
This commit is contained in:
2025-05-19 05:22:13 +00:00
56 changed files with 6265 additions and 1322 deletions

View File

@ -1,35 +0,0 @@
name: Docker Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Container Registry
uses: docker/login-action@v2
with:
registry: ${{ secrets.REPO_HOST }}
username: ${{ github.repository_owner }}
password: ${{ secrets.DEPLOY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.sha }}
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest

31
.gitignore vendored
View File

@ -1,11 +1,24 @@
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# build output
dist/
# Fresh build directory
_fresh/
# npm dependencies
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

4
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View File

@ -1,3 +0,0 @@
{
"deno.enable": true
}

View File

@ -1,35 +1,27 @@
FROM denoland/deno:alpine AS builder
FROM node:lts-alpine AS builder
WORKDIR /app
# Install build dependencies for native modules
RUN apk add --no-cache build-base python3
RUN npm i -g pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install
COPY . .
RUN pnpm run build
# Create node_modules directory and install dependencies
RUN deno cache -r main.ts
# Build Fresh application in a more controlled way (without task)
RUN deno run -A dev.ts build || deno run -A --unstable-worker-options --node-modules-dir main.ts build
FROM denoland/deno:alpine
FROM node:lts-alpine AS runtime
WORKDIR /app
# Copy the Deno cache and node_modules
COPY --from=builder /deno-dir/ /deno-dir/
COPY --from=builder /app/node_modules/ /app/node_modules/
RUN npm i -g pnpm
# Copy application code
COPY --from=builder /app/ /app/
COPY --from=builder /app/dist ./dist
COPY package.json pnpm-lock.yaml ./
# Ensure static assets directories permissions are set correctly
RUN chmod -R 755 /app/static /app/_fresh
RUN pnpm install --prod
ENV DENO_DEPLOYMENT=production
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
EXPOSE 8000
# Run with appropriate flags for static file serving
CMD ["run", "--allow-net", "--allow-read", "--allow-env", "--node-modules-dir", "main.ts"]
CMD ["node", "./dist/server/entry.mjs"]

View File

@ -1,3 +1,3 @@
# Personal Site
Re-written with Deno + Fresh :)
Re-written with Astro + Preact :)

27
astro.config.mjs Normal file
View File

@ -0,0 +1,27 @@
// @ts-check
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import preact from '@astrojs/preact';
import node from '@astrojs/node';
import icon from 'astro-icon';
import mdx from '@astrojs/mdx';
// https://astro.build/config
export default defineConfig({
vite: {
plugins: [tailwindcss()]
},
integrations: [preact(), icon(), mdx()],
adapter: node({
mode: 'standalone'
}),
});

View File

@ -1,15 +0,0 @@
export default function HomeButtonLinks() {
return (
<div class="flex flex-row gap-4 text-3xl">
<a
href="/resume"
target="_blank"
rel="noopener noreferrer"
aria-label="React"
class="btn btn-dash btn-primary"
>
Resumé
</a>
</div>
);
}

View File

@ -1,37 +0,0 @@
import { LuArrowRight, LuClock } from "@preact-icons/lu";
import { Post } from "../lib/posts.ts";
export default function PostCard(props: { post: Post }) {
const { post } = props;
return (
<div class="card bg-accent shadow-lg w-full sm:w-[calc(50%-1rem)] md:w-96 min-w-[280px] max-w-sm shrink">
<div class="card-body p-4 sm:p-6">
<h2 class="card-title text-base-100 justify-center text-center break-words">
{post.title}
</h2>
<p class="text-center text-base-100 break-words">{post.blurb}</p>
<div class="flex flex-wrap items-center justify-center text-base-100 opacity-75 gap-2 text-sm sm:text-base">
<LuClock class="flex-shrink-0" />
<span>
{post.publishedAt!.toLocaleDateString("en-us", {
month: "long",
day: "numeric",
year: "numeric",
})}
</span>
</div>
<div class="card-actions justify-end mt-4">
<a
href={`/post/${post.slug}`}
class="btn btn-circle btn-sm sm:btn-md btn-primary text-accent"
>
<LuArrowRight class="text-lg" />
</a>
</div>
</div>
</div>
);
}

View File

@ -1,37 +0,0 @@
import { LuLink } from "@preact-icons/lu";
interface Project {
id: string;
name: string;
description: string;
link: string;
}
export default function ProjectCard(props: { project: Project }) {
const { project } = props;
return (
<div class="card bg-accent shadow-lg w-full sm:w-[calc(50%-1rem)] md:w-96 min-w-[280px] max-w-sm shrink">
<div class="card-body p-6">
<h2 class="card-title text-xl md:text-2xl font-bold justify-center text-center break-words text-base-100">
{project.name}
</h2>
<p class="text-center break-words my-4 text-base-100">
{project.description}
</p>
<div class="card-actions justify-end mt-4 ">
<a
href={project.link}
target="_blank"
rel="noopener noreferrer"
class="btn btn-circle btn-secondary text-accent"
aria-label={`Visit ${project.name}`}
>
<LuLink class="text-lg" />
</a>
</div>
</div>
</div>
);
}

View File

@ -1,44 +0,0 @@
import { LuMail } from "@preact-icons/lu";
import { SiBluesky, SiForgejo, SiRss } from "@preact-icons/si";
export default function SocialLinks() {
return (
<div class="flex flex-row gap-4 text-xl sm:text-3xl">
<a
href="mailto:me@atri.dad"
aria-label="Email me"
class="hover:text-primary transition-colors"
>
<LuMail />
</a>
<a
href="/feed"
aria-label="RSS Feed"
class="hover:text-primary transition-colors"
>
<SiRss />
</a>
<a
href="https://git.atri.dad/atridad"
target="_blank"
rel="noopener noreferrer"
aria-label="Forgejo (Git)"
class="hover:text-primary transition-colors"
>
<SiForgejo />
</a>
<a
href="https://bsky.app/profile/atri.dad"
target="_blank"
rel="noopener noreferrer"
aria-label="Bluesky Profile"
class="hover:text-primary transition-colors"
>
<SiBluesky />
</a>
</div>
);
}

View File

@ -1,85 +0,0 @@
import {
SiDeno,
SiDocker,
SiGo,
SiPostgresql,
SiReact,
SiRedis,
SiTypescript,
} from "@preact-icons/si";
export default function TechLinks() {
return (
<div class="flex flex-row gap-4 text-xl sm:text-3xl">
<a
href="https://react.dev/"
target="_blank"
rel="noopener noreferrer"
aria-label="React"
class="hover:text-primary transition-colors"
>
<SiReact />
</a>
<a
href="https://www.typescriptlang.org/"
target="_blank"
rel="noopener noreferrer"
aria-label="TypeScript"
class="hover:text-primary transition-colors"
>
<SiTypescript />
</a>
<a
href="https://deno.com/"
target="_blank"
rel="noopener noreferrer"
aria-label="Deno"
class="hover:text-primary transition-colors"
>
<SiDeno />
</a>
<a
href="https://go.dev/"
target="_blank"
rel="noopener noreferrer"
aria-label="Go"
class="hover:text-primary transition-colors"
>
<SiGo />
</a>
<a
href="https://www.postgresql.org/"
target="_blank"
rel="noopener noreferrer"
aria-label="PostgreSQL"
class="hover:text-primary transition-colors"
>
<SiPostgresql />
</a>
<a
href="https://redis.io/"
target="_blank"
rel="noopener noreferrer"
aria-label="Redis"
class="hover:text-primary transition-colors"
>
<SiRedis />
</a>
<a
href="https://www.docker.com/"
target="_blank"
rel="noopener noreferrer"
aria-label="Docker"
class="hover:text-primary transition-colors"
>
<SiDocker />
</a>
</div>
);
}

View File

@ -1,32 +0,0 @@
{
"nodeModulesDir": "auto",
"lock": false,
"tasks": {
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
"manifest": "deno task cli manifest $(pwd)",
"start": "deno run -A --watch=static/,routes/ dev.ts",
"build": "deno run -A dev.ts build",
"preview": "deno run -A main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
},
"lint": { "rules": { "tags": ["fresh", "recommended"] } },
"exclude": ["**/_fresh/*"],
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
"@deno/gfm": "jsr:@deno/gfm@^0.11.0",
"@pakornv/fresh-plugin-tailwindcss": "jsr:@pakornv/fresh-plugin-tailwindcss@^1.0.2",
"@preact-icons/lu": "jsr:@preact-icons/lu@^1.0.13",
"@preact-icons/si": "jsr:@preact-icons/si@^1.0.13",
"@std/front-matter": "jsr:@std/front-matter@^1.0.9",
"@std/path": "jsr:@std/path@^1.0.9",
"@tailwindcss/typography": "npm:@tailwindcss/typography@^0.5.16",
"daisyui": "npm:daisyui@^5.0.35",
"preact": "npm:preact@10.26.6",
"@preact/signals": "npm:@preact/signals@1.2.2",
"@preact/signals-core": "npm:@preact/signals-core@1.5.1",
"$std/": "https://deno.land/std@0.216.0/",
"tailwindcss": "npm:tailwindcss@^4.1.5"
},
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" }
}

8
dev.ts
View File

@ -1,8 +0,0 @@
#!/usr/bin/env -S deno run -A --watch=static/,routes/
import dev from "$fresh/dev.ts";
import config from "./fresh.config.ts";
import "$std/dotenv/load.ts";
await dev(import.meta.url, "./main.ts", config);

View File

@ -1,10 +1,8 @@
version: '3.8'
services:
app:
image: ${IMAGE:-ghcr.io/yourusername/your-fresh-project:latest}
restart: unless-stopped
environment:
- DENO_DEPLOYMENT=production
image: ${IMAGE}
ports:
- "3000:8000"
- "${APP_PORT}:4321"
environment:
NODE_ENV: production
restart: unless-stopped

View File

@ -1,6 +0,0 @@
import { defineConfig } from "$fresh/server.ts";
import tailwind from "@pakornv/fresh-plugin-tailwindcss";
export default defineConfig({
plugins: [tailwind()],
});

View File

@ -1,43 +0,0 @@
// DO NOT EDIT. This file is generated by Fresh.
// This file SHOULD be checked into source version control.
// This file is automatically updated during development when running `dev.ts`.
import * as $_404 from "./routes/_404.tsx";
import * as $_app from "./routes/_app.tsx";
import * as $_layout from "./routes/_layout.tsx";
import * as $api_chat from "./routes/api/chat.ts";
import * as $api_ping from "./routes/api/ping.ts";
import * as $chat from "./routes/chat.tsx";
import * as $index from "./routes/index.tsx";
import * as $post_slug_ from "./routes/post/[slug].tsx";
import * as $posts from "./routes/posts.tsx";
import * as $projects from "./routes/projects.tsx";
import * as $resume from "./routes/resume.tsx";
import * as $Chat from "./islands/Chat.tsx";
import * as $NavigationBar from "./islands/NavigationBar.tsx";
import * as $ScrollUpButton from "./islands/ScrollUpButton.tsx";
import type { Manifest } from "$fresh/server.ts";
const manifest = {
routes: {
"./routes/_404.tsx": $_404,
"./routes/_app.tsx": $_app,
"./routes/_layout.tsx": $_layout,
"./routes/api/chat.ts": $api_chat,
"./routes/api/ping.ts": $api_ping,
"./routes/chat.tsx": $chat,
"./routes/index.tsx": $index,
"./routes/post/[slug].tsx": $post_slug_,
"./routes/posts.tsx": $posts,
"./routes/projects.tsx": $projects,
"./routes/resume.tsx": $resume,
},
islands: {
"./islands/Chat.tsx": $Chat,
"./islands/NavigationBar.tsx": $NavigationBar,
"./islands/ScrollUpButton.tsx": $ScrollUpButton,
},
baseUrl: import.meta.url,
} satisfies Manifest;
export default manifest;

View File

@ -1,161 +0,0 @@
import { useEffect, useState } from "preact/hooks";
interface ChatMessage {
text: string;
sender: string;
timestamp: string;
}
export default function Chat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [newMessage, setNewMessage] = useState("");
const [username, setUsername] = useState("");
const [socket, setSocket] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [userCount, setUserCount] = useState(0);
useEffect(() => {
if (!username) {
const randomNum = Math.floor(Math.random() * 10000);
setUsername(`HumanGuest${randomNum}`);
}
const wsProtocol = globalThis.location.protocol === "https:"
? "wss:"
: "ws:";
const ws = new WebSocket(
`${wsProtocol}//${globalThis.location.host}/api/chat`,
);
ws.onopen = () => {
console.log("Connected to chat");
setIsConnected(true);
setSocket(ws);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "user_count") {
setUserCount(data.count);
} else {
setMessages((prev) => [...prev, data]);
// Auto-scroll to bottom on new message
const chatBox = document.getElementById("chat-messages");
if (chatBox) {
setTimeout(() => {
chatBox.scrollTop = chatBox.scrollHeight;
}, 50);
}
}
} catch (err) {
console.error("Error processing message:", err);
}
};
ws.onclose = () => {
console.log("Disconnected from chat");
setIsConnected(false);
};
return () => {
ws.close();
};
}, []);
const sendMessage = (e: Event) => {
e.preventDefault();
if (!newMessage.trim() || !socket) return;
const messageData = {
text: newMessage,
sender: username,
timestamp: new Date().toISOString(),
};
socket.send(JSON.stringify(messageData));
setNewMessage("");
};
return (
<div class="w-full max-w-4xl mx-auto bg-[#1E2127] rounded-lg shadow-lg overflow-hidden border border-gray-800 flex flex-col h-[70vh]">
{/* Header */}
<div class="p-4 bg-secondary text-white">
<h2 class="text-2xl font-bold">Live Chat</h2>
<p class="text-sm">
{isConnected
? `${userCount} online • Messages are not saved`
: "Connecting..."}
</p>
</div>
<div
id="chat-messages"
class="flex-grow overflow-y-auto bg-[#1E2127] text-gray-300 p-4"
>
{messages.length === 0
? (
<p class="text-center text-gray-500 py-8">
No messages yet.
</p>
)
: (
messages.map((msg, i) => (
<div
key={i}
class={`mb-3 max-w-[85%] ${
msg.sender === username ? "ml-auto" : ""
}`}
>
<div
class={`px-4 py-2 rounded-lg ${
msg.sender === username
? "bg-secondary text-white rounded-br-none"
: "bg-gray-800 text-gray-200 rounded-bl-none"
}`}
>
<div class="flex justify-between items-baseline mb-1">
<span class="font-bold text-sm">
{msg.sender === username ? "You" : msg.sender}
</span>
<span class="text-xs opacity-70 ml-2">
{new Date(msg.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
<p class="break-words">{msg.text}</p>
</div>
</div>
))
)}
</div>
<div class="p-3 border-t border-gray-800 pb-6 md:pb-3">
<form onSubmit={sendMessage} class="relative">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.currentTarget.value)}
placeholder="Type your message..."
class="w-full pl-4 pr-20 py-3 bg-gray-800 text-white rounded-lg border-0 focus:outline-none focus:ring-1 focus:ring-secondary placeholder-gray-500"
disabled={!isConnected}
/>
<button
type="submit"
class="absolute right-0 top-0 h-full bg-secondary text-white px-5 rounded-r-lg font-medium"
disabled={!isConnected || !newMessage.trim()}
>
Send
</button>
</form>
<p class="mt-2 text-xs text-gray-500">
You are connected as{" "}
<span class="font-medium text-gray-400">{username}</span>
</p>
</div>
</div>
);
}

View File

@ -1,45 +0,0 @@
import { extractYaml } from "@std/front-matter";
import { join } from "@std/path";
const POSTS_DIR = "./posts";
interface FrontMatter {
title: string;
published_at: string;
blurb: string;
}
export interface Post {
slug: string;
title: string;
publishedAt: Date | null;
blurb: string;
content: string;
}
export async function getPost(slug: string): Promise<Post> {
const text = await Deno.readTextFile(join(POSTS_DIR, `${slug}.md`));
const { attrs, body } = extractYaml<FrontMatter>(text);
const post = {
slug,
title: attrs.title,
publishedAt: attrs.published_at ? new Date(attrs.published_at) : null,
blurb: attrs.blurb,
content: body,
};
return post;
}
export async function getPosts(): Promise<Post[]> {
const files = Deno.readDir(POSTS_DIR);
const promises = [];
for await (const file of files) {
if (file.name.startsWith(".")) continue;
const slug = file.name.replace(".md", "");
promises.push(getPost(slug));
}
const posts = (await Promise.all(promises) as Post[])
.filter((post) => post.publishedAt instanceof Date);
posts.sort((a, b) => b.publishedAt!.getTime() - a.publishedAt!.getTime());
return posts;
}

13
main.ts
View File

@ -1,13 +0,0 @@
/// <reference no-default-lib="true" />
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/// <reference lib="dom.asynciterable" />
/// <reference lib="deno.ns" />
import "$std/dotenv/load.ts";
import { start } from "$fresh/server.ts";
import manifest from "./fresh.gen.ts";
import config from "./fresh.config.ts";
await start(manifest, config);

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^4.2.6",
"@astrojs/node": "^9.2.1",
"@astrojs/preact": "^4.0.11",
"@preact/signals": "^2.0.4",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.7",
"astro": "^5.7.13",
"astro-icon": "^1.1.5",
"lucide-preact": "^0.511.0",
"preact": "^10.26.6",
"tailwindcss": "^4.1.7"
},
"devDependencies": {
"@iconify-json/mdi": "^1.2.3",
"@iconify-json/simple-icons": "^1.2.34",
"daisyui": "^5.0.35"
}
}

5429
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,58 +0,0 @@
---
title: Re-write it in Deno!
published_at: 2025-04-25T15:00:00.000Z
---
So... all new site! I use this site as something of an experiment for whatever
technology interests me. One recently got my attention:
_Deno!_
Yes... Deno the friendly re-imagining of Node.js by Ryan Dahl, the the creator
of Node.js! Now given my recent dive into Golang and HTMX, this might seem odd.
Let me explain:
## Built in TSX and Typescript support!
Deno has built in Typescript support, which has been huge for me. Lets table the
whole "Bun and new Node can do that too" discussion for now. Its simply magical
to have a Typescript codebase without a single tsconfig in sight!
## Security!
Look, the secrity model of Deno is incredible. The idea that the runtime will
default-deny permissions unless you as the developer enables them is an awesome
move.
## Imports
Being able to seamlessly pull from JSR and NPM is amazing! Having those two
ecosystems work without the frustrating package.json dance is refreshing. I
mean, just being able to import directly from URLs? Game changer!
## Built-in tooling
The tooling that ships with Deno is first-class! Formatter, linter, test runner,
doc generator... all built right in! No more spending half a day configuring
eslint, prettier, and jest just to get started on a project. Just use
`deno fmt`, `deno lint`, `deno test` and you're good to go.
## Web standards first
Fetch, web streams, event listeners and so on all work just like they do in the
browser. Coming from Node where you have to remember different APIs between
back-end and front-end JS, this is absolutely a breath of fresh air.
## What about Golang? GOTH Stack?
I still love Go as a language and will continue to use it for certain things.
Its incredibly fast and the concurrency model is awesome, but sometimes you just
want to put together a quick web application and JS frameworks are just much
easier to reach for.
## Whats next?
I'm rebuilding this entire site with Deno Fresh, which is their web framework.
It has their island architecture which means minimal JS sent to the client. Will
I stick with it? Who knows! All I know is this has definitely been a blast of a
re-write.

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -274,8 +274,8 @@
"href": "https://git.atri.dad/atridad",
"label": ""
},
"icon": "forgejo",
"network": "Forgejo",
"icon": "gitea",
"network": "Gitea",
"visible": true,
"username": "atridad"
}

View File

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -1,12 +0,0 @@
import { Head } from "$fresh/runtime.ts";
export default function Error404() {
return (
<>
<Head>
<title>404 - Page not found</title>
</Head>
<h1>404 Page Not Found</h1>
</>
);
}

View File

@ -1,17 +0,0 @@
import { type PageProps } from "$fresh/server.ts";
export default function App({ Component }: PageProps) {
return (
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Atridad Lahiji</title>
<link rel="stylesheet" href="/styles.css" />
<link rel="icon" type="image/x-icon" href="/favicon.ico"></link>
</head>
<body>
<Component />
</body>
</html>
);
}

View File

@ -1,27 +0,0 @@
import { PageProps } from "$fresh/server.ts";
import { Head } from "$fresh/runtime.ts";
import NavigationBar from "../islands/NavigationBar.tsx";
import ScrollUpButton from "../islands/ScrollUpButton.tsx";
export default function Layout({ Component, url }: PageProps) {
const currentPath = url.pathname;
return (
<>
<Head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<title>Atridad Lahiji</title>
</Head>
<body class="flex flex-col min-h-screen">
<main class="flex-grow flex flex-col gap-4 items-center justify-center">
<Component />
</main>
<NavigationBar currentPath={currentPath} />
<ScrollUpButton />
</body>
</>
);
}

View File

@ -1,85 +0,0 @@
import { FreshContext } from "$fresh/server.ts";
const chatConnections = new Set<WebSocket>();
// HTML sanitization
function sanitizeText(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Process and sanitize message object
function processChatMessage(message: string): string {
try {
const parsed = JSON.parse(message);
if (typeof parsed.text === "string") {
parsed.text = sanitizeText(parsed.text.trim().slice(0, 2000));
}
if (typeof parsed.sender === "string") {
parsed.sender = sanitizeText(parsed.sender.trim().slice(0, 50));
}
if (!parsed.timestamp) {
parsed.timestamp = new Date().toISOString();
}
return JSON.stringify(parsed);
} catch (error) {
console.error("Invalid message format:", error);
return JSON.stringify({
text: "Error: Invalid message format",
sender: "System",
timestamp: new Date().toISOString(),
});
}
}
// Broadcast current user count to all clients
function broadcastUserCount() {
const count = chatConnections.size;
const message = JSON.stringify({
type: "user_count",
count: count,
});
for (const client of chatConnections) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
}
export const handler = (req: Request, _ctx: FreshContext): Response => {
const { socket, response } = Deno.upgradeWebSocket(req);
socket.onopen = () => {
chatConnections.add(socket);
console.log(`New connection: ${chatConnections.size} users connected`);
broadcastUserCount();
};
// Handle messages
socket.onmessage = (event) => {
const sanitizedMessage = processChatMessage(event.data);
for (const client of chatConnections) {
if (client.readyState === WebSocket.OPEN) {
client.send(sanitizedMessage);
}
}
};
socket.onclose = () => {
chatConnections.delete(socket);
console.log(`Connection closed: ${chatConnections.size} users connected`);
broadcastUserCount();
};
return response;
};

View File

@ -1,5 +0,0 @@
import { FreshContext } from "$fresh/server.ts";
export const handler = (_req: Request, _ctx: FreshContext): Response => {
return new Response("pong");
};

View File

@ -1,25 +0,0 @@
import Chat from "../islands/Chat.tsx";
export default function ChatPage() {
return (
<div class="min-h-screen p-4 pb-24">
<div class="flex items-center justify-center mb-6">
<h1 class="text-3xl font-bold text-secondary">Chat Room</h1>
<span class="ml-3 border border-pink-500 text-pink-500 rounded-full px-3 py-1 text-sm">
Demo
</span>
</div>
<div class="max-w-4xl mx-auto">
<Chat />
</div>
<div class="mt-4 text-center text-xs text-gray-500">
<p>
This is an ephemeral chat room. Messages are only visible to users
currently online and aren't stored after you leave.
</p>
</div>
</div>
);
}

View File

@ -1,80 +0,0 @@
import { render } from "@deno/gfm";
import { Handlers, PageProps } from "$fresh/server.ts";
import { getPost, Post } from "../../lib/posts.ts";
import { LuClock } from "@preact-icons/lu";
export const handler: Handlers<Post> = {
async GET(_req, ctx) {
try {
const post = await getPost(ctx.params.slug);
return post.publishedAt ? ctx.render(post) : ctx.renderNotFound();
} catch (err) {
console.error(err);
return ctx.renderNotFound();
}
},
};
export default function PostPage(props: PageProps<Post>) {
const post = props.data;
return (
<>
<div class="min-h-screen p-4 md:p-8">
<div class="max-w-3xl mx-auto">
<div class="p-4 md:p-8">
{/* Header section */}
<h1 class="text-4xl md:text-5xl font-bold text-primary mb-6">
{post.title}
</h1>
<div class="flex flex-wrap items-center gap-4 mb-6">
{/* Date with clock icon */}
<div class="flex items-center flex-row gap-2 text-base-content opacity-75">
<LuClock />
<time>
{post.publishedAt!.toLocaleDateString("en-us", {
month: "long",
day: "numeric",
year: "numeric",
})}
</time>
</div>
{/* Back button */}
<a href="/posts" class="btn btn-outline btn-primary btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 17l-5-5m0 0l5-5m-5 5h12"
/>
</svg>
Back
</a>
</div>
{/* Divider line */}
<div class="divider"></div>
{/* Content section */}
<div
class="max-w-none prose"
data-color-mode="dark"
data-dark-theme="dark"
// deno-lint-ignore react-no-danger
dangerouslySetInnerHTML={{ __html: render(post.content) }}
/>
</div>
</div>
</div>
</>
);
}

View File

@ -1,24 +0,0 @@
import { Handlers, PageProps } from "$fresh/server.ts";
import { getPosts, Post } from "../lib/posts.ts";
import PostCard from "../components/PostCard.tsx";
export const handler: Handlers<Post[]> = {
async GET(_req, ctx) {
const posts = await getPosts();
return ctx.render(posts);
},
};
export default function PostsPage(props: PageProps<Post[]>) {
const posts = props.data;
return (
<div class="min-h-screen p-4 sm:p-8">
<h1 class="text-3xl sm:text-4xl font-bold text-primary mb-6 sm:mb-8 text-center">
Posts
</h1>
<div class="flex flex-row flex-wrap justify-center gap-4 sm:gap-6 max-w-6xl mx-auto">
{posts.map((post) => <PostCard key={post.slug} post={post} />)}
</div>
</div>
);
}

View File

@ -1,60 +0,0 @@
import ProjectCard from "../components/ProjectCard.tsx";
interface Project {
id: string;
name: string;
description: string;
link: string;
}
export default function ProjectsPage() {
const projects: Project[] = [
{
id: "bluesky-pds-manager",
name: "BlueSky PDS Manager",
description:
"A web-based BlueSky PDS Manager. Manage your invite codes and users with a simple web UI.",
link: "https://pdsman.atri.dad",
},
{
id: "pollo",
name: "Pollo",
description: "A dead-simple real-time voting tool.",
link: "https://git.atri.dad/atridad/pollo",
},
{
id: "goth-stack",
name: "GOTH Stack",
description:
"🚀 A Web Application Template Powered by HTMX + Go + Tailwind 🚀",
link: "https://git.atri.dad/atridad/goth.stack",
},
{
id: "himbot",
name: "Himbot",
description:
"A discord bot written in Go. Loosly named after my username online (HimbothySwaggins).",
link: "https://git.atri.dad/atridad/himbot",
},
{
id: "loadr",
name: "loadr",
description:
"A lightweight REST load testing tool with robust support for different verbs, token auth, and performance reports.",
link: "https://git.atri.dad/atridad/loadr",
},
];
return (
<div class="min-h-screen p-4 sm:p-8">
<h1 class="text-3xl sm:text-4xl font-bold text-secondary mb-6 sm:mb-8 text-center">
Projects
</h1>
<div class="flex flex-row flex-wrap justify-center gap-4 sm:gap-6 max-w-6xl mx-auto">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
</div>
);
}

View File

@ -1,239 +0,0 @@
import { Head } from "$fresh/runtime.ts";
import { Handlers, PageProps } from "$fresh/server.ts";
import {
LuMail,
LuGithub,
LuLinkedin,
LuGlobe,
LuGitBranch,
LuDownload,
} from "@preact-icons/lu";
interface ResumeData {
basics: {
name: string;
email: string;
url?: { href: string };
};
sections: {
summary: { name: string; content: string };
profiles: { name: string; items: { network: string; username: string; url: { href: string } }[] };
skills: { name: string; items: { id: string; name: string; level: number }[] };
experience: { name: string; items: { id: string; company: string; position: string; date: string; location: string; summary: string; url?: { href: string } }[] };
education: { name: string; items: { id: string; institution: string; studyType: string; area: string; date: string; summary: string }[] };
volunteer: { name: string; items: { id: string; organization: string; position: string; date: string /* No summary here */ }[] };
};
}
export const handler: Handlers<ResumeData> = {
async GET(_req, ctx) {
try {
const resp = await fetch(new URL("/files/resume.json", ctx.url).href);
if (!resp.ok) {
console.error(`Error fetching resume.json: ${resp.status} ${resp.statusText}`);
return ctx.render(undefined);
}
const resumeData: ResumeData = await resp.json();
const skillsSection = resumeData.sections.skills;
if (skillsSection && skillsSection.items) {
const tsSkill = skillsSection.items.find(s => s.name === "Typescrpt");
if (tsSkill) {
tsSkill.name = "Typescript";
}
}
return ctx.render(resumeData);
} catch (error) {
console.error("Error processing resume data:", error);
return ctx.render(undefined);
}
},
};
export default function ResumePage({ data }: PageProps<ResumeData | undefined>) {
if (!data) {
return (
<>
<Head><title>Error Loading Resume</title></Head>
<div class="container mx-auto p-4 max-w-4xl text-center">
<h1 class="text-2xl font-bold text-error">Error loading resume data.</h1>
<p>Please try refreshing the page.</p>
</div>
</>
);
}
const { basics, sections } = data;
const { summary, profiles, skills, experience, education, volunteer } = sections;
return (
<>
<Head>
<title>{basics.name} - Resume</title>
<meta name="description" content={`${basics.name}'s professional resume.`} />
</Head>
<div class="container mx-auto p-4 max-w-4xl">
<h1 class="text-4xl font-bold mb-6 text-center">{basics.name}</h1>
{/* Contact Info */}
<div class="flex justify-center items-center flex-wrap gap-x-4 gap-y-2 mb-6">
{basics.email && (
<a href={`mailto:${basics.email}`} class="link link-hover inline-flex items-center gap-1">
<LuMail /> {basics.email}
</a>
)}
{profiles.items.find(p => p.network === "GitHub") && (
<a href={profiles.items.find(p => p.network === "GitHub")!.url.href} target="_blank" rel="noopener noreferrer" class="link link-hover inline-flex items-center gap-1">
<LuGithub /> GitHub
</a>
)}
{profiles.items.find(p => p.network === "linkedin") && (
<a href={profiles.items.find(p => p.network === "linkedin")!.url.href} target="_blank" rel="noopener noreferrer" class="link link-hover inline-flex items-center gap-1">
<LuLinkedin /> LinkedIn
</a>
)}
</div>
{/* Download Resume Button */}
<div class="text-center mb-8">
<a
href="/files/Atridad_Lahiji_Resume_Public.pdf"
download="Atridad_Lahiji_Resume.pdf"
class="btn btn-primary inline-flex items-center gap-2"
>
<LuDownload /> Download Resume (PDF)
</a>
</div>
{/* Summary Card */}
{summary && (
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl">{summary.name || "Summary"}</h2>
<div dangerouslySetInnerHTML={{ __html: summary.content }}></div>
</div>
</div>
)}
{/* Profiles Card */}
{profiles && profiles.items && profiles.items.length > 0 && (
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl">{profiles.name || "Profiles"}</h2>
<div class="flex flex-wrap gap-4">
{profiles.items.map((profile) => {
let IconComponent = LuGlobe;
const networkLower = profile.network.toLowerCase();
if (networkLower === "github") IconComponent = LuGithub;
else if (networkLower === "linkedin") IconComponent = LuLinkedin;
else if (networkLower === "forgejo") IconComponent = LuGitBranch;
return (
<a
key={profile.network}
href={profile.url.href}
target="_blank"
rel="noopener noreferrer"
class="link link-hover inline-flex items-center gap-1"
>
<IconComponent /> {profile.network} ({profile.username})
</a>
);
})}
</div>
</div>
</div>
)}
{/* Skills Card */}
{skills && skills.items && skills.items.length > 0 && (
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl">{skills.name || "Skills"}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{skills.items.map((skill) => (
<div key={skill.id || skill.name}>
<label class="label">
<span class="label-text">{skill.name}</span>
</label>
<progress
class="progress progress-primary w-full"
value={skill.level * 20}
max="100"
>
</progress>
</div>
))}
</div>
</div>
</div>
)}
{/* Experience Card */}
{experience && experience.items && experience.items.length > 0 && (
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl">{experience.name || "Experience"}</h2>
<div class="space-y-4">
{experience.items.map((exp, index) => (
<div key={exp.id || index} class="collapse collapse-arrow bg-base-100">
<input type="radio" name="resume-accordion-experience" checked={index === 0} readOnly />
<div class="collapse-title text-xl font-medium">
{exp.position} at {exp.company} ({exp.date})
{exp.location && (
<span class="text-sm font-normal float-right pt-1">{exp.location}</span>
)}
</div>
<div class="collapse-content">
{exp.url && exp.url.href && (
<a href={exp.url.href} target="_blank" rel="noopener noreferrer" class="link link-primary block mb-2">{exp.url.href}</a>
)}
<div dangerouslySetInnerHTML={{ __html: exp.summary }}></div>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Education Card */}
{education && education.items && education.items.length > 0 && (
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl">{education.name || "Education"}</h2>
<div class="space-y-4">
{education.items.map((edu, index) => (
<div key={edu.id || index}>
<h3 class="text-lg font-semibold">{edu.institution}</h3>
<p>{edu.studyType} - {edu.area} ({edu.date})</p>
{edu.summary && (
<div class="ml-4 text-sm mt-1" dangerouslySetInnerHTML={{ __html: edu.summary }}></div>
)}
</div>
))}
</div>
</div>
</div>
)}
{/* Volunteering Card */}
{volunteer && volunteer.items && volunteer.items.length > 0 && (
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl">{volunteer.name || "Volunteering"}</h2>
<div class="space-y-4">
{volunteer.items.map((vol, index) => (
<div key={vol.id || index}>
<h3 class="text-lg font-semibold">{vol.organization}</h3>
<p>{vol.position} ({vol.date})</p>
</div>
))}
</div>
</div>
</div>
)}
</div>
</>
);
}

View File

@ -1,12 +1,6 @@
import { useComputed, useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
import {
LuCodeXml,
LuHouse,
LuMessageCircle,
LuNotebookPen,
LuFileText,
} from "@preact-icons/lu";
import { Home, NotebookPen, FileText, CodeXml } from 'lucide-preact';
interface NavigationBarProps {
currentPath: string;
@ -27,7 +21,7 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
};
useEffect(() => {
let scrollTimer: number | undefined;
let scrollTimer: ReturnType<typeof setTimeout> | undefined;
const handleScroll = () => {
isScrolling.value = true;
@ -59,7 +53,7 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
<li class="mx-1">
<a href="/" class={currentPath === "/" ? "menu-active" : ""}>
<div class="tooltip" data-tip="Home">
<LuHouse class="text-xl" />
<Home />
</div>
</a>
</li>
@ -70,7 +64,7 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
class={isPostsPath(currentPath) ? "menu-active" : ""}
>
<div class="tooltip" data-tip="Posts">
<LuNotebookPen class="text-xl" />
<NotebookPen />
</div>
</a>
</li>
@ -81,7 +75,7 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
class={currentPath === "/resume" ? "menu-active" : ""}
>
<div class="tooltip" data-tip="Resume">
<LuFileText class="text-xl" />
<FileText />
</div>
</a>
</li>
@ -92,18 +86,7 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
class={currentPath.startsWith("/projects") ? "menu-active" : ""}
>
<div class="tooltip" data-tip="Projects">
<LuCodeXml class="text-xl" />
</div>
</a>
</li>
<li class="mx-1">
<a
href="/chat"
class={currentPath.startsWith("/chat") ? "menu-active" : ""}
>
<div class="tooltip" data-tip="Chat">
<LuMessageCircle class="text-xl" />
<CodeXml />
</div>
</a>
</li>

View File

@ -0,0 +1,55 @@
---
import type { CollectionEntry } from 'astro:content';
import { Icon } from 'astro-icon/components';
export interface Props {
post: CollectionEntry<'posts'>;
}
const { post } = Astro.props;
const { title, description: blurb, pubDate } = post.data;
const { slug } = post;
---
<div class="card bg-accent shadow-lg w-full sm:w-[calc(50%-1rem)] md:w-96 min-w-[280px] max-w-sm shrink">
<div class="card-body p-3">
<h2 class="card-title text-base-100 justify-center text-center break-words font-bold text-lg mb-2">
{title}
</h2>
<p class="text-center text-base-100 break-words mb-3 text-base">
{blurb || 'No description available.'}
</p>
<div class="flex flex-wrap items-center justify-center text-base-100 opacity-75 gap-2 text-sm mb-2">
<Icon name="mdi:clock" class="text-xl" />
<span>
{new Date(pubDate).toLocaleDateString("en-us", {
month: "long",
day: "numeric",
year: "numeric",
})}
</span>
</div>
{post.data.tags && post.data.tags.length > 0 && (
<div class="flex gap-2 flex-wrap mb-2 justify-center">
{post.data.tags.map((tag: string) => (
<span class="flex items-center flex-row gap-2 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-sm">
<Icon name="mdi:tag" class="text-lg" />
{tag}
</span>
))}
</div>
)}
<div class="card-actions justify-end mt-2">
<a
href={`/post/${slug}`}
class="btn btn-circle btn-sm bg-base-100 hover:bg-base-200 text-accent"
aria-label={`Read more about ${title}`}
>
<Icon name="mdi:arrow-right" class="text-lg" />
</a>
</div>
</div>
</div>

View File

@ -0,0 +1,40 @@
---
import { Icon } from 'astro-icon/components';
interface Project {
id: string;
name: string;
description: string;
link: string;
}
export interface Props {
project: Project;
}
const { project } = Astro.props;
---
<div class="card bg-accent shadow-lg w-full sm:w-[calc(50%-1rem)] md:w-96 min-w-[280px] max-w-sm shrink">
<div class="card-body p-6">
<h2 class="card-title text-xl md:text-2xl font-bold justify-center text-center break-words text-base-100">
{project.name}
</h2>
<p class="text-center break-words my-4 text-base-100">
{project.description}
</p>
<div class="card-actions justify-end mt-4">
<a
href={project.link}
target="_blank"
rel="noopener noreferrer"
class="btn btn-circle btn-sm bg-base-100 hover:bg-base-200 text-accent"
aria-label={`Visit ${project.name}`}
>
<Icon name="mdi:link" class="text-lg" />
</a>
</div>
</div>
</div>

View File

@ -1,26 +1,26 @@
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
import { LuArrowUp } from "@preact-icons/lu";
import { ArrowUp } from 'lucide-preact';
export default function ScrollUpButton() {
const isVisible = useSignal(false);
useEffect(() => {
const checkScroll = () => {
isVisible.value = globalThis.scrollY > 300;
isVisible.value = window.scrollY > 300;
};
checkScroll();
globalThis.addEventListener("scroll", checkScroll);
window.addEventListener("scroll", checkScroll);
return () => {
globalThis.removeEventListener("scroll", checkScroll);
window.removeEventListener("scroll", checkScroll);
};
}, []);
const scrollToTop = () => {
globalThis.scrollTo({
window.scrollTo({
top: 0,
behavior: "smooth",
});
@ -39,7 +39,7 @@ export default function ScrollUpButton() {
}`}
aria-label="Scroll to top"
>
<LuArrowUp class="text-lg" />
<ArrowUp class="text-lg" />
</button>
);
}
}

View File

@ -0,0 +1,41 @@
---
import { Icon } from 'astro-icon/components';
---
<div class="flex flex-row gap-4 text-xl sm:text-3xl">
<a
href="mailto:me@atri.dad"
aria-label="Email me"
class="hover:text-primary transition-colors"
>
<Icon name="mdi:email" />
</a>
<a
href="/feed"
aria-label="RSS Feed"
class="hover:text-primary transition-colors"
>
<Icon name="mdi:rss" />
</a>
<a
href="https://git.atri.dad/atridad"
target="_blank"
rel="noopener noreferrer"
aria-label="Forgejo (Git)"
class="hover:text-primary transition-colors"
>
<Icon name="simple-icons:gitea" />
</a>
<a
href="https://bsky.app/profile/atri.dad"
target="_blank"
rel="noopener noreferrer"
aria-label="Bluesky Profile"
class="hover:text-primary transition-colors"
>
<Icon name="simple-icons:bluesky" />
</a>
</div>

View File

@ -0,0 +1,74 @@
---
import { Icon } from 'astro-icon/components';
---
<div class="flex flex-row gap-4 text-xl sm:text-3xl">
<a
href="https://react.dev/"
target="_blank"
rel="noopener noreferrer"
aria-label="React"
class="hover:text-primary transition-colors"
>
<Icon name="simple-icons:react" />
</a>
<a
href="https://www.typescriptlang.org/"
target="_blank"
rel="noopener noreferrer"
aria-label="TypeScript"
class="hover:text-primary transition-colors"
>
<Icon name="simple-icons:typescript" />
</a>
<a
href="https://astro.build/"
target="_blank"
rel="noopener noreferrer"
aria-label="Deno"
class="hover:text-primary transition-colors"
>
<Icon name="simple-icons:astro" />
</a>
<a
href="https://go.dev/"
target="_blank"
rel="noopener noreferrer"
aria-label="Go"
class="hover:text-primary transition-colors"
>
<Icon name="simple-icons:go" />
</a>
<a
href="https://www.postgresql.org/"
target="_blank"
rel="noopener noreferrer"
aria-label="PostgreSQL"
class="hover:text-primary transition-colors"
>
<Icon name="simple-icons:postgresql" />
</a>
<a
href="https://redis.io/"
target="_blank"
rel="noopener noreferrer"
aria-label="Redis"
class="hover:text-primary transition-colors"
>
<Icon name="simple-icons:redis" />
</a>
<a
href="https://www.docker.com/"
target="_blank"
rel="noopener noreferrer"
aria-label="Docker"
class="hover:text-primary transition-colors"
>
<Icon name="simple-icons:docker" />
</a>
</div>

16
src/content/config.ts Normal file
View File

@ -0,0 +1,16 @@
import { defineCollection, z } from 'astro:content';
const postsCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
}),
});
export const collections = {
'posts': postsCollection,
};

View File

@ -1,6 +1,8 @@
---
title: Current List of Favourite Tools
published_at: 2025-01-28T15:00:00.000Z
title: "Current List of Favourite Tools"
description: "A running list of my favourite tools to use day-to-day."
pubDate: "2025-01-28"
tags: ["list"]
---
I change what I use _constantly_ in order to find something that feels just

View File

@ -1,10 +1,12 @@
---
title: Welcome!
published_at: 2024-10-20T15:00:00.000Z
title: "Welcome"
description: "Welcome to my website! :)"
pubDate: "2024-10-20"
tags: ["meta"]
---
Welcome to my site! This is a place for me to share my thoughts and updates on
my projects. I hope you find something interesting here.
Feel free to reach out if you have any questions or comments. I'd love to hear
from you! I can be reached by email at [me@atri.dad](mailto:me@atri.dad).
from you! I can be reached by email at [me@atri.dad](mailto:me@atri.dad).

26
src/layouts/Layout.astro Normal file
View File

@ -0,0 +1,26 @@
---
import { ClientRouter } from "astro:transitions";
import NavigationBar from "../components/NavigationBar";
import ScrollUpButton from "../components/ScrollUpButton";
const currentPath = Astro.url.pathname;
import '../styles/global.css';
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<title>Atridad Lahiji</title>
<ClientRouter />
</head>
<body class="flex flex-col min-h-screen">
<main class="flex-grow flex flex-col gap-4 items-center justify-center">
<slot />
</main>
<NavigationBar client:load currentPath={currentPath} />
<ScrollUpButton client:load />
</body>
</html>

View File

@ -1,11 +1,11 @@
import HomeButtonLinks from "../components/HomeButtonLinks.tsx";
import SocialLinks from "../components/SocialLinks.tsx";
import TechLinks from "../components/TechLinks.tsx";
---
import SocialLinks from '../components/SocialLinks.astro';
import TechLinks from '../components/TechLinks.astro';
import Layout from '../layouts/Layout.astro';
---
export default function Home() {
return (
<>
<img
<Layout>
<img
src="/logo.webp"
alt="A drawing of Atridad Lahiji by Shelze!"
height={150}
@ -28,7 +28,5 @@ export default function Home() {
<TechLinks />
<HomeButtonLinks />
</>
);
}
<!-- <HomeButtonLinks /> -->
</Layout>

View File

@ -0,0 +1,62 @@
---
import { getCollection, type CollectionEntry } from 'astro:content';
import { Icon } from 'astro-icon/components';
import Layout from '../../layouts/Layout.astro';
export async function getStaticPaths() {
const posts = await getCollection('posts');
return posts.map((post: CollectionEntry<'posts'>) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post }: { post: CollectionEntry<'posts'> } = Astro.props;
const { Content } = await post.render();
---
<Layout>
<div class="min-h-screen p-4 md:p-8">
<div class="max-w-3xl mx-auto">
<div class="p-4 md:p-8">
<h1 class="text-4xl md:text-5xl font-bold text-primary mb-6">
{post.data.title}
</h1>
<div class="flex flex-wrap items-center gap-4 mb-6">
<div class="flex items-center flex-row gap-2 text-base-content opacity-75">
<Icon name="mdi:clock" class="text-xl" />
<time datetime={post.data.pubDate.toISOString()}>
{new Date(post.data.pubDate).toLocaleDateString('en-us', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</time>
</div>
{/* Back button */}
<a href="/posts" class="btn btn-outline btn-primary btn-sm">
<Icon name="mdi:arrow-left" class="text-lg" />
Back
</a>
</div>
{post.data.tags && post.data.tags.length > 0 && (
<div class="flex gap-2 flex-wrap mb-6">
{post.data.tags.map((tag: string) => (
<span class="flex items-center flex-row gap-2 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-sm">
<Icon name="mdi:tag" class="text-lg" />
{tag}
</span>
))}
</div>
)}
<article class="prose prose-lg dark:prose-invert max-w-none mt-6">
<Content />
</article>
</div>
</div>
</div>
</Layout>

30
src/pages/posts.astro Normal file
View File

@ -0,0 +1,30 @@
---
import Layout from "../layouts/Layout.astro";
import { getCollection, type CollectionEntry } from "astro:content";
import PostCard from "../components/PostCard.astro";
// Get all posts from the content collection
const posts = await getCollection("posts");
// Sort posts by date, newest first
const sortedPosts = posts.sort(
(a: CollectionEntry<"posts">, b: CollectionEntry<"posts">) => new Date(b.data.pubDate).valueOf() - new Date(a.data.pubDate).valueOf()
);
---
<Layout>
<div class="min-h-screen p-4 sm:p-8">
<h1 class="text-3xl sm:text-4xl font-bold text-primary mb-6 sm:mb-8 text-center">
Posts
</h1>
<div class="flex flex-row flex-wrap justify-center gap-4 sm:gap-6 max-w-6xl mx-auto">
{sortedPosts.map((post: CollectionEntry<"posts">) => (
<PostCard post={post} />
))}
</div>
{sortedPosts.length === 0 && (
<p class="text-center text-gray-500 mt-12">No posts available yet. Check back soon!</p>
)}
</div>
</Layout>

58
src/pages/projects.astro Normal file
View File

@ -0,0 +1,58 @@
---
import Layout from "../layouts/Layout.astro";
import ProjectCard from "../components/ProjectCard.astro";
const projects = [
{
id: "bluesky-pds-manager",
name: "BlueSky PDS Manager",
description:
"A web-based BlueSky PDS Manager. Manage your invite codes and users with a simple web UI.",
link: "https://pdsman.atri.dad",
},
{
id: "pollo",
name: "Pollo",
description: "A dead-simple real-time voting tool.",
link: "https://git.atri.dad/atridad/pollo",
},
{
id: "goth-stack",
name: "GOTH Stack",
description:
"🚀 A Web Application Template Powered by HTMX + Go + Tailwind 🚀",
link: "https://git.atri.dad/atridad/goth.stack",
},
{
id: "himbot",
name: "Himbot",
description:
"A discord bot written in Go. Loosly named after my username online (HimbothySwaggins).",
link: "https://git.atri.dad/atridad/himbot",
},
{
id: "loadr",
name: "loadr",
description:
"A lightweight REST load testing tool with robust support for different verbs, token auth, and performance reports.",
link: "https://git.atri.dad/atridad/loadr",
},
];
---
<Layout>
<div class="min-h-screen p-4 sm:p-8">
<h1 class="text-3xl sm:text-4xl font-bold text-secondary mb-6 sm:mb-8 text-center">
Projects
</h1>
<div class="flex flex-row flex-wrap justify-center gap-4 sm:gap-6 max-w-6xl mx-auto">
{projects.map((project) => (
<ProjectCard project={project} />
))}
</div>
{projects.length === 0 && (
<p class="text-center text-gray-500 mt-12">No projects available yet. Check back soon!</p>
)}
</div>
</Layout>

232
src/pages/resume.astro Normal file
View File

@ -0,0 +1,232 @@
---
import { Icon } from 'astro-icon/components';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import Layout from '../layouts/Layout.astro';
import '../styles/global.css';
interface ResumeData {
basics: {
name: string;
email: string;
url?: { href: string };
};
sections: {
summary: { name: string; content: string };
profiles: { name: string; items: { network: string; username: string; url: { href: string } }[] };
skills: { name: string; items: { id: string; name: string; level: number }[] };
experience: { name: string; items: { id: string; company: string; position: string; date: string; location: string; summary: string; url?: { href: string } }[] };
education: { name: string; items: { id: string; institution: string; studyType: string; area: string; date: string; summary: string }[] };
volunteer: { name: string; items: { id: string; organization: string; position: string; date: string }[] };
};
}
let resumeData: ResumeData | undefined = undefined;
let fetchError: string | null = null;
try {
let resumeJson: string;
try {
resumeJson = await readFile(join(process.cwd(), 'public', 'files', 'resume.json'), 'utf-8');
} catch (err) {
resumeJson = await readFile('/Users/atridad/Downloads/resume.json', 'utf-8');
}
resumeData = JSON.parse(resumeJson);
if (resumeData && resumeData.sections && resumeData.sections.skills) {
const skillsSection = resumeData.sections.skills;
if (skillsSection.items) {
const tsSkill = skillsSection.items.find(s => s.name === "Typescrpt");
if (tsSkill) {
tsSkill.name = "Typescript";
}
}
}
} catch (error) {
console.error("Error processing resume data:", error);
fetchError = "An error occurred while processing resume data. Please make sure the resume.json file is in the correct location.";
resumeData = undefined;
}
const data = resumeData;
---
{(!data || fetchError) && (
<Layout>
<div class="container mx-auto p-4 max-w-4xl text-center">
<h1 class="text-2xl font-bold text-red-600">Error loading resume data.</h1>
<p>{fetchError || "Please try refreshing the page."}</p>
</div>
</Layout>
)}
{data && !fetchError && (
<Layout>
<div class="container mx-auto p-4 max-w-4xl">
<h1 class="text-4xl font-bold mb-6 text-center">{data.basics.name}</h1>
<div class="flex justify-center items-center flex-wrap gap-x-4 gap-y-2 mb-6">
{data.basics.email && (
<a href={`mailto:${data.basics.email}`} class="link link-hover inline-flex items-center gap-1">
<Icon name="mdi:email" /> {data.basics.email}
</a>
)}
{data.sections.profiles.items.find(p => p.network === "GitHub") && (
<a href={data.sections.profiles.items.find(p => p.network === "GitHub")!.url.href} target="_blank" rel="noopener noreferrer" class="link link-hover inline-flex items-center gap-1">
<Icon name="simple-icons:github" /> GitHub
</a>
)}
{data.sections.profiles.items.find(p => p.network === "linkedin") && (
<a href={data.sections.profiles.items.find(p => p.network === "linkedin")!.url.href} target="_blank" rel="noopener noreferrer" class="link link-hover inline-flex items-center gap-1">
<Icon name="simple-icons:linkedin" /> LinkedIn
</a>
)}
</div>
<div class="text-center mb-8">
<a
href="/files/Atridad_Lahiji_Resume.pdf"
download="Atridad_Lahiji_Resume.pdf"
class="btn btn-primary inline-flex items-center gap-2"
>
<Icon name="mdi:download" /> Download Resume (PDF)
</a>
</div>
{data.sections.summary && (
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl">{data.sections.summary.name || "Summary"}</h2>
<div set:html={data.sections.summary.content}></div>
</div>
</div>
)}
{data.sections.profiles && data.sections.profiles.items && data.sections.profiles.items.length > 0 && (
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl">{data.sections.profiles.name || "Profiles"}</h2>
<div class="flex flex-wrap gap-4">
{data.sections.profiles.items.map((profile) => {
let iconName = "mdi:web";
const networkLower = profile.network.toLowerCase();
if (networkLower === "github") iconName = "simple-icons:github";
else if (networkLower === "linkedin") iconName = "simple-icons:linkedin";
else if (networkLower === "gitea") iconName = "simple-icons:gitea";
return (
<a
href={profile.url.href}
target="_blank"
rel="noopener noreferrer"
class="link link-hover inline-flex items-center gap-1"
>
<Icon name={iconName} /> {profile.network} ({profile.username})
</a>
);
})}
</div>
</div>
</div>
)}
{data.sections.skills && data.sections.skills.items && data.sections.skills.items.length > 0 && (
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl">{data.sections.skills.name || "Skills"}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.sections.skills.items.map((skill) => (
<div>
<label class="label">
<span class="label-text">{skill.name}</span>
</label>
<progress
class="progress progress-primary w-full"
value={skill.level * 20}
max="100"
>
</progress>
</div>
))}
</div>
</div>
</div>
)}
{data.sections.experience && data.sections.experience.items && data.sections.experience.items.length > 0 && (
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl">{data.sections.experience.name || "Experience"}</h2>
<div class="space-y-4">
{data.sections.experience.items.map((exp, index) => (
<details class="collapse collapse-arrow bg-base-100" open={index === 0 ? true : undefined}>
<summary class="collapse-title text-xl font-medium">
{exp.position} at {exp.company} ({exp.date})
{exp.location && (
<span class="text-sm font-normal float-right pt-1">{exp.location}</span>
)}
</summary>
<div class="collapse-content">
{exp.url && exp.url.href && (
<a href={exp.url.href} target="_blank" rel="noopener noreferrer" class="link link-primary block mb-2">{exp.url.href}</a>
)}
<div class="mt-2">
<ul class="list">
{exp.summary.replace(/<\/?ul>|<\/?p>/g, '')
.split('<li>')
.filter(item => item.trim() !== '')
.map(item => (
<li class="list-row">
{item.replace('</li>', '')}
</li>
))
}
</ul>
</div>
</div>
</details>
))}
</div>
</div>
</div>
)}
{data.sections.education && data.sections.education.items && data.sections.education.items.length > 0 && (
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl">{data.sections.education.name || "Education"}</h2>
<div class="space-y-4">
{data.sections.education.items.map((edu, index) => (
<div>
<h3 class="text-lg font-semibold">{edu.institution}</h3>
<p>{edu.studyType} - {edu.area} ({edu.date})</p>
{edu.summary && (
<div class="ml-4 text-sm mt-1" set:html={edu.summary}></div>
)}
</div>
))}
</div>
</div>
</div>
)}
{data.sections.volunteer && data.sections.volunteer.items && data.sections.volunteer.items.length > 0 && (
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl">{data.sections.volunteer.name || "Volunteering"}</h2>
<div class="space-y-4">
{data.sections.volunteer.items.map((vol, index) => (
<div>
<h3 class="text-lg font-semibold">{vol.organization}</h3>
<p>{vol.position} ({vol.date})</p>
</div>
))}
</div>
</div>
</div>
)}
</div>
</Layout>
)}

37
src/styles/global.css Normal file
View File

@ -0,0 +1,37 @@
@import "tailwindcss";
@plugin "daisyui";
@plugin "@tailwindcss/typography";
@plugin "daisyui/theme" {
name: "chaoticbisexual";
default: true;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(25.33% 0.016 252.42);
--color-base-200: oklch(23.26% 0.014 253.1);
--color-base-300: oklch(21.15% 0.012 254.09);
--color-base-content: oklch(97.807% 0.029 256.847);
--color-primary: oklch(65% 0.241 354.308);
--color-primary-content: oklch(96% 0.018 272.314);
--color-secondary: oklch(60% 0.25 292.717);
--color-secondary-content: oklch(94% 0.028 342.258);
--color-accent: oklch(78% 0.154 211.53);
--color-accent-content: oklch(38% 0.063 188.416);
--color-neutral: oklch(40% 0.17 325.612);
--color-neutral-content: oklch(92% 0.004 286.32);
--color-info: oklch(74% 0.16 232.661);
--color-info-content: oklch(29% 0.066 243.157);
--color-success: oklch(76% 0.177 163.223);
--color-success-content: oklch(37% 0.077 168.94);
--color-warning: oklch(82% 0.189 84.429);
--color-warning-content: oklch(41% 0.112 45.904);
--color-error: oklch(71% 0.194 13.428);
--color-error-content: oklch(27% 0.105 12.094);
--radius-selector: 1rem;
--radius-field: 1rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 1;
}

View File

@ -1,37 +0,0 @@
@import "tailwindcss";
@plugin "daisyui";
@plugin "@tailwindcss/typography";
@plugin "daisyui/theme" {
name: "chaoticbisexual";
default: true;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(25.33% 0.016 252.42);
--color-base-200: oklch(23.26% 0.014 253.1);
--color-base-300: oklch(21.15% 0.012 254.09);
--color-base-content: oklch(97.807% 0.029 256.847);
--color-primary: oklch(65% 0.241 354.308);
--color-primary-content: oklch(96% 0.018 272.314);
--color-secondary: oklch(60% 0.25 292.717);
--color-secondary-content: oklch(94% 0.028 342.258);
--color-accent: oklch(78% 0.154 211.53);
--color-accent-content: oklch(38% 0.063 188.416);
--color-neutral: oklch(40% 0.17 325.612);
--color-neutral-content: oklch(92% 0.004 286.32);
--color-info: oklch(74% 0.16 232.661);
--color-info-content: oklch(29% 0.066 243.157);
--color-success: oklch(76% 0.177 163.223);
--color-success-content: oklch(37% 0.077 168.94);
--color-warning: oklch(82% 0.189 84.429);
--color-warning-content: oklch(41% 0.112 45.904);
--color-error: oklch(71% 0.194 13.428);
--color-error-content: oklch(27% 0.105 12.094);
--radius-selector: 1rem;
--radius-field: 1rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 1;
}

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"include": [
".astro/types.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.astro"
],
"exclude": [
"node_modules",
"dist"
]
}