Compare commits
106 Commits
bda8535c51
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
7922b2da18
|
|||
|
4c8105d263
|
|||
|
fa9d2700f2
|
|||
|
79f5b5c9e9
|
|||
|
3e43e73abf
|
|||
|
f03318f7dc
|
|||
|
1db15c64e1
|
|||
|
a98bf7c7c6
|
|||
|
c3c8867a37
|
|||
|
2430f89737
|
|||
|
7925fab524
|
|||
|
271dad89a1
|
|||
|
47946c0703
|
|||
|
4b78414562
|
|||
|
0cf1cfa2b0
|
|||
|
399cff82b0
|
|||
|
cf163bb0b2
|
|||
|
cc3a408050
|
|||
|
46c42cd765
|
|||
|
89c1c739c1
|
|||
|
33dfea1802
|
|||
|
0efb72fffd
|
|||
|
dce37681af
|
|||
|
6b77ce091d
|
|||
|
ba1193896f
|
|||
|
63282cf34d
|
|||
|
3eac226630
|
|||
|
9518a0f18b
|
|||
|
74304dba4d
|
|||
|
0512645035
|
|||
|
09fdbf7ec7
|
|||
|
d3844a5870
|
|||
|
d06a453461
|
|||
|
c048d0d47a
|
|||
|
a26c990a21
|
|||
|
210edc771c
|
|||
|
154bbc9669
|
|||
|
d354b35d05
|
|||
|
4c1def9cf9
|
|||
|
a29e81f05d
|
|||
|
9a9705e8f7
|
|||
|
4954fe855f
|
|||
|
f4d0ae2780
|
|||
|
e9fed3a5b7
|
|||
|
d6d75eff37
|
|||
|
ebb980f275
|
|||
|
3b5f33aaf7
|
|||
|
70ee8a2c42
|
|||
|
203f83bfcb
|
|||
|
4ab28078e8
|
|||
|
a8e017caf2
|
|||
|
ee10cbaf60
|
|||
|
88c10f9690
|
|||
|
0998bacd86
|
|||
|
b87357c175
|
|||
|
b159236e59
|
|||
|
be20d75288
|
|||
|
3b1f9ae02c
|
|||
|
a09a2faa32
|
|||
| 517becb322 | |||
|
0fb9fd2009
|
|||
|
50a4ff1332
|
|||
| 0d474804ec | |||
|
94146782cb
|
|||
|
fb260d499b
|
|||
|
6daeac418b
|
|||
| a837b9380b | |||
| 0b4ba7ba63 | |||
| 7d14ba51fa | |||
| 9b98476df6 | |||
|
50e1627ea3
|
|||
| 18cd6511c9 | |||
| 6d380ec376 | |||
| 1f6e7a2552 | |||
| 0aab89c58c | |||
| 75931d4a43 | |||
|
6c9fabe770
|
|||
|
fd48065550
|
|||
|
a2a3b114dd
|
|||
|
08537db2ab
|
|||
| c78ff8c37d | |||
| 87e4d54059 | |||
| 294a8f2ad3 | |||
| 4f570af33e | |||
| 4b30c8f794 | |||
| f5d622f857 | |||
|
9a846f5d76
|
|||
|
9373b2894a
|
|||
|
e053b59501
|
|||
|
ec58d44b9d
|
|||
| bd71602d95 | |||
| c2063f6feb | |||
| 6c4d1b53c6 | |||
| 96add46a4d | |||
| fe8dc4b794 | |||
| 9de5f46201 | |||
|
485d1eaa34
|
|||
|
0e457c0c82
|
|||
| 0d5dd82fd4 | |||
|
f85cf0c719
|
|||
|
14de7b0d22
|
|||
|
d19830a6fa
|
|||
|
805eb86848
|
|||
|
cb8d38b2d1
|
|||
| df8ff3ab20 | |||
|
1082834f33
|
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
dist
|
||||
.env*
|
||||
*.md
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# RSS Feed Configuration
|
||||
# Will default to GMT
|
||||
PUBLIC_RSS_TIMEZONE=America/Edmonton
|
||||
@@ -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
5
.gitignore
vendored
@@ -22,3 +22,8 @@ pnpm-debug.log*
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
|
||||
# nix
|
||||
.direnv/
|
||||
result
|
||||
|
||||
|
||||
58
Dockerfile
58
Dockerfile
@@ -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
177
README.md
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
27
flake.lock
generated
Normal 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
|
||||
}
|
||||
44
package.json
44
package.json
@@ -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
5740
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
107
shell.nix
107
shell.nix
@@ -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
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 |
@@ -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"
|
||||
|
||||
314
src/components/FuzzyText.vue
Normal file
314
src/components/FuzzyText.vue
Normal 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
27
src/components/Icon.astro
Normal 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
30
src/components/Icon.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
122
src/components/NavigationBar.vue
Normal file
122
src/components/NavigationBar.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
59
src/components/ResumeDownloadButton.vue
Normal file
59
src/components/ResumeDownloadButton.vue
Normal 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>
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
317
src/components/ResumeSettingsModal.vue
Normal file
317
src/components/ResumeSettingsModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
96
src/components/ResumeSkills.vue
Normal file
96
src/components/ResumeSkills.vue
Normal 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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
50
src/components/ScrollUpButton.vue
Normal file
50
src/components/ScrollUpButton.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
438
src/config.ts
Normal 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;
|
||||
@@ -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
59
src/config/icons.ts
Normal 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",
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
30
src/content/posts/2026-infra-setup.md
Normal file
30
src/content/posts/2026-infra-setup.md
Normal 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:
|
||||
|
||||

|
||||
|
||||
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).
|
||||
36
src/content/posts/ascently-climbing-tracker.md
Normal file
36
src/content/posts/ascently-climbing-tracker.md
Normal 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).
|
||||
@@ -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.
|
||||
41
src/content/posts/linux-for-new-users.md
Normal file
41
src/content/posts/linux-for-new-users.md
Normal 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).
|
||||
@@ -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
24
src/pages/404.astro
Normal 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>
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
106
src/pages/api/resume/generate.ts
Normal file
106
src/pages/api/resume/generate.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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]]
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
392
src/pdf/ResumeDocument.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
241
src/types.ts
Normal 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[];
|
||||
}
|
||||
@@ -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
125
src/utils/gitea.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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("/");
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user