Compare commits

..

106 Commits

Author SHA1 Message Date
7922b2da18 Update bun.lock
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m35s
2026-03-07 23:58:24 -07:00
4c8105d263 Fixed sameOrigin. Turns out I just had it as text and not json being
All checks were successful
Docker Deploy / build-and-push (push) Successful in 2m50s
passed :/
2026-03-07 00:35:39 -07:00
fa9d2700f2 Update astro.config.mjs
All checks were successful
Docker Deploy / build-and-push (push) Successful in 2m55s
2026-03-06 23:47:37 -07:00
79f5b5c9e9 oops
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m39s
2026-03-06 23:31:22 -07:00
3e43e73abf Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 5m25s
2026-03-03 13:31:43 -07:00
f03318f7dc Fixed some description text
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m4s
2026-03-03 13:23:13 -07:00
1db15c64e1 Deps 2026-03-01 01:16:31 -07:00
a98bf7c7c6 Update config.ts
All checks were successful
Docker Deploy / build-and-push (push) Successful in 2m41s
2026-02-25 23:45:49 -07:00
c3c8867a37 Update 2026-infra-setup.md
All checks were successful
Docker Deploy / build-and-push (push) Successful in 2m39s
2026-02-25 23:33:23 -07:00
2430f89737 Update 2026-infra-setup.md
All checks were successful
Docker Deploy / build-and-push (push) Successful in 2m46s
2026-02-25 19:54:16 -07:00
7925fab524 Updated infra post
All checks were successful
Docker Deploy / build-and-push (push) Successful in 2m50s
2026-02-25 18:20:29 -07:00
271dad89a1 Bun-ify
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m57s
2026-02-24 23:25:47 -07:00
47946c0703 deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m17s
2026-02-23 11:32:53 -07:00
4b78414562 Updated resume
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m8s
2026-02-16 23:52:02 -07:00
0cf1cfa2b0 Added Dart
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m51s
2026-02-16 14:13:28 -07:00
399cff82b0 Theme change
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m14s
2026-02-15 00:42:13 -07:00
cf163bb0b2 Add RSS links to each post
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m35s
2026-02-14 18:00:49 -07:00
cc3a408050 Added Matrix and Mastodon for fediiiiii
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m53s
2026-02-14 16:52:17 -07:00
46c42cd765 Optimized docker
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m1s
2026-02-12 15:04:10 -07:00
89c1c739c1 Re-worked icons
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m48s
2026-02-12 14:22:59 -07:00
33dfea1802 Added Haschel
Some checks failed
Docker Deploy / build-and-push (push) Failing after 4m8s
2026-02-11 23:11:13 -07:00
0efb72fffd Fixed opengraph again
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m4s
2026-02-07 13:18:18 -07:00
dce37681af Opengraph
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m39s
2026-02-07 00:28:33 -07:00
6b77ce091d Upgraded the projects view. Looks and acts MUCH nicer
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m21s
2026-02-03 11:16:07 -07:00
ba1193896f Improved posts UX
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m32s
2026-02-03 11:00:13 -07:00
63282cf34d New year, new post!
All checks were successful
Docker Deploy / build-and-push (push) Successful in 5m59s
2026-02-03 10:29:03 -07:00
3eac226630 Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m17s
2026-01-31 23:25:49 -07:00
9518a0f18b Added 404
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m13s
2026-01-25 00:27:35 -07:00
74304dba4d Added 404 2026-01-25 00:26:45 -07:00
0512645035 More optimizations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 5m6s
2026-01-24 23:58:30 -07:00
09fdbf7ec7 Updated nav a bit
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m35s
2026-01-24 18:57:44 -07:00
d3844a5870 Updated repos
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m48s
2026-01-24 18:30:50 -07:00
d06a453461 Some optimizations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m25s
2026-01-24 18:23:59 -07:00
c048d0d47a ???
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m47s
2026-01-24 17:24:18 -07:00
a26c990a21 4.0.0
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-01-24 17:24:00 -07:00
210edc771c deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m11s
2026-01-16 10:59:15 -07:00
154bbc9669 Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m43s
2026-01-14 18:08:26 -07:00
d354b35d05 Catppuccin Macciato because it fucking rules dude
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m8s
2025-12-31 19:58:37 -07:00
4c1def9cf9 Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m16s
2025-12-30 00:43:08 -07:00
a29e81f05d Update ascently-climbing-tracker.md
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m29s
2025-12-27 22:36:56 -07:00
9a9705e8f7 Bumped Node
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m49s
2025-12-25 23:39:04 -07:00
4954fe855f Re-adding nix
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m5s
2025-12-25 02:02:45 -07:00
f4d0ae2780 Added nix icon
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m8s
2025-12-25 01:29:49 -07:00
e9fed3a5b7 Update linux-for-new-users.md
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m24s
2025-12-23 08:53:06 -07:00
d6d75eff37 Fixed RSS date/time
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m20s
2025-12-21 01:45:28 -07:00
ebb980f275 Ehhh lets not suggest KDE on Pop
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m9s
2025-12-21 01:16:25 -07:00
3b5f33aaf7 New article!
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m36s
2025-12-21 01:09:02 -07:00
70ee8a2c42 Optimizations + minor cleanups
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m15s
2025-12-18 00:30:01 -07:00
203f83bfcb Updated UX to remove the weird looking cards. No more cards!
All checks were successful
Docker Deploy / build-and-push (push) Successful in 5m47s
2025-12-15 10:28:48 -07:00
4ab28078e8 Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m15s
2025-12-12 22:44:15 -07:00
a8e017caf2 ???
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m53s
2025-12-12 10:53:43 -07:00
ee10cbaf60 Update projects
Some checks failed
Docker Deploy / build-and-push (push) Failing after 3m15s
2025-12-12 00:45:45 -07:00
88c10f9690 Fixed some leftovers from a depricated feature
Some checks failed
Docker Deploy / build-and-push (push) Failing after 5m46s
2025-12-02 10:21:04 -07:00
0998bacd86 Simplified the PDF gen quite a bit :)
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m37s
2025-12-01 18:16:17 -07:00
b87357c175 Made card grids not ass
All checks were successful
Docker Deploy / build-and-push (push) Successful in 6m7s
2025-11-23 01:51:14 -07:00
b159236e59 Fixed class warnings
All checks were successful
Docker Deploy / build-and-push (push) Successful in 11m54s
2025-11-22 17:33:52 -07:00
be20d75288 Removed release and commit count its useless.
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2025-11-22 17:31:52 -07:00
3b1f9ae02c Remove focus
All checks were successful
Docker Deploy / build-and-push (push) Successful in 8m23s
2025-11-22 17:11:01 -07:00
a09a2faa32 Content updates
All checks were successful
Docker Deploy / build-and-push (push) Successful in 9m39s
2025-10-20 15:08:13 -06:00
517becb322 Small edits
All checks were successful
Docker Deploy / build-and-push (push) Successful in 5m27s
2025-10-16 23:11:46 -06:00
0fb9fd2009 Added phone
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m5s
2025-10-16 11:15:16 -06:00
50a4ff1332 New blog and homepage focus
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m43s
2025-10-16 10:52:20 -06:00
0d474804ec Cleaned up link code for projects and added git vs web links
All checks were successful
Docker Deploy / build-and-push (push) Successful in 5m7s
2025-10-14 12:59:10 -06:00
94146782cb ???
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m6s
2025-10-14 00:47:36 -06:00
fb260d499b Deps
Some checks failed
Docker Deploy / build-and-push (push) Failing after 2m44s
2025-10-13 23:48:14 -06:00
6daeac418b Fixed projects 2025-10-13 23:48:02 -06:00
a837b9380b Fixed link
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m9s
2025-10-09 10:44:36 -06:00
0b4ba7ba63 Deps and bump
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2025-10-09 09:36:47 -06:00
7d14ba51fa IDK why I didn't do this... using server islands :') 2025-10-09 09:36:08 -06:00
9b98476df6 3.1.0 - Added Gitea integration for Projects page
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m25s
2025-10-08 16:12:26 -06:00
50e1627ea3 Update resume.toml
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m31s
2025-10-02 01:18:17 -06:00
18cd6511c9 Resume optimization
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m1s
2025-10-01 22:25:46 -06:00
6d380ec376 Fixed tech used section
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m27s
2025-10-01 10:23:13 -06:00
1f6e7a2552 Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m58s
2025-10-01 09:28:27 -06:00
0aab89c58c Update resume.toml 2025-10-01 09:27:58 -06:00
75931d4a43 3.0.0 - Dependency updates, improved typesafe config, improve typing
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m44s
2025-09-22 15:07:03 -06:00
6c9fabe770 Resume update
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m1s
2025-09-10 01:23:57 -06:00
fd48065550 pls
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m5s
2025-09-04 00:04:42 -06:00
a2a3b114dd Added a new project and fixed scrollupbutton 2025-09-04 00:03:02 -06:00
08537db2ab Oops
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m15s
2025-08-26 16:44:01 -06:00
c78ff8c37d Anotha one
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m35s
2025-08-19 21:19:19 -06:00
87e4d54059 MoaR
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m40s
2025-08-19 21:14:22 -06:00
294a8f2ad3 More updates
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m36s
2025-08-19 21:09:13 -06:00
4f570af33e Moar!
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m47s
2025-08-19 16:02:36 -06:00
4b30c8f794 More updates
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m32s
2025-08-19 15:26:57 -06:00
f5d622f857 Deps + Resume Update
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m49s
2025-08-19 14:23:08 -06:00
9a846f5d76 Update Resume
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m16s
2025-08-18 16:11:30 -06:00
9373b2894a Fixed more styling
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m38s
2025-08-18 11:57:19 -06:00
e053b59501 Remove period
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m37s
2025-08-18 11:50:07 -06:00
ec58d44b9d 2.0.0 - Overhaul
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m13s
2025-08-18 11:44:55 -06:00
bd71602d95 More misc style changes
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m32s
2025-08-14 15:35:54 -06:00
c2063f6feb CSS updates + deps
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2025-08-14 15:32:24 -06:00
6c4d1b53c6 Update README.md
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m25s
2025-08-13 15:24:07 +00:00
96add46a4d Typo
All checks were successful
Docker Deploy / build-and-push (push) Successful in 2m54s
2025-08-12 16:36:16 -06:00
fe8dc4b794 Style changes and new blog post
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2025-08-12 16:35:47 -06:00
9de5f46201 Resume Update
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m53s
2025-08-12 11:22:02 -06:00
485d1eaa34 Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m34s
2025-08-12 01:38:28 -06:00
0e457c0c82 1.1.1 - Updated projects
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2025-08-12 01:37:44 -06:00
0d5dd82fd4 Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m20s
2025-08-06 15:06:04 -06:00
f85cf0c719 Moved from nix-shell -> flakes
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m27s
2025-07-25 16:51:25 -06:00
14de7b0d22 One more
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m36s
2025-07-25 14:42:13 -06:00
d19830a6fa More format changes
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2025-07-25 14:38:25 -06:00
805eb86848 Nav border
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m44s
2025-07-25 11:52:41 -06:00
cb8d38b2d1 Resume Updates
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m40s
2025-07-25 11:38:51 -06:00
df8ff3ab20 CSS compat is hard apparently
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m9s
2025-07-23 22:48:56 -06:00
1082834f33 Various style updates
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m44s
2025-07-22 17:58:26 -06:00
71 changed files with 4689 additions and 9382 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.git
.gitignore
dist
.env*
*.md
.vscode
.idea
.DS_Store

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
# RSS Feed Configuration
# Will default to GMT
PUBLIC_RSS_TIMEZONE=America/Edmonton

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

View File

@@ -12,20 +12,20 @@ jobs:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ${{ secrets.REPO_HOST }}
username: ${{ github.repository_owner }}
password: ${{ secrets.DEPLOY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
@@ -33,3 +33,6 @@ jobs:
tags: |
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.sha }}
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
provenance: false
cache-from: type=registry,ref=${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:buildcache
cache-to: type=registry,ref=${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:buildcache,mode=max

5
.gitignore vendored
View File

@@ -22,3 +22,8 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
# nix
.direnv/
result

View File

@@ -1,50 +1,28 @@
FROM node:24-alpine AS builder
FROM oven/bun:1.3.9-alpine AS base
WORKDIR /app
# Install Chromium and dependencies for Playwright in a single layer
RUN apk add --no-cache \
chromium \
nss \
freetype \
freetype-dev \
harfbuzz \
ca-certificates \
ttf-freefont \
curl \
&& rm -rf /var/cache/apk/*
FROM base AS prod-deps
COPY package.json bun.lock ./
RUN --mount=type=cache,id=bun,target=/root/.bun/install/cache \
bun install --production --frozen-lockfile || bun install --production
# Tell Playwright to use the installed Chromium
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/bin
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
FROM base AS builder
COPY package.json bun.lock ./
RUN --mount=type=cache,id=bun,target=/root/.bun/install/cache \
bun install --frozen-lockfile || bun install
# Install pnpm
RUN npm i -g pnpm
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install all dependencies (including dev dependencies for build)
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
RUN bun run build
# Build the application
RUN pnpm run build
FROM base AS runtime
WORKDIR /app
# Install only production dependencies and clean up
RUN pnpm install --prod --frozen-lockfile \
&& pnpm store prune \
&& npm cache clean --force
COPY --from=builder /app/dist ./dist
COPY --from=prod-deps /app/node_modules ./node_modules
COPY package.json ./
# Set environment variables
ENV HOST=0.0.0.0 \
PORT=4321 \
NODE_ENV=production
# Expose port
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
# Start the application
CMD ["node", "./dist/server/entry.mjs"]
CMD ["bun", "run", "./dist/server/entry.mjs"]

177
README.md
View File

@@ -1,178 +1,5 @@
# Personal Website
My personal website built with Astro and Preact!
My personal website built with Astro, Vue, and Preact!
## Features
- **Resume**
- **Blog Posts**
- **Projects**
- **Talks**
- **Terminal View**
** Nix shell is required for local development! Install it on your OS of choice OR use NixOS!
## Development
```bash
# Install dependencies
pnpm i
# Start development server
pnpm shell # Enter nix-shell
pnpm dev
# Build for production
pnpm build
```
## Resume Configuration
The resume system supports multiple sections that can be enabled, disabled, and customized.
### Available Resume Sections
| Section | Required Fields |
|---------|-----------------|
| **basics** | `name`, `email`, `profiles` |
| **summary** | `content` |
| **experience** | `company`, `position`, `location`, `date`, `description` |
| **education** | `institution`, `degree`, `field`, `date` |
| **skills** | `name`, `level` (1-5) |
| **volunteer** | `organization`, `position`, `date` |
| **awards** | `title`, `organization`, `date` |
| **profiles** | `network`, `username`, `url` |
### Section Configuration
Each section can be configured in `src/config/data.ts`:
```typescript
export const resumeConfig: ResumeConfig = {
tomlFile: "/files/resume.toml",
layout: {
leftColumn: ["experience", "volunteer", "awards"],
rightColumn: ["skills", "education"],
},
sections: {
summary: {
title: "Professional Summary",
enabled: true,
},
experience: {
title: "Work Experience",
enabled: true,
},
awards: {
title: "Awards & Recognition",
enabled: true,
},
// ... other sections
},
};
```
### Layout Configuration
The resume layout is fully customizable. You can control which sections appear in which column and their order:
```typescript
layout: {
leftColumn: ["experience", "volunteer", "awards"],
rightColumn: ["skills", "education"],
}
```
**Available sections for layout:**
- `experience` - Work experience
- `education` - Educational background
- `skills` - Technical and professional skills
- `volunteer` - Volunteer work
- `awards` - Awards and recognition
**Layout Rules:**
- Sections can be placed in either column
- Order within each column is determined by array order
- Missing sections are automatically excluded
- The `summary` section always appears at the top (full width)
- The `profiles` section appears in the header area
**Example Layouts:**
*Skills-focused layout:*
```typescript
layout: {
leftColumn: ["skills", "education"],
rightColumn: ["experience", "awards", "volunteer"],
}
```
*Experience-heavy layout:*
```typescript
layout: {
leftColumn: ["experience"],
rightColumn: ["skills", "education", "volunteer", "awards"],
}
```
### Resume Data Format (TOML)
Create a `resume.toml` file in the `public/files/` directory:
```toml
[basics]
name = "Your Name"
email = "your.email@example.com"
[[basics.profiles]]
network = "GitHub"
username = "yourusername"
url = "https://github.com/yourusername"
[[basics.profiles]]
network = "LinkedIn"
username = "yourname"
url = "https://linkedin.com/in/yourname"
[summary]
content = "Your professional summary here..."
[[experience]]
company = "Company Name"
position = "Job Title"
location = "City, State"
date = "2020 - Present"
description = [
"Achievement or responsibility 1",
"Achievement or responsibility 2"
]
url = "https://company.com"
[[education]]
institution = "University Name"
degree = "Bachelor of Science"
field = "Computer Science"
date = "2016 - 2020"
details = [
"Relevant coursework or achievements"
]
[[skills]]
name = "JavaScript"
level = 4
[[skills]]
name = "Python"
level = 5
[[volunteer]]
organization = "Organization Name"
position = "Volunteer Position"
date = "2019 - Present"
[[awards]]
title = "Award Title"
organization = "Awarding Organization"
date = "2023"
description = "Brief description of the award"
```
**Note:** Preact is used just for PDF generation.

View File

@@ -1,23 +1,23 @@
// @ts-check
import { defineConfig } from "astro/config";
import tailwindcss from "@tailwindcss/vite";
import preact from "@astrojs/preact";
import vue from "@astrojs/vue";
import node from "@astrojs/node";
import icon from "astro-icon";
import mdx from "@astrojs/mdx";
// https://astro.build/config
export default defineConfig({
site: "https://atri.dad",
redirects: {
"/feed": "/rss.xml",
},
output: "server",
build: {
inlineStylesheets: "always",
},
vite: {
plugins: [tailwindcss()],
},
// Configure default image behavior
image: {
responsiveStyles: true,
layout: "constrained",
@@ -25,38 +25,7 @@ export default defineConfig({
objectPosition: "center",
},
integrations: [
preact(),
mdx(),
icon({
include: {
mdi: [
"clock",
"tag",
"arrow-right",
"link",
"email",
"rss",
"download",
"web",
"arrow-left",
],
"simple-icons": [
"gitea",
"bluesky",
"react",
"typescript",
"astro",
"go",
"postgresql",
"redis",
"docker",
"github",
"linkedin",
],
},
}),
],
integrations: [vue(), mdx()],
adapter: node({
mode: "standalone",

1373
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,4 +5,5 @@ services:
- "${APP_PORT}:4321"
environment:
NODE_ENV: production
PUBLIC_RSS_TIMEZONE: ${PUBLIC_RSS_TIMEZONE:-}
restart: unless-stopped

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1766473571,
"narHash": "sha256-5G1NDO2PulBx1RoaA6U1YoUDX0qZslpPxv+n5GX6Qto=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "76701a179d3a98b07653e2b0409847499b2a07d3",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,41 +1,31 @@
{
"name": "atridotdad",
"type": "module",
"version": "1.0.0",
"version": "4.2.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"shell": "nix-shell"
"nix": "nix develop"
},
"dependencies": {
"@astrojs/mdx": "^4.3.1",
"@astrojs/node": "^9.3.0",
"@astrojs/preact": "^4.1.0",
"@astrojs/rss": "^4.0.12",
"@astrojs/mdx": "4.3.13",
"@astrojs/node": "9.5.4",
"@astrojs/rss": "4.0.15",
"@astrojs/vue": "5.1.4",
"@iarna/toml": "^2.2.5",
"@preact/signals": "^2.2.1",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"astro": "^5.12.0",
"astro-icon": "^1.1.5",
"lucide-preact": "^0.525.0",
"playwright": "^1.54.1",
"preact": "^10.26.9",
"sharp": "^0.34.3",
"tailwindcss": "^4.1.11"
"@react-pdf/renderer": "^4.3.2",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
"astro": "5.18.0",
"react": "^19.2.4",
"sharp": "^0.34.5",
"tailwindcss": "^4.2.1",
"vue": "^3.5.29"
},
"devDependencies": {
"@iconify-json/mdi": "^1.2.3",
"@iconify-json/simple-icons": "^1.2.43",
"daisyui": "^5.0.46"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"sharp",
"puppeteer"
]
"@types/react": "^19.2.14",
"daisyui": "^5.5.19"
}
}
}

5740
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

107
shell.nix
View File

@@ -1,107 +0,0 @@
{ pkgs ? import <nixpkgs> {} }:
let
isDarwin = pkgs.stdenv.isDarwin;
isLinux = pkgs.stdenv.isLinux;
commonBuildInputs = with pkgs; [
nodejs_24
nodePackages.pnpm
git
curl
];
playwrightCommonLibs = with pkgs; [
glib
nss
nspr
dbus
atk
at-spi2-atk
at-spi2-core
cups
expat
libxkbcommon
cairo
pango
fontconfig
freetype
harfbuzz
icu
libpng
gnutls
];
playwrightLinuxSpecificLibs = with pkgs; [
glibc
libgcc
xorg.libX11
xorg.libxcb
xorg.libXext
xorg.libXfixes
xorg.libXrandr
xorg.libXcomposite
xorg.libXdamage
xorg.libXcursor
xorg.libXi
xorg.libXrender
xorg.libXtst
mesa
libglvnd
libdrm
udev
alsa-lib
];
playwrightSelfDownloadLibs = playwrightCommonLibs ++ (if isLinux then playwrightLinuxSpecificLibs else []);
playwrightLibPath = pkgs.lib.makeBinPath playwrightSelfDownloadLibs;
in
pkgs.mkShell {
buildInputs = commonBuildInputs ++ (
if isDarwin
then playwrightCommonLibs
else [ pkgs.chromium ] ++ playwrightSelfDownloadLibs
);
shellHook = ''
echo "🚀 atridotdad development environment loaded!"
echo "Node version: $(node --version)"
echo "pnpm version: $(pnpm --version)"
${if isDarwin then ''
echo "Chromium path: Playwright will download its own for macOS"
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=0
export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=false
export LD_LIBRARY_PATH="${playwrightLibPath}:$LD_LIBRARY_PATH"
PLAYWRIGHT_BROWSERS_PATH="$HOME/.cache/ms-playwright"
if [ ! -d "$PLAYWRIGHT_BROWSERS_PATH" ] || [ -z "$(ls -A "$PLAYWRIGHT_BROWSERS_PATH")" ]; then
echo "🌐 Installing Playwright browsers (for macOS)..."
pnpm exec playwright install
else
echo " Playwright browsers already installed (for macOS)."
fi
'' else if isLinux then ''
echo "Chromium path: ${pkgs.chromium}/bin/chromium"
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
export PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH="${pkgs.chromium}/bin/chromium"
export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
export PUPPETEER_EXECUTABLE_PATH="${pkgs.chromium}/bin/chromium"
'' else ''
echo "Unsupported OS detected."
''}
if [ ! -d "node_modules" ]; then
echo "📦 Installing pnpm dependencies..."
pnpm install
fi
'';
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = if isDarwin then "0" else "1";
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH = if isDarwin then null else "${pkgs.chromium}/bin/chromium";
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD = if isDarwin then "false" else "true";
PUPPETEER_EXECUTABLE_PATH = if isDarwin then null else "${pkgs.chromium}/bin/chromium";
}

BIN
src/assets/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

View File

@@ -4,31 +4,25 @@ email = "me@atri.dad"
website = "https://atri.dad"
[layout]
left_column = ["experience", "volunteer"]
right_column = ["skills", "education", "awards"]
left_column = ["experience"]
right_column = ["skills", "education", "awards", "volunteer"]
[[basics.profiles]]
network = "LinkedIn"
username = "atridadl"
url = "https://www.linkedin.com/in/atridadl/"
[[basics.profiles]]
network = "Gitea"
username = "atridad"
url = "https://git.atri.dad/atridad"
[summary]
content = "I am a full-stack web developer and researcher with a background maintaining and developing for large-scale enterprise software systems."
[[experience]]
company = "Atash Consulting"
position = "Owner/Developer"
location = "Edmonton, Alberta"
date = "June 2019 Present"
date = "June 2019 - Present"
description = [
"Builds mobile and web applications for small-medium sized businesses",
"Provides consulting on application development, system architecture, DevOps, etc",
"Hosting websites for small-medium sized businesses",
"Runs an independent software consultancy delivering web, mobile, and DevOps solutions for clients across various industries",
"Builds functional, accessible websites and cross-platform mobile applications for iOS and Android",
"Implements CI/CD pipelines, containerized infrastructure, and end-to-end testing to streamline development",
"Provides ongoing IT support, systems architecture guidance, and technical strategy backed by over a decade of experience",
]
url = "https://atash.dev"
@@ -36,43 +30,48 @@ url = "https://atash.dev"
company = "University of Saskatchewan CEPHIL Lab"
position = "Technical Lead"
location = "Saskatoon, Saskatchewan"
date = "November 2023 Present"
date = "November 2023 - Present"
description = [
"Technical lead and supervisor to a developer intern",
"Developing mobile and web applications with Flutter and React (Astro)",
"Coordinating with other grant researchers to deliver a minimum viable product",
"Gathering requirements from stakeholders to craft a product timeline",
"Lead architecture and implementation of a research application for medication management, including PostgreSQL data model, containerized services, and application stack",
"Work with investigators and students to translate research and data requirements into system design, workflows, and reporting",
"Design and optimize SQL queries and views to generate validated reports and dashboards for study data",
"Supervise and mentor a developer intern and produce technical documentation for research staff"
]
url = "https://cephil.ca/"
[[experience]]
company = "University of Saskatchewan, Department of Computer Science"
position = "Teaching Assistant"
location = "Saskatoon, Saskatchewan"
date = "2024 - Present"
description = [
"Marker for CMPT 141 (Introduction to Computer Organization and Architecture), grading assignments and providing feedback to help students develop an intuition for low level architecture",
"Lab instructor for CMPT 370 (Intermediate Software Engineering), leading weekly labs, guiding project teams, and supporting design and implementation exercises",
"Marker for CMPT 141 (Introduction to Computer Science), grading assignments and providing feedback to help students build foundational programming skills"
]
[[experience]]
company = "Alberta Motor Association"
position = "Software Developer II"
location = "Edmonton, Alberta"
date = "August 2021 November 2023"
date = "August 2021 - November 2023"
description = [
"Developed and maintained internal enterprise-level business applications leveraging Amazon Web Services (AWS)",
"Used React and Create React App (CRA) for standalone applications and micro-front-ends",
"Developed an in-house payment gateway for all AMA services that integrates with Stripe",
"Managed financial reporting for the finance team",
"Provided tier 3 support for internal services",
"Participated in a bi-monthly 24/7 on-call rotation",
"Mentored students in the organization's Developer in Training program",
"Developed and maintained internal enterprise applications on AWS, integrating with core membership, billing, and reporting systems",
"Used React and TypeScript to build Single Page Apps and Micro Frontends interacting with distributed back end services",
"Built and operated an in house payment gateway integrating with Stripe, with emphasis on reliability, observability, and data integrity",
"Provided tier 3 support and participated in a 24/7 on call rotation, troubleshooting production issues on Linux based systems"
]
url = "https://ama.ab.ca/"
[[experience]]
company = "University of Alberta IST"
position = "Software Developer"
location = "Edmonton, Alberta"
date = "October 2019 August 2021"
date = "October 2019 - August 2021"
description = [
"Secondment from previous role",
"Front-end development of web applications using Vue.js",
"Leveraged Amazon Web Services to adopt a serverless architecture",
"Maintained a secure exam application developed in-house",
"Monitored and maintained an exam scheduling system hosted on-premises",
"Developed front end web applications using Vue.js to support teaching and assessment workflows",
"Worked with both on premises and AWS hosted services for exam and scheduling systems, including authentication and access control",
"Maintained a secure exam application, collaborating with instructors and staff to address system issues and improve documentation"
]
url = "https://www.ualberta.ca/en/information-services-and-technology/index.html"
@@ -80,122 +79,111 @@ url = "https://www.ualberta.ca/en/information-services-and-technology/index.html
company = "University of Alberta IST"
position = "Support Analyst"
location = "Edmonton, Alberta"
date = "July 2017 October 2019"
date = "July 2017 - October 2019"
description = [
"Provided support for our Moodle installation to students, faculty, and staff",
"Front-end development of web applications using Vue.js",
"Provided functional and technical support for the university's Moodle installation to students, faculty, and staff",
"Created documentation and user guidance, assisted with training, and contributed to small Vue.js based extensions to learning tools"
]
url = "https://www.ualberta.ca/en/information-services-and-technology/index.html"
[[education]]
institution = "University of Saskatchewan"
degree = "Masters"
degree = "Master's"
field = "Computer Science"
date = "2024 Present"
date = "2024 - Present"
details = [
"Supervisor: Dr. Nathaniel Osgood",
"CMPT 838: Computer Security",
"CMPT 815: Computer Systems and Performance Evaluation",
"CMPT 868: Social Computing and Participative Web",
"CMPT 811: Human Computer Interation"
]
[[education]]
institution = "University of Saskatchewan"
degree = "Bachelors (3 Year)"
field = "Computer Science"
date = "2016 2019"
date = "2016 - 2019"
[[education]]
institution = "University of Saskatchewan"
degree = "Bachelors"
field = "Computer Engineering"
date = "2012 2017"
date = "2012 - 2017"
[[skills]]
name = "HTML + CSS + JavaScript"
name = "Web Development (HTML, CSS, JavaScript/TypeScript, PHP)"
level = 5
[[skills]]
name = "TypeScript"
name = "Modern Full-stack Frameworks (Next, Astro, Nuxt 3)"
level = 5
[[skills]]
name = "Python"
name = "Modern Front-end Libraries (React, Vue, Svelte)"
level = 5
[[skills]]
name = "Project Management"
level = 4
[[skills]]
name = "C# (.NET)"
level = 3
[[skills]]
name = "Swift"
level = 3
[[skills]]
name = "Kotlin"
level = 3
[[skills]]
name = "SQL (PostgreSQL, MySQL, SQLite)"
level = 4
[[skills]]
name = "Vitest, Jest, and Playwright"
name = ".NET (C#, Blazor, EF Core)"
level = 3
[[skills]]
name = "Testing Frameworks (Vitest, Jest, Playwright, Cypress)"
level = 3
[[skills]]
name = "CI/CD (Github Actions, Jenkins, etc.)"
level = 4
[[skills]]
name = "Github Actions"
level = 4
[[skills]]
name = "Docker"
name = "Containerization (Docker & Podman)"
level = 5
[[skills]]
name = "CMS Platforms (Wordpress & Drupal)"
level = 3
[[skills]]
name = "System Administration (Linux & Windows Server)"
level = 5
[[skills]]
name = "Native Mobile Development (Swift & Kotlin)"
level = 3
[[skills]]
name = "Cloud Infrastructure (AWS, Azure, DigitalOcean"
level = 4
[[skills]]
name = "Infrastructure as Code (CDK & Terraform)"
level = 4
[[skills]]
name = "Scripting (Python, Bash, etc.)"
level = 4
[[skills]]
name = "Nix"
level = 3
[[skills]]
name = "Amazon Web Services (AWS)"
level = 4
[[skills]]
name = "Infrastructure as Code (IaC)"
level = 5
[[skills]]
name = "System Administration (Linux)"
level = 4
[[skills]]
name = "Project Leadership"
level = 3
[[skills]]
name = "Project Magagement"
level = 3
[[skills]]
name = "Time Management"
level = 4
[[skills]]
name = "Problem Solving"
level = 5
[[skills]]
name = "Attention to Detail"
level = 5
[[volunteer]]
organization = "Big Brother Big Sisters"
position = "Mentor"
date = "2021 2022"
date = "2021 - 2022"
[[awards]]
title = "IT Innovation Award - Team"
organization = "University of Alberta IST"
date = "2020"
description = "The IT Innovation Award recognizes one team for their innovative use of hardware and/or software technology to successfully deploy a major IT project with significant impact to research, teaching, administration and/or the University experience."
[[awards]]
title = "IT Client Service Award - Team"

View File

@@ -0,0 +1,314 @@
<!-- Credit for this to https://vue-bits.dev/text-animations/fuzzy-text -->
<script setup lang="ts">
import { onMounted, onUnmounted, watch, nextTick, useTemplateRef } from "vue";
interface FuzzyTextProps {
text: string;
fontSize?: number | string;
fontWeight?: string | number;
fontFamily?: string;
color?: string;
enableHover?: boolean;
baseIntensity?: number;
hoverIntensity?: number;
}
const props = withDefaults(defineProps<FuzzyTextProps>(), {
text: "",
fontSize: "clamp(2rem, 8vw, 8rem)",
fontWeight: 900,
fontFamily: "inherit",
color: "#fff",
enableHover: true,
baseIntensity: 0.18,
hoverIntensity: 0.5,
});
const canvasRef = useTemplateRef<HTMLCanvasElement>("canvasRef");
let animationFrameId: number;
let isCancelled = false;
let cleanup: (() => void) | null = null;
const waitForFont = async (
fontFamily: string,
fontWeight: string | number,
fontSize: string,
): Promise<boolean> => {
if (document.fonts?.check) {
const fontString = `${fontWeight} ${fontSize} ${fontFamily}`;
if (document.fonts.check(fontString)) {
return true;
}
try {
await document.fonts.load(fontString);
return document.fonts.check(fontString);
} catch (error) {
console.warn("Font loading failed:", error);
return false;
}
}
return new Promise((resolve) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
resolve(false);
return;
}
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
const testWidth = ctx.measureText("M").width;
let attempts = 0;
const checkFont = () => {
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
const newWidth = ctx.measureText("M").width;
if (newWidth !== testWidth && newWidth > 0) {
resolve(true);
} else if (attempts < 20) {
attempts++;
setTimeout(checkFont, 50);
} else {
resolve(false);
}
};
setTimeout(checkFont, 10);
});
};
const initCanvas = async () => {
if (document.fonts?.ready) {
await document.fonts.ready;
}
if (isCancelled) return;
const canvas = canvasRef.value;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const computedFontFamily =
props.fontFamily === "inherit"
? window.getComputedStyle(canvas).fontFamily || "sans-serif"
: props.fontFamily;
const fontSizeStr =
typeof props.fontSize === "number"
? `${props.fontSize}px`
: props.fontSize;
let numericFontSize: number;
if (typeof props.fontSize === "number") {
numericFontSize = props.fontSize;
} else {
const temp = document.createElement("span");
temp.style.fontSize = props.fontSize;
temp.style.fontFamily = computedFontFamily;
document.body.appendChild(temp);
const computedSize = window.getComputedStyle(temp).fontSize;
numericFontSize = parseFloat(computedSize);
document.body.removeChild(temp);
}
const fontLoaded = await waitForFont(
computedFontFamily,
props.fontWeight,
fontSizeStr,
);
if (!fontLoaded) {
console.warn(`Font not loaded: ${computedFontFamily}`);
}
const text = props.text;
const offscreen = document.createElement("canvas");
const offCtx = offscreen.getContext("2d");
if (!offCtx) return;
const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;
offCtx.font = fontString;
const testMetrics = offCtx.measureText("M");
if (testMetrics.width === 0) {
setTimeout(() => {
if (!isCancelled) {
initCanvas();
}
}, 100);
return;
}
offCtx.textBaseline = "alphabetic";
const metrics = offCtx.measureText(text);
const actualLeft = metrics.actualBoundingBoxLeft ?? 0;
const actualRight = metrics.actualBoundingBoxRight ?? metrics.width;
const actualAscent = metrics.actualBoundingBoxAscent ?? numericFontSize;
const actualDescent =
metrics.actualBoundingBoxDescent ?? numericFontSize * 0.2;
const textBoundingWidth = Math.ceil(actualLeft + actualRight);
const tightHeight = Math.ceil(actualAscent + actualDescent);
const extraWidthBuffer = 10;
const offscreenWidth = textBoundingWidth + extraWidthBuffer;
offscreen.width = offscreenWidth;
offscreen.height = tightHeight;
const xOffset = extraWidthBuffer / 2;
offCtx.font = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;
offCtx.textBaseline = "alphabetic";
offCtx.fillStyle = props.color;
offCtx.fillText(text, xOffset - actualLeft, actualAscent);
const horizontalMargin = 50;
const verticalMargin = 0;
canvas.width = offscreenWidth + horizontalMargin * 2;
canvas.height = tightHeight + verticalMargin * 2;
ctx.translate(horizontalMargin, verticalMargin);
const interactiveLeft = horizontalMargin + xOffset;
const interactiveTop = verticalMargin;
const interactiveRight = interactiveLeft + textBoundingWidth;
const interactiveBottom = interactiveTop + tightHeight;
let isHovering = false;
const fuzzRange = 30;
const run = () => {
if (isCancelled) return;
ctx.clearRect(
-fuzzRange,
-fuzzRange,
offscreenWidth + 2 * fuzzRange,
tightHeight + 2 * fuzzRange,
);
const intensity = isHovering
? props.hoverIntensity
: props.baseIntensity;
for (let j = 0; j < tightHeight; j++) {
const dx = Math.floor(
intensity * (Math.random() - 0.5) * fuzzRange,
);
ctx.drawImage(
offscreen,
0,
j,
offscreenWidth,
1,
dx,
j,
offscreenWidth,
1,
);
}
animationFrameId = window.requestAnimationFrame(run);
};
run();
const isInsideTextArea = (x: number, y: number) =>
x >= interactiveLeft &&
x <= interactiveRight &&
y >= interactiveTop &&
y <= interactiveBottom;
const handleMouseMove = (e: MouseEvent) => {
if (!props.enableHover) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
isHovering = isInsideTextArea(x, y);
};
const handleMouseLeave = () => {
isHovering = false;
};
const handleTouchMove = (e: TouchEvent) => {
if (!props.enableHover) return;
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const touch = e.touches[0];
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
isHovering = isInsideTextArea(x, y);
};
const handleTouchEnd = () => {
isHovering = false;
};
if (props.enableHover) {
canvas.addEventListener("mousemove", handleMouseMove);
canvas.addEventListener("mouseleave", handleMouseLeave);
canvas.addEventListener("touchmove", handleTouchMove, {
passive: false,
});
canvas.addEventListener("touchend", handleTouchEnd);
}
cleanup = () => {
window.cancelAnimationFrame(animationFrameId);
if (props.enableHover) {
canvas.removeEventListener("mousemove", handleMouseMove);
canvas.removeEventListener("mouseleave", handleMouseLeave);
canvas.removeEventListener("touchmove", handleTouchMove);
canvas.removeEventListener("touchend", handleTouchEnd);
}
};
};
onMounted(() => {
nextTick(() => {
initCanvas();
});
});
onUnmounted(() => {
isCancelled = true;
if (animationFrameId) {
window.cancelAnimationFrame(animationFrameId);
}
if (cleanup) {
cleanup();
}
});
watch(
[
() => props.text,
() => props.fontSize,
() => props.fontWeight,
() => props.fontFamily,
() => props.color,
() => props.enableHover,
() => props.baseIntensity,
() => props.hoverIntensity,
],
() => {
isCancelled = true;
if (animationFrameId) {
window.cancelAnimationFrame(animationFrameId);
}
if (cleanup) {
cleanup();
}
isCancelled = false;
nextTick(() => {
initCanvas();
});
},
);
</script>
<template>
<canvas ref="canvasRef" />
</template>

27
src/components/Icon.astro Normal file
View File

@@ -0,0 +1,27 @@
---
import { icons, type IconName } from "../config/icons";
interface Props {
name: IconName;
class?: string;
"class:list"?: any;
}
const { name, class: className, "class:list": classList } = Astro.props;
const svg = icons[name];
if (!svg) {
throw new Error(`Icon "${name}" not found in icon registry`);
}
---
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="1em"
height="1em"
fill="none"
class:list={[className, classList]}
aria-hidden="true"
set:html={svg}
/>

30
src/components/Icon.vue Normal file
View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from "vue";
import { icons, type IconName } from "../config/icons";
const props = defineProps<{
name: IconName;
class?: string;
}>();
const svg = computed(() => {
const content = icons[props.name];
if (!content) {
console.error(`Icon "${props.name}" not found in icon registry`);
}
return content;
});
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="1em"
height="1em"
fill="none"
:class="props.class"
aria-hidden="true"
v-html="svg"
/>
</template>

View File

@@ -1,41 +0,0 @@
import { Icon } from 'astro-icon/components';
import type { IconType, LucideIcon, AstroIconName, CustomIconComponent } from '../types';
interface IconRendererProps {
icon: IconType;
size?: number;
class?: string;
[key: string]: any; // For additional props like client:load for custom components
}
// Type guard functions
function isLucideIcon(icon: IconType): icon is LucideIcon {
return typeof icon === 'function' && icon.length <= 1; // Lucide icons are function components
}
function isAstroIconName(icon: IconType): icon is AstroIconName {
return typeof icon === 'string';
}
function isCustomComponent(icon: IconType): icon is CustomIconComponent {
return typeof icon === 'function' && !isLucideIcon(icon);
}
export default function IconRenderer({ icon, size, class: className, ...props }: IconRendererProps) {
if (isLucideIcon(icon)) {
const LucideComponent = icon;
return <LucideComponent size={size} class={className} {...props} />;
}
if (isAstroIconName(icon)) {
return <Icon name={icon} class={className} {...props} />;
}
if (isCustomComponent(icon)) {
const CustomComponent = icon;
return <CustomComponent class={className} {...props} />;
}
// Fallback
return null;
}

View File

@@ -1,122 +0,0 @@
import { useComputed, useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
import { navigationItems } from "../config/data";
import type { LucideIcon } from "../types";
interface NavigationBarProps {
currentPath: string;
}
export default function NavigationBar({ currentPath }: NavigationBarProps) {
const isScrolling = useSignal(false);
const prevScrollPos = useSignal(0);
const currentClientPath = useSignal(currentPath);
const isVisible = useComputed(() => {
if (prevScrollPos.value < 50) return true;
const currentPos = typeof window !== "undefined" ? globalThis.scrollY : 0;
return prevScrollPos.value > currentPos;
});
// Filter out disabled navigation items
const enabledNavigationItems = navigationItems.filter(
(item) => item.enabled !== false,
);
// Update client path when location changes
useEffect(() => {
const updatePath = () => {
if (typeof window !== "undefined") {
currentClientPath.value = window.location.pathname;
}
};
// Set initial path
updatePath();
// Listen for Astro's view transition events
const handleAstroNavigation = () => {
updatePath();
};
// Listen for astro:page-load event which fires after navigation completes
document.addEventListener("astro:page-load", handleAstroNavigation);
// Also listen for astro:after-swap as a backup
document.addEventListener("astro:after-swap", handleAstroNavigation);
// Listen for regular navigation events as fallback
window.addEventListener("popstate", updatePath);
return () => {
document.removeEventListener("astro:page-load", handleAstroNavigation);
document.removeEventListener("astro:after-swap", handleAstroNavigation);
window.removeEventListener("popstate", updatePath);
};
}, []);
// Use the client path
const activePath = currentClientPath.value;
// Normalize path
const normalizedPath =
activePath.endsWith("/") && activePath.length > 1
? activePath.slice(0, -1)
: activePath;
useEffect(() => {
let scrollTimer: ReturnType<typeof setTimeout> | undefined;
const handleScroll = () => {
isScrolling.value = true;
prevScrollPos.value = globalThis.scrollY;
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
isScrolling.value = false;
}, 200);
};
globalThis.addEventListener("scroll", handleScroll);
return () => {
globalThis.removeEventListener("scroll", handleScroll);
if (scrollTimer) clearTimeout(scrollTimer);
};
}, []);
return (
<div
class={`fixed bottom-3 sm:bottom-4 left-1/2 transform -translate-x-1/2 z-20 transition-all duration-300 ${
isScrolling.value ? "opacity-30" : "opacity-100"
} ${isVisible.value ? "translate-y-0" : "translate-y-20"}`}
>
<div class="overflow-visible">
<ul class="menu menu-horizontal bg-base-200 rounded-box p-1.5 sm:p-2 flex flex-nowrap whitespace-nowrap">
{enabledNavigationItems.map((item) => {
const Icon = item.icon as LucideIcon;
const isActive = item.isActive
? item.isActive(normalizedPath)
: normalizedPath === item.path;
return (
<li key={item.id} class="mx-0.5 sm:mx-1">
<a
href={item.path}
class={`tooltip tooltip-top min-h-[44px] min-w-[44px] inline-flex items-center justify-center ${isActive ? "menu-active" : ""}`}
aria-label={item.tooltip}
data-tip={item.tooltip}
>
<Icon size={18} class="sm:w-5 sm:h-5" />
<span class="sr-only">{item.name}</span>
</a>
</li>
);
})}
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { config } from "../config";
import Icon from "./Icon.vue";
const props = defineProps<{
currentPath: string;
}>();
const isVisible = ref(true);
const isScrolling = ref(false);
const currentClientPath = ref(props.currentPath);
const enabledNavigationItems = config.navigationItems.filter(
(item) => item.enabled !== false,
);
const activePath = computed(() => currentClientPath.value);
const normalizedPath = computed(() => {
const path = activePath.value;
return path.endsWith("/") && path.length > 1 ? path.slice(0, -1) : path;
});
const updatePath = () => {
if (typeof window !== "undefined") {
currentClientPath.value = window.location.pathname;
}
};
let lastScrollY = 0;
let ticking = false;
let scrollTimer: ReturnType<typeof setTimeout> | undefined;
const updateScroll = () => {
const currentScrollY = window.scrollY;
if (currentScrollY < 50) {
isVisible.value = true;
} else {
if (Math.abs(currentScrollY - lastScrollY) > 0) {
isVisible.value = currentScrollY < lastScrollY;
}
}
lastScrollY = currentScrollY;
ticking = false;
};
const onScroll = () => {
isScrolling.value = true;
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
isScrolling.value = false;
}, 200);
if (!ticking) {
window.requestAnimationFrame(updateScroll);
ticking = true;
}
};
onMounted(() => {
updatePath();
lastScrollY = window.scrollY;
document.addEventListener("astro:page-load", updatePath);
document.addEventListener("astro:after-swap", updatePath);
window.addEventListener("popstate", updatePath);
window.addEventListener("scroll", onScroll, { passive: true });
});
onUnmounted(() => {
document.removeEventListener("astro:page-load", updatePath);
document.removeEventListener("astro:after-swap", updatePath);
window.removeEventListener("popstate", updatePath);
window.removeEventListener("scroll", onScroll);
if (scrollTimer) clearTimeout(scrollTimer);
});
</script>
<template>
<div
class="fixed bottom-3 sm:bottom-4 left-1/2 transform -translate-x-1/2 z-20 transition-all duration-300"
:class="[
isScrolling ? 'opacity-30' : 'opacity-100',
isVisible ? 'translate-y-0' : 'translate-y-20',
]"
>
<div class="overflow-visible">
<ul
class="menu menu-horizontal bg-base-200 rounded-box border border-solid border-primary p-1.5 sm:p-2 flex flex-nowrap whitespace-nowrap"
>
<li
v-for="item in enabledNavigationItems"
:key="item.id"
class="mx-0.5 sm:mx-1"
>
<a
:href="item.path"
class="tooltip tooltip-top min-h-11 min-w-11 inline-flex items-center justify-center"
:class="{
'menu-active': item.isActive
? item.isActive(normalizedPath)
: normalizedPath === item.path,
}"
:aria-label="item.tooltip"
:data-tip="item.tooltip"
data-astro-prefetch="hover"
>
<Icon
:name="item.icon"
class="w-[18px] h-[18px] sm:w-5 sm:h-5"
/>
<span class="sr-only">{{ item.name }}</span>
</a>
</li>
</ul>
</div>
</div>
</template>

View File

@@ -1,65 +0,0 @@
---
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-6 break-words">
<h2
class="card-title text-xl md:text-2xl font-bold justify-center text-center break-words text-base-100"
>
{title}
</h2>
<p class="text-center break-words my-4 text-base-100">
{blurb || "No description available."}
</p>
<div
class="flex flex-wrap items-center justify-center text-base-100 opacity-75 gap-2 text-sm mb-4"
>
<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-4 justify-center">
{post.data.tags.map((tag: string) => (
<div class="badge badge-primary">
<Icon name="mdi:tag" class="text-lg" />
{tag}
</div>
))}
</div>
)
}
<div class="card-actions justify-end mt-4">
<a
href={`/post/${slug}`}
class="btn btn-sm bg-base-100 hover:bg-secondary text-accent"
aria-label={`Read more about ${title}`}
>
<Icon name="mdi:arrow-right" class="text-lg" />
</a>
</div>
</div>
</div>

View File

@@ -1,38 +0,0 @@
---
import { Icon } from "astro-icon/components";
import type { Project } from "../types";
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 break-words">
<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-sm bg-base-100 hover:bg-secondary text-accent"
aria-label={`Visit ${project.name}`}
>
<Icon name="mdi:link" class="text-lg" />
</a>
</div>
</div>
</div>

View File

@@ -1,66 +0,0 @@
import { useState } from "preact/hooks";
interface ResumeDownloadButtonProps {
className?: string;
}
export default function ResumeDownloadButton({
className = "",
}: ResumeDownloadButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDownload = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/resume/pdf?t=${Date.now()}`);
if (!response.ok) {
throw new Error(
`Failed to generate PDF: ${response.status} ${response.statusText}`,
);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
// Create a temporary link element and trigger download
const link = document.createElement("a");
link.href = url;
link.download = "Atridad_Lahiji_Resume.pdf";
document.body.appendChild(link);
link.click();
// Clean up
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
console.error("Error downloading PDF:", err);
setError(err instanceof Error ? err.message : "Failed to download PDF");
} finally {
setIsLoading(false);
}
};
return (
<div class="text-center mb-6 sm:mb-8">
<button
onClick={handleDownload}
disabled={isLoading}
class={`btn btn-primary rounded-full inline-flex items-center gap-2 text-sm sm:text-base ${className}`}
>
{isLoading ? (
<>
<span class="loading loading-spinner"></span>
Generating PDF...
</>
) : (
<>Download Resume</>
)}
</button>
{error && <div class="mt-2 text-error text-sm">{error}</div>}
</div>
);
}

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { ref } from "vue";
const isLoading = ref(false);
const error = ref<string | null>(null);
const handleDownload = async () => {
isLoading.value = true;
error.value = null;
try {
const response = await fetch(`/api/resume/generate?t=${Date.now()}`);
if (!response.ok) {
throw new Error(
`Failed to generate PDF: ${response.status} ${response.statusText}`,
);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "Atridad_Lahiji_Resume.pdf";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
console.error("Error downloading PDF:", err);
error.value =
err instanceof Error ? err.message : "Failed to download PDF";
} finally {
isLoading.value = false;
}
};
</script>
<template>
<div class="text-center mb-6 sm:mb-8">
<button
@click="handleDownload"
:disabled="isLoading"
class="btn btn-primary font-bold rounded-full inline-flex items-center gap-2 text-sm sm:text-base"
:class="{
'text-primary border-2 border-primary': isLoading,
}"
>
<template v-if="isLoading">
<span class="loading loading-spinner"></span>
Generating PDF...
</template>
<template v-else> Download Resume </template>
</button>
<div v-if="error" class="mt-2 text-error text-sm">{{ error }}</div>
</div>
</template>

View File

@@ -1,319 +0,0 @@
import { useState } from "preact/hooks";
import { useSignal } from "@preact/signals";
import { Settings } from "lucide-preact";
interface ResumeSettingsModalProps {
className?: string;
}
export default function ResumeSettingsModal({
className = "",
}: ResumeSettingsModalProps) {
const [tomlContent, setTomlContent] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"upload" | "edit">("upload");
const dragActive = useSignal(false);
const modalOpen = useSignal(false);
const openModal = () => {
modalOpen.value = true;
};
const closeModal = () => {
modalOpen.value = false;
setError(null);
setTomlContent("");
setActiveTab("upload");
};
const handleFileUpload = (file: File) => {
if (!file.name.endsWith(".toml")) {
setError("Please upload a .toml file");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
setTomlContent(content);
setError(null);
setActiveTab("edit");
};
reader.onerror = () => {
setError("Error reading file");
};
reader.readAsText(file);
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
dragActive.value = false;
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
handleFileUpload(files[0]);
}
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
dragActive.value = true;
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
dragActive.value = false;
};
const handleFileInput = (e: Event) => {
const target = e.target as HTMLInputElement;
const files = target.files;
if (files && files.length > 0) {
handleFileUpload(files[0]);
}
};
const downloadTemplate = async () => {
try {
const response = await fetch("/api/resume/template");
if (!response.ok) {
throw new Error("Failed to download template");
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "resume-template.toml";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
setError("Failed to download template");
}
};
const generatePDF = async () => {
if (!tomlContent.trim()) {
setError("Please provide TOML content");
return;
}
setIsGenerating(true);
setError(null);
try {
const response = await fetch("/api/resume/pdf", {
method: "POST",
headers: {
"Content-Type": "text/plain",
},
body: tomlContent,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
errorText || `Failed to generate PDF: ${response.status}`,
);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "resume.pdf";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
console.error("Error generating PDF:", err);
setError(err instanceof Error ? err.message : "Failed to generate PDF");
} finally {
setIsGenerating(false);
}
};
const loadTemplate = async () => {
try {
const response = await fetch("/api/resume/template");
if (!response.ok) {
throw new Error("Failed to load template");
}
const template = await response.text();
setTomlContent(template);
setActiveTab("edit");
setError(null);
} catch (err) {
setError("Failed to load template");
}
};
return (
<>
{/* Floating Settings Button */}
<button
onClick={openModal}
class={`fixed top-4 right-4 z-20 btn btn-square btn-secondary hover:bg-primary opacity-100 translate-y-0 min-h-[44px] min-w-[44px] ${className}`}
aria-label="Resume Settings"
>
<Settings class="text-lg" />
</button>
{/* Modal */}
<div class={`modal ${modalOpen.value ? "modal-open" : ""}`}>
<div class="modal-box w-11/12 max-w-5xl h-[90vh] flex flex-col">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">Resume Generator</h3>
<button
onClick={closeModal}
class="btn btn-sm btn-circle btn-ghost"
>
</button>
</div>
<div class="flex-1 overflow-hidden flex flex-col">
<p class="text-base-content/70 mb-4">
Create a custom PDF resume from a TOML file. Download the
template, edit it with your information, and generate your resume.
</p>
{/* Action Buttons */}
<div class="flex flex-wrap gap-2 mb-6">
<button onClick={downloadTemplate} class="btn btn-primary btn-sm">
Download Template
</button>
<button onClick={loadTemplate} class="btn btn-secondary btn-sm">
Load Template in Editor
</button>
</div>
{/* Tabs */}
<div class="flex justify-center mb-4">
<div
role="tablist"
class="inline-flex bg-base-300 border border-base-content/20 rounded-full p-1"
>
<button
role="tab"
class={`px-4 py-2 rounded-full text-sm transition-all duration-200 ${
activeTab === "upload"
? "bg-primary font-bold text-primary-content shadow-sm"
: "text-base-content/70 hover:text-base-content font-bold hover:bg-base-200"
}`}
onClick={() => setActiveTab("upload")}
>
Upload File
</button>
<button
role="tab"
class={`px-4 py-2 rounded-full text-sm font-bold transition-all duration-200 ${
activeTab === "edit"
? "bg-primary text-primary-content shadow-sm"
: "text-base-content/70 hover:text-base-content font-bold hover:bg-base-200"
}`}
onClick={() => setActiveTab("edit")}
>
Edit TOML
</button>
</div>
</div>
{/* Content Area */}
<div class="flex-1 overflow-hidden">
{/* Upload Tab */}
{activeTab === "upload" && (
<div class="h-full">
<div
class={`border-2 border-dashed rounded-lg p-6 text-center transition-colors h-full flex items-center justify-center ${
dragActive.value
? "border-primary bg-primary/10"
: "border-base-300 hover:border-primary/50"
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<div class="space-y-4">
<div>
<p class="text-lg font-medium">
Drop your TOML file here
</p>
<p class="text-base-content/70">
or click below to browse
</p>
</div>
<input
type="file"
accept=".toml"
onChange={handleFileInput}
class="file-input file-input-primary file-input-sm w-full max-w-xs"
/>
</div>
</div>
</div>
)}
{/* Edit Tab */}
{activeTab === "edit" && (
<div class="h-full flex flex-col space-y-2">
<div class="label">
<span class="label-text font-bold">TOML Content</span>
<span class="label-text-alt">
Edit your resume data below
</span>
</div>
<textarea
class="textarea textarea-bordered flex-1 font-mono text-xs resize-none w-full min-h-0"
placeholder="Paste your TOML content here or load the template..."
value={tomlContent}
onInput={(e) =>
setTomlContent((e.target as HTMLTextAreaElement).value)
}
/>
</div>
)}
</div>
{/* Error Display */}
{error && (
<div class="alert alert-error mt-4">
<span class="text-sm">{error}</span>
</div>
)}
{/* Generate Button */}
{tomlContent.trim() && (
<div class="mt-4">
<button
onClick={generatePDF}
disabled={isGenerating}
class="btn btn-primary btn-sm w-full"
>
{isGenerating ? (
<>
<span class="loading loading-spinner loading-xs"></span>
Generating PDF...
</>
) : (
"Generate Custom Resume PDF"
)}
</button>
</div>
)}
</div>
</div>
<div class="modal-backdrop backdrop-blur-sm" onClick={closeModal}></div>
</div>
</>
);
}

View File

@@ -0,0 +1,317 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import Icon from "./Icon.vue";
const tomlContent = ref("");
const isGenerating = ref(false);
const error = ref<string | null>(null);
const activeTab = ref<"upload" | "edit">("upload");
const dragActive = ref(false);
const modalOpen = ref(false);
const hasContent = computed(() => tomlContent.value.trim().length > 0);
const openModal = () => {
modalOpen.value = true;
};
const closeModal = () => {
modalOpen.value = false;
error.value = null;
tomlContent.value = "";
activeTab.value = "upload";
};
const handleFileUpload = (file: File) => {
if (!file.name.endsWith(".toml")) {
error.value = "Please upload a .toml file";
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
tomlContent.value = content;
error.value = null;
activeTab.value = "edit";
};
reader.onerror = () => {
error.value = "Error reading file";
};
reader.readAsText(file);
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
dragActive.value = false;
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
handleFileUpload(files[0]);
}
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
dragActive.value = true;
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
dragActive.value = false;
};
const handleFileInput = (e: Event) => {
const target = e.target as HTMLInputElement;
const files = target.files;
if (files && files.length > 0) {
handleFileUpload(files[0]);
}
};
const downloadTemplate = async () => {
try {
const response = await fetch("/api/resume/template");
if (!response.ok) {
throw new Error("Failed to download template");
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "resume-template.toml";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
error.value = "Failed to download template";
}
};
const generatePDF = async () => {
if (!hasContent.value) {
error.value = "Please provide TOML content";
return;
}
isGenerating.value = true;
error.value = null;
try {
const response = await fetch("/api/resume/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ toml: tomlContent.value }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
errorText || `Failed to generate PDF: ${response.status}`,
);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "resume.pdf";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
console.error("Error generating PDF:", err);
error.value =
err instanceof Error ? err.message : "Failed to generate PDF";
} finally {
isGenerating.value = false;
}
};
const loadTemplate = async () => {
try {
const response = await fetch("/api/resume/template");
if (!response.ok) {
throw new Error("Failed to load template");
}
const template = await response.text();
tomlContent.value = template;
activeTab.value = "edit";
error.value = null;
} catch (err) {
error.value = "Failed to load template";
}
};
</script>
<template>
<!-- Floating Settings Button -->
<button
@click="openModal"
class="fixed top-4 right-4 z-20 btn btn-secondary hover:btn-primary btn-circle"
:class="$attrs.class"
aria-label="Resume Settings"
>
<Icon name="settings" class="text-lg" />
</button>
<!-- Modal -->
<div class="modal" :class="{ 'modal-open': modalOpen }">
<div
class="modal-box w-11/12 max-w-5xl h-[90vh] flex flex-col relative z-50"
>
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">Resume Generator</h3>
<button
@click="closeModal"
class="btn btn-circle btn-secondary hover:btn-primary"
>
<Icon name="x" class="text-lg" />
</button>
</div>
<div class="flex-1 overflow-hidden flex flex-col">
<p class="text-base-content/70 mb-4">
Create a custom PDF resume from a TOML file. Download the
template, edit it with your information, and generate your
resume.
</p>
<!-- Action Buttons -->
<div class="flex flex-wrap gap-2 mb-6">
<button
@click="downloadTemplate"
class="btn btn-primary btn-sm font-bold"
>
Download Template
</button>
<button
@click="loadTemplate"
class="btn btn-secondary btn-sm font-bold"
>
Load Template in Editor
</button>
</div>
<!-- Tabs -->
<div class="flex justify-center mb-4">
<div
role="tablist"
class="inline-flex bg-base-300 border border-base-content/20 rounded-full p-1"
>
<button
role="tab"
class="px-4 py-2 rounded-full text-sm transition-all duration-200 font-bold"
:class="
activeTab === 'upload'
? 'btn btn-primary shadow-sm'
: 'text-base-content/70 hover:text-base-content hover:bg-base-200'
"
@click="activeTab = 'upload'"
>
Upload TOML
</button>
<button
role="tab"
class="px-4 py-2 rounded-full text-sm font-bold transition-all duration-200"
:class="
activeTab === 'edit'
? 'btn btn-primary shadow-sm'
: 'text-base-content/70 hover:text-base-content hover:bg-base-200'
"
@click="activeTab = 'edit'"
>
Edit TOML
</button>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 overflow-hidden">
<!-- Upload Tab -->
<div v-if="activeTab === 'upload'" class="h-full">
<div
class="border-2 border-dashed rounded-lg p-6 text-center transition-colors h-full flex items-center justify-center"
:class="
dragActive ? 'bg-primary/20' : 'border-primary'
"
@drop="handleDrop"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
>
<div class="space-y-4">
<div>
<p class="text-lg font-medium">
Drop your TOML file here
</p>
<p class="text-base-content/70">
or click below to browse
</p>
</div>
<input
type="file"
accept=".toml"
@change="handleFileInput"
class="file-input file-input-primary file-input-sm w-full max-w-xs"
/>
</div>
</div>
</div>
<!-- Edit Tab -->
<div
v-if="activeTab === 'edit'"
class="h-full flex flex-col space-y-2"
>
<div class="label">
<span class="label-text font-bold"
>TOML Content</span
>
<span class="label-text-alt">
Edit your resume data below
</span>
</div>
<textarea
class="textarea textarea-bordered flex-1 font-mono text-xs resize-none w-full min-h-0"
placeholder="Paste your TOML content here or load the template..."
v-model="tomlContent"
></textarea>
</div>
</div>
<!-- Error Display -->
<div v-if="error" class="alert alert-error mt-4">
<span class="text-sm">{{ error }}</span>
</div>
<!-- Generate Button -->
<div v-if="hasContent" class="mt-4">
<button
@click="generatePDF"
:disabled="isGenerating"
class="btn btn-primary btn-sm w-full"
>
<template v-if="isGenerating">
<span
class="loading loading-spinner loading-xs"
></span>
Generating PDF...
</template>
<template v-else> Generate Custom Resume PDF </template>
</button>
</div>
</div>
</div>
<div class="modal-backdrop" @click="closeModal"></div>
</div>
</template>

View File

@@ -1,92 +0,0 @@
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
interface Skill {
id: string;
name: string;
level: number;
}
interface ResumeSkillsProps {
skills: Skill[];
}
export default function ResumeSkills({ skills }: ResumeSkillsProps) {
const animatedLevels = useSignal<{ [key: string]: number }>({});
const hasAnimated = useSignal(false);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !hasAnimated.value) {
hasAnimated.value = true;
skills.forEach((skill) => {
animateSkill(skill.id, skill.level);
});
}
});
},
{ threshold: 0.3 },
);
const skillsElement = document.getElementById("skills-section");
if (skillsElement) {
observer.observe(skillsElement);
}
return () => {
if (skillsElement) {
observer.unobserve(skillsElement);
}
};
}, [skills]);
const animateSkill = (skillId: string, targetLevel: number) => {
const steps = 60;
const increment = targetLevel / steps;
let currentStep = 0;
const animate = () => {
if (currentStep <= steps) {
const currentValue = Math.min(increment * currentStep, targetLevel);
animatedLevels.value = {
...animatedLevels.value,
[skillId]: currentValue,
};
currentStep++;
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
};
return (
<div id="skills-section" class="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
{skills.map((skill) => {
const currentLevel = animatedLevels.value[skill.id] || 0;
const progressValue = currentLevel * 20;
return (
<div key={skill.id}>
<div class="flex justify-between items-center p-1 sm:p-2">
<span class="text-sm sm:text-base font-medium">
{skill.name}
</span>
<span class="text-xs sm:text-sm text-base-content/70">
{Math.round(currentLevel)}/5
</span>
</div>
<progress
class="progress progress-primary w-full h-2 sm:h-3 min-h-2 transition-all duration-100 ease-out"
value={progressValue}
max="100"
aria-label={`${skill.name} skill level: ${Math.round(currentLevel)} out of 5`}
></progress>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
interface Skill {
id: string;
name: string;
level: number;
}
const props = defineProps<{
skills: Skill[];
}>();
const skillsSection = ref<HTMLElement | null>(null);
const animatedLevels = ref<{ [key: string]: number }>({});
const hasAnimated = ref(false);
let observer: IntersectionObserver | null = null;
let animationFrameId: number | null = null;
const animateSkills = () => {
const duration = 1000;
const startTime = performance.now();
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
props.skills.forEach((skill) => {
animatedLevels.value[skill.id] = skill.level * progress;
});
if (progress < 1) {
animationFrameId = requestAnimationFrame(animate);
}
};
animationFrameId = requestAnimationFrame(animate);
};
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !hasAnimated.value) {
hasAnimated.value = true;
animateSkills();
if (skillsSection.value && observer) {
observer.unobserve(skillsSection.value);
}
}
});
},
{ threshold: 0.3 },
);
if (skillsSection.value) {
observer.observe(skillsSection.value);
}
});
onUnmounted(() => {
if (observer) {
observer.disconnect();
}
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
});
</script>
<template>
<div id="skills-section" ref="skillsSection" class="space-y-3 sm:space-y-4">
<div v-for="skill in skills" :key="skill.id" class="p-1 sm:p-2">
<div class="flex justify-between items-center mb-2">
<span
class="text-sm sm:text-base font-medium truncate pr-2 min-w-0 flex-1"
:title="skill.name"
>
{{ skill.name }}
</span>
<span
class="text-xs sm:text-sm text-base-content/70 whitespace-nowrap"
>
{{ Math.round(animatedLevels[skill.id] || 0) }}/5
</span>
</div>
<progress
class="progress progress-primary w-full h-2 sm:h-3 min-h-2 transition-all duration-100 ease-out"
:value="(animatedLevels[skill.id] || 0) * 20"
max="100"
:aria-label="`${skill.name} skill level: ${Math.round(animatedLevels[skill.id] || 0)} out of 5`"
></progress>
</div>
</div>
</template>

View File

@@ -1,45 +0,0 @@
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
import { ArrowUp } from "lucide-preact";
export default function ScrollUpButton() {
const isVisible = useSignal(false);
useEffect(() => {
const checkScroll = () => {
isVisible.value = window.scrollY > 300;
};
checkScroll();
window.addEventListener("scroll", checkScroll);
return () => {
window.removeEventListener("scroll", checkScroll);
};
}, []);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
};
return (
<button
type="button"
onClick={scrollToTop}
class={`fixed bottom-20 right-4 z-20 bg-secondary hover:bg-primary
p-3 rounded-full transition-all duration-300 min-h-[44px] min-w-[44px] inline-flex items-center justify-center
${
isVisible.value
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-10 pointer-events-none"
}`}
aria-label="Scroll to top"
>
<ArrowUp class="text-lg" />
</button>
);
}

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import Icon from "./Icon.vue";
const isVisible = ref(false);
let ticking = false;
const updateScroll = () => {
isVisible.value = window.scrollY > 50;
ticking = false;
};
const onScroll = () => {
if (!ticking) {
window.requestAnimationFrame(updateScroll);
ticking = true;
}
};
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
};
onMounted(() => {
updateScroll();
window.addEventListener("scroll", onScroll, { passive: true });
});
onUnmounted(() => {
window.removeEventListener("scroll", onScroll);
});
</script>
<template>
<button
@click="scrollToTop"
class="fixed bottom-4 right-4 z-20 btn btn-secondary hover:btn-primary btn-circle transition-all duration-300"
:class="
isVisible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-10 pointer-events-none'
"
aria-label="Scroll to top"
>
<Icon name="arrow-up" class="text-lg" />
</button>
</template>

View File

@@ -1,31 +1,24 @@
---
import { Icon } from "astro-icon/components";
import { socialLinks } from "../config/data";
// Helper function to check if icon is a string (Astro icon)
function isAstroIcon(icon: any): icon is string {
return typeof icon === "string";
}
import Icon from "./Icon.astro";
import { config } from "../config";
---
<div class="flex flex-row gap-4 text-3xl">
<div class="flex flex-row gap-3 text-3xl flex-wrap justify-center">
{
socialLinks.map((link) => {
return (
<a
href={link.url}
target={link.url.startsWith("http") ? "_blank" : undefined}
rel={
link.url.startsWith("http")
? "noopener noreferrer"
: undefined
}
aria-label={link.ariaLabel}
class="hover:text-primary transition-colors"
>
<Icon name={link.icon} />
</a>
);
})
config.socialLinks.map((link) => (
<a
href={link.url}
target={link.url.startsWith("http") ? "_blank" : undefined}
rel={
link.url.startsWith("http")
? "noopener noreferrer"
: undefined
}
aria-label={link.ariaLabel}
class="hover:text-primary transition-colors"
>
<Icon name={link.icon} />
</a>
))
}
</div>

View File

@@ -1,49 +0,0 @@
---
import { Icon } from "astro-icon/components";
import type { Talk } from "../types";
interface Props {
talk: Talk;
}
const { talk } = 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 break-words">
<h2
class="card-title text-xl md:text-2xl font-bold justify-center text-center break-words text-base-100"
>
{talk.name}
</h2>
<p class="text-center break-words my-4 text-base-100">
{talk.description}
</p>
<div class="flex flex-col gap-2 mb-4 text-sm">
{
talk.date && (
<div class="flex items-center gap-2">
<span class="font-semibold">Date:</span>
<span>{talk.date}</span>
</div>
)
}
</div>
<div class="card-actions justify-end mt-4">
<a
href={talk.link}
target="_blank"
rel="noopener noreferrer"
class="btn btn-circle btn-sm bg-base-100 hover:bg-base-200 text-accent"
aria-label={`Visit ${talk.name}`}
>
<Icon name="mdi:link" class="text-lg" />
</a>
</div>
</div>
</div>

View File

@@ -1,30 +1,20 @@
---
import { Icon } from "astro-icon/components";
import { techLinks } from "../config/data";
// Helper function to check if icon is a string (Astro icon)
function isAstroIcon(icon: any): icon is string {
return typeof icon === "string";
}
import Icon from "./Icon.astro";
import { config } from "../config";
---
<div class="flex flex-row gap-4 text-3xl">
<div class="flex flex-row gap-3 text-3xl flex-wrap justify-center">
{
techLinks.map((link) => {
if (isAstroIcon(link.icon)) {
return (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
aria-label={link.ariaLabel}
class="hover:text-primary transition-colors"
>
<Icon name={link.icon} />
</a>
);
}
return null;
})
config.techLinks.map((link) => (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
aria-label={link.ariaLabel}
class="hover:text-primary transition-colors"
>
<Icon name={link.icon} />
</a>
))
}
</div>

View File

@@ -1,234 +0,0 @@
import { useState, useEffect, useRef } from "preact/hooks";
import type { JSX } from "preact";
import type { Command } from "../utils/terminal/types";
import { buildFileSystem } from "../utils/terminal/fs";
import {
executeCommand,
type CommandContext,
} from "../utils/terminal/commands";
import {
getCompletions,
formatOutput,
saveCommandToHistory,
loadCommandHistory,
} from "../utils/terminal/utils";
const Terminal = () => {
const [currentPath, setCurrentPath] = useState("/");
const [commandHistory, setCommandHistory] = useState<Command[]>([
{
input: "",
output:
'Welcome to Atridad\'s Shell!\nType "help" to see available commands.\n',
timestamp: new Date(),
path: "/",
},
]);
const [currentInput, setCurrentInput] = useState("");
const [historyIndex, setHistoryIndex] = useState(-1);
const [fileSystem, setFileSystem] = useState<{ [key: string]: any }>({});
const [isTrainRunning, setIsTrainRunning] = useState(false);
const [trainPosition, setTrainPosition] = useState(100);
const [persistentHistory, setPersistentHistory] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const terminalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (terminalRef.current && !isTrainRunning) {
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
}
}, [commandHistory, isTrainRunning]);
// Load command history from localStorage
useEffect(() => {
const history = loadCommandHistory();
setPersistentHistory(history);
}, []);
// Initialize file system
useEffect(() => {
buildFileSystem().then(setFileSystem);
}, []);
const handleSubmit = (e: JSX.TargetedEvent<HTMLFormElement>) => {
e.preventDefault();
const commandContext: CommandContext = {
currentPath,
fileSystem,
setCurrentPath,
setIsTrainRunning,
setTrainPosition,
};
const output = executeCommand(currentInput, commandContext);
const newCommand: Command = {
input: currentInput,
output,
timestamp: new Date(),
path: currentPath,
};
// Save command to persistent history
const updatedHistory = saveCommandToHistory(
currentInput,
persistentHistory,
);
setPersistentHistory(updatedHistory);
if (currentInput.trim().toLowerCase() === "clear") {
setCommandHistory([]);
} else {
setCommandHistory((prev: Command[]) => [...prev, newCommand]);
}
setCurrentInput("");
setHistoryIndex(-1);
};
const handleKeyDown = (e: JSX.TargetedKeyboardEvent<HTMLInputElement>) => {
if (e.key === "Tab") {
e.preventDefault();
const { completion, replaceFrom } = getCompletions(
currentInput,
currentPath,
fileSystem,
);
if (completion) {
const beforeReplacement = currentInput.substring(0, replaceFrom);
const newInput = beforeReplacement + completion;
setCurrentInput(newInput + (completion.endsWith("/") ? "" : " "));
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (persistentHistory.length > 0) {
const newIndex =
historyIndex === -1
? persistentHistory.length - 1
: Math.max(0, historyIndex - 1);
setHistoryIndex(newIndex);
setCurrentInput(persistentHistory[newIndex]);
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (historyIndex !== -1) {
const newIndex = Math.min(
persistentHistory.length - 1,
historyIndex + 1,
);
if (
newIndex === persistentHistory.length - 1 &&
historyIndex === newIndex
) {
setHistoryIndex(-1);
setCurrentInput("");
} else {
setHistoryIndex(newIndex);
setCurrentInput(persistentHistory[newIndex]);
}
}
}
};
return (
<div className="bg-base-100 text-base-content font-mono text-sm h-full flex flex-col rounded-lg border-2 border-primary shadow-2xl relative">
<div className="bg-base-200 px-4 py-2 rounded-t-lg border-b border-base-300">
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-error rounded-full"></div>
<div className="w-3 h-3 bg-warning rounded-full"></div>
<div className="w-3 h-3 bg-success rounded-full"></div>
<span className="ml-4 text-base-content/70 text-xs">
guest@atri.dad: {currentPath}
</span>
</div>
</div>
<div
ref={terminalRef}
className={`flex-1 p-4 overflow-y-auto scrollbar-thin scrollbar-thumb-base-300 scrollbar-track-base-100 relative ${
isTrainRunning ? "opacity-0" : "opacity-100"
}`}
onClick={() => !isTrainRunning && inputRef.current?.focus()}
>
<div className="min-h-full">
{commandHistory.map((command: Command, index: number) => (
<div key={index} className="mb-2">
{command.input && (
<div className="flex items-center">
<span className="text-primary font-semibold">
guest@atri.dad
</span>
<span className="text-base-content">:</span>
<span className="text-secondary font-semibold">
{command.path}
</span>
<span className="text-base-content">$ </span>
<span className="text-accent">{command.input}</span>
</div>
)}
{command.output && (
<div
className="whitespace-pre-wrap text-base-content/80 mt-1"
dangerouslySetInnerHTML={{
__html: formatOutput(command.output),
}}
/>
)}
</div>
))}
{!isTrainRunning && (
<form onSubmit={handleSubmit} className="flex items-center">
<span className="text-primary font-semibold">guest@atri.dad</span>
<span className="text-base-content">:</span>
<span className="text-secondary font-semibold">
{currentPath}
</span>
<span className="text-base-content">$ </span>
<input
ref={inputRef}
type="text"
value={currentInput}
onInput={(e) =>
setCurrentInput((e.target as HTMLInputElement).value)
}
onKeyDown={handleKeyDown}
className="flex-1 bg-transparent border-none outline-none text-accent ml-1"
spellcheck={false}
/>
</form>
)}
</div>
</div>
{/* Train animation overlay - positioned over the content area but outside the opacity div */}
{isTrainRunning && (
<div className="absolute inset-x-0 top-16 bottom-0 flex items-center justify-center overflow-hidden pointer-events-none">
<div
className="text-white font-mono text-xs whitespace-nowrap"
style={{
transform: `translateX(${trainPosition}%)`,
transition: "none",
}}
>
<pre className="leading-none">{`
==== ________ ___________
_D _| |_______/ \\__I_I_____===__|_________|
|(_)--- | H\\________/ | | =|___ ___| _________________
/ | | H | | | | ||_| |_|| _| \\_____A
| | | H |__--------------------| [___] | =| |
| ________|___H__/__|_____/[][]~\\_______| | -| |
|/ | |-----------I_____I [][] [] D |=======|____|________________________|_
__/ =| o |=-O=====O=====O=====O \\ ____Y___________|__|__________________________|_
|/-=|___|= || || || |_____/~\\___/ |_D__D__D_| |_D__D__D_|
\\_/ \\__/ \\__/ \\__/ \\__/ \\_/ \\_/ \\_/ \\_/ \\_/`}</pre>
</div>
</div>
)}
</div>
);
};
export default Terminal;

438
src/config.ts Normal file
View File

@@ -0,0 +1,438 @@
import type { Config } from "./types";
import logo from "./assets/logo.webp";
import resumeToml from "./assets/resume.toml?raw";
export const config: Config = {
personalInfo: {
name: "Atridad Lahiji",
profileImage: {
src: logo,
alt: "A drawing of Atridad Lahiji by Shelze!",
},
tagline: "Researcher, Full-Stack Developer, and IT Professional",
description: "Researcher, Full-Stack Developer, and IT Professional",
},
homepageSections: {
socialLinks: {
title: "Places I Exist:",
description: "Find me across the web",
},
techStack: {
title: "Technologies I Use:",
description: "Technologies and tools I work with",
},
},
resumeConfig: {
tomlFile: resumeToml,
layout: {
leftColumn: ["experience", "volunteer"],
rightColumn: ["skills", "education", "awards"],
},
sections: {
enabled: [
"summary",
"experience",
"education",
"skills",
"volunteer",
"awards",
],
summary: {
title: "Summary",
},
experience: {
title: "Professional Experience",
},
education: {
title: "Education",
},
skills: {
title: "Skills",
},
volunteer: {
title: "Volunteer Work",
},
awards: {
title: "Awards",
},
},
},
siteConfig: {
personal: {
name: "Atridad Lahiji",
profileImage: {
src: logo,
alt: "A drawing of Atridad Lahiji by Shelze!",
},
tagline: "Researcher, Full-Stack Developer, and IT Professional",
description: "Researcher, Full-Stack Developer, and IT Professional",
},
homepage: {
socialLinks: {
title: "Places I Exist:",
description: "Find me across the web",
},
techStack: {
title: "Technologies I Use:",
description: "Technologies and tools I work with",
},
},
resume: {
tomlFile: resumeToml,
layout: {
leftColumn: ["experience", "volunteer"],
rightColumn: ["skills", "education", "awards"],
},
sections: {
enabled: [
"summary",
"experience",
"education",
"skills",
"volunteer",
"awards",
],
summary: {
title: "Summary",
},
experience: {
title: "Professional Experience",
},
education: {
title: "Education",
},
skills: {
title: "Skills",
},
volunteer: {
title: "Volunteer Work",
},
awards: {
title: "Awards",
},
},
},
meta: {
title: "Atridad Lahiji",
description:
"Personal website of Atridad Lahiji - Researcher, Full-Stack Developer, and IT Professional",
url: "https://atri.dad",
author: "Atridad Lahiji",
},
openGraph: {
image: {
url: "/logo.webp",
width: 1024,
height: 1024,
type: "image/webp",
alt: "Atridad Lahiji",
},
type: "website",
locale: "en_US",
siteName: "Atridad Lahiji",
},
pageOpenGraph: {
home: {
title: "Atridad Lahiji",
description:
"Personal website of Atridad Lahiji - Researcher, Full-Stack Developer, and IT Professional",
},
posts: {
title: "Blog Posts",
description: "Thoughts and ramblings.",
},
projects: {
title: "Projects",
description: "Projects I'm working on.",
},
talks: {
title: "Talks",
description: "Conference talks and presentations by Atridad Lahiji",
},
resume: {
title: "Resume",
description: "Resume + TOML Resume Generator",
},
},
giteaDomains: ["https://git.atri.dad"],
},
talks: [
{
id: "devedmonton-hateoas",
name: "Hypermedia as the engine of application state - An Introduction",
description:
"A basic introduction to the concepts behind HATEOAS or Hypermedia as the engine of application state.",
link: "/files/DevEdmonton_Talk_HATEOAS.pdf",
},
],
projects: [
{
id: "ascently",
name: "Ascently",
description:
"Offline-first FOSS app designed to help climbers track their sessions",
gitLink: "https://git.atri.dad/atridad/Ascently",
webLink: "https://ascently.atri.dad",
iosLink: "https://apps.apple.com/ca/app/ascently/id6753959144",
androidLink:
"https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://git.atri.dad/atridad/Ascently/releases",
},
{
id: "himbocrypt",
name: "HimboCrypt",
description: "A robust end-to-end encryption engine and CLI.",
gitLink: "https://git.atri.dad/atridad/himbocrypt",
},
{
id: "muse",
name: "muse",
description: "Go-based music generation using TOML song definitions",
gitLink: "https://git.atri.dad/atridad/muse",
},
{
id: "magiccounter",
name: "MagicCounter",
description: "FOSS Magic the Gathering Health Tracker",
gitLink: "https://git.atri.dad/atridad/MagicCounter",
iosLink: "https://apps.apple.com/ca/app/magiccounter/id6756251972",
androidLink:
"https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://git.atri.dad/atridad/MagicCounter/releases",
},
{
id: "himbot",
name: "Himbot",
description:
"A discord bot written in Go. Loosly named after my username online (HimbothySwaggins).",
gitLink: "https://git.atri.dad/atridad/himbot",
},
{
id: "lavitz",
name: "Lavitz",
description:
"My NixOS desktop configuration, named after a character in Legend of Dragoon for the PS1: Lavitz.",
gitLink: "https://git.atri.dad/atridad/lavitz",
},
{
id: "haschel",
name: "Haschel",
description:
"My NixOS proxy server configuration, named after a character in Legend of Dragoon for the PS1: Haschel",
gitLink: "https://git.atri.dad/atridad/haschel",
},
{
id: "dart",
name: "Dart",
description:
"My Nix macOS configuration, named after a character in Legend of Dragoon for the PS1: Dart",
gitLink: "https://git.atri.dad/atridad/dart",
},
{
id: "atrodotdad",
name: "Personal Site",
description: "My personal website built with Astro.",
webLink: "https://atri.dad",
gitLink: "https://git.atri.dad/atridad/atridotdad",
},
],
sections: {
resume: {
name: "Resume",
path: "/resume",
description: "Professional experience, skills, and background",
},
posts: {
name: "Blog Posts",
path: "/posts",
description: "Technical articles and thoughts",
},
talks: {
name: "Talks",
path: "/talks",
description: "Conference talks and presentations",
},
projects: {
name: "Projects",
path: "/projects",
description: "Personal and professional projects",
},
},
socialLinks: [
{
id: "email",
name: "Email",
url: "mailto:me@atri.dad",
icon: "email",
ariaLabel: "Email me",
},
{
id: "rss",
name: "RSS Feed",
url: "/feed",
icon: "rss",
ariaLabel: "RSS Feed",
},
{
id: "gitea",
name: "Forgejo (Git)",
url: "https://git.atri.dad/atridad",
icon: "gitea",
ariaLabel: "Forgejo (Git)",
},
{
id: "bluesky",
name: "Bluesky",
url: "https://bsky.app/profile/atri.dad",
icon: "bluesky",
ariaLabel: "Bluesky Profile",
},
{
id: "matrix",
name: "Matrix",
url: "https://matrix.to/#/@atridad:atri.dad",
icon: "matrix",
ariaLabel: "Matrix Profile",
},
{
id: "mastodon",
name: "Mastodon",
url: "https://fedi.atri.dad/@atridad",
icon: "mastodon",
ariaLabel: "Mastodon Profile",
},
],
techLinks: [
{
id: "react",
name: "React",
url: "https://react.dev/",
icon: "react",
ariaLabel: "React",
},
{
id: "vuejs",
name: "Vue.js",
url: "https://vuejs.org//",
icon: "vuedotjs",
ariaLabel: "Vue.js",
},
{
id: "typescript",
name: "TypeScript",
url: "https://www.typescriptlang.org/",
icon: "typescript",
ariaLabel: "TypeScript",
},
{
id: "astro",
name: "Astro",
url: "https://astro.build/",
icon: "astro",
ariaLabel: "Astro",
},
{
id: "go",
name: "Go",
url: "https://go.dev/",
icon: "go",
ariaLabel: "Go",
},
{
id: "postgresql",
name: "PostgreSQL",
url: "https://www.postgresql.org/",
icon: "postgresql",
ariaLabel: "PostgreSQL",
},
{
id: "dotnet",
name: "DotNet",
url: "https://dot.net/",
icon: "dotnet",
ariaLabel: "DotNet",
},
{
id: "docker",
name: "Docker",
url: "https://www.docker.com/",
icon: "docker",
ariaLabel: "Docker",
},
{
id: "kotlin",
name: "Kotlin",
url: "https://kotlinlang.org/",
icon: "kotlin",
ariaLabel: "Kotlin",
},
{
id: "swift",
name: "Swift",
url: "https://www.swift.org/",
icon: "swift",
ariaLabel: "Swift",
},
{
id: "nix",
name: "Nix",
url: "https://nixos.org",
icon: "nixos",
ariaLabel: "Nix",
},
],
navigationItems: [
{
id: "home",
name: "Home",
path: "/",
tooltip: "Home",
icon: "house",
enabled: true,
},
{
id: "posts",
name: "Posts",
path: "/posts",
tooltip: "Posts",
icon: "newspaper",
enabled: true,
isActive: (path: string) =>
path.startsWith("/posts") || path.startsWith("/post/"),
},
{
id: "resume",
name: "Resume",
path: "/resume",
tooltip: "Resume",
icon: "file-user",
enabled: !!(resumeToml && resumeToml.trim()),
},
{
id: "projects",
name: "Projects",
path: "/projects",
tooltip: "Projects",
icon: "code-xml",
enabled: true,
isActive: (path: string) => path.startsWith("/projects"),
},
{
id: "talks",
name: "Talks",
path: "/talks",
tooltip: "Talks",
icon: "megaphone",
enabled: true,
isActive: (path: string) => path.startsWith("/talks"),
},
],
} as const;

View File

@@ -1,326 +0,0 @@
import type {
Talk,
Project,
SocialLink,
TechLink,
NavigationItem,
PersonalInfo,
HomepageSections,
SiteConfig,
ResumeConfig,
} from "../types";
// Import Lucide Icons
import {
Home,
Newspaper,
FileUser,
CodeXml,
Terminal as TerminalIcon,
Megaphone,
} from "lucide-preact";
import logo from "../assets/logo_real.webp";
import resumeToml from "../assets/resume.toml?raw";
// Astro Icon references
const EMAIL_ICON = "mdi:email";
const RSS_ICON = "mdi:rss";
const GITEA_ICON = "simple-icons:gitea";
const BLUESKY_ICON = "simple-icons:bluesky";
const REACT_ICON = "simple-icons:react";
const TYPESCRIPT_ICON = "simple-icons:typescript";
const ASTRO_ICON = "simple-icons:astro";
const GO_ICON = "simple-icons:go";
const POSTGRESQL_ICON = "simple-icons:postgresql";
const REDIS_ICON = "simple-icons:redis";
const DOCKER_ICON = "simple-icons:docker";
// Personal Information Configuration
export const personalInfo: PersonalInfo = {
name: "Atridad Lahiji",
profileImage: {
src: logo,
alt: "A drawing of Atridad Lahiji by Shelze!",
},
tagline: "Researcher, Full-Stack Developer, and IT Professional.",
description: "Researcher, Full-Stack Developer, and IT Professional.",
};
// Homepage Section Configuration
export const homepageSections: HomepageSections = {
socialLinks: {
title: "Places I Exist:",
description: "Find me across the web",
},
techStack: {
title: "Stuff I Use:",
description: "Technologies and tools I work with",
},
};
// Resume Configuration
export const resumeConfig: ResumeConfig = {
tomlFile: resumeToml,
layout: {
leftColumn: ["experience", "volunteer"],
rightColumn: ["skills", "education", "awards"],
},
sections: {
enabled: [
"summary",
"experience",
"education",
"skills",
"volunteer",
"awards",
],
summary: {
title: "Summary",
enabled: true,
},
experience: {
title: "Professional Experience",
enabled: true,
},
education: {
title: "Education",
enabled: true,
},
skills: {
title: "Technical Skills",
enabled: true,
},
volunteer: {
title: "Volunteer Work",
enabled: true,
},
awards: {
title: "Awards & Recognition",
enabled: true,
},
},
};
// Site Metadata Configuration
export const siteConfig: SiteConfig = {
personal: personalInfo,
homepage: homepageSections,
resume: resumeConfig,
meta: {
title: "Atridad Lahiji",
description:
"Personal website of Atridad Lahiji - Researcher, Full-Stack Developer, and IT Professional",
url: "https://atri.dad",
author: "Atridad Lahiji",
},
};
export const talks: Talk[] = [
{
id: "devedmonton-hateoas",
name: "Hypermedia as the engine of application state - An Introduction",
description:
"A basic introduction to the concepts behind HATEOAS or Hypermedia as the engine of application state.",
link: "/files/DevEdmonton_Talk_HATEOAS.pdf",
},
];
export 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",
},
];
export const sections = {
resume: {
name: "Resume",
path: "/resume",
description: "Professional experience, skills, and background",
},
posts: {
name: "Blog Posts",
path: "/posts",
description: "Technical articles and thoughts",
},
talks: {
name: "Talks",
path: "/talks",
description: "Conference talks and presentations",
},
projects: {
name: "Projects",
path: "/projects",
description: "Personal and professional projects",
},
} as const;
export const socialLinks: SocialLink[] = [
{
id: "email",
name: "Email",
url: "mailto:me@atri.dad",
icon: EMAIL_ICON,
ariaLabel: "Email me",
},
{
id: "rss",
name: "RSS Feed",
url: "/feed",
icon: RSS_ICON,
ariaLabel: "RSS Feed",
},
{
id: "gitea",
name: "Forgejo (Git)",
url: "https://git.atri.dad/atridad",
icon: GITEA_ICON,
ariaLabel: "Forgejo (Git)",
},
{
id: "bluesky",
name: "Bluesky",
url: "https://bsky.app/profile/atri.dad",
icon: BLUESKY_ICON,
ariaLabel: "Bluesky Profile",
},
];
export const techLinks: TechLink[] = [
{
id: "react",
name: "React",
url: "https://react.dev/",
icon: REACT_ICON,
ariaLabel: "React",
},
{
id: "typescript",
name: "TypeScript",
url: "https://www.typescriptlang.org/",
icon: TYPESCRIPT_ICON,
ariaLabel: "TypeScript",
},
{
id: "astro",
name: "Astro",
url: "https://astro.build/",
icon: ASTRO_ICON,
ariaLabel: "Astro",
},
{
id: "go",
name: "Go",
url: "https://go.dev/",
icon: GO_ICON,
ariaLabel: "Go",
},
{
id: "postgresql",
name: "PostgreSQL",
url: "https://www.postgresql.org/",
icon: POSTGRESQL_ICON,
ariaLabel: "PostgreSQL",
},
{
id: "redis",
name: "Redis",
url: "https://redis.io/",
icon: REDIS_ICON,
ariaLabel: "Redis",
},
{
id: "docker",
name: "Docker",
url: "https://www.docker.com/",
icon: DOCKER_ICON,
ariaLabel: "Docker",
},
];
export const navigationItems: NavigationItem[] = [
{
id: "home",
name: "Home",
path: "/",
tooltip: "Home",
icon: Home,
enabled: true,
},
{
id: "posts",
name: "Posts",
path: "/posts",
tooltip: "Posts",
icon: Newspaper,
enabled: true,
isActive: (path: string) =>
path.startsWith("/posts") || path.startsWith("/post/"),
},
{
id: "resume",
name: "Resume",
path: "/resume",
tooltip: "Resume",
icon: FileUser,
enabled: !!(resumeConfig.tomlFile && resumeConfig.tomlFile.trim()),
},
{
id: "projects",
name: "Projects",
path: "/projects",
tooltip: "Projects",
icon: CodeXml,
enabled: true,
isActive: (path: string) => path.startsWith("/projects"),
},
{
id: "talks",
name: "Talks",
path: "/talks",
tooltip: "Talks",
icon: Megaphone,
enabled: true,
isActive: (path: string) => path.startsWith("/talks"),
},
{
id: "terminal",
name: "Terminal",
path: "/terminal",
tooltip: "Terminal",
icon: TerminalIcon,
enabled: true,
},
];

59
src/config/icons.ts Normal file
View File

@@ -0,0 +1,59 @@
export const icons = {
clock: `<path fill="currentColor" d="M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m4.2 14.2L11 13V7h1.5v5.2l4.5 2.7z"/>`,
tag: `<path fill="currentColor" d="M5.5 7A1.5 1.5 0 0 1 4 5.5A1.5 1.5 0 0 1 5.5 4A1.5 1.5 0 0 1 7 5.5A1.5 1.5 0 0 1 5.5 7m15.91 4.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.11 0-2 .89-2 2v7c0 .55.22 1.05.59 1.41l8.99 9c.37.36.87.59 1.42.59s1.05-.23 1.41-.59l7-7c.37-.36.59-.86.59-1.41c0-.56-.23-1.06-.59-1.42"/>`,
"arrow-right": `<path fill="currentColor" d="M4 11v2h12l-5.5 5.5l1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5L16 11z"/>`,
link: `<path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7a5 5 0 0 0-5 5a5 5 0 0 0 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1M8 13h8v-2H8zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4a5 5 0 0 0 5-5a5 5 0 0 0-5-5"/>`,
email: `<path fill="currentColor" d="m20 8l-8 5l-8-5V6l8 5l8-5m0-2H4c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2"/>`,
rss: `<path fill="currentColor" d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27zm0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93z"/>`,
download: `<path fill="currentColor" d="M5 20h14v-2H5m14-9h-4V3H9v6H5l7 7z"/>`,
web: `<path fill="currentColor" d="M16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2m-5.15 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95a8.03 8.03 0 0 1-4.33 3.56M14.34 14H9.66c-.1-.66-.16-1.32-.16-2s.06-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2M12 19.96c-.83-1.2-1.5-2.53-1.91-3.96h3.82c-.41 1.43-1.08 2.76-1.91 3.96M8 8H5.08A7.92 7.92 0 0 1 9.4 4.44C8.8 5.55 8.35 6.75 8 8m-2.92 8H8c.35 1.25.8 2.45 1.4 3.56A8 8 0 0 1 5.08 16m-.82-2C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2M12 4.03c.83 1.2 1.5 2.54 1.91 3.97h-3.82c.41-1.43 1.08-2.77 1.91-3.97M18.92 8h-2.95a15.7 15.7 0 0 0-1.38-3.56c1.84.63 3.37 1.9 4.33 3.56M12 2C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2"/>`,
"arrow-left": `<path fill="currentColor" d="M20 11v2H8l5.5 5.5l-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5L8 11z"/>`,
"source-commit": `<path fill="currentColor" d="M17 12a5 5 0 0 1-4 4.9V21h-2v-4.1a5 5 0 0 1 0-9.8V3h2v4.1a5 5 0 0 1 4 4.9m-5-3a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3"/>`,
"code-tags": `<path fill="currentColor" d="m14.6 16.6l4.6-4.6l-4.6-4.6L16 6l6 6l-6 6zm-5.2 0L4.8 12l4.6-4.6L8 6l-6 6l6 6z"/>`,
"tag-multiple": `<path fill="currentColor" d="M5.5 9A1.5 1.5 0 0 0 7 7.5A1.5 1.5 0 0 0 5.5 6A1.5 1.5 0 0 0 4 7.5A1.5 1.5 0 0 0 5.5 9m11.91 2.58c.36.36.59.86.59 1.42c0 .55-.22 1.05-.59 1.41l-5 5a1.996 1.996 0 0 1-2.83 0l-6.99-6.99C2.22 12.05 2 11.55 2 11V6c0-1.11.89-2 2-2h5c.55 0 1.05.22 1.41.58zm-3.87-5.87l1-1l6.87 6.87c.37.36.59.87.59 1.42s-.22 1.05-.58 1.41l-5.38 5.38l-1-1L20.75 13z"/>`,
"clock-outline": `<path fill="currentColor" d="M12 20a8 8 0 0 0 8-8a8 8 0 0 0-8-8a8 8 0 0 0-8 8a8 8 0 0 0 8 8m0-18a10 10 0 0 1 10 10a10 10 0 0 1-10 10C6.47 22 2 17.5 2 12A10 10 0 0 1 12 2m.5 5v5.25l4.5 2.67l-.75 1.23L11 13V7z"/>`,
apple: `<path fill="currentColor" d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47c-1.34.03-1.77-.79-3.29-.79c-1.53 0-2 .77-3.27.82c-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51c1.28-.02 2.5.87 3.29.87c.78 0 2.26-1.07 3.81-.91c.65.03 2.47.26 3.64 1.98c-.09.06-2.17 1.28-2.15 3.81c.03 3.02 2.65 4.03 2.68 4.04c-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5c.13 1.17-.34 2.35-1.04 3.19c-.69.85-1.83 1.51-2.95 1.42c-.15-1.15.41-2.35 1.05-3.11"/>`,
"google-play": `<path fill="currentColor" d="M3 20.5v-17c0-.59.34-1.11.84-1.35L13.69 12l-9.85 9.85c-.5-.25-.84-.76-.84-1.35m13.81-5.38L6.05 21.34l8.49-8.49zm3.35-4.31c.34.27.59.69.59 1.19s-.22.9-.57 1.18l-2.29 1.32l-2.5-2.5l2.5-2.5zM6.05 2.66l10.76 6.22l-2.27 2.27z"/>`,
"code-braces": `<path fill="currentColor" d="M8 3a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2H3v2h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h2v-2H8v-5a2 2 0 0 0-2-2a2 2 0 0 0 2-2V5h2V3m6 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2a2 2 0 0 1-2-2V5h-2V3z"/>`,
circle: `<path fill="currentColor" d="M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2"/>`,
"open-in-new": `<path fill="currentColor" d="M14 3v2h3.59l-9.83 9.83l1.41 1.41L19 6.41V10h2V3m-2 16H5V5h7V3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7h-2z"/>`,
phone: `<path fill="currentColor" d="M6.62 10.79c1.44 2.83 3.76 5.15 6.59 6.59l2.2-2.2c.28-.28.67-.36 1.02-.25c1.12.37 2.32.57 3.57.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57c.11.35.03.74-.25 1.02z"/>`,
gitea: `<path fill="currentColor" d="M4.209 4.603c-.247 0-.525.02-.84.088c-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768c1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367c2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068c0 0 2.107-4.471 2.107-8.823c-.042-1.318-.367-1.55-.443-1.627c-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17c-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12m8.33 2.554c.26.003.509.127.509.127l.868.422l-.529 1.075a.69.69 0 0 0-.614.359a.69.69 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527a.69.69 0 0 0 .347.763a.686.686 0 0 0 .867-.206a.69.69 0 0 0-.069-.882l.916-1.874a.7.7 0 0 0 .237-.02a.66.66 0 0 0 .271-.137a9 9 0 0 1 1.016.512a.76.76 0 0 1 .286.282c.073.21-.073.569-.073.569c-.087.29-.702 1.55-.702 1.55a.69.69 0 0 0-.676.477a.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431c.19-.397.515-1.16.515-1.16c.035-.066.218-.394.103-.814c-.095-.435-.48-.638-.48-.638c-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.7.7 0 0 0-.148-.241l.516-1.062l2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657c-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.1 1.1 0 0 1-.393-.045l-.202-.08l-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.9.9 0 0 1 .35-.077"/>`,
bluesky: `<path fill="currentColor" d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037c-.856 3.061-3.978 3.842-6.755 3.37c4.854.826 6.089 3.562 3.422 6.299c-5.065 5.196-7.28-1.304-7.847-2.97c-.104-.305-.152-.448-.153-.327c0-.121-.05.022-.153.327c-.568 1.666-2.782 8.166-7.847 2.97c-2.667-2.737-1.432-5.473 3.422-6.3c-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/>`,
react: `<path fill="currentColor" d="M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236a2.236 2.236 0 0 1-2.236-2.236a2.236 2.236 0 0 1 2.235-2.236a2.236 2.236 0 0 1 2.236 2.236m2.648-10.69c-1.346 0-3.107.96-4.888 2.622c-1.78-1.653-3.542-2.602-4.887-2.602c-.41 0-.783.093-1.106.278c-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03c-.704 3.113-.39 5.588.988 6.38c.32.187.69.275 1.102.275c1.345 0 3.107-.96 4.888-2.624c1.78 1.654 3.542 2.603 4.887 2.603c.41 0 .783-.09 1.106-.275c1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032c.704-3.11.39-5.587-.988-6.38a2.17 2.17 0 0 0-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127c.666.382.955 1.835.73 3.704c-.054.46-.142.945-.25 1.44a23.5 23.5 0 0 0-3.107-.534A24 24 0 0 0 12.769 4.7c1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28c-.686.72-1.37 1.537-2.02 2.442a23 23 0 0 0-3.113.538a15 15 0 0 1-.254-1.42c-.23-1.868.054-3.32.714-3.707c.19-.09.4-.127.563-.132zm4.882 3.05q.684.704 1.36 1.564c-.44-.02-.89-.034-1.345-.034q-.691-.001-1.36.034c.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093q.61.874 1.183 1.86q.557.961 1.018 1.946c-.308.655-.646 1.31-1.013 1.95c-.38.66-.773 1.288-1.18 1.87a25.6 25.6 0 0 1-4.412.005a27 27 0 0 1-1.183-1.86q-.557-.961-1.018-1.946a25 25 0 0 1 1.013-1.954c.38-.66.773-1.286 1.18-1.868A25 25 0 0 1 12 8.098zm-3.635.254c-.24.377-.48.763-.704 1.16q-.336.585-.635 1.174c-.265-.656-.49-1.31-.676-1.947c.64-.15 1.315-.283 2.015-.386zm7.26 0q1.044.153 2.006.387c-.18.632-.405 1.282-.66 1.933a26 26 0 0 0-1.345-2.32zm3.063.675q.727.226 1.375.498c1.732.74 2.852 1.708 2.852 2.476c-.005.768-1.125 1.74-2.857 2.475c-.42.18-.88.342-1.355.493a24 24 0 0 0-1.1-2.98c.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98a23 23 0 0 0-1.086 2.964c-.484-.15-.944-.318-1.37-.5c-1.732-.737-2.852-1.706-2.852-2.474s1.12-1.742 2.852-2.476c.42-.18.88-.342 1.356-.494m11.678 4.28c.265.657.49 1.312.676 1.948c-.64.157-1.316.29-2.016.39a26 26 0 0 0 1.341-2.338zm-9.945.02c.2.392.41.783.64 1.175q.345.586.705 1.143a22 22 0 0 1-2.006-.386c.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423c.23 1.868-.054 3.32-.714 3.708c-.147.09-.338.128-.563.128c-1.012 0-2.514-.807-4.11-2.28c.686-.72 1.37-1.536 2.02-2.44c1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532c.66.905 1.345 1.727 2.035 2.446c-1.595 1.483-3.092 2.295-4.11 2.295a1.2 1.2 0 0 1-.553-.132c-.666-.38-.955-1.834-.73-3.703c.054-.46.142-.944.25-1.438zm4.56.64q.661.032 1.345.034q.691.001 1.36-.034c-.44.572-.895 1.095-1.345 1.565q-.684-.706-1.36-1.565"/>`,
vuedotjs: `<path fill="currentColor" d="M24 1.61h-9.94L12 5.16L9.94 1.61H0l12 20.78ZM12 14.08L5.16 2.23h4.43L12 6.41l2.41-4.18h4.43Z"/>`,
typescript: `<path fill="currentColor" d="M1.125 0C.502 0 0 .502 0 1.125v21.75C0 23.498.502 24 1.125 24h21.75c.623 0 1.125-.502 1.125-1.125V1.125C24 .502 23.498 0 22.875 0zm17.363 9.75q.918 0 1.627.111a6.4 6.4 0 0 1 1.306.34v2.458a4 4 0 0 0-.643-.361a5 5 0 0 0-.717-.26a5.5 5.5 0 0 0-1.426-.2q-.45 0-.819.086a2.1 2.1 0 0 0-.623.242q-.254.156-.393.374a.9.9 0 0 0-.14.49q0 .294.156.529q.156.234.443.444c.287.21.423.276.696.41q.41.203.926.416q.705.296 1.266.628q.561.333.963.753q.402.418.614.957q.213.538.214 1.253q0 .986-.373 1.656a3 3 0 0 1-1.012 1.085a4.4 4.4 0 0 1-1.487.596q-.85.18-1.79.18a10 10 0 0 1-1.84-.164a5.5 5.5 0 0 1-1.512-.493v-2.63a5.03 5.03 0 0 0 3.237 1.2q.5 0 .872-.09q.373-.09.623-.25q.249-.162.373-.38a1.02 1.02 0 0 0-.074-1.089a2.1 2.1 0 0 0-.537-.5a5.6 5.6 0 0 0-.807-.444a28 28 0 0 0-1.007-.436q-1.377-.575-2.053-1.405t-.676-2.005q0-.92.369-1.582q.368-.662 1.004-1.089a4.5 4.5 0 0 1 1.47-.629a7.5 7.5 0 0 1 1.77-.201m-15.113.188h9.563v2.166H9.506v9.646H6.789v-9.646H3.375z"/>`,
astro: `<path fill="currentColor" d="M8.358 20.162c-1.186-1.07-1.532-3.316-1.038-4.944c.856 1.026 2.043 1.352 3.272 1.535c1.897.283 3.76.177 5.522-.678c.202-.098.388-.229.608-.36c.166.473.209.95.151 1.437c-.14 1.185-.738 2.1-1.688 2.794c-.38.277-.782.525-1.175.787c-1.205.804-1.531 1.747-1.078 3.119l.044.148a3.16 3.16 0 0 1-1.407-1.188a3.3 3.3 0 0 1-.544-1.815c-.004-.32-.004-.642-.048-.958c-.106-.769-.472-1.113-1.161-1.133c-.707-.02-1.267.411-1.415 1.09c-.012.053-.028.104-.045.165zm-5.961-4.445s3.24-1.575 6.49-1.575l2.451-7.565c.092-.366.36-.614.662-.614s.57.248.662.614l2.45 7.565c3.85 0 6.491 1.575 6.491 1.575L16.088.727C15.93.285 15.663 0 15.303 0H8.697c-.36 0-.615.285-.784.727z"/>`,
go: `<path fill="currentColor" d="M1.811 10.231c-.047 0-.058-.023-.035-.059l.246-.315c.023-.035.081-.058.128-.058h4.172c.046 0 .058.035.035.07l-.199.303c-.023.036-.082.07-.117.07zM.047 11.306c-.047 0-.059-.023-.035-.058l.245-.316c.023-.035.082-.058.129-.058h5.328c.047 0 .07.035.058.07l-.093.28c-.012.047-.058.07-.105.07zm2.828 1.075c-.047 0-.059-.035-.035-.07l.163-.292c.023-.035.07-.07.117-.07h2.337c.047 0 .07.035.07.082l-.023.28c0 .047-.047.082-.082.082zm12.129-2.36c-.736.187-1.239.327-1.963.514c-.176.046-.187.058-.34-.117c-.174-.199-.303-.327-.548-.444c-.737-.362-1.45-.257-2.115.175c-.795.514-1.204 1.274-1.192 2.22c.011.935.654 1.706 1.577 1.835c.795.105 1.46-.175 1.987-.77c.105-.13.198-.27.315-.434H10.47c-.245 0-.304-.152-.222-.35c.152-.362.432-.97.596-1.274a.32.32 0 0 1 .292-.187h4.253c-.023.316-.023.631-.07.947a5 5 0 0 1-.958 2.29c-.841 1.11-1.94 1.8-3.33 1.986c-1.145.152-2.209-.07-3.143-.77c-.865-.655-1.356-1.52-1.484-2.595c-.152-1.274.222-2.419.993-3.424c.83-1.086 1.928-1.776 3.272-2.02c1.098-.2 2.15-.07 3.096.571c.62.41 1.063.97 1.356 1.648c.07.105.023.164-.117.2m3.868 6.461c-1.064-.024-2.034-.328-2.852-1.029a3.67 3.67 0 0 1-1.262-2.255c-.21-1.32.152-2.489.947-3.529c.853-1.122 1.881-1.706 3.272-1.95c1.192-.21 2.314-.095 3.33.595c.923.63 1.496 1.484 1.648 2.605c.198 1.578-.257 2.863-1.344 3.962c-.771.783-1.718 1.273-2.805 1.495c-.315.06-.63.07-.934.106m2.78-4.72c-.011-.153-.011-.27-.034-.387c-.21-1.157-1.274-1.81-2.384-1.554c-1.087.245-1.788.935-2.045 2.033c-.21.912.234 1.835 1.075 2.21c.643.28 1.285.244 1.905-.07c.923-.48 1.425-1.228 1.484-2.233z"/>`,
postgresql: `<path fill="currentColor" d="M23.56 14.723a.5.5 0 0 0-.057-.12q-.21-.395-1.007-.231c-1.654.34-2.294.13-2.526-.02c1.342-2.048 2.445-4.522 3.041-6.83c.272-1.05.798-3.523.122-4.73a1.6 1.6 0 0 0-.15-.236C21.693.91 19.8.025 17.51.001c-1.495-.016-2.77.346-3.116.479a10 10 0 0 0-.516-.082a8 8 0 0 0-1.312-.127c-1.182-.019-2.203.264-3.05.84C8.66.79 4.729-.534 2.296 1.19C.935 2.153.309 3.873.43 6.304c.041.818.507 3.334 1.243 5.744q.69 2.26 1.433 3.582q.83 1.493 1.714 1.79c.448.148 1.133.143 1.858-.729a56 56 0 0 1 1.945-2.206c.435.235.906.362 1.39.377v.004a11 11 0 0 0-.247.305c-.339.43-.41.52-1.5.745c-.31.064-1.134.233-1.146.811a.6.6 0 0 0 .091.327c.227.423.922.61 1.015.633c1.335.333 2.505.092 3.372-.679c-.017 2.231.077 4.418.345 5.088c.221.553.762 1.904 2.47 1.904q.375.001.829-.094c1.782-.382 2.556-1.17 2.855-2.906c.15-.87.402-2.875.539-4.101c.017-.07.036-.12.057-.136c0 0 .07-.048.427.03l.044.007l.254.022l.015.001c.847.039 1.911-.142 2.531-.43c.644-.3 1.806-1.033 1.595-1.67M2.37 11.876c-.744-2.435-1.178-4.885-1.212-5.571c-.109-2.172.417-3.683 1.562-4.493c1.837-1.299 4.84-.54 6.108-.13l-.01.01C6.795 3.734 6.843 7.226 6.85 7.44c0 .082.006.199.016.36c.034.586.1 1.68-.074 2.918c-.16 1.15.194 2.276.973 3.089q.12.126.252.237c-.347.371-1.1 1.193-1.903 2.158c-.568.682-.96.551-1.088.508c-.392-.13-.813-.587-1.239-1.322c-.48-.839-.963-2.032-1.415-3.512m6.007 5.088a1.6 1.6 0 0 1-.432-.178c.089-.039.237-.09.483-.14c1.284-.265 1.482-.451 1.915-1a8 8 0 0 1 .367-.443a.4.4 0 0 0 .074-.13c.17-.151.272-.11.436-.042c.156.065.308.26.37.475c.03.102.062.295-.045.445c-.904 1.266-2.222 1.25-3.168 1.013m2.094-3.988l-.052.14c-.133.357-.257.689-.334 1.004c-.667-.002-1.317-.288-1.81-.803c-.628-.655-.913-1.566-.783-2.5c.183-1.308.116-2.447.08-3.059l-.013-.22c.296-.262 1.666-.996 2.643-.772c.446.102.718.406.83.928c.585 2.704.078 3.83-.33 4.736a9 9 0 0 0-.23.546m7.364 4.572q-.024.266-.062.596l-.146.438a.4.4 0 0 0-.018.108c-.006.475-.054.649-.115.87a4.8 4.8 0 0 0-.18 1.057c-.11 1.414-.878 2.227-2.417 2.556c-1.515.325-1.784-.496-2.02-1.221a7 7 0 0 0-.078-.227c-.215-.586-.19-1.412-.157-2.555c.016-.561-.025-1.901-.33-2.646q.006-.44.019-.892a.4.4 0 0 0-.016-.113a2 2 0 0 0-.044-.208c-.122-.428-.42-.786-.78-.935c-.142-.059-.403-.167-.717-.087c.067-.276.183-.587.309-.925l.053-.142c.06-.16.134-.325.213-.5c.426-.948 1.01-2.246.376-5.178c-.237-1.098-1.03-1.634-2.232-1.51c-.72.075-1.38.366-1.709.532a6 6 0 0 0-.196.104c.092-1.106.439-3.174 1.736-4.482a4 4 0 0 1 .303-.276a.35.35 0 0 0 .145-.064c.752-.57 1.695-.85 2.802-.833q.616.01 1.174.081c1.94.355 3.244 1.447 4.036 2.383c.814.962 1.255 1.931 1.431 2.454c-1.323-.134-2.223.127-2.68.78c-.992 1.418.544 4.172 1.282 5.496c.135.242.252.452.289.54c.24.583.551.972.778 1.256c.07.087.138.171.189.245c-.4.116-1.12.383-1.055 1.717a35 35 0 0 1-.084.815c-.046.208-.07.46-.1.766m.89-1.621c-.04-.832.27-.919.597-1.01l.135-.041a1 1 0 0 0 .134.103c.57.376 1.583.421 3.007.134c-.202.177-.519.4-.953.601c-.41.19-1.096.333-1.747.364c-.72.034-1.086-.08-1.173-.151m.57-9.271a7 7 0 0 1-.105 1.001c-.055.358-.112.728-.127 1.177c-.014.436.04.89.093 1.33c.107.887.216 1.8-.207 2.701a4 4 0 0 1-.188-.385a8 8 0 0 0-.325-.617c-.616-1.104-2.057-3.69-1.32-4.744c.38-.543 1.342-.566 2.179-.463m.228 7.013l-.085-.107l-.035-.044c.726-1.2.584-2.387.457-3.439c-.052-.432-.1-.84-.088-1.222c.013-.407.066-.755.118-1.092c.064-.415.13-.844.111-1.35a.6.6 0 0 0 .012-.19c-.046-.486-.6-1.938-1.73-3.253a7.8 7.8 0 0 0-2.688-2.04A9.3 9.3 0 0 1 17.62.746c2.052.046 3.675.814 4.824 2.283a1 1 0 0 1 .067.1c.723 1.356-.276 6.275-2.987 10.54m-8.816-6.116c-.025.18-.31.423-.621.423l-.081-.006a.8.8 0 0 1-.506-.315c-.046-.06-.12-.178-.106-.285a.22.22 0 0 1 .093-.149c.118-.089.352-.122.61-.086c.316.044.642.193.61.418m7.93-.411c.011.08-.049.2-.153.31a.72.72 0 0 1-.408.223l-.075.005c-.293 0-.541-.234-.56-.371c-.024-.177.264-.31.56-.352c.298-.042.612.009.636.185"/>`,
dotnet: `<path fill="currentColor" d="M24 8.77h-2.468v7.565h-1.425V8.77h-2.462V7.53H24zm-6.852 7.565h-4.821V7.53h4.63v1.24h-3.205v2.494h2.953v1.234h-2.953v2.604h3.396zm-6.708 0H8.882L4.78 9.863a3 3 0 0 1-.258-.51h-.036q.048.283.048 1.21v5.772H3.157V7.53h1.659l3.965 6.32q.25.392.323.54h.024q-.06-.35-.06-1.185V7.529h1.372zm-8.703-.693a.868.829 0 0 1-.869.829a.868.829 0 0 1-.868-.83a.868.829 0 0 1 .868-.828a.868.829 0 0 1 .869.829"/>`,
docker: `<path fill="currentColor" d="M13.983 11.078h2.119a.186.186 0 0 0 .186-.185V9.006a.186.186 0 0 0-.186-.186h-2.119a.185.185 0 0 0-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 0 0 .186-.186V3.574a.186.186 0 0 0-.186-.185h-2.118a.185.185 0 0 0-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 0 0 .186-.186V6.29a.186.186 0 0 0-.186-.185h-2.118a.185.185 0 0 0-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 0 0 .184-.186V6.29a.185.185 0 0 0-.185-.185H8.1a.185.185 0 0 0-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 0 0 .185-.186V6.29a.185.185 0 0 0-.185-.185H5.136a.186.186 0 0 0-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 0 0 .186-.185V9.006a.186.186 0 0 0-.186-.186h-2.118a.185.185 0 0 0-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 0 0 .184-.185V9.006a.185.185 0 0 0-.184-.186h-2.12a.185.185 0 0 0-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 0 0 .185-.185V9.006a.185.185 0 0 0-.184-.186h-2.12a.186.186 0 0 0-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 0 0 .184-.185V9.006a.185.185 0 0 0-.184-.186h-2.12a.185.185 0 0 0-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51q-.508.001-1.01.087c-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199l-.226.327c-.284.438-.49.922-.612 1.43c-.23.97-.09 1.882.403 2.661c-.595.332-1.55.413-1.744.42H.751a.75.75 0 0 0-.75.748a11.4 11.4 0 0 0 .692 4.062c.545 1.428 1.355 2.48 2.41 3.124c1.18.723 3.1 1.137 5.275 1.137a15.7 15.7 0 0 0 2.93-.266a12.3 12.3 0 0 0 3.823-1.389a10.5 10.5 0 0 0 2.61-2.136c1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009c.309-.293.55-.65.707-1.046l.098-.288Z"/>`,
github: `<path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>`,
kotlin: `<path fill="currentColor" d="M24 24H0V0h24L12 12Z"/>`,
swift: `<path fill="currentColor" d="m7.508 0l-.86.002q-.362.001-.724.01q-.198.005-.395.015A9 9 0 0 0 4.348.15A5.5 5.5 0 0 0 2.85.645A5.04 5.04 0 0 0 .645 2.848c-.245.48-.4.972-.495 1.5c-.093.52-.122 1.05-.136 1.576a35 35 0 0 0-.012.724L0 7.508v8.984l.002.862q.002.36.012.722c.014.526.043 1.057.136 1.576c.095.528.25 1.02.495 1.5a5.03 5.03 0 0 0 2.205 2.203c.48.244.97.4 1.498.495c.52.093 1.05.124 1.576.138q.362.01.724.01q.43.003.86.002h8.984l.86-.002q.362 0 .724-.01a10.5 10.5 0 0 0 1.578-.138a5.3 5.3 0 0 0 1.498-.495a5.04 5.04 0 0 0 2.203-2.203c.245-.48.4-.972.495-1.5c.093-.52.124-1.05.138-1.576q.01-.361.01-.722q.003-.431.002-.862V7.508l-.002-.86a34 34 0 0 0-.01-.724a10.5 10.5 0 0 0-.138-1.576a5.3 5.3 0 0 0-.495-1.5A5.04 5.04 0 0 0 21.152.645A5.3 5.3 0 0 0 19.654.15a10.5 10.5 0 0 0-1.578-.138a35 35 0 0 0-.722-.01L16.492 0zm6.035 3.41c4.114 2.47 6.545 7.162 5.549 11.131c-.024.093-.05.181-.076.272l.002.001c2.062 2.538 1.5 5.258 1.236 4.745c-1.072-2.086-3.066-1.568-4.088-1.043a7 7 0 0 1-.281.158l-.02.012l-.002.002c-2.115 1.123-4.957 1.205-7.812-.022a12.57 12.57 0 0 1-5.64-4.838c.649.48 1.35.902 2.097 1.252c3.019 1.414 6.051 1.311 8.197-.002C9.651 12.73 7.101 9.67 5.146 7.191a10.6 10.6 0 0 1-1.005-1.384c2.34 2.142 6.038 4.83 7.365 5.576C8.69 8.408 6.208 4.743 6.324 4.86c4.436 4.47 8.528 6.996 8.528 6.996c.154.085.27.154.36.213q.128-.322.224-.668c.708-2.588-.09-5.548-1.893-7.992z"/>`,
flutter: `<path fill="currentColor" d="M14.314 0L2.3 12L6 15.7L21.684.013h-7.357zm.014 11.072L7.857 17.53l6.47 6.47H21.7l-6.46-6.468l6.46-6.46h-7.37z"/>`,
nixos: `<path fill="currentColor" d="m7.352 1.592l-1.364.002L5.32 2.75l1.557 2.713l-3.137-.008l-1.32 2.34h11.69l-1.353-2.332l-3.192-.006l-2.214-3.865zm6.175 0l-2.687.025l5.846 10.127l1.341-2.34l-1.59-2.765l2.24-3.85l-.683-1.182h-1.336l-1.57 2.705l-1.56-2.72zm6.887 4.195l-5.846 10.125l2.696-.008l1.601-2.76l4.453.016l.682-1.183l-.666-1.157l-3.13-.008L21.778 8.1l-1.365-2.313zM9.432 8.086l-2.696.008l-1.601 2.76l-4.453-.016L0 12.02l.666 1.157l3.13.008l-1.575 2.71l1.365 2.315zM7.33 12.25l-.006.01l-.002-.004l-1.342 2.34l1.59 2.765l-2.24 3.85l.684 1.182H7.35l.004-.006h.001l1.567-2.698l1.558 2.72l2.688-.026l-.004-.006h.01zm2.55 3.93l1.354 2.332l3.192.006l2.215 3.865l1.363-.002l.668-1.156l-1.557-2.713l3.137.008l1.32-2.34z"/>`,
house: `<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
newspaper: `<path d="M15 18h-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M18 14h-8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><rect width="8" height="4" x="10" y="6" rx="1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
"file-user": `<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 2v5a1 1 0 0 0 1 1h5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M16 22a4 4 0 0 0-8 0" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="15" r="3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
"code-xml": `<path d="m18 16 4-4-4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="m6 8-4 4 4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="m14.5 4-5 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
megaphone: `<path d="M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 6v8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
settings: `<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
x: `<path d="M18 6 6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="m6 6 12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
"arrow-up": `<path d="m5 12 7-7 7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 19V5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
mastodon: `<path fill="currentColor" d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>`,
matrix: `<path fill="currentColor" d="M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033c.309-.443.683-.784 1.117-1.024.433-.245.936-.365 1.5-.365.54 0 1.033.107 1.481.314.448.208.785.582 1.02 1.108.254-.374.6-.706 1.034-.992.434-.287.95-.43 1.546-.43.453 0 .872.056 1.26.167.388.11.716.286.993.53.276.245.489.559.646.951.152.392.23.863.23 1.417v5.728h-2.349V11.52c0-.286-.01-.559-.032-.812a1.755 1.755 0 0 0-.18-.66 1.106 1.106 0 0 0-.438-.448c-.194-.11-.457-.166-.785-.166-.332 0-.6.064-.803.189a1.38 1.38 0 0 0-.48.499 1.946 1.946 0 0 0-.231.696 5.56 5.56 0 0 0-.06.785v4.768h-2.35v-4.8c0-.254-.004-.503-.018-.752a2.074 2.074 0 0 0-.143-.688 1.052 1.052 0 0 0-.415-.503c-.194-.125-.476-.19-.854-.19-.111 0-.259.024-.439.074-.18.051-.36.143-.53.282-.171.138-.319.337-.439.595-.12.259-.18.6-.18 1.02v4.966H5.46V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z"/>`,
} as const;
export type IconName = keyof typeof icons;
export const pdfIconPaths: Record<string, string> = {
email:
"m20 8l-8 5l-8-5V6l8 5l8-5m0-2H4c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2",
phone:
"M6.62 10.79c1.44 2.83 3.76 5.15 6.59 6.59l2.2-2.2c.28-.28.67-.36 1.02-.25c1.12.37 2.32.57 3.57.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57c.11.35.03.74-.25 1.02z",
gitea:
"M4.209 4.603c-.247 0-.525.02-.84.088c-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768c1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367c2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068c0 0 2.107-4.471 2.107-8.823c-.042-1.318-.367-1.55-.443-1.627c-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17c-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12m8.33 2.554c.26.003.509.127.509.127l.868.422l-.529 1.075a.69.69 0 0 0-.614.359a.69.69 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527a.69.69 0 0 0 .347.763a.686.686 0 0 0 .867-.206a.69.69 0 0 0-.069-.882l.916-1.874a.7.7 0 0 0 .237-.02a.66.66 0 0 0 .271-.137a9 9 0 0 1 1.016.512a.76.76 0 0 1 .286.282c.073.21-.073.569-.073.569c-.087.29-.702 1.55-.702 1.55a.69.69 0 0 0-.676.477a.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431c.19-.397.515-1.16.515-1.16c.035-.066.218-.394.103-.814c-.095-.435-.48-.638-.48-.638c-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.7.7 0 0 0-.148-.241l.516-1.062l2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657c-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.1 1.1 0 0 1-.393-.045l-.202-.08l-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.9.9 0 0 1 .35-.077",
};

View File

@@ -1,7 +1,8 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const postsCollection = defineCollection({
type: 'content',
const posts = defineCollection({
loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: "./src/content/posts" }),
schema: z.object({
title: z.string(),
description: z.string(),
@@ -12,5 +13,5 @@ const postsCollection = defineCollection({
});
export const collections = {
'posts': postsCollection,
};
posts,
};

View File

@@ -0,0 +1,30 @@
---
title: "My 2026 Infrastructure"
description: "Building a Homelab that scales."
pubDate: "2026-02-25"
tags: ["devops", "infra", "nas", "nix"]
---
In the year of our lord, 2026, I figured I'd go over how I have set up this website, along with the other services I host for myself and for my business. Hopefully this proves useful to perspective self-hosters out there!
## The Network
One word: Tailscale.
Tailscale is a wireguard based mesh network where your devices connect to eachother on a "tailnet". Each device can access eachother directly via a name and a tailnet domain you are assigned. For instance, if your device is called ```megatron```, you might connect to it through another machine on the network using ```megatron.stinky-panda.ts.net```. It's that easy! This is the glue of the network, which allows me to expose services from my home without exposing my home IP.
## Lloyd
Lloyd is the main machine here. It runs TrueNAS Scale as its hypervisor, and runs a combination of applications from their "apps" section and docker containers I deploy manually. It, along with everything else on my network, is named after characters from the 1999 PS1 game ```The Legend of Dragoon```. Lloyd is not ever directly exposed from my network, but is instead connected to Tailscale where it connects to the next piece of the puzzle.
## Haschel
Following with my theme, my proxy server hosted on OVHCloud is called Haschel. Haschel is responsible for proxying using NGINX. Now, typically you would use this to point to local services. This is why Tailscale is so useful, however. Say I need to point to a web server on port ```6969``` on Lloyd. All I would do is point to port ```6969``` and hostname ```lloyd.stinky-panda.ts.net```. Tailscale routes the request to Lloyd, and the only IP address ever exposed in the process belongs to Haschel hosted on OVHCloud. One quirk of Haschel is it is running NixOS, which is a Nix based operating system that can be declaratively configured. This means that everything that runs on Haschel can be defined in [this](https://git.atri.dad/atridad/haschel) git repo, and my continuous integration takes care of connecting to the actual server and re-building with the new configuration. Due to the flexibility of Nix, I can swap VPS providers at any time and be up and running in under an hour.
## Putting it all together
I realize that not everyone is familiar with multi-cloud setups or mesh networking, so I made a diagram which I hope will make it clear:
![Diagram of Tailnet](https://msrc.atri.dad/img/pako:eNqNVetv2jAQ_1csT4hWCgjyIuTDpLWV1k4rQwtC02AfTOKAhbGRbVYo4n_f5UHCo9VqpHC-u9_5Hr7zHscyoTjEjcaeCWZCtJ8KhJpmQVe0GaLmjGjatGremChGZpzqZqmKMp14OVdyI5IM8SnNVwEC6VqxFVG7e8mlyuW-3bOJeykf0a2pddIAfvalzp1UCVW1luu6PY9UWpwJ-u4xmsZSJGeO2IFNHL_SMFQZdqZwYX9FmLhbzt_CCshi4d1bwJhvNFgvsUIKeiWqsRd-p1KYiL3mxei6620z4x-m4nBoNKZirsh6gb7_LJT1ZlYwRoRxHRNO0WSK682AmhepllP8p9DPVsIUjQ2TorKSrUei4wXlgC6pWvRj_IjGw6hmDL4-DX6hmwHbooSmUIPk9uyEmvrO5S4Bm_l_zR6pDR18iSoQFSAsyCcByRHU3NwcqdvbQnIRcUTVXxZTnQVc0VlQ-VFvBzy6q7lRdzKkSktBOIqYoSeAyJ7cS6E33DAxvxI6k2-U8x3Efcp1J2MCgBcCZT0TeJNnyFCsSGpyn-HMU7E_abfb76cBtVrocTQaRkB8RmelKTeZxlDJ7Q4Nida52km2i2_OAFELor7i2Fcc54rjXnG8K45_9L3RQA-KxBtOUD5BEqTNDgZIWcCMPrmvKeM8zDrE0kbJJQ3Lfii3rYToBVGK7ELkIc-Ks14Ny248tVjdgdxg2ZCVzVnSd9L-EV0Mm1P0MZkFOJjRfppcOvTu0UUSCmiazgI__jC0qnOBLt38P7r4xhxK_kBTpIvgBzCUjn50UxJ8xI_cBtwLK7KtyLEi14o8K_JPTWILzxVLcGigby28ogoGI2xx_h5McV7lKQ6BhHmQtcEUw7wC2JqI31Kujkh4MuYLHKaEa9ht1gkx9IERaOhVxVXQBdnI3wiDw66T28DhHm9xaHttt-85Hbfb6fm-3-n6Ft7hsBU47U7gd1zX6XY923Hdg4Vf82M77aDndWA5geMHbr8XWJgmzEj1XLyC-WN4-Ac64hx8?type=png)
Feel free to reach out if you have any questions about how I got everything working. I can be reached by email at [me@atri.dad](mailto:me@atri.dad).

View File

@@ -0,0 +1,36 @@
---
title: "Building Ascently: Why I Chose Native Over Cross-Platform"
description: "Building a fully native climbing tracker and why I didn't use Flutter or React Native."
pubDate: "2025-10-16"
tags: ["projects", "open-source", "mobile"]
---
I've been climbing for a couple of years now, and I wanted a simple way to track my sessions and progress. The apps I tried required accounts I didn't want to create, or just felt... off. So I went and made my own.
## Why Native?
I built Ascently twice. Once in SwiftUI for iOS, and once in Jetpack Compose for Android. No Flutter, no React Native, no cross-platform frameworks.
This seems like a lot more work than its worth. Why would anyone willingly write the same app twice?
**Different platforms are different**
iOS users expect iOS features and design. Material You on Android looks and feels different than iOS's design language, and thats _good_. This is part of what makes these platforms unique. I wanted Ascently to feel hand-crafted for each platform and not like some fully custom design that feels out of place. SwiftUI and Jetpack Compose let me do this while maintaining the declaritive approach I appreciate so much from frameworks like Flutter. I also just genuinely enjoyed learning both platforms.
## Offline-First
Building Ascently offline-first was front-of-mind from the beginning. Your sessions are saved locally and are always accessible. If you want sync, you can run your own server. But you don't _have_ to. Your data always remains yours, on your device, until you **explicitly** decide otherwise.
## Privacy as a Feature
No analytics. No tracking. No data collection. I am tired of apps treating privacy like a checkbox to tick instead of a fundamental design choice. I went out of my way to ensure that the ONLY time network calls are made is when you explicitly choose to sync or enable the health integration.
This made the architecture simpler. No need to figure out integrating an analytics SDK to integrate. Turns out you don't need to fuss around with building the perfect "privacy respecting analytics" system if you just **don't** collect analytics to begin with.
## What I've Learned
Building native apps on both platforms taught me that cross-platform frameworks solve a real problem—but not _my_ problem. I wanted to learn native development while solving a problem I felt was never properly solved. And of course I wanted full control over the tech stack without abstractions or code generation getting in the way.
## Try It (pls >.<)
If you climb and you're looking for a simple app to manage your sessions, please give [Ascently](https://ascently.atri.dad) a shot. All the code is available at [git.atri.dad/atridad/Ascently](https://git.atri.dad/atridad/Ascently).

View File

@@ -9,21 +9,12 @@ I change what I use _constantly_ in order to find something that feels just
right. I wanted to share them here and update them here so when someone asks, I
can just point them to this article.
1. Sublime Text - Currently my favourite text editor. Fast, simple, and
extensible. Just a joy to use!
2. Sublime Merge - Honestly one of the fastest and best looking git GUIs around!
Awesome for visualizing changes when you have larger code changes.
3. Ghostty - A Zig based terminal emulator by one of the founders of Hashicorp.
Runs great on MacOS and Linux. No windows for those who are into that.
4. OrbStack - A faster alternative to Docker Desktop that also runs VMs!
5. Bitwarden - An open-source password manager. Easy to self host with
Vaultwarden and with the recent updates, it has SSH Agent support!
6. iA Writer - A minimalist Markdown editor. For MacOS and Windows only, but
really the MacOS version is the most mature. Awesome for focus.
7. Dataflare - A simple but powerful cross-platform database client. Supports
most common databases, including LibSQL which is rare!
8. Bruno - A simple and powerful API client, similar to Postman. An critical
tool to debug API endpoints.
1. Zed - Zed is a performany open-source code editor that allows you to configure away all of the AI and signin features. Performs well and has its own extensive extension ecosystem. My config to clean it up can be found [here](https://git.atri.dad/atridad/zed-config).
3. Ghostty - A Zig based terminal emulator by one of the founders of Hashicorp. Runs great on MacOS and Linux. No windows for those who are into that.
4. Bitwarden - An open-source password manager. Easy to self host with Vaultwarden and with the recent updates, it has SSH Agent support!
5. iA Writer - A minimalist Markdown editor. For MacOS and Windows only, but really the MacOS version is the most mature. Awesome for focus.
6. Dataflare - A simple but powerful cross-platform database client. Supports most common databases, including LibSQL which is rare!
7. Bruno - A simple and powerful API client, similar to Postman. An critical tool to debug API endpoints.
I hope you found this helpful! This will be periodically updated to avoid
outdated recommendations.
outdated recommendations.

View File

@@ -0,0 +1,41 @@
---
title: "Switching to Linux"
description: "Leaving the bloat behind."
pubDate: "2025-12-21"
tags: ["linux", "techtips"]
---
I have decided to move my daily workflow away from Windows. Microsoft has, for a long time now, been engaging in an age-old practice known as enshittification. Windows used to be the place I did my work, communicated with friends, and played _far_ too many hours of MapleStory. Now the entire operating system feels like i'm running adware. The final straw was the aggressive integration of AI features, which are being pushed onto users regardless of interest **or** privacy concerns.
### What is Linux?
Linux is an operating system that stays out of your way. It does everything you would expect a modern operating system to do, but it does so without the telemetry or rushed AI features. Linux offers a level of ownership that is rare in modern technology. When you install software or change a setting, the system respects that decision without reverting it after an update. Now, the more complex answer is that Linux is a kernel, and different "distributions" are more complete operating systems built around the Linux kernel. I will get to which ones I recommend for newcomers in a moment.
### Gaming
Traditionally Linux struggled with gaming for the simple reason that games were not built with support for Linux in mind. This meant that translation layers such as Wine were required to run most games, and even this was not a guarantee. Thanks to Valve and the broader Linux gaming community, we have made significant progress here! Steam's Proton compatibility layer has done wonders for this, helping Linux users run most of their library. Unless you are playing titles with aggressive kernel-level anti-cheat (Battlefield 6, Apex Legends, etc), the gaming experience is indistinguishable from Windows.
### Desktop Environments
One concept that confuses new Linux users is the Desktop Environment (DE). On Windows, the desktop environment is tightly coupled with the operating system, and is not highly customizable. With Linux, the interface is just another piece of software you can swap out. This means you can choose a DE that behaves like Windows, macOS, or something fully keyboard-driven. Having this level of choice allows you to tailor your machine to *you*.
If you would like a more macOS-like experience, I recommend either **GNOME** or **COSMIC**. If you are looking for a Windows-like experience, **KDE** is the way to go.
### Distributions
While the amount of choice in Linux distributions can seem daunting, there are a few I can whole-heartedly recommend for new users:
#### [Fedora](https://www.fedoraproject.org/workstation/download)
Fedora is a fully open-source, rock-solid, and easy to use distribution. It provides regularly updated software packages and has a built-in "app store" that makes installing and updating software painless.
For this distribution, I would recommend the GNOME or KDE Desktop Environments.
#### [Pop!_OS](https://system76.com/pop/download/)
Pop!_OS is an open-source, Debian-based distribution that focuses on excellent support, stability, and performance with its COSMIC desktop environment. It also provides regularly updated software packages and has a built-in "app store" that makes installing and updating software painless.
For this distribution, I would recommend the COSMIC Desktop Environment.
### Hardware Recommendations
You can use any modern graphics card with these distributions, but the experience varies. Historically, Nvidia cards have faced challenges, particularly with driver setup and sleep/wake functionality. In contrast, AMD and Intel include drivers directly in the kernel, offering a more seamless "plug and play" experience. While Nvidia cards are definitely viable, using AMD often results in fewer maintenance headaches and greater system stability. If you have the flexibility to choose, AMD is my recommendation for the best possible experience.
Feel free to reach out if you have any questions about making the switch. I can be reached by email at [me@atri.dad](mailto:me@atri.dad).

View File

@@ -1,43 +1,67 @@
---
import { ClientRouter } from "astro:transitions";
import NavigationBar from "../components/NavigationBar";
import ScrollUpButton from "../components/ScrollUpButton";
import { siteConfig } from "../config/data";
import NavigationBar from "../components/NavigationBar.vue";
import ScrollUpButton from "../components/ScrollUpButton.vue";
import { config } from "../config";
import type { OpenGraphImage } from "../types";
const currentPath = Astro.url.pathname;
import "../styles/global.css";
export interface Props {
title?: string;
description?: string;
title?: string;
description?: string;
ogImage?: OpenGraphImage;
ogType?: "website" | "article";
}
const { title, description } = Astro.props;
const { title, description, ogImage, ogType } = Astro.props;
const pageTitle = title
? `${title} | ${siteConfig.meta.title}`
: siteConfig.meta.title;
const pageDescription = description || siteConfig.meta.description;
? `${title} | ${config.siteConfig.meta.title}`
: config.siteConfig.meta.title;
const pageDescription = description || config.siteConfig.meta.description;
const og = config.siteConfig.openGraph;
const canonicalUrl = new URL(Astro.url.pathname, config.siteConfig.meta.url)
.href;
const resolvedOgImage = ogImage || og.image;
const resolvedOgImageUrl = new URL(resolvedOgImage.url, config.siteConfig.meta.url).href;
const resolvedOgType = ogType || og.type || "website";
---
<!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} />
<meta name="description" content={pageDescription} />
<meta name="author" content={siteConfig.meta.author} />
<title>{pageTitle}</title>
<ClientRouter />
</head>
<body class="flex flex-col min-h-screen overflow-x-hidden">
<main
class="flex-grow flex flex-col gap-4 items-center justify-center pb-[68px] sm:pb-[76px]"
>
<slot />
</main>
<NavigationBar client:load currentPath={currentPath} />
<ScrollUpButton client:load />
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="canonical" href={canonicalUrl} />
<meta name="generator" content={Astro.generator} />
<meta name="description" content={pageDescription} />
<meta name="author" content={config.siteConfig.meta.author} />
<title>{pageTitle}</title>
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={pageDescription} />
<meta property="og:type" content={resolvedOgType} />
<meta property="og:url" content={canonicalUrl} />
{og.siteName && <meta property="og:site_name" content={og.siteName} />}
{og.locale && <meta property="og:locale" content={og.locale} />}
<meta property="og:image" content={resolvedOgImageUrl} />
<meta property="og:image:width" content={String(resolvedOgImage.width)} />
<meta property="og:image:height" content={String(resolvedOgImage.height)} />
<meta property="og:image:type" content={resolvedOgImage.type} />
<meta property="og:image:alt" content={resolvedOgImage.alt} />
<ClientRouter />
</head>
<body class="flex flex-col min-h-screen overflow-x-hidden">
<main
class="grow flex flex-col gap-4 items-center justify-center pb-17 sm:pb-19"
>
<slot />
</main>
<NavigationBar client:load currentPath={currentPath} />
<ScrollUpButton client:load />
</body>
</html>

24
src/pages/404.astro Normal file
View File

@@ -0,0 +1,24 @@
---
import Layout from "../layouts/Layout.astro";
import FuzzyText from "../components/FuzzyText.vue";
---
<Layout title="404 - Not Found" description="Page not found">
<div class="flex flex-col items-center justify-center w-full">
<FuzzyText
text="404"
:font-size="140"
font-weight="900"
color="#c6a0f6"
:enable-hover="true"
:base-intensity="0.18"
:hover-intensity="0.5"
client:load
/>
<a
href="/"
class="mt-4 text-primary hover:text-primary/80 transition-colors"
>Take me back!</a
>
</div>
</Layout>

View File

@@ -1,42 +1,32 @@
import { getCollection } from 'astro:content';
import type { APIRoute } from 'astro';
import { getCollection } from "astro:content";
import type { APIRoute } from "astro";
export const GET: APIRoute = async () => {
try {
const posts = await getCollection('posts');
// Get the raw content from each post
const postsWithContent = await Promise.all(
posts.map(async (post) => {
const { Content } = await post.render();
// Get the raw markdown content by reading the file
const rawContent = post.body;
return {
slug: post.slug,
title: post.data.title,
description: post.data.description,
pubDate: post.data.pubDate.toISOString().split('T')[0],
tags: post.data.tags || [],
content: rawContent
};
})
);
const posts = await getCollection("posts");
const postsWithContent = posts.map((post) => ({
slug: post.id,
title: post.data.title,
description: post.data.description,
pubDate: post.data.pubDate.toISOString().split("T")[0],
tags: post.data.tags || [],
content: post.body,
}));
return new Response(JSON.stringify(postsWithContent), {
status: 200,
headers: {
'Content-Type': 'application/json'
}
"Content-Type": "application/json",
},
});
} catch (error) {
console.error('Error fetching posts:', error);
console.error("Error fetching posts:", error);
return new Response(JSON.stringify([]), {
status: 500,
headers: {
'Content-Type': 'application/json'
}
"Content-Type": "application/json",
},
});
}
};
};

View File

@@ -1,23 +1,21 @@
import type { APIRoute } from "astro";
import * as TOML from "@iarna/toml";
import { siteConfig } from "../../config/data";
import { config } from "../../config";
export const GET: APIRoute = async ({ request }) => {
try {
// Check if resume TOML content is configured
if (!siteConfig.resume.tomlFile || !siteConfig.resume.tomlFile.trim()) {
if (!config.resumeConfig.tomlFile || !config.resumeConfig.tomlFile.trim()) {
return new Response("Resume not configured", { status: 404 });
}
let tomlContent: string;
// Check if tomlFile is a path (starts with /) or raw content
if (siteConfig.resume.tomlFile.startsWith("/")) {
// It's a file path - fetch it
if (config.resumeConfig.tomlFile.startsWith("/")) {
const url = new URL(request.url);
const baseUrl = `${url.protocol}//${url.host}`;
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
const response = await fetch(`${baseUrl}${config.resumeConfig.tomlFile}`);
if (!response.ok) {
throw new Error(
@@ -27,15 +25,14 @@ export const GET: APIRoute = async ({ request }) => {
tomlContent = await response.text();
} else {
// It's raw TOML content
tomlContent = siteConfig.resume.tomlFile;
tomlContent = config.resumeConfig.tomlFile;
}
const resumeData = TOML.parse(tomlContent);
return new Response(JSON.stringify(resumeData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=300", // Cache for 5 minutes
"Cache-Control": "public, max-age=300",
},
});
} catch (error) {

View File

@@ -0,0 +1,106 @@
import type { APIRoute } from "astro";
import { config } from "../../../config";
import type { ResumeData } from "../../../types";
import * as TOML from "@iarna/toml";
import { renderToStream } from "@react-pdf/renderer";
import { ResumeDocument } from "../../../pdf/ResumeDocument";
import { pdfIconPaths } from "../../../config/icons";
const generatePDF = async (data: ResumeData) => {
const resumeConfig = config.resumeConfig;
const icons: { [key: string]: string } = {
email: pdfIconPaths.email,
phone: pdfIconPaths.phone,
};
if (data.basics.profiles) {
for (const profile of data.basics.profiles) {
const key = profile.network.toLowerCase();
if (pdfIconPaths[key]) {
icons[key] = pdfIconPaths[key];
}
}
}
return await renderToStream(ResumeDocument({ data, resumeConfig, icons }));
};
export const GET: APIRoute = async ({ request }) => {
try {
if (!config.resumeConfig.tomlFile || !config.resumeConfig.tomlFile.trim()) {
return new Response("Resume not configured", { status: 404 });
}
let tomlContent: string;
if (config.resumeConfig.tomlFile.startsWith("/")) {
const url = new URL(request.url);
const baseUrl = `${url.protocol}//${url.host}`;
const response = await fetch(`${baseUrl}${config.resumeConfig.tomlFile}`);
if (!response.ok) {
throw new Error(
`Failed to fetch resume: ${response.status} ${response.statusText}`,
);
}
tomlContent = await response.text();
} else {
tomlContent = config.resumeConfig.tomlFile;
}
const resumeData: ResumeData = TOML.parse(
tomlContent,
) as unknown as ResumeData;
const stream = await generatePDF(resumeData);
return new Response(stream as any, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="Atridad_Lahiji_Resume.pdf"`,
},
});
} catch (error) {
console.error("Error generating PDF:", error);
return new Response("Error generating PDF", { status: 500 });
}
};
export const POST: APIRoute = async ({ request }) => {
try {
const { toml: tomlContent } = await request.json();
if (!tomlContent.trim()) {
return new Response("TOML content is required", { status: 400 });
}
let resumeData: ResumeData;
try {
resumeData = TOML.parse(tomlContent) as unknown as ResumeData;
} catch (parseError) {
return new Response(
`Invalid TOML format: ${parseError instanceof Error ? parseError.message : "Unknown error"}`,
{ status: 400 },
);
}
if (!resumeData.basics?.name) {
return new Response("Resume must include basics.name", { status: 400 });
}
const stream = await generatePDF(resumeData);
const filename = `${resumeData.basics.name.replace(/[^a-zA-Z0-9]/g, "_")}_Resume.pdf`;
return new Response(stream as any, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
} catch (error) {
console.error("Error generating PDF:", error);
return new Response("Error generating PDF", { status: 500 });
}
};

View File

@@ -1,492 +0,0 @@
import type { APIRoute } from "astro";
import { chromium } from "playwright";
import { siteConfig } from "../../../config/data";
import * as TOML from "@iarna/toml";
// Helper function to fetch and return SVG icon from Simple Icons CDN
async function getSimpleIcon(iconName: string): Promise<string> {
try {
const response = await fetch(
`https://cdn.jsdelivr.net/npm/simple-icons@v10/icons/${iconName.toLowerCase()}.svg`,
);
if (!response.ok) {
console.warn(`Failed to fetch icon: ${iconName}`);
return "";
}
const svgContent = await response.text();
return svgContent.replace(
"<svg",
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;"',
);
} catch (error) {
console.warn(`Error fetching icon ${iconName}:`, error);
return "";
}
}
// Helper function to get MDI icon SVG
function getMdiIcon(iconName: string): string {
const iconMap: { [key: string]: string } = {
"mdi:email":
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;" viewBox="0 0 24 24"><path d="M20,8L12,13L4,8V6L12,11L20,6M20,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6C2.89,4 20,4.89 20,4Z"/></svg>',
"mdi:download":
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;" viewBox="0 0 24 24"><path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"/></svg>',
"mdi:link":
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;" viewBox="0 0 24 24"><path d="M3.9,12C3.9,10.29 5.29,8.9 7,8.9H11V7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H11V15.1H7C5.29,15.1 3.9,13.71 3.9,12M8,13H16V11H8V13M17,7H13V8.9H17C18.71,8.9 20.1,10.29 20.1,12C20.1,13.71 18.71,15.1 17,15.1H13V17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7Z"/></svg>',
};
return iconMap[iconName] || "";
}
interface ResumeData {
basics: {
name: string;
email: string;
website?: string;
profiles: {
network: string;
username: string;
url: string;
}[];
};
layout?: {
left_column?: string[];
right_column?: string[];
};
summary: {
content: string;
};
experience: {
company: string;
position: string;
location: string;
date: string;
description: string[];
url?: string;
}[];
education: {
institution: string;
degree: string;
field: string;
date: string;
details?: string[];
}[];
skills: {
name: string;
level: number;
}[];
volunteer: {
organization: string;
position: string;
date: string;
}[];
awards: {
title: string;
organization: string;
date: string;
description?: string;
}[];
}
// Template helper functions
const createSection = (
title: string,
content: string,
spacing = "space-y-3",
) => `
<section>
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300">
${title}
</h2>
<div class="${spacing}">
${content}
</div>
</section>
`;
const createExperienceItem = (exp: any) => `
<div class="mb-3 pl-2 border-l-2 border-blue-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${exp.position}</h3>
<div class="text-xs text-gray-600 mb-1">
<span class="font-medium">${exp.company}</span>
<span class="mx-1">•</span>
<span>${exp.date}</span>
<span class="mx-1">•</span>
<span>${exp.location}</span>
</div>
<ul class="text-xs text-gray-700 leading-tight ml-3 list-disc">
${exp.description.map((item: string) => `<li class="mb-1">${item}</li>`).join("")}
</ul>
</div>
`;
const createSkillItem = (skill: any) => {
const progressValue = skill.level * 20;
return `
<div class="mb-1">
<div class="flex justify-between items-center mb-0.5">
<span class="text-xs font-medium text-gray-900">${skill.name}</span>
<span class="text-xs text-gray-600">${skill.level}/5</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: ${progressValue}%"></div>
</div>
</div>
`;
};
const createEducationItem = (edu: any) => {
const detailsList = edu.details
? edu.details
.map((detail: string) => `<li class="mb-1">${detail}</li>`)
.join("")
: "";
return `
<div class="mb-3 pl-2 border-l-2 border-green-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${edu.institution}</h3>
<div class="text-xs text-gray-600 mb-1">
<span class="font-medium">${edu.degree} in ${edu.field}</span>
<span class="mx-1">•</span>
<span>${edu.date}</span>
</div>
${detailsList ? `<ul class="text-xs text-gray-700 leading-tight ml-3 list-disc">${detailsList}</ul>` : ""}
</div>
`;
};
const createVolunteerItem = (vol: any) => `
<div class="mb-2 pl-2 border-l-2 border-purple-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${vol.organization}</h3>
<div class="text-xs text-gray-600">
<span class="font-medium">${vol.position}</span>
<span class="mx-1">•</span>
<span>${vol.date}</span>
</div>
</div>
`;
const createAwardItem = (award: any) => `
<div class="mb-2 pl-2 border-l-2 border-yellow-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${award.title}</h3>
<div class="text-xs text-gray-600 mb-1">
<span class="font-medium">${award.organization}</span>
<span class="mx-1">•</span>
<span>${award.date}</span>
</div>
${award.description ? `<div class="text-xs text-gray-700 leading-tight">${award.description}</div>` : ""}
</div>
`;
const createHead = (name: string) => `
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${name} - Resume</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@media print {
body {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
}
.resume-container {
max-width: 8.5in;
min-height: 11in;
}
</style>
</head>
`;
const createHeader = (
basics: any,
emailIcon: string,
profileIcons: { [key: string]: string },
) => `
<header class="text-center mb-3 pb-2 border-b-2 border-gray-300">
<h1 class="text-3xl font-bold text-gray-900 mb-1">${basics.name}</h1>
<div class="flex justify-center items-center flex-wrap gap-4 text-xs text-gray-600">
${basics.email ? `<div class="flex items-center gap-1">${emailIcon} ${basics.email}</div>` : ""}
${
basics.profiles
?.map((profile: any) => {
const icon = profileIcons[profile.network] || "";
const displayUrl = profile.url
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
return `<div class="flex items-center gap-1">${icon} ${displayUrl}</div>`;
})
.join("") || ""
}
</div>
</header>
`;
const createSummarySection = (summary: any, resumeConfig: any) => {
if (
!summary ||
!summary.content ||
resumeConfig.sections.summary?.enabled === false
)
return "";
return `
<section class="mb-3">
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300">
${resumeConfig.sections.summary?.title || "Summary"}
</h2>
<div class="text-xs text-gray-700 leading-tight">${summary.content}</div>
</section>
`;
};
const createColumnSections = (
sectionNames: string[],
sections: { [key: string]: string },
resumeConfig: any,
) => {
const sectionConfig = {
experience: {
title: resumeConfig.sections.experience?.title || "Experience",
enabled: resumeConfig.sections.experience?.enabled !== false,
spacing: "space-y-3",
},
skills: {
title: resumeConfig.sections.skills?.title || "Skills",
enabled: resumeConfig.sections.skills?.enabled !== false,
spacing: "space-y-1",
},
education: {
title: resumeConfig.sections.education?.title || "Education",
enabled: resumeConfig.sections.education?.enabled !== false,
spacing: "space-y-3",
},
volunteer: {
title: resumeConfig.sections.volunteer?.title || "Volunteer Work",
enabled: resumeConfig.sections.volunteer?.enabled !== false,
spacing: "space-y-2",
},
awards: {
title: resumeConfig.sections.awards?.title || "Awards & Recognition",
enabled: resumeConfig.sections.awards?.enabled !== false,
spacing: "space-y-2",
},
};
return sectionNames
.map((sectionName) => {
const config = sectionConfig[sectionName as keyof typeof sectionConfig];
const content = sections[sectionName];
// Skip if section doesn't exist in config, has no content, or is disabled
if (!config || !content || content.trim() === "" || !config.enabled)
return "";
return createSection(config.title, content, config.spacing);
})
.filter((section) => section !== "")
.join("");
};
const fetchProfileIcons = async (profiles: any[]) => {
const profileIcons: { [key: string]: string } = {};
if (profiles) {
for (const profile of profiles) {
const iconName = profile.network.toLowerCase();
profileIcons[profile.network] = await getSimpleIcon(iconName);
}
}
return profileIcons;
};
const generateResumeHTML = async (data: ResumeData): Promise<string> => {
const resumeConfig = siteConfig.resume;
// Use layout from TOML data, fallback to site config, then to default
const layout = data.layout
? {
leftColumn: data.layout.left_column || [
"experience",
"volunteer",
"awards",
],
rightColumn: data.layout.right_column || ["skills", "education"],
}
: resumeConfig.layout || {
leftColumn: ["experience", "volunteer", "awards"],
rightColumn: ["skills", "education"],
};
// Pre-fetch icons
const profileIcons = await fetchProfileIcons(data.basics.profiles);
const emailIcon = getMdiIcon("mdi:email");
// Generate section content
const sections = {
experience: Array.isArray(data.experience)
? data.experience.map(createExperienceItem).join("")
: "",
skills: Array.isArray(data.skills)
? data.skills.map(createSkillItem).join("")
: "",
education: Array.isArray(data.education)
? data.education.map(createEducationItem).join("")
: "",
volunteer: Array.isArray(data.volunteer)
? data.volunteer.map(createVolunteerItem).join("")
: "",
awards: Array.isArray(data.awards)
? data.awards.map(createAwardItem).join("")
: "",
};
return `
<!DOCTYPE html>
<html lang="en">
${createHead(data.basics.name)}
<body class="bg-white text-gray-900 text-xs leading-tight p-3">
<div class="resume-container mx-auto">
${createHeader(data.basics, emailIcon, profileIcons)}
${createSummarySection(data.summary, resumeConfig)}
<div class="grid grid-cols-2 gap-4">
<div class="space-y-4">
${createColumnSections(layout.leftColumn ?? [], sections, resumeConfig)}
</div>
<div class="space-y-4">
${createColumnSections(layout.rightColumn ?? [], sections, resumeConfig)}
</div>
</div>
</div>
</body>
</html>
`;
};
async function generatePDFFromToml(tomlContent: string): Promise<Uint8Array> {
const resumeData: ResumeData = TOML.parse(
tomlContent,
) as unknown as ResumeData;
const htmlContent = await generateResumeHTML(resumeData);
const browser = await chromium.launch({
headless: true,
executablePath:
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ||
(process.env.NODE_ENV === "production"
? "/usr/bin/chromium-browser"
: undefined),
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-web-security",
"--disable-features=VizDisplayCompositor",
],
});
const page = await browser.newPage();
await page.setContent(htmlContent, { waitUntil: "networkidle" });
const pdfBuffer = await page.pdf({
format: "A4",
margin: {
top: "0.2in",
bottom: "0.2in",
left: "0.2in",
right: "0.2in",
},
printBackground: true,
scale: 0.9,
});
await browser.close();
return pdfBuffer;
}
export const GET: APIRoute = async ({ request }) => {
try {
if (!siteConfig.resume.tomlFile || !siteConfig.resume.tomlFile.trim()) {
return new Response("Resume not configured", { status: 404 });
}
let tomlContent: string;
// Check if tomlFile is a path (starts with /) or raw content
if (siteConfig.resume.tomlFile.startsWith("/")) {
// It's a file path - fetch it
const url = new URL(request.url);
const baseUrl = `${url.protocol}//${url.host}`;
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
if (!response.ok) {
throw new Error(
`Failed to fetch resume: ${response.status} ${response.statusText}`,
);
}
tomlContent = await response.text();
} else {
// It's raw TOML content
tomlContent = siteConfig.resume.tomlFile;
}
const pdfBuffer = await generatePDFFromToml(tomlContent);
return new Response(pdfBuffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="Atridad_Lahiji_Resume.pdf"`,
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
},
});
} catch (error) {
console.error("Error generating PDF:", error);
return new Response("Error generating PDF", { status: 500 });
}
};
export const POST: APIRoute = async ({ request }) => {
try {
const tomlContent = await request.text();
if (!tomlContent.trim()) {
return new Response("TOML content is required", { status: 400 });
}
// Validate TOML content
let resumeData: ResumeData;
try {
resumeData = TOML.parse(tomlContent) as unknown as ResumeData;
} catch (parseError) {
return new Response(
`Invalid TOML format: ${parseError instanceof Error ? parseError.message : "Unknown error"}`,
{ status: 400 },
);
}
// Basic validation
if (!resumeData.basics?.name) {
return new Response("Resume must include basics.name", { status: 400 });
}
const pdfBuffer = await generatePDFFromToml(tomlContent);
const filename = `${resumeData.basics.name.replace(/[^a-zA-Z0-9]/g, "_")}_Resume.pdf`;
return new Response(pdfBuffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
},
});
} catch (error) {
console.error("Error generating PDF:", error);
return new Response("Error generating PDF", { status: 500 });
}
};

View File

@@ -7,6 +7,7 @@ export const GET: APIRoute = async () => {
[basics]
name = "Your Full Name"
email = "your.email@example.com"
phone = "+1 (555) 123-4567" # Optional
website = "https://yourwebsite.com"
# Add your social media profiles
@@ -15,10 +16,6 @@ network = "GitHub"
username = "yourusername"
url = "https://github.com/yourusername"
[[basics.profiles]]
network = "LinkedIn"
username = "yourname"
url = "https://linkedin.com/in/yourname"
[[basics.profiles]]
network = "Bluesky"
@@ -47,10 +44,8 @@ location = "City, Province/Country"
date = "Jan 2020 - Present"
url = "https://company.com" # Optional
description = [
"Describe a key achievement or responsibility",
"Quantify your impact with numbers when possible",
"Use action verbs to describe what you accomplished",
"Add more bullet points as needed",
"Point 1",
"Point 2",
]
[[experience]]
@@ -59,9 +54,8 @@ position = "Previous Job Title"
location = "City, Province/Country"
date = "Jun 2018 - Dec 2019"
description = [
"Another achievement from your previous role",
"Focus on results and impact",
"Keep descriptions concise but informative",
"Point 1",
"Point 2",
]
# Education
@@ -71,7 +65,8 @@ degree = "Bachelor of Science"
field = "Computer Science"
date = "2014 - 2018"
details = [
"Relevant coursework: Data Structures, Algorithms, Software Engineering",
"Course 1",
"Course 2",
]
[[education]]

View File

@@ -3,39 +3,43 @@ import { Image } from "astro:assets";
import SocialLinks from "../components/SocialLinks.astro";
import TechLinks from "../components/TechLinks.astro";
import Layout from "../layouts/Layout.astro";
import { personalInfo, homepageSections } from "../config/data";
import { config } from "../config";
---
<Layout>
<Layout
title={config.siteConfig.pageOpenGraph.home.title}
description={config.siteConfig.pageOpenGraph.home.description}
ogImage={config.siteConfig.pageOpenGraph.home.image}
ogType={config.siteConfig.pageOpenGraph.home.type}
>
<Image
src={personalInfo.profileImage.src}
alt={personalInfo.profileImage.alt}
width={300}
height={300}
src={config.personalInfo.profileImage.src}
alt={config.personalInfo.profileImage.alt}
widths={[192, 384]}
sizes="12rem"
layout="constrained"
priority={true}
loading="eager"
fetchpriority="high"
class="rounded-full mx-auto"
style="max-width: 12rem; width: 100%;"
/>
<h1
class="bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent text-4xl sm:text-6xl font-bold text-center"
>
{personalInfo.name}
<h1 class="text-primary text-4xl sm:text-6xl font-bold text-center">
{config.personalInfo.name}
</h1>
<h2 class="text-xl sm:text-3xl font-bold text-center mx-6">
{personalInfo.tagline}
{config.personalInfo.tagline}
</h2>
<h3 class="text-lg sm:text-2xl font-bold">
{homepageSections.socialLinks.title}
{config.homepageSections.socialLinks.title}
</h3>
<SocialLinks />
<h3 class="text-lg sm:text-2xl font-bold">
{homepageSections.techStack.title}
{config.homepageSections.techStack.title}
</h3>
<TechLinks />

View File

@@ -1,6 +1,6 @@
---
import { getCollection, type CollectionEntry } from "astro:content";
import { Icon } from "astro-icon/components";
import { getCollection, render, type CollectionEntry } from "astro:content";
import Icon from "../../components/Icon.astro";
import Layout from "../../layouts/Layout.astro";
export const prerender = true;
@@ -8,17 +8,21 @@ export const prerender = true;
export async function getStaticPaths() {
const posts = await getCollection("posts");
return posts.map((post: CollectionEntry<"posts">) => ({
params: { slug: post.slug },
params: { slug: post.id },
props: { post },
}));
}
const { post }: { post: CollectionEntry<"posts"> } = Astro.props;
const { Content } = await post.render();
const { Content } = await render(post);
---
<Layout>
<div class="min-h-screen p-4 md:p-8">
<Layout
title={post.data.title}
description={post.data.description}
ogType="article"
>
<div class="w-full 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">
@@ -29,7 +33,7 @@ const { Content } = await post.render();
<div
class="flex items-center flex-row gap-2 text-base-content opacity-75"
>
<Icon name="mdi:clock" class="text-xl" />
<Icon name="clock" class="text-xl" />
<time datetime={post.data.pubDate.toISOString()}>
{
new Date(post.data.pubDate).toLocaleDateString(
@@ -45,18 +49,31 @@ const { Content } = await post.render();
</div>
{/* Back button */}
<a href="/posts" class="btn btn-outline btn-primary btn-sm">
<Icon name="mdi:arrow-left" class="text-lg" />
<a
href="/posts"
class="btn btn-outline btn-primary btn-sm font-bold"
>
<Icon name="arrow-left" class="text-lg" />
Back
</a>
{/* RSS feed button */}
<a
href="/feed"
class="btn btn-outline btn-primary btn-sm font-bold"
aria-label="RSS Feed"
>
<Icon name="rss" class="text-lg" />
RSS
</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) => (
<div class="badge badge-primary">
<Icon name="mdi:tag" class="text-lg" />
<div class="badge badge-primary font-bold">
<Icon name="tag" class="text-lg" />
{tag}
</div>
))}

View File

@@ -1,39 +1,82 @@
---
import Layout from "../layouts/Layout.astro";
import { getCollection, type CollectionEntry } from "astro:content";
import PostCard from "../components/PostCard.astro";
import { config } from "../config";
// 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(),
(a: CollectionEntry<"posts">, b: CollectionEntry<"posts">) =>
new Date(b.data.pubDate).valueOf() - new Date(a.data.pubDate).valueOf(),
);
function formatDate(date: Date): string {
return date.toLocaleDateString("en-us", {
month: "short",
day: "numeric",
year: "numeric",
});
}
---
<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) => (
<PostCard post={post} />
))}
</div>
<Layout
title={config.siteConfig.pageOpenGraph.posts.title}
description={config.siteConfig.pageOpenGraph.posts.description}
ogImage={config.siteConfig.pageOpenGraph.posts.image}
ogType={config.siteConfig.pageOpenGraph.posts.type}
>
<div class="w-full max-w-3xl mx-auto 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>
{
sortedPosts.length === 0 && (
<p class="text-center text-gray-500 mt-12">
No posts available yet. Check back soon!
</p>
)
}
</div>
{
sortedPosts.length === 0 ? (
<p class="text-center text-gray-500 mt-12">
No posts available yet. Check back soon!
</p>
) : (
<ul class="flex flex-col bg-base-100 rounded-box shadow-md border border-base-content/20 divide-y divide-base-content/20">
{sortedPosts.map((post) => (
<li class="flex items-center hover:bg-base-200/50 transition-colors p-4 group relative rounded-none first:rounded-t-box last:rounded-b-box">
<a href={`/post/${post.id}`} class="absolute inset-0 z-0" aria-label={`Read ${post.data.title}`}></a>
<div class="w-24 sm:w-32 flex-none opacity-80 flex flex-col justify-center text-right pr-4 border-r border-base-content/20 z-10 pointer-events-none">
<span class="font-mono text-sm sm:text-base font-bold">
{post.data.pubDate.toLocaleDateString("en-us", { month: "short", day: "numeric" })}
</span>
<span class="text-xs opacity-70">
{post.data.pubDate.getFullYear()}
</span>
</div>
<div class="flex-grow flex flex-col gap-1 justify-center pl-4 z-10 pointer-events-none">
<h2
class="font-bold text-lg sm:text-xl text-primary group-hover:text-accent transition-colors"
>
{post.data.title}
</h2>
<p class="text-sm opacity-80 line-clamp-2 leading-relaxed">
{post.data.description || "No description available."}
</p>
{post.data.tags && post.data.tags.length > 0 && (
<div class="flex gap-2 mt-1">
{post.data.tags.slice(0, 3).map((tag: string) => (
<span class="badge badge-xs badge-outline opacity-70">
{tag}
</span>
))}
</div>
)}
</div>
</li>
))}
</ul>
)
}
</div>
</Layout>

View File

@@ -1,22 +1,194 @@
---
import Layout from "../layouts/Layout.astro";
import ProjectCard from "../components/ProjectCard.astro";
import { projects } from "../config/data";
import Icon from "../components/Icon.astro";
import { config } from "../config";
import { fetchGiteaInfoFromUrl, formatRelativeTime } from "../utils/gitea";
import type { Project } from "../types";
export const prerender = false;
Astro.response.headers.set(
"Cache-Control",
"public, max-age=300, s-maxage=300, stale-while-revalidate=60",
);
function isGiteaDomain(url: string): boolean {
if (!config.siteConfig.giteaDomains) return true;
try {
const urlObj = new URL(url);
return config.siteConfig.giteaDomains.some(
(domain) => urlObj.origin === new URL(domain).origin,
);
} catch {
return false;
}
}
const projectsWithGiteaInfo = await Promise.all(
config.projects.map(async (project) => {
if (
project.gitLink &&
!project.giteaInfo &&
isGiteaDomain(project.gitLink)
) {
const giteaInfo = await fetchGiteaInfoFromUrl(project.gitLink);
if (giteaInfo) {
return { ...project, giteaInfo } as Project;
}
}
return project;
}),
);
const sortedProjects = projectsWithGiteaInfo.sort((a, b) => {
const aTime = a.giteaInfo?.updatedAt
? new Date(a.giteaInfo.updatedAt).getTime()
: 0;
const bTime = b.giteaInfo?.updatedAt
? new Date(b.giteaInfo.updatedAt).getTime()
: 0;
return bTime - aTime;
});
---
<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">
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} />
))}
<Layout
title={config.siteConfig.pageOpenGraph.projects.title}
description={config.siteConfig.pageOpenGraph.projects.description}
ogImage={config.siteConfig.pageOpenGraph.projects.image}
ogType={config.siteConfig.pageOpenGraph.projects.type}
>
<div class="w-full max-w-3xl mx-auto p-4 sm:p-8">
<h1
class="text-3xl sm:text-4xl font-bold text-primary mb-6 sm:mb-8 text-center"
>
Projects
</h1>
<ul
class="flex flex-col bg-base-100 rounded-box shadow-md border border-base-content/20 divide-y divide-base-content/20"
>
{
sortedProjects.map((project) => (
<li class="flex items-center hover:bg-base-200/50 transition-colors p-4 group relative rounded-none first:rounded-t-box last:rounded-b-box">
{/* Project icon/avatar */}
<div class="flex-none z-10 mr-4">
{project.giteaInfo?.avatarUrl ? (
<img
src={project.giteaInfo.avatarUrl}
alt={`${project.name} avatar`}
class="w-12 h-12 rounded-lg object-cover"
/>
) : (
<div class="w-12 h-12 rounded-lg bg-accent flex items-center justify-center">
<Icon
name="code-braces"
class="w-6 h-6 text-accent-content"
/>
</div>
)}
</div>
{/* Main content */}
<div class="flex-grow flex flex-col justify-center gap-1 z-10 pointer-events-none">
<div class="flex flex-col sm:flex-row sm:items-baseline gap-1 sm:gap-2">
<h3 class="font-bold text-lg sm:text-xl text-primary group-hover:text-accent transition-colors">
{project.name}
</h3>
{project.giteaInfo?.updatedAt && (
<span class="text-xs opacity-60">
{formatRelativeTime(
project.giteaInfo.updatedAt,
)}
</span>
)}
</div>
<p class="text-sm opacity-80 leading-relaxed">
{project.description}
</p>
{/* Languages & Topics */}
<div class="flex flex-wrap gap-1 mt-1">
{project.giteaInfo?.languages
?.slice(0, 3)
.map((lang: string) => (
<span class="badge badge-xs badge-primary">
{lang}
</span>
))}
{project.giteaInfo?.topics
?.slice(0, 4)
.map((topic: string) => (
<span class="badge badge-xs badge-outline opacity-70">
{topic}
</span>
))}
</div>
</div>
{/* Action buttons */}
<div class="flex-none flex flex-wrap gap-1 justify-end ml-4 z-20">
{project.webLink && (
<a
href={project.webLink}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-square btn-ghost text-primary hover:bg-primary hover:text-primary-content transition-all"
aria-label={`Visit ${project.name} website`}
>
<Icon name="web" class="w-5 h-5" />
</a>
)}
{project.gitLink && (
<a
href={project.gitLink}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-square btn-ghost text-success hover:bg-success hover:text-success-content transition-all"
aria-label={`View ${project.name} source`}
>
<Icon
name="gitea"
class="w-5 h-5"
/>
</a>
)}
{project.iosLink && (
<a
href={project.iosLink}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-square btn-ghost text-accent hover:bg-accent hover:text-accent-content transition-all"
aria-label={`${project.name} on iOS`}
>
<Icon name="apple" class="w-5 h-5" />
</a>
)}
{project.androidLink && (
<a
href={project.androidLink}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-square btn-ghost text-success hover:bg-success hover:text-success-content transition-all"
aria-label={`${project.name} on Android`}
>
<Icon
name="google-play"
class="w-5 h-5"
/>
</a>
)}
</div>
</li>
))
}
</ul>
{
sortedProjects.length === 0 && (
<p class="text-center text-gray-500 mt-12">
No projects available yet. Check back soon!
</p>
)
}
</div>
{projects.length === 0 && (
<p class="text-center text-gray-500 mt-12">No projects available yet. Check back soon!</p>
)}
</div>
</Layout>
</Layout>

View File

@@ -1,10 +1,10 @@
---
import { Icon } from "astro-icon/components";
import Icon from "../components/Icon.astro";
import Layout from "../layouts/Layout.astro";
import ResumeSkills from "../components/ResumeSkills";
import ResumeDownloadButton from "../components/ResumeDownloadButton";
import ResumeSettingsModal from "../components/ResumeSettingsModal";
import { siteConfig } from "../config/data";
import ResumeSkills from "../components/ResumeSkills.vue";
import ResumeDownloadButton from "../components/ResumeDownloadButton.vue";
import ResumeSettingsModal from "../components/ResumeSettingsModal.vue";
import { config } from "../config";
import "../styles/global.css";
import * as TOML from "@iarna/toml";
@@ -57,16 +57,18 @@ interface ResumeData {
let resumeData: ResumeData | undefined = undefined;
let fetchError: string | null = null;
if (!siteConfig.resume.tomlFile || !siteConfig.resume.tomlFile.trim()) {
if (!config.resumeConfig.tomlFile || !config.resumeConfig.tomlFile.trim()) {
return Astro.redirect("/");
}
try {
let tomlContent: string;
if (siteConfig.resume.tomlFile.startsWith("/")) {
if (config.resumeConfig.tomlFile.startsWith("/")) {
const baseUrl = Astro.url.origin;
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
const response = await fetch(
`${baseUrl}${config.resumeConfig.tomlFile}`,
);
if (!response.ok) {
throw new Error(
@@ -76,7 +78,7 @@ try {
tomlContent = await response.text();
} else {
tomlContent = siteConfig.resume.tomlFile;
tomlContent = config.resumeConfig.tomlFile;
}
resumeData = TOML.parse(tomlContent) as unknown as ResumeData;
@@ -86,14 +88,19 @@ try {
}
const data = resumeData;
const resumeConfig = siteConfig.resume;
const resumeConfig = config.resumeConfig;
if (!data) {
return Astro.redirect("/");
}
---
<Layout title="Resume">
<Layout
title={config.siteConfig.pageOpenGraph.resume.title}
description={config.siteConfig.pageOpenGraph.resume.description}
ogImage={config.siteConfig.pageOpenGraph.resume.image}
ogType={config.siteConfig.pageOpenGraph.resume.type}
>
<ResumeSettingsModal client:load />
<div class="container mx-auto p-4 sm:p-6 lg:p-8 max-w-4xl w-full">
<h1
@@ -111,13 +118,13 @@ if (!data) {
href={`mailto:${data.basics.email}`}
class="link link-hover inline-flex items-center gap-1 text-sm sm:text-base"
>
<Icon name="mdi:email" /> {data.basics.email}
<Icon name="email" /> {data.basics.email}
</a>
)
}
{
data.basics.profiles?.map((profile) => {
const iconName = `simple-icons:${profile.network.toLowerCase()}`;
const iconName = profile.network.toLowerCase();
return (
<a
href={profile.url}
@@ -136,26 +143,29 @@ if (!data) {
<ResumeDownloadButton client:load />
{
data.summary && resumeConfig.sections.summary?.enabled && (
<div class="card bg-base-200 shadow-xl mb-4 sm:mb-6">
<div class="card-body p-4 sm:p-6 break-words">
<h2 class="card-title text-xl sm:text-2xl">
{resumeConfig.sections.summary.title || "Summary"}
</h2>
<div>{data.summary.content}</div>
data.summary &&
resumeConfig.sections.enabled.includes("summary") && (
<div class="card bg-base-300 shadow-xl mb-4 sm:mb-6">
<div class="card-body p-4 sm:p-6 wrap-break-word">
<h2 class="card-title text-xl sm:text-2xl">
{resumeConfig.sections.summary?.title ||
"Summary"}
</h2>
<div>{data.summary.content}</div>
</div>
</div>
</div>
)
)
}
{
data.skills &&
data.skills.length > 0 &&
resumeConfig.sections.skills?.enabled && (
<div class="card bg-base-200 shadow-xl mb-4 sm:mb-6">
<div class="card-body p-4 sm:p-6 break-words">
resumeConfig.sections.enabled.includes("skills") && (
<div class="card bg-base-300 shadow-xl mb-4 sm:mb-6">
<div class="card-body p-4 sm:p-6 wrap-break-word">
<h2 class="card-title text-xl sm:text-2xl">
{resumeConfig.sections.skills.title || "Skills"}
{resumeConfig.sections.skills?.title ||
"Skills"}
</h2>
<ResumeSkills
skills={data.skills.map((skill, index) => ({
@@ -173,11 +183,11 @@ if (!data) {
{
data.experience &&
data.experience.length > 0 &&
resumeConfig.sections.experience?.enabled && (
<div class="card bg-base-200 shadow-xl mb-4 sm:mb-6">
<div class="card-body p-4 sm:p-6 break-words">
resumeConfig.sections.enabled.includes("experience") && (
<div class="card bg-base-300 shadow-xl mb-4 sm:mb-6">
<div class="card-body p-4 sm:p-6 wrap-break-word">
<h2 class="card-title text-xl sm:text-2xl">
{resumeConfig.sections.experience.title ||
{resumeConfig.sections.experience?.title ||
"Experience"}
</h2>
<div class="space-y-4 sm:space-y-6">
@@ -207,8 +217,8 @@ if (!data) {
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-primary hover:text-primary-focus text-sm mt-2"
>
<Icon name="mdi:link" />
Company Website
<Icon name="link" />
Website
</a>
)}
</div>
@@ -222,11 +232,11 @@ if (!data) {
{
data.education &&
data.education.length > 0 &&
resumeConfig.sections.education?.enabled && (
<div class="card bg-base-200 shadow-xl mb-4 sm:mb-6">
<div class="card-body p-4 sm:p-6 break-words">
resumeConfig.sections.enabled.includes("education") && (
<div class="card bg-base-300 shadow-xl mb-4 sm:mb-6">
<div class="card-body p-4 sm:p-6 wrap-break-word">
<h2 class="card-title text-xl sm:text-2xl">
{resumeConfig.sections.education.title ||
{resumeConfig.sections.education?.title ||
"Education"}
</h2>
<div class="space-y-4">
@@ -264,11 +274,11 @@ if (!data) {
{
data.volunteer &&
data.volunteer.length > 0 &&
resumeConfig.sections.volunteer?.enabled && (
<div class="card bg-base-200 shadow-xl mb-4 sm:mb-6">
<div class="card-body p-4 sm:p-6 break-words">
resumeConfig.sections.enabled.includes("volunteer") && (
<div class="card bg-base-300 shadow-xl mb-4 sm:mb-6">
<div class="card-body p-4 sm:p-6 wrap-break-word">
<h2 class="card-title text-xl sm:text-2xl">
{resumeConfig.sections.volunteer.title ||
{resumeConfig.sections.volunteer?.title ||
"Volunteer Work"}
</h2>
<div class="space-y-4">
@@ -296,11 +306,11 @@ if (!data) {
{
data.awards &&
data.awards.length > 0 &&
resumeConfig.sections.awards?.enabled && (
<div class="card bg-base-200 shadow-xl mb-4 sm:mb-6">
<div class="card-body p-4 sm:p-6 break-words">
resumeConfig.sections.enabled.includes("awards") && (
<div class="card bg-base-300 shadow-xl mb-4 sm:mb-6">
<div class="card-body p-4 sm:p-6 wrap-break-word">
<h2 class="card-title text-xl sm:text-2xl">
{resumeConfig.sections.awards.title ||
{resumeConfig.sections.awards?.title ||
"Awards & Recognition"}
</h2>
<div class="space-y-4">

View File

@@ -1,19 +1,77 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import { getCollection } from "astro:content";
function formatPubDate(date) {
const timezone = process.env.PUBLIC_RSS_TIMEZONE
? process.env.PUBLIC_RSS_TIMEZONE
: import.meta.env.PUBLIC_RSS_TIMEZONE;
if (!timezone) {
return date;
}
try {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
timeZoneName: "longOffset",
});
const parts = formatter.formatToParts(date);
const offsetPart = parts.find((p) => p.type === "timeZoneName");
const offset = offsetPart ? offsetPart.value.replace("GMT", "") : "+00:00";
const dateStr = `${year}-${month}-${day}T00:00:00${offset}`;
return new Date(dateStr);
} catch (e) {
console.warn(`Invalid timezone "${timezone}":`, e.message);
return date;
}
}
export async function GET(context) {
const posts = await getCollection('posts');
return rss({
title: 'Atridad Lahiji',
description: 'Recent posts from Atridad Lahiji',
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description || '',
link: `/post/${post.slug}/`,
})),
customData: `<language>en-us</language>`,
const posts = await getCollection("posts");
// Sort posts by date, newest first
posts.sort((a, b) => new Date(b.data.pubDate) - new Date(a.data.pubDate));
const siteUrl = context.site?.toString().replace(/\/$/, "") || "";
const items = posts
.map((post) => {
const title = post.data.title;
const description = post.data.description || "";
const link = `${siteUrl}/post/${post.id}/`;
const pubDate = formatPubDate(post.data.pubDate).toUTCString();
return ` <item>
<title><![CDATA[${title}]]></title>
<link>${link}</link>
<guid isPermaLink="true">${link}</guid>
<description><![CDATA[${description}]]></description>
<pubDate>${pubDate}</pubDate>
</item>`;
})
.join("\n");
const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Atridad Lahiji</title>
<description>Recent posts from Atridad Lahiji</description>
<link>${siteUrl}/</link>
<language>en-us</language>
<atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml" />
${items}
</channel>
</rss>`;
return new Response(rssXml, {
headers: {
"Content-Type": "application/xml",
},
});
}
}

View File

@@ -1,27 +1,83 @@
---
import Layout from "../layouts/Layout.astro";
import TalkCard from "../components/TalkCard.astro";
import { talks } from "../config/data";
import Icon from "../components/Icon.astro";
import { config } from "../config";
// Sort talks by date, newest first
const sortedTalks = [...config.talks].sort((a, b) => {
if (!a.date || !b.date) return 0;
return new Date(b.date).valueOf() - new Date(a.date).valueOf();
});
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-us", {
month: "short",
day: "numeric",
year: "numeric",
});
}
---
<Layout>
<div class="min-h-screen p-4 sm:p-8">
<Layout
title={config.siteConfig.pageOpenGraph.talks.title}
description={config.siteConfig.pageOpenGraph.talks.description}
ogImage={config.siteConfig.pageOpenGraph.talks.image}
ogType={config.siteConfig.pageOpenGraph.talks.type}
>
<div class="w-full max-w-3xl mx-auto p-4 sm:p-8">
<h1
class="text-3xl sm:text-4xl font-bold text-primary mb-6 sm:mb-8 text-center"
>
Talks
</h1>
<div
class="flex flex-row flex-wrap justify-center gap-4 sm:gap-6 max-w-6xl mx-auto"
>
{talks.map((talk) => <TalkCard talk={talk} />)}
</div>
{
talks.length === 0 && (
sortedTalks.length === 0 ? (
<p class="text-center text-gray-500 mt-12">
No talks available yet. Check back soon!
</p>
) : (
<ul class="flex flex-col bg-base-100 rounded-box shadow-md border border-base-content/20 divide-y divide-base-content/20">
{sortedTalks.map((talk) => {
const talkDate = talk.date ? new Date(talk.date) : null;
return (
<li class="flex items-center hover:bg-base-200/50 transition-colors p-4 group relative rounded-none first:rounded-t-box last:rounded-b-box">
<a
href={talk.link}
target="_blank"
rel="noopener noreferrer"
class="absolute inset-0 z-0"
aria-label={`View ${talk.name}`}
></a>
<div class="w-24 sm:w-32 flex-none opacity-80 flex flex-col justify-center text-right pr-4 border-r border-base-content/20 z-10 pointer-events-none">
{talkDate ? (
<>
<span class="font-mono text-sm sm:text-base font-bold">
{talkDate.toLocaleDateString("en-us", { month: "short", day: "numeric" })}
</span>
<span class="text-xs opacity-70">
{talkDate.getFullYear()}
</span>
</>
) : (
<span class="text-xs opacity-50 italic">Undated</span>
)}
</div>
<div class="flex-grow flex flex-col gap-1 justify-center pl-4 z-10 pointer-events-none">
<h2 class="font-bold text-lg sm:text-xl text-primary group-hover:text-accent transition-colors flex items-center gap-2">
{talk.name}
<Icon name="open-in-new" class="w-4 h-4 opacity-50 group-hover:opacity-100 transition-opacity" />
</h2>
<p class="text-sm opacity-80 line-clamp-2 leading-relaxed">
{talk.description}
</p>
</div>
</li>
);
})}
</ul>
)
}
</div>

View File

@@ -1,28 +0,0 @@
---
import Layout from "../layouts/Layout.astro";
import TerminalComponent from "../components/Terminal";
import "../styles/global.css";
---
<Layout>
<div class="container mx-auto p-4 max-w-6xl w-full">
<div class="mb-4 text-center">
<h1
class="text-3xl sm:text-4xl font-bold text-primary mb-6 sm:mb-8 text-center"
>
Terminal
</h1>
</div>
<div class="h-[60vh] max-h-[500px] min-h-[400px]">
<TerminalComponent client:load />
</div>
</div>
</Layout>
<style>
html,
body {
height: auto;
overflow: auto;
}
</style>

392
src/pdf/ResumeDocument.tsx Normal file
View File

@@ -0,0 +1,392 @@
/** @jsxImportSource react */
import {
Document,
Page,
Text,
View,
StyleSheet,
Link,
Svg,
Path,
} from "@react-pdf/renderer";
import type { ResumeData } from "../types";
const styles = StyleSheet.create({
page: {
padding: 24,
fontFamily: "Helvetica",
fontSize: 8,
lineHeight: 1.3,
color: "#111827",
},
header: {
marginBottom: 10,
paddingBottom: 6,
borderBottomWidth: 1,
borderBottomColor: "#D1D5DB",
alignItems: "center",
flexDirection: "column",
display: "flex",
},
name: {
fontSize: 16,
fontWeight: "bold",
marginBottom: 6,
textAlign: "center",
},
contactRow: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
flexWrap: "wrap",
color: "#4B5563",
fontSize: 8,
marginTop: 2,
},
contactItem: {
flexDirection: "row",
alignItems: "center",
marginHorizontal: 8,
marginBottom: 2,
},
icon: {
width: 9,
height: 9,
marginRight: 4,
},
section: {
marginBottom: 8,
},
sectionTitle: {
fontSize: 10,
fontWeight: "bold",
borderBottomWidth: 1,
borderBottomColor: "#D1D5DB",
marginBottom: 4,
paddingBottom: 2,
},
columns: {
flexDirection: "row",
},
column: {
flex: 1,
marginLeft: 8,
marginRight: 8,
},
// Experience
experienceItem: {
marginBottom: 6,
paddingLeft: 8,
borderLeftWidth: 2,
borderLeftColor: "#2563EB",
},
itemTitle: {
fontSize: 9,
fontWeight: "bold",
marginBottom: 1,
},
itemSubtitle: {
fontSize: 8,
color: "#4B5563",
marginBottom: 1,
},
list: {
marginLeft: 8,
},
listItem: {
flexDirection: "row",
marginBottom: 1,
},
bullet: {
width: 8,
fontSize: 8,
marginRight: 2,
},
listContent: {
flex: 1,
color: "#374151",
fontSize: 8,
},
// Skills
skillItem: {
marginBottom: 2,
},
skillHeader: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 1,
},
progressBarBg: {
height: 4,
backgroundColor: "#E5E7EB",
borderRadius: 2,
},
progressBarFill: {
height: 4,
backgroundColor: "#2563EB",
borderRadius: 2,
},
// Education
educationItem: {
marginBottom: 6,
paddingLeft: 8,
borderLeftWidth: 2,
borderLeftColor: "#16A34A",
},
// Volunteer
volunteerItem: {
marginBottom: 4,
paddingLeft: 8,
borderLeftWidth: 2,
borderLeftColor: "#9333EA",
},
// Awards
awardItem: {
marginBottom: 4,
paddingLeft: 8,
borderLeftWidth: 2,
borderLeftColor: "#CA8A04",
},
summary: {
marginBottom: 8,
color: "#374151",
fontSize: 8,
},
});
const Icon = ({
path,
color = "currentColor",
}: {
path: string;
color?: string;
}) => (
<Svg viewBox="0 0 24 24" style={styles.icon}>
<Path d={path} fill={color} />
</Svg>
);
const ExperienceSection = ({
experience,
}: {
experience: ResumeData["experience"];
}) => (
<>
{experience?.map((exp, i) => (
<View key={i} style={styles.experienceItem}>
<Text style={styles.itemTitle}>{exp.position}</Text>
<Text style={styles.itemSubtitle}>
{exp.company} {exp.date} {exp.location}
</Text>
<View style={styles.list}>
{exp.description?.map((desc, j) => (
<View key={j} style={styles.listItem}>
<Text style={styles.bullet}></Text>
<Text style={styles.listContent}>{desc}</Text>
</View>
))}
</View>
</View>
))}
</>
);
const SkillsSection = ({ skills }: { skills: ResumeData["skills"] }) => (
<>
{skills?.map((skill, i) => (
<View key={i} style={styles.skillItem}>
<View style={styles.skillHeader}>
<Text style={{ fontSize: 8 }}>{skill.name}</Text>
<Text style={{ color: "#4B5563", fontSize: 8 }}>{skill.level}/5</Text>
</View>
<View style={styles.progressBarBg}>
<View
style={[styles.progressBarFill, { width: `${skill.level * 20}%` }]}
/>
</View>
</View>
))}
</>
);
const EducationSection = ({
education,
}: {
education: ResumeData["education"];
}) => (
<>
{education?.map((edu, i) => (
<View key={i} style={styles.educationItem}>
<Text style={styles.itemTitle}>{edu.institution}</Text>
<Text style={styles.itemSubtitle}>
{edu.degree} in {edu.field} {edu.date}
</Text>
{edu.details && (
<View style={styles.list}>
{edu.details.map((detail, j) => (
<View key={j} style={styles.listItem}>
<Text style={styles.bullet}></Text>
<Text style={styles.listContent}>{detail}</Text>
</View>
))}
</View>
)}
</View>
))}
</>
);
const VolunteerSection = ({
volunteer,
}: {
volunteer: ResumeData["volunteer"];
}) => (
<>
{volunteer?.map((vol, i) => (
<View key={i} style={styles.volunteerItem}>
<Text style={styles.itemTitle}>{vol.organization}</Text>
<Text style={styles.itemSubtitle}>
{vol.position} {vol.date}
</Text>
</View>
))}
</>
);
const AwardsSection = ({ awards }: { awards: ResumeData["awards"] }) => (
<>
{awards?.map((award, i) => (
<View key={i} style={styles.awardItem}>
<Text style={styles.itemTitle}>{award.title}</Text>
<Text style={styles.itemSubtitle}>
{award.organization} {award.date}
</Text>
{award.description && (
<Text style={{ color: "#374151" }}>{award.description}</Text>
)}
</View>
))}
</>
);
interface ResumeDocumentProps {
data: ResumeData;
resumeConfig: any;
icons: { [key: string]: string };
}
export const ResumeDocument = ({
data,
resumeConfig,
icons,
}: ResumeDocumentProps) => {
const layout = data.layout
? {
leftColumn: data.layout.left_column || [
"experience",
"volunteer",
"awards",
],
rightColumn: data.layout.right_column || ["skills", "education"],
}
: resumeConfig.layout || {
leftColumn: ["experience", "volunteer", "awards"],
rightColumn: ["skills", "education"],
};
const renderSectionContent = (sectionName: string) => {
switch (sectionName) {
case "experience":
return <ExperienceSection experience={data.experience} />;
case "skills":
return <SkillsSection skills={data.skills} />;
case "education":
return <EducationSection education={data.education} />;
case "volunteer":
return <VolunteerSection volunteer={data.volunteer} />;
case "awards":
return <AwardsSection awards={data.awards} />;
default:
return null;
}
};
const renderColumn = (sectionNames: string[]) => {
return sectionNames.map((name) => {
const config = resumeConfig.sections[name];
const content = renderSectionContent(name);
// Check if section has content (simple check)
const hasContent =
data[name as keyof ResumeData] &&
(data[name as keyof ResumeData] as any[]).length > 0;
if (!config || !hasContent || config.enabled === false) return null;
return (
<View key={name} style={styles.section}>
<Text style={styles.sectionTitle}>{config.title}</Text>
{content}
</View>
);
});
};
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.name}>{data.basics.name}</Text>
<View style={styles.contactRow}>
{data.basics.email && (
<View style={styles.contactItem}>
{icons["email"] && <Icon path={icons["email"]} />}
<Text>{data.basics.email}</Text>
</View>
)}
{data.basics.phone && (
<View style={styles.contactItem}>
{icons["phone"] && <Icon path={icons["phone"]} />}
<Text>{data.basics.phone}</Text>
</View>
)}
{data.basics.profiles?.map((profile: any, i: number) => (
<View key={i} style={styles.contactItem}>
{icons[profile.network.toLowerCase()] && (
<Icon path={icons[profile.network.toLowerCase()]} />
)}
<Link
src={profile.url}
style={{ color: "#4B5563", textDecoration: "none" }}
>
{profile.url.replace(/^https?:\/\//, "").replace(/\/$/, "")}
</Link>
</View>
))}
</View>
</View>
{/* Summary */}
{data.summary && resumeConfig.sections.summary?.enabled !== false && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{resumeConfig.sections.summary?.title || "Summary"}
</Text>
<Text style={styles.summary}>{data.summary.content}</Text>
</View>
)}
{/* Columns */}
<View style={styles.columns}>
<View style={[styles.column, { marginLeft: 0, marginRight: 8 }]}>
{renderColumn(layout.leftColumn)}
</View>
<View style={[styles.column, { marginLeft: 8, marginRight: 0 }]}>
{renderColumn(layout.rightColumn)}
</View>
</View>
</Page>
</Document>
);
};

View File

@@ -1,47 +1,6 @@
@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;
}
@font-face {
font-family: "Indie Flower";
src:
url("IndieFlower.woff2") format("woff2"),
url("IndieFlower.woff") format("woff");
font-weight: normal;
font-style: normal;
font-display: swap;
@plugin "daisyui" {
themes: dracula --default;
}

241
src/types.ts Normal file
View File

@@ -0,0 +1,241 @@
import type { ImageMetadata } from "astro";
import type { GiteaRepoInfo } from "./utils/gitea";
import type { IconName } from "./config/icons";
export interface Talk {
id: string;
name: string;
description: string;
link: string;
date?: string;
}
export interface Project {
id: string;
name: string;
description: string;
webLink?: string;
status?: string;
iosLink?: string;
androidLink?: string;
gitLink?: string;
giteaInfo?: GiteaRepoInfo;
}
export interface SocialLink {
id: string;
name: string;
url: string;
icon: IconName;
ariaLabel: string;
}
export interface TechLink {
id: string;
name: string;
url: string;
icon: IconName;
ariaLabel: string;
}
export interface NavigationItem {
id: string;
name: string;
path: string;
tooltip: string;
icon: IconName;
enabled?: boolean;
isActive?: (path: string) => boolean;
}
export type ResumeSectionKey =
| "summary"
| "experience"
| "education"
| "skills"
| "volunteer"
| "profiles"
| "awards";
export interface ResumeConfig {
tomlFile: string;
layout?: {
leftColumn?: ResumeSectionKey[];
rightColumn?: ResumeSectionKey[];
};
sections: {
enabled: ResumeSectionKey[];
summary?: {
title?: string;
};
experience?: {
title?: string;
};
education?: {
title?: string;
};
skills?: {
title?: string;
};
volunteer?: {
title?: string;
};
profiles?: {
title?: string;
};
awards?: {
title?: string;
};
};
}
export interface ResumeData {
basics: {
name: string;
email: string;
phone?: string;
website?: string;
profiles: {
network: string;
username: string;
url: string;
}[];
};
layout?: {
left_column?: string[];
right_column?: string[];
};
summary: {
content: string;
};
experience: {
company: string;
position: string;
location: string;
date: string;
description: string[];
url?: string;
}[];
education: {
institution: string;
degree: string;
field: string;
date: string;
details?: string[];
}[];
skills: {
name: string;
level: number;
}[];
volunteer: {
organization: string;
position: string;
date: string;
}[];
awards: {
title: string;
organization: string;
date: string;
description?: string;
}[];
}
export interface PersonalInfo {
name: string;
profileImage: {
src: ImageMetadata;
alt: string;
width?: number;
height?: number;
};
tagline: string;
description?: string;
}
export interface HomepageSections {
socialLinks: {
title: string;
description?: string;
};
techStack: {
title: string;
description?: string;
};
}
export interface OpenGraphImage {
url: string;
width: number;
height: number;
type: string;
alt: string;
}
export interface OpenGraphConfig {
image: OpenGraphImage;
type?: "website" | "article";
locale?: string;
siteName?: string;
}
export interface PageOpenGraph {
title?: string;
description?: string;
image?: OpenGraphImage;
type?: "website" | "article";
}
export interface SiteConfig {
personal: PersonalInfo;
homepage: HomepageSections;
resume: ResumeConfig;
meta: {
title: string;
description: string;
url: string;
author: string;
};
openGraph: OpenGraphConfig;
pageOpenGraph: {
home: PageOpenGraph;
posts: PageOpenGraph;
projects: PageOpenGraph;
talks: PageOpenGraph;
resume: PageOpenGraph;
};
giteaDomains?: string[];
}
export interface Config {
personalInfo: PersonalInfo;
homepageSections: HomepageSections;
resumeConfig: ResumeConfig;
siteConfig: SiteConfig;
talks: readonly Talk[];
projects: readonly Project[];
sections: {
readonly resume: {
readonly name: string;
readonly path: string;
readonly description: string;
};
readonly posts: {
readonly name: string;
readonly path: string;
readonly description: string;
};
readonly talks: {
readonly name: string;
readonly path: string;
readonly description: string;
};
readonly projects: {
readonly name: string;
readonly path: string;
readonly description: string;
};
};
socialLinks: readonly SocialLink[];
techLinks: readonly TechLink[];
navigationItems: readonly NavigationItem[];
}

View File

@@ -1,125 +0,0 @@
import type { ImageMetadata } from "astro";
import type { ComponentType } from "preact";
// Icon Types
export type LucideIcon = ComponentType<{ size?: number; class?: string }>;
export type AstroIconName = string; // For astro-icon string references like "mdi:email"
export type CustomIconComponent = ComponentType<any>;
export type IconType = LucideIcon | AstroIconName | CustomIconComponent;
export interface Talk {
id: string;
name: string;
description: string;
link: string;
date?: string;
}
export interface Project {
id: string;
name: string;
description: string;
link: string;
technologies?: string[];
status?: string;
}
export interface SocialLink {
id: string;
name: string;
url: string;
icon: IconType;
ariaLabel: string;
}
export interface TechLink {
id: string;
name: string;
url: string;
icon: IconType;
ariaLabel: string;
}
export interface NavigationItem {
id: string;
name: string;
path: string;
tooltip: string;
icon: IconType;
enabled?: boolean;
isActive?: (path: string) => boolean;
}
export interface ResumeConfig {
tomlFile: string; // Can be a file path or raw TOML content
layout?: {
leftColumn?: string[];
rightColumn?: string[];
};
sections: {
enabled: string[];
summary?: {
title?: string;
enabled?: boolean;
};
experience?: {
title?: string;
enabled?: boolean;
};
education?: {
title?: string;
enabled?: boolean;
};
skills?: {
title?: string;
enabled?: boolean;
};
volunteer?: {
title?: string;
enabled?: boolean;
};
profiles?: {
title?: string;
enabled?: boolean;
};
awards?: {
title?: string;
enabled?: boolean;
};
};
}
export interface PersonalInfo {
name: string;
profileImage: {
src: ImageMetadata;
alt: string;
width?: number;
height?: number;
};
tagline: string;
description?: string;
}
export interface HomepageSections {
socialLinks: {
title: string;
description?: string;
};
techStack: {
title: string;
description?: string;
};
}
export interface SiteConfig {
personal: PersonalInfo;
homepage: HomepageSections;
resume: ResumeConfig;
meta: {
title: string;
description: string;
url: string;
author: string;
};
}

125
src/utils/gitea.ts Normal file
View File

@@ -0,0 +1,125 @@
export interface GiteaRepoInfo {
languages: string[];
updatedAt: string;
size: number;
defaultBranch: string;
topics: string[];
avatarUrl?: string;
}
export interface GiteaConfig {
domain: string;
owner: string;
repo: string;
}
export function parseGiteaUrl(url: string): GiteaConfig | null {
try {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split("/").filter((p) => p);
if (pathParts.length >= 2) {
return {
domain: urlObj.origin,
owner: pathParts[0],
repo: pathParts[1],
};
}
} catch (e) {
// Invalid URL
}
return null;
}
export async function fetchGiteaRepoInfo(
config: GiteaConfig,
): Promise<GiteaRepoInfo | null> {
try {
const apiUrl = `${config.domain}/api/v1/repos/${config.owner}/${config.repo}`;
const response = await fetch(apiUrl, {
headers: {
Accept: "application/json",
},
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return null;
}
const data = await response.json();
let languages: string[] = [];
try {
const languagesUrl = `${config.domain}/api/v1/repos/${config.owner}/${config.repo}/languages`;
const languagesResponse = await fetch(languagesUrl, {
headers: {
Accept: "application/json",
},
signal: AbortSignal.timeout(5000),
});
if (languagesResponse.ok) {
const languagesData = await languagesResponse.json();
languages = Object.keys(languagesData).sort(
(a, b) => languagesData[b] - languagesData[a],
);
}
} catch (error) {
// Ignore
}
return {
languages: languages.length > 0 ? languages : [],
updatedAt: data.updated_at || data.pushed_at || "",
size: data.size || 0,
defaultBranch: data.default_branch || "main",
topics: Array.isArray(data.topics) ? data.topics : [],
avatarUrl: data.avatar_url,
};
} catch (error) {
return null;
}
}
export async function fetchGiteaInfoFromUrl(
url: string,
): Promise<GiteaRepoInfo | null> {
const config = parseGiteaUrl(url);
if (!config) {
return null;
}
return fetchGiteaRepoInfo(config);
}
const MINUTE_MS = 60_000;
const HOUR_MS = 3_600_000;
const DAY_MS = 86_400_000;
const pluralize = (n: number, unit: string) =>
`${n} ${unit}${n !== 1 ? "s" : ""} ago`;
export function formatRelativeTime(dateString: string): string {
if (!dateString) return "Unknown";
const diffMs = Date.now() - new Date(dateString).getTime();
const diffMinutes = Math.floor(diffMs / MINUTE_MS);
const diffHours = Math.floor(diffMs / HOUR_MS);
const diffDays = Math.floor(diffMs / DAY_MS);
if (diffMinutes < 60) return pluralize(diffMinutes, "minute");
if (diffHours < 24) return pluralize(diffHours, "hour");
if (diffDays < 30) return pluralize(diffDays, "day");
if (diffDays < 365) return pluralize(Math.floor(diffDays / 30), "month");
return pluralize(Math.floor(diffDays / 365), "year");
}
export function formatRepoSize(sizeKb: number): string {
if (sizeKb < 1024) {
return `${sizeKb} KB`;
} else if (sizeKb < 1024 * 1024) {
return `${(sizeKb / 1024).toFixed(1)} MB`;
} else {
return `${(sizeKb / (1024 * 1024)).toFixed(1)} GB`;
}
}

View File

@@ -1,250 +0,0 @@
import type { FileSystemNode } from "./types";
import { getCurrentDirectory, resolvePath } from "./fs";
export interface CommandContext {
currentPath: string;
fileSystem: { [key: string]: FileSystemNode };
setCurrentPath: (path: string) => void;
setIsTrainRunning: (running: boolean) => void;
setTrainPosition: (position: number) => void;
}
export function executeCommand(input: string, context: CommandContext): string {
const trimmedInput = input.trim();
if (!trimmedInput) return "";
const [command, ...args] = trimmedInput.split(" ");
switch (command.toLowerCase()) {
case "help":
return handleHelp();
case "ls":
return handleLs(args, context);
case "cd":
return handleCd(args, context);
case "pwd":
return handlePwd(context);
case "cat":
return handleCat(args, context);
case "tree":
return handleTree(context);
case "clear":
return "";
case "whoami":
return handleWhoami();
case "open":
return handleOpen(args);
case "sl":
return handleSl(context);
default:
return `${command}: command not found. Type 'help' for available commands.`;
}
}
function handleHelp(): string {
return `Available commands:
ls [path] - List directory contents
cd <path> - Change directory
cat <file> - Display file contents
pwd - Show current directory
clear - Clear terminal
tree - Show directory structure
whoami - Display user info
open <path> - Open page in browser (simulated)
help - Show this help message
Navigation:
Use "cd .." to go up one directory
Use "cd /" to go to root directory
File paths can be relative or absolute
Use TAB for auto-completion
Examples:
ls
cd resume
cat about.txt
open /resume
open /talks
cat /talks/README.txt
cd social
cat /tech/README.txt`;
}
function handleLs(args: string[], context: CommandContext): string {
const { currentPath, fileSystem } = context;
const targetPath = args[0] ? resolvePath(currentPath, args[0]) : currentPath;
const pathParts = targetPath.split("/").filter((part: string) => part !== "");
let target = fileSystem["/"];
for (const part of pathParts) {
if (
target?.children &&
target.children[part] &&
target.children[part].type === "directory"
) {
target = target.children[part];
} else if (pathParts.length > 0) {
return `ls: cannot access '${targetPath}': No such file or directory`;
}
}
if (!target?.children) {
return `ls: cannot access '${targetPath}': Not a directory`;
}
const items = Object.values(target.children)
.map((item) => {
const color = item.type === "directory" ? "\x1b[34m" : "\x1b[0m";
const suffix = item.type === "directory" ? "/" : "";
return `${color}${item.name}${suffix}\x1b[0m`;
})
.join(" ");
return items || "Directory is empty";
}
function handleCd(args: string[], context: CommandContext): string {
const { currentPath, fileSystem, setCurrentPath } = context;
const targetPath = args[0] ? resolvePath(currentPath, args[0]) : "/";
const pathParts = targetPath.split("/").filter((part: string) => part !== "");
let current = fileSystem["/"];
for (const part of pathParts) {
if (
current?.children &&
current.children[part] &&
current.children[part].type === "directory"
) {
current = current.children[part];
} else {
return `cd: no such file or directory: ${targetPath}`;
}
}
setCurrentPath(targetPath || "/");
return "";
}
function handlePwd(context: CommandContext): string {
return context.currentPath || "/";
}
function handleCat(args: string[], context: CommandContext): string {
const { currentPath, fileSystem } = context;
if (!args[0]) {
return "cat: missing file argument";
}
const filePath = resolvePath(currentPath, args[0]);
const pathParts = filePath.split("/").filter((part: string) => part !== "");
const fileName = pathParts.pop();
let current = fileSystem["/"];
for (const part of pathParts) {
if (
current?.children &&
current.children[part] &&
current.children[part].type === "directory"
) {
current = current.children[part];
} else {
return `cat: ${filePath}: No such file or directory`;
}
}
if (!fileName || !current?.children || !current.children[fileName]) {
return `cat: ${filePath}: No such file or directory`;
}
const file = current.children[fileName];
if (file.type !== "file") {
return `cat: ${filePath}: Is a directory`;
}
return file.content || "";
}
function handleTree(context: CommandContext): string {
const { fileSystem } = context;
const buildTree = (
node: FileSystemNode,
prefix: string = "",
isLast: boolean = true,
): string => {
let result = "";
if (!node.children) return result;
const entries = Object.entries(node.children);
entries.forEach(([name, child], index) => {
const isLastChild = index === entries.length - 1;
const connector = isLastChild ? "└── " : "├── ";
const color = child.type === "directory" ? "\x1b[34m" : "\x1b[0m";
const suffix = child.type === "directory" ? "/" : "";
result += `${prefix}${connector}${color}${name}${suffix}\x1b[0m\n`;
if (child.type === "directory") {
const newPrefix = prefix + (isLastChild ? " " : "│ ");
result += buildTree(child, newPrefix, isLastChild);
}
});
return result;
};
return ".\n" + buildTree(fileSystem["/"]);
}
function handleWhoami(): string {
return "guest@atri.dad";
}
function handleOpen(args: string[]): string {
const path = args[0];
if (!path) {
return "open: missing path argument";
}
let url = "";
if (path === "/resume" || path.startsWith("/resume")) {
url = "/resume";
} else if (path === "/projects" || path.startsWith("/projects")) {
url = "/projects";
} else if (path === "/posts" || path.startsWith("/posts")) {
url = "/posts";
} else if (path === "/talks" || path.startsWith("/talks")) {
url = "/talks";
} else if (path === "/" || path === "/about.txt") {
url = "/";
} else {
return `open: cannot open '${path}': No associated page`;
}
window.open(url, "_blank");
return `Opening ${url} in new tab...`;
}
function handleSl(context: CommandContext): string {
const { setIsTrainRunning, setTrainPosition } = context;
setIsTrainRunning(true);
setTrainPosition(100);
const animateTrain = () => {
let position = 100;
const interval = setInterval(() => {
position -= 1.5;
setTrainPosition(position);
if (position < -50) {
clearInterval(interval);
setIsTrainRunning(false);
}
}, 60);
};
setTimeout(animateTrain, 100);
return "";
}

View File

@@ -1,384 +0,0 @@
import type { FileSystemNode, ResumeData } from "./types";
import { talks, projects, socialLinks, techLinks } from "../../config/data";
export async function buildFileSystem(): Promise<{
[key: string]: FileSystemNode;
}> {
try {
const response = await fetch("/api/resume.json");
if (!response.ok) {
throw new Error(
`Failed to fetch resume data: ${response.status} ${response.statusText}`,
);
}
const resumeData: any = await response.json();
// Fetch blog posts
const postsResponse = await fetch("/api/posts.json");
let postsData = [];
try {
postsData = await postsResponse.json();
} catch (error) {
console.log("Could not fetch posts data:", error);
}
// Build resume files from rxresume json
const resumeFiles = buildResumeFiles(resumeData);
const postsFiles = buildPostsFiles(postsData);
const talksFiles = buildTalksFiles();
const projectsFiles = buildProjectsFiles();
const socialFiles = buildSocialFiles();
const techFiles = buildTechFiles();
const contactContent = buildContactContent(resumeData);
const fs: { [key: string]: FileSystemNode } = {
"/": {
type: "directory",
name: "/",
children: {
"about.txt": {
type: "file",
name: "about.txt",
content: `${resumeData.basics.name}\nResearcher, Full-Stack Developer, and IT Professional.\n\nExplore the directories:\n- /resume - Professional experience and skills\n- /posts - Blog posts and articles\n- /talks - Conference presentations\n- /projects - Personal and professional projects\n- /social - Social media and contact links\n- /tech - Technologies and tools I use\n\nType "ls" to see all available files and directories.`,
},
resume: {
type: "directory",
name: "resume",
children: resumeFiles,
},
posts: {
type: "directory",
name: "posts",
children: postsFiles,
},
talks: {
type: "directory",
name: "talks",
children: talksFiles,
},
projects: {
type: "directory",
name: "projects",
children: projectsFiles,
},
social: {
type: "directory",
name: "social",
children: socialFiles,
},
tech: {
type: "directory",
name: "tech",
children: techFiles,
},
"contact.txt": {
type: "file",
name: "contact.txt",
content: contactContent,
},
},
},
};
return fs;
} catch (error) {
console.error("Error loading resume data:", error);
return buildFallbackFileSystem();
}
}
function buildResumeFiles(resumeData: any): { [key: string]: FileSystemNode } {
const resumeFiles: { [key: string]: FileSystemNode } = {};
try {
if (resumeData.summary) {
resumeFiles["summary.txt"] = {
type: "file",
name: "summary.txt",
content: resumeData.summary.content,
};
}
if (resumeData.skills && Array.isArray(resumeData.skills)) {
const skillsContent = resumeData.skills
.map((skill: any) => `${skill.name} (Level: ${skill.level}/5)`)
.join("\n");
resumeFiles["skills.txt"] = {
type: "file",
name: "skills.txt",
content: skillsContent,
};
}
if (resumeData.experience && Array.isArray(resumeData.experience)) {
const experienceContent = resumeData.experience
.map((exp: any) => {
const description = Array.isArray(exp.description)
? exp.description.join("\n• ")
: "";
return `${exp.position} at ${exp.company}\n${exp.date} | ${exp.location}\n• ${description}\n${exp.url ? `URL: ${exp.url}` : ""}\n`;
})
.join("\n---\n\n");
resumeFiles["experience.txt"] = {
type: "file",
name: "experience.txt",
content: experienceContent,
};
}
if (resumeData.education && Array.isArray(resumeData.education)) {
const educationContent = resumeData.education
.map(
(edu: any) =>
`${edu.institution}\n${edu.degree} - ${edu.field}\n${edu.date}\n${edu.details && Array.isArray(edu.details) ? edu.details.join("\n• ") : ""}`,
)
.join("\n\n---\n\n");
resumeFiles["education.txt"] = {
type: "file",
name: "education.txt",
content: educationContent,
};
}
if (resumeData.volunteer && Array.isArray(resumeData.volunteer)) {
const volunteerContent = resumeData.volunteer
.map((vol: any) => `${vol.organization}\n${vol.position}\n${vol.date}`)
.join("\n\n---\n\n");
resumeFiles["volunteer.txt"] = {
type: "file",
name: "volunteer.txt",
content: volunteerContent,
};
}
if (resumeData.awards && Array.isArray(resumeData.awards)) {
const awardsContent = resumeData.awards
.map(
(award: any) =>
`${award.title}\n${award.organization}\n${award.date}\n${award.description || ""}`,
)
.join("\n\n---\n\n");
resumeFiles["awards.txt"] = {
type: "file",
name: "awards.txt",
content: awardsContent,
};
}
} catch (error) {
console.error("Error building resume files:", error);
}
return resumeFiles;
}
function buildPostsFiles(postsData: any[]): { [key: string]: FileSystemNode } {
const postsFiles: { [key: string]: FileSystemNode } = {};
postsData.forEach((post: any) => {
const fileName = `${post.slug}.md`;
let content = `---
title: "${post.title}"
description: "${post.description}"
pubDate: "${post.pubDate}"
tags: [${post.tags.map((tag: string) => `"${tag}"`).join(", ")}]
---
${post.content}`;
postsFiles[fileName] = {
type: "file",
name: fileName,
content,
};
});
return postsFiles;
}
function buildTalksFiles(): { [key: string]: FileSystemNode } {
const talksFiles: { [key: string]: FileSystemNode } = {};
talks.forEach((talk) => {
const fileName = `${talk.id}.txt`;
let content = `${talk.name}
${talk.description}
${talk.date || ""}
${talk.link}`;
talksFiles[fileName] = {
type: "file",
name: fileName,
content,
};
});
return talksFiles;
}
function buildProjectsFiles(): { [key: string]: FileSystemNode } {
const projectsFiles: { [key: string]: FileSystemNode } = {};
projects.forEach((project) => {
const fileName = `${project.id}.txt`;
let content = `${project.name}
${project.description}
${project.status || ""}
${project.technologies ? project.technologies.join(", ") : ""}
${project.link}`;
projectsFiles[fileName] = {
type: "file",
name: fileName,
content,
};
});
return projectsFiles;
}
function buildSocialFiles(): { [key: string]: FileSystemNode } {
const socialFiles: { [key: string]: FileSystemNode } = {};
socialLinks.forEach((link) => {
const fileName = `${link.id}.txt`;
let content = `${link.name}
${link.url}`;
socialFiles[fileName] = {
type: "file",
name: fileName,
content,
};
});
return socialFiles;
}
function buildTechFiles(): { [key: string]: FileSystemNode } {
const techFiles: { [key: string]: FileSystemNode } = {};
techLinks.forEach((link) => {
const fileName = `${link.id}.txt`;
let content = `${link.name}
${link.url}`;
techFiles[fileName] = {
type: "file",
name: fileName,
content,
};
});
return techFiles;
}
function buildContactContent(resumeData: any): string {
try {
const basics = resumeData.basics || {};
const email = basics.email || "Not provided";
const profiles = basics.profiles || [];
return [
`Email: ${email}`,
"",
"Social Profiles:",
...profiles.map((profile: any) => `${profile.network}: ${profile.url}`),
].join("\n");
} catch (error) {
console.error("Error building contact content:", error);
return "Contact information unavailable";
}
}
function buildFallbackFileSystem(): { [key: string]: FileSystemNode } {
const talksFiles = buildTalksFiles();
const projectsFiles = buildProjectsFiles();
const socialFiles = buildSocialFiles();
const techFiles = buildTechFiles();
return {
"/": {
type: "directory",
name: "/",
children: {
"about.txt": {
type: "file",
name: "about.txt",
content:
"Atridad Lahiji\nResearcher, Full-Stack Developer, and IT Professional.\n\nError loading resume data. Basic navigation still available.\n\nExplore the directories:\n- /talks - Conference presentations\n- /projects - Personal and professional projects\n- /social - Social media and contact links\n- /tech - Technologies and tools I use\n\nType 'ls' to see all available files and directories.",
},
talks: {
type: "directory",
name: "talks",
children: talksFiles,
},
projects: {
type: "directory",
name: "projects",
children: projectsFiles,
},
social: {
type: "directory",
name: "social",
children: socialFiles,
},
tech: {
type: "directory",
name: "tech",
children: techFiles,
},
"help.txt": {
type: "file",
name: "help.txt",
content:
"Available commands:\n- ls - list files\n- cd <directory> - change directory\n- cat <file> - view file contents\n- pwd - show current directory\n- clear - clear terminal\n- help - show this help\n- train - run the train animation",
},
},
},
};
}
export function getCurrentDirectory(
fileSystem: { [key: string]: FileSystemNode },
currentPath: string,
): FileSystemNode {
const pathParts = currentPath
.split("/")
.filter((part: string) => part !== "");
let current = fileSystem["/"];
for (const part of pathParts) {
if (
current?.children &&
current.children[part] &&
current.children[part].type === "directory"
) {
current = current.children[part];
}
}
return current;
}
export function resolvePath(currentPath: string, path: string): string {
if (path.startsWith("/")) {
return path;
}
const currentParts = currentPath
.split("/")
.filter((part: string) => part !== "");
const pathParts = path.split("/");
for (const part of pathParts) {
if (part === "..") {
currentParts.pop();
} else if (part !== "." && part !== "") {
currentParts.push(part);
}
}
return "/" + currentParts.join("/");
}

View File

@@ -1,40 +0,0 @@
export interface FileSystemNode {
type: 'directory' | 'file';
name: string;
content?: string;
children?: { [key: string]: FileSystemNode };
}
export 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 }[] };
};
}
export interface Command {
input: string;
output: string;
timestamp: Date;
path: string;
}
export interface TerminalState {
currentPath: string;
commandHistory: Command[];
currentInput: string;
historyIndex: number;
fileSystem: { [key: string]: FileSystemNode };
isTrainRunning: boolean;
trainPosition: number;
persistentHistory: string[];
}

View File

@@ -1,108 +0,0 @@
import type { FileSystemNode } from "./types";
import { resolvePath } from "./fs";
export function getCompletions(
input: string,
currentPath: string,
fileSystem: { [key: string]: FileSystemNode },
): { completion: string | null; replaceFrom: number } {
const parts = input.trim().split(" ");
const command = parts[0];
const partialPath = parts[parts.length - 1] || "";
// Only complete paths for these commands
if (parts.length > 1 && ["ls", "cd", "cat", "open"].includes(command)) {
// Path completion
const isAbsolute = partialPath.startsWith("/");
const pathToComplete = isAbsolute
? partialPath
: resolvePath(currentPath, partialPath);
// Find the directory to search in and the prefix to match
let dirPath: string;
let searchPrefix: string;
if (pathToComplete.endsWith("/")) {
// Path ends with slash - complete inside this directory
dirPath = pathToComplete;
searchPrefix = "";
} else {
// Base case - find directory and prefix
const lastSlash = pathToComplete.lastIndexOf("/");
if (lastSlash >= 0) {
dirPath = pathToComplete.substring(0, lastSlash + 1);
searchPrefix = pathToComplete.substring(lastSlash + 1);
} else {
dirPath = currentPath.endsWith("/") ? currentPath : currentPath + "/";
searchPrefix = pathToComplete;
}
}
// Calculate where to start replacement in the original input
const spaceBeforeArg = input.lastIndexOf(" ");
const replaceFrom = spaceBeforeArg >= 0 ? spaceBeforeArg + 1 : 0;
// Navigate to the directory
const dirParts = dirPath.split("/").filter((part: string) => part !== "");
let current = fileSystem["/"];
for (const part of dirParts) {
if (
current?.children &&
current.children[part] &&
current.children[part].type === "directory"
) {
current = current.children[part];
} else {
return { completion: null, replaceFrom };
}
}
if (!current?.children) {
return { completion: null, replaceFrom };
}
// Get first matching item
const match = Object.keys(current.children).find((name) =>
name.startsWith(searchPrefix),
);
if (match) {
const item = current.children[match];
const completion = item.type === "directory" ? `${match}/` : match;
return { completion, replaceFrom };
}
}
return { completion: null, replaceFrom: input.length };
}
export function formatOutput(text: string): string {
return text
.replace(/\x1b\[34m/g, '<span class="text-primary">')
.replace(/\x1b\[0m/g, "</span>");
}
export function saveCommandToHistory(
command: string,
persistentHistory: string[],
): string[] {
if (command.trim()) {
const updatedHistory = [...persistentHistory, command].slice(-100); // Keep last 100 commands
localStorage.setItem("terminal-history", JSON.stringify(updatedHistory));
return updatedHistory;
}
return persistentHistory;
}
export function loadCommandHistory(): string[] {
const savedHistory = localStorage.getItem("terminal-history");
if (savedHistory) {
try {
return JSON.parse(savedHistory);
} catch (error) {
console.error("Error loading command history:", error);
}
}
return [];
}

View File

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