Compare commits
79 Commits
75931d4a43
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
e6f6be20ce
|
|||
|
972abaf3af
|
|||
|
5b656ebb03
|
|||
|
0060d309cb
|
|||
|
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 |
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"]
|
||||
|
||||
168
README.md
168
README.md
@@ -1,169 +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 with flakes enabled 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 nix # Build with flakes
|
||||
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 Layout:**
|
||||
|
||||
```typescript
|
||||
layout: {
|
||||
leftColumn: ["skills", "education"],
|
||||
rightColumn: ["experience", "awards", "volunteer"],
|
||||
}
|
||||
```
|
||||
|
||||
### Resume 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,41 +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",
|
||||
"kotlin",
|
||||
"swift",
|
||||
"flutter"
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
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
|
||||
|
||||
46
flake.lock
generated
46
flake.lock
generated
@@ -1,59 +1,25 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1753250450,
|
||||
"narHash": "sha256-i+CQV2rPmP8wHxj0aq4siYyohHwVlsh40kV89f3nw1s=",
|
||||
"owner": "NixOS",
|
||||
"lastModified": 1766473571,
|
||||
"narHash": "sha256-5G1NDO2PulBx1RoaA6U1YoUDX0qZslpPxv+n5GX6Qto=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "fc02ee70efb805d3b2865908a13ddd4474557ecf",
|
||||
"rev": "76701a179d3a98b07653e2b0409847499b2a07d3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-25.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
120
flake.nix
120
flake.nix
@@ -1,120 +0,0 @@
|
||||
# flake.nix
|
||||
{
|
||||
description = "A portable development environment for atridotdad with Nix Flakes";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
|
||||
isDarwin = pkgs.stdenv.isDarwin;
|
||||
isLinux = pkgs.stdenv.isLinux;
|
||||
|
||||
commonDevTools = with pkgs; [
|
||||
nodejs_24
|
||||
nodePackages.pnpm
|
||||
git
|
||||
curl
|
||||
];
|
||||
|
||||
# Common libraries needed for Playwright
|
||||
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
|
||||
];
|
||||
|
||||
# Linux-specific libraries for Playwright
|
||||
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
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = commonDevTools ++ (
|
||||
if isDarwin
|
||||
then playwrightCommonLibs # For macOS, Playwright will download Chromium.
|
||||
else [ pkgs.chromium ] ++ playwrightSelfDownloadLibs # For Linux, provide Chromium and its dependencies
|
||||
);
|
||||
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = if isDarwin then "0" else "1";
|
||||
|
||||
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH = pkgs.lib.optionalString isLinux "${pkgs.chromium}/bin/chromium";
|
||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD = if isDarwin then "false" else "true";
|
||||
PUPPETEER_EXECUTABLE_PATH = pkgs.lib.optionalString isLinux "${pkgs.chromium}/bin/chromium";
|
||||
|
||||
shellHook = ''
|
||||
echo "🚀 atridotdad development environment loaded!"
|
||||
echo "Node version: $(node --version)"
|
||||
echo "pnpm version: $(pnpm --version)"
|
||||
|
||||
${pkgs.lib.optionalString isDarwin ''
|
||||
echo "Chromium path: Playwright will download its own for macOS"
|
||||
|
||||
export LD_LIBRARY_PATH="${playwrightLibPath}:$LD_LIBRARY_PATH"
|
||||
|
||||
PLAYWRIGHT_BROWSERS_PATH="''${TMPDIR:-$HOME/.cache}/ms-playwright"
|
||||
export PLAYWRIGHT_BROWSERS_PATH
|
||||
|
||||
if [ ! -d "$PLAYWRIGHT_BROWSERS_PATH" ] || [ -z "$(ls -A "$PLAYWRIGHT_BROWSERS_PATH")" ]; then
|
||||
echo "🌐 Installing Playwright browsers (for macOS)..."
|
||||
pnpm exec playwright install chromium
|
||||
else
|
||||
echo "✅ Playwright browsers already installed (for macOS)."
|
||||
fi
|
||||
''}
|
||||
|
||||
${pkgs.lib.optionalString isLinux ''
|
||||
echo "Chromium path: ${pkgs.chromium}/bin/chromium"
|
||||
''}
|
||||
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "📦 Installing pnpm dependencies..."
|
||||
pnpm install --frozen-lockfile
|
||||
fi
|
||||
'';
|
||||
};
|
||||
});
|
||||
}
|
||||
41
package.json
41
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "atridotdad",
|
||||
"type": "module",
|
||||
"version": "3.0.0",
|
||||
"version": "4.2.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
@@ -10,33 +10,22 @@
|
||||
"nix": "nix develop"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.3.6",
|
||||
"@astrojs/node": "^9.4.4",
|
||||
"@astrojs/preact": "^4.1.1",
|
||||
"@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.3.1",
|
||||
"@tailwindcss/typography": "^0.5.18",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"astro": "^5.13.10",
|
||||
"astro-icon": "^1.1.5",
|
||||
"lucide-preact": "^0.544.0",
|
||||
"playwright": "^1.55.0",
|
||||
"preact": "^10.27.2",
|
||||
"sharp": "^0.34.4",
|
||||
"tailwindcss": "^4.1.13"
|
||||
"@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.52",
|
||||
"daisyui": "^5.1.14"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@tailwindcss/oxide",
|
||||
"esbuild",
|
||||
"puppeteer",
|
||||
"sharp"
|
||||
]
|
||||
"@types/react": "^19.2.14",
|
||||
"daisyui": "^5.5.19"
|
||||
}
|
||||
}
|
||||
|
||||
5497
pnpm-lock.yaml
generated
5497
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 |
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,74 +4,74 @@ 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"
|
||||
|
||||
[[experience]]
|
||||
company = "University of Saskatchewan CEPHIL Lab"
|
||||
position = "Technical Lead"
|
||||
location = "Saskatoon, Saskatchewan"
|
||||
date = "November 2023 - Present"
|
||||
description = [
|
||||
"Supports a developer intern through supervision and mentorship",
|
||||
"Builds reports and dashboards for data analysis",
|
||||
"Spearheads architectural decisions for system-wide components (database, CI/CD, applications, etc.)",
|
||||
"Gathers requirements from stakeholders to craft a product timeline",
|
||||
"Develops mobile and web applications with Flutter and React (Astro)",
|
||||
"Contributes to the web-based dashboard development",
|
||||
]
|
||||
url = "https://cephil.ca/"
|
||||
|
||||
[[experience]]
|
||||
company = "Atash Consulting"
|
||||
position = "Owner/Developer"
|
||||
location = "Edmonton, Alberta"
|
||||
date = "June 2019 - Present"
|
||||
description = [
|
||||
"Builds native mobile applications and their accompanying APIs for small-to-medium sized businesses",
|
||||
"Develops web applications for small-to-medium sized businesses",
|
||||
"Builds reports and dashboards for data analysis",
|
||||
"Provides consulting on application development, system architecture, DevOps, etc.",
|
||||
"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"
|
||||
|
||||
[[experience]]
|
||||
company = "University of Saskatchewan CEPHIL Lab"
|
||||
position = "Technical Lead"
|
||||
location = "Saskatoon, Saskatchewan"
|
||||
date = "November 2023 - Present"
|
||||
description = [
|
||||
"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"
|
||||
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",
|
||||
"Provided tier 3 support for internal services",
|
||||
"Participated in a 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"
|
||||
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"
|
||||
|
||||
@@ -81,14 +81,14 @@ position = "Support Analyst"
|
||||
location = "Edmonton, Alberta"
|
||||
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"
|
||||
details = [
|
||||
@@ -96,7 +96,7 @@ details = [
|
||||
"CMPT 838: Computer Security",
|
||||
"CMPT 815: Computer Systems and Performance Evaluation",
|
||||
"CMPT 868: Social Computing and Participative Web",
|
||||
"CMPT 811: Human COmputer Interation"
|
||||
"CMPT 811: Human Computer Interation"
|
||||
]
|
||||
|
||||
[[education]]
|
||||
@@ -124,7 +124,7 @@ name = "Modern Front-end Libraries (React, Vue, Svelte)"
|
||||
level = 5
|
||||
|
||||
[[skills]]
|
||||
name = "CI/CD (Github Actions, Jenkins, etc.)"
|
||||
name = "Project Management"
|
||||
level = 4
|
||||
|
||||
[[skills]]
|
||||
@@ -136,13 +136,21 @@ name = ".NET (C#, Blazor, EF Core)"
|
||||
level = 3
|
||||
|
||||
[[skills]]
|
||||
name = "CMS Platforms (Wordpress & Drupal)"
|
||||
name = "Testing Frameworks (Vitest, Jest, Playwright, Cypress)"
|
||||
level = 3
|
||||
|
||||
[[skills]]
|
||||
name = "CI/CD (Github Actions, Jenkins, etc.)"
|
||||
level = 4
|
||||
|
||||
[[skills]]
|
||||
name = "Containerization (Docker & Podman)"
|
||||
level = 5
|
||||
|
||||
[[skills]]
|
||||
name = "CMS Platforms (Wordpress & Drupal)"
|
||||
level = 3
|
||||
|
||||
[[skills]]
|
||||
name = "System Administration (Linux & Windows Server)"
|
||||
level = 5
|
||||
@@ -151,10 +159,6 @@ level = 5
|
||||
name = "Native Mobile Development (Swift & Kotlin)"
|
||||
level = 3
|
||||
|
||||
[[skills]]
|
||||
name = "Testing Frameworks (Vitest, Jest, Playwright, Cypress)"
|
||||
level = 3
|
||||
|
||||
[[skills]]
|
||||
name = "Cloud Infrastructure (AWS, Azure, DigitalOcean"
|
||||
level = 4
|
||||
@@ -167,14 +171,6 @@ level = 4
|
||||
name = "Scripting (Python, Bash, etc.)"
|
||||
level = 4
|
||||
|
||||
[[skills]]
|
||||
name = "Project Magagement"
|
||||
level = 4
|
||||
|
||||
[[skills]]
|
||||
name = "Client Support"
|
||||
level = 5
|
||||
|
||||
[[skills]]
|
||||
name = "Nix"
|
||||
level = 3
|
||||
|
||||
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,51 +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;
|
||||
}
|
||||
16
src/components/Logo.astro
Normal file
16
src/components/Logo.astro
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import { config } from "../config";
|
||||
---
|
||||
|
||||
<Image
|
||||
src={config.personalInfo.profileImage.src}
|
||||
alt={config.personalInfo.profileImage.alt}
|
||||
widths={[192, 384]}
|
||||
sizes="12rem"
|
||||
layout="constrained"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
class="rounded-full mx-auto"
|
||||
style="max-width: 12rem; width: 100%;"
|
||||
/>
|
||||
@@ -1,122 +0,0 @@
|
||||
import { useComputed, useSignal } from "@preact/signals";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { config } from "../config";
|
||||
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 = config.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 border-1 border-solid border-primary 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,63 +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 text-accent-content w-full max-w-sm shrink shadow-md">
|
||||
<div class="card-body break-words">
|
||||
<h2
|
||||
class="card-title text-xl md:text-2xl font-bold justify-center text-center break-words"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<p class="text-center break-words my-4">
|
||||
{blurb || "No description available."}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-center 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 font-bold">
|
||||
<Icon name="mdi:tag" class="text-lg" />
|
||||
{tag}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="card-actions justify-end">
|
||||
<a
|
||||
href={`/post/${slug}`}
|
||||
class="btn btn-circle"
|
||||
aria-label={`Read more about ${title}`}
|
||||
>
|
||||
<Icon name="mdi:arrow-right" class="text-lg" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,51 +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 text-accent-content w-full max-w-sm shrink shadow-md"
|
||||
>
|
||||
<div class="card-body break-words">
|
||||
<h2
|
||||
class="card-title text-xl md:text-2xl font-bold justify-center text-center break-words"
|
||||
>
|
||||
{project.name}
|
||||
</h2>
|
||||
|
||||
<p class="text-center break-words">
|
||||
{project.description}
|
||||
</p>
|
||||
|
||||
{
|
||||
project.tags && project.tags.length > 0 && (
|
||||
<div class="flex gap-2 flex-wrap mb-4 justify-center">
|
||||
{project.tags.map((tag: string) => (
|
||||
<div class="badge badge-primary font-bold">
|
||||
<Icon name="mdi:tag" class="text-lg" />
|
||||
{tag}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="card-actions justify-end">
|
||||
<a
|
||||
href={project.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-circle"
|
||||
aria-label={`Visit ${project.name}`}
|
||||
>
|
||||
<Icon name="mdi:link" class="text-lg" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,70 +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 font-bold rounded-full inline-flex items-center gap-2 text-sm sm:text-base ${
|
||||
isLoading
|
||||
? "text-primary border-2 border-primary"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{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, X } 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-secondary hover:btn-primary btn-circle ${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 relative z-50">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="font-bold text-lg">Resume Generator</h3>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
class="btn btn-circle btn-secondary hover:btn-primary"
|
||||
>
|
||||
<X className="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 onClick={downloadTemplate} class="btn btn-primary btn-sm font-bold">
|
||||
Download Template
|
||||
</button>
|
||||
<button onClick={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 ${
|
||||
activeTab === "upload"
|
||||
? "btn btn-primary shadow-sm"
|
||||
: "text-base-content/70 hover:text-base-content hover:bg-base-200"
|
||||
}`}
|
||||
onClick={() => setActiveTab("upload")}
|
||||
>
|
||||
Upload TOML
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
class={`px-4 py-2 rounded-full text-sm font-bold transition-all duration-200 ${
|
||||
activeTab === "edit"
|
||||
? "btn btn-primary font-bold 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
|
||||
? "bg-primary/20"
|
||||
: "border-primary"
|
||||
}`}
|
||||
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" 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,95 +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="space-y-3 sm:space-y-4">
|
||||
{skills.map((skill) => {
|
||||
const currentLevel = animatedLevels.value[skill.id] || 0;
|
||||
const progressValue = currentLevel * 20;
|
||||
|
||||
return (
|
||||
<div 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(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,44 +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 > 50;
|
||||
};
|
||||
|
||||
checkScroll();
|
||||
|
||||
window.addEventListener("scroll", checkScroll);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", checkScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
class={`fixed bottom-4 right-4 z-20 btn btn-secondary hover:btn-primary
|
||||
btn-circle transition-all duration-300
|
||||
${
|
||||
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,5 +1,5 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import Icon from "./Icon.astro";
|
||||
import { config } from "../config";
|
||||
---
|
||||
|
||||
|
||||
@@ -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 text-accent-content 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"
|
||||
>
|
||||
{talk.name}
|
||||
</h2>
|
||||
|
||||
<p class="text-center break-words my-4">
|
||||
{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"
|
||||
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 Icon from "./Icon.astro";
|
||||
import { config } from "../config";
|
||||
|
||||
// Helper function to check if icon is a string (Astro icon)
|
||||
function isAstroIcon(icon: any): icon is string {
|
||||
return typeof icon === "string";
|
||||
}
|
||||
---
|
||||
|
||||
<div class="flex flex-row gap-3 text-3xl flex-wrap justify-center">
|
||||
{
|
||||
config.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>
|
||||
|
||||
218
src/config.ts
218
src/config.ts
@@ -1,27 +1,8 @@
|
||||
import type { Config } from "./types";
|
||||
|
||||
// Import Lucide Icons
|
||||
import { Home, Newspaper, FileUser, CodeXml, Megaphone } from "lucide-preact";
|
||||
|
||||
import logo from "./assets/logo_real.webp";
|
||||
import logo from "./assets/logo.webp";
|
||||
import resumeToml from "./assets/resume.toml?raw";
|
||||
|
||||
// Astro Icon references
|
||||
const EMAIL_ICON = "mdi:email" as const;
|
||||
const RSS_ICON = "mdi:rss" as const;
|
||||
const GITEA_ICON = "simple-icons:gitea" as const;
|
||||
const BLUESKY_ICON = "simple-icons:bluesky" as const;
|
||||
const REACT_ICON = "simple-icons:react" as const;
|
||||
const TYPESCRIPT_ICON = "simple-icons:typescript" as const;
|
||||
const ASTRO_ICON = "simple-icons:astro" as const;
|
||||
const GO_ICON = "simple-icons:go" as const;
|
||||
const POSTGRESQL_ICON = "simple-icons:postgresql" as const;
|
||||
const REDIS_ICON = "simple-icons:redis" as const;
|
||||
const DOCKER_ICON = "simple-icons:docker" as const;
|
||||
const KOTLIN_ICON = "simple-icons:kotlin" as const;
|
||||
const SWIFT_ICON = "simple-icons:swift" as const;
|
||||
const FLUTTER_ICON = "simple-icons:flutter" as const;
|
||||
|
||||
export const config: Config = {
|
||||
personalInfo: {
|
||||
name: "Atridad Lahiji",
|
||||
@@ -142,6 +123,42 @@ export const config: Config = {
|
||||
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: [
|
||||
@@ -156,63 +173,71 @@ export const config: Config = {
|
||||
|
||||
projects: [
|
||||
{
|
||||
id: "openclimb",
|
||||
name: "OpenClimb",
|
||||
description: "FOSS Rock Climbing Tracker for iOS and Android",
|
||||
link: "https://git.atri.dad/atridad/OpenClimb",
|
||||
tags: [
|
||||
"kotlin",
|
||||
"jetpack compose",
|
||||
"swift",
|
||||
"swiftui",
|
||||
"mobile",
|
||||
"monorepo",
|
||||
],
|
||||
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",
|
||||
link: "https://git.atri.dad/atridad/muse",
|
||||
tags: ["golang", "cli"],
|
||||
gitLink: "https://git.atri.dad/atridad/muse",
|
||||
},
|
||||
{
|
||||
id: "magiccounter",
|
||||
name: "MagicCounter",
|
||||
description: "Jeckpack Compose based Magic the Gathering Health Tracker",
|
||||
link: "https://git.atri.dad/atridad/MagicCounter",
|
||||
tags: ["kotlin", "mobile"],
|
||||
},
|
||||
{
|
||||
id: "mealient",
|
||||
name: "Mealient (Fork of project by Kirill Kamakin)",
|
||||
description: "An Android client for a self-hosted recipe manager Mealie.",
|
||||
link: "https://git.atri.dad/atridad/Mealient",
|
||||
tags: ["kotlin", "mobile", "fork"],
|
||||
},
|
||||
{
|
||||
id: "goth-stack",
|
||||
name: "GOTH Stack",
|
||||
description:
|
||||
"🚀 A Web Application Template Powered by HTMX + Go + Tailwind 🚀",
|
||||
link: "https://git.atri.dad/atridad/goth.stack",
|
||||
tags: ["golang", "web"],
|
||||
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).",
|
||||
link: "https://git.atri.dad/atridad/himbot",
|
||||
tags: ["golang", "webserver"],
|
||||
gitLink: "https://git.atri.dad/atridad/himbot",
|
||||
},
|
||||
{
|
||||
id: "loadr",
|
||||
name: "loadr",
|
||||
id: "lavitz",
|
||||
name: "Lavitz",
|
||||
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",
|
||||
tags: ["golang", "cli"],
|
||||
"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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -244,30 +269,44 @@ export const config: Config = {
|
||||
id: "email",
|
||||
name: "Email",
|
||||
url: "mailto:me@atri.dad",
|
||||
icon: EMAIL_ICON,
|
||||
icon: "email",
|
||||
ariaLabel: "Email me",
|
||||
},
|
||||
{
|
||||
id: "rss",
|
||||
name: "RSS Feed",
|
||||
url: "/feed",
|
||||
icon: RSS_ICON,
|
||||
icon: "rss",
|
||||
ariaLabel: "RSS Feed",
|
||||
},
|
||||
{
|
||||
id: "gitea",
|
||||
name: "Forgejo (Git)",
|
||||
url: "https://git.atri.dad/atridad",
|
||||
icon: GITEA_ICON,
|
||||
icon: "gitea",
|
||||
ariaLabel: "Forgejo (Git)",
|
||||
},
|
||||
{
|
||||
id: "bluesky",
|
||||
name: "Bluesky",
|
||||
url: "https://bsky.app/profile/atri.dad",
|
||||
icon: BLUESKY_ICON,
|
||||
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: [
|
||||
@@ -275,71 +314,78 @@ export const config: Config = {
|
||||
id: "react",
|
||||
name: "React",
|
||||
url: "https://react.dev/",
|
||||
icon: REACT_ICON,
|
||||
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_ICON,
|
||||
icon: "typescript",
|
||||
ariaLabel: "TypeScript",
|
||||
},
|
||||
{
|
||||
id: "astro",
|
||||
name: "Astro",
|
||||
url: "https://astro.build/",
|
||||
icon: ASTRO_ICON,
|
||||
icon: "astro",
|
||||
ariaLabel: "Astro",
|
||||
},
|
||||
{
|
||||
id: "go",
|
||||
name: "Go",
|
||||
url: "https://go.dev/",
|
||||
icon: GO_ICON,
|
||||
icon: "go",
|
||||
ariaLabel: "Go",
|
||||
},
|
||||
{
|
||||
id: "postgresql",
|
||||
name: "PostgreSQL",
|
||||
url: "https://www.postgresql.org/",
|
||||
icon: POSTGRESQL_ICON,
|
||||
icon: "postgresql",
|
||||
ariaLabel: "PostgreSQL",
|
||||
},
|
||||
{
|
||||
id: "redis",
|
||||
name: "Redis",
|
||||
url: "https://redis.io/",
|
||||
icon: REDIS_ICON,
|
||||
ariaLabel: "Redis",
|
||||
id: "dotnet",
|
||||
name: "DotNet",
|
||||
url: "https://dot.net/",
|
||||
icon: "dotnet",
|
||||
ariaLabel: "DotNet",
|
||||
},
|
||||
{
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
url: "https://www.docker.com/",
|
||||
icon: DOCKER_ICON,
|
||||
icon: "docker",
|
||||
ariaLabel: "Docker",
|
||||
},
|
||||
{
|
||||
id: "kotlin",
|
||||
name: "Kotlin",
|
||||
url: "https://kotlinlang.org/",
|
||||
icon: KOTLIN_ICON,
|
||||
icon: "kotlin",
|
||||
ariaLabel: "Kotlin",
|
||||
},
|
||||
{
|
||||
id: "swift",
|
||||
name: "Swift",
|
||||
url: "https://www.swift.org/",
|
||||
icon: SWIFT_ICON,
|
||||
icon: "swift",
|
||||
ariaLabel: "Swift",
|
||||
},
|
||||
{
|
||||
id: "flutter",
|
||||
name: "Flutter",
|
||||
url: "https://flutter.dev",
|
||||
icon: FLUTTER_ICON,
|
||||
ariaLabel: "Flutter",
|
||||
id: "nix",
|
||||
name: "Nix",
|
||||
url: "https://nixos.org",
|
||||
icon: "nixos",
|
||||
ariaLabel: "Nix",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -349,7 +395,7 @@ export const config: Config = {
|
||||
name: "Home",
|
||||
path: "/",
|
||||
tooltip: "Home",
|
||||
icon: Home,
|
||||
icon: "house",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
@@ -357,7 +403,7 @@ export const config: Config = {
|
||||
name: "Posts",
|
||||
path: "/posts",
|
||||
tooltip: "Posts",
|
||||
icon: Newspaper,
|
||||
icon: "newspaper",
|
||||
enabled: true,
|
||||
isActive: (path: string) =>
|
||||
path.startsWith("/posts") || path.startsWith("/post/"),
|
||||
@@ -367,7 +413,7 @@ export const config: Config = {
|
||||
name: "Resume",
|
||||
path: "/resume",
|
||||
tooltip: "Resume",
|
||||
icon: FileUser,
|
||||
icon: "file-user",
|
||||
enabled: !!(resumeToml && resumeToml.trim()),
|
||||
},
|
||||
{
|
||||
@@ -375,7 +421,7 @@ export const config: Config = {
|
||||
name: "Projects",
|
||||
path: "/projects",
|
||||
tooltip: "Projects",
|
||||
icon: CodeXml,
|
||||
icon: "code-xml",
|
||||
enabled: true,
|
||||
isActive: (path: string) => path.startsWith("/projects"),
|
||||
},
|
||||
@@ -384,7 +430,7 @@ export const config: Config = {
|
||||
name: "Talks",
|
||||
path: "/talks",
|
||||
tooltip: "Talks",
|
||||
icon: Megaphone,
|
||||
icon: "megaphone",
|
||||
enabled: true,
|
||||
isActive: (path: string) => path.startsWith("/talks"),
|
||||
},
|
||||
|
||||
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).
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
title: "Current List of Favourite Tools"
|
||||
description: "A running list of my favourite tools to use day-to-day."
|
||||
pubDate: "2025-01-28"
|
||||
tags: ["list"]
|
||||
---
|
||||
|
||||
I change what I use _constantly_ in order to find something that feels just
|
||||
right. I wanted to share them here and update them here so when someone asks, I
|
||||
can just point them to this article.
|
||||
|
||||
1. VSCodium - The joys of VSCode and its extension ecosystem, but without Microsoft's insane amount of tracking and AI slop
|
||||
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.
|
||||
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,22 +1,32 @@
|
||||
---
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
import NavigationBar from "../components/NavigationBar";
|
||||
import ScrollUpButton from "../components/ScrollUpButton";
|
||||
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;
|
||||
ogImage?: OpenGraphImage;
|
||||
ogType?: "website" | "article";
|
||||
}
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
const { title, description, ogImage, ogType } = Astro.props;
|
||||
|
||||
const pageTitle = title
|
||||
? `${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>
|
||||
@@ -25,15 +35,29 @@ const pageDescription = description || config.siteConfig.meta.description;
|
||||
<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="flex-grow flex flex-col gap-4 items-center justify-center pb-[68px] sm:pb-[76px]"
|
||||
class="grow flex flex-col gap-4 items-center justify-center pb-17 sm:pb-19"
|
||||
>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,9 +11,7 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
|
||||
let tomlContent: string;
|
||||
|
||||
// Check if tomlFile is a path (starts with /) or raw content
|
||||
if (config.resumeConfig.tomlFile.startsWith("/")) {
|
||||
// It's a file path - fetch it
|
||||
const url = new URL(request.url);
|
||||
const baseUrl = `${url.protocol}//${url.host}`;
|
||||
|
||||
@@ -27,7 +25,6 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
|
||||
tomlContent = await response.text();
|
||||
} else {
|
||||
// It's raw TOML content
|
||||
tomlContent = config.resumeConfig.tomlFile;
|
||||
}
|
||||
const resumeData = TOML.parse(tomlContent);
|
||||
@@ -35,7 +32,7 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
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 { config } from "../../../config";
|
||||
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 = config.resumeConfig;
|
||||
// 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 (!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 (config.resumeConfig.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}${config.resumeConfig.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 = config.resumeConfig.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"
|
||||
|
||||
@@ -4,19 +4,16 @@ import SocialLinks from "../components/SocialLinks.astro";
|
||||
import TechLinks from "../components/TechLinks.astro";
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { config } from "../config";
|
||||
import Logo from "../components/Logo.astro";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<Image
|
||||
src={config.personalInfo.profileImage.src}
|
||||
alt={config.personalInfo.profileImage.alt}
|
||||
width={300}
|
||||
height={300}
|
||||
layout="constrained"
|
||||
priority={true}
|
||||
class="rounded-full mx-auto"
|
||||
style="max-width: 12rem; width: 100%;"
|
||||
/>
|
||||
<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}
|
||||
>
|
||||
<Logo client:idle />
|
||||
|
||||
<h1 class="text-primary text-4xl sm:text-6xl font-bold text-center">
|
||||
{config.personalInfo.name}
|
||||
|
||||
@@ -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,16 +8,20 @@ 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>
|
||||
<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">
|
||||
@@ -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(
|
||||
@@ -49,9 +53,19 @@ const { Content } = await post.render();
|
||||
href="/posts"
|
||||
class="btn btn-outline btn-primary btn-sm font-bold"
|
||||
>
|
||||
<Icon name="mdi:arrow-left" class="text-lg" />
|
||||
<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>
|
||||
|
||||
{
|
||||
@@ -59,7 +73,7 @@ const { Content } = await post.render();
|
||||
<div class="flex gap-2 flex-wrap mb-6">
|
||||
{post.data.tags.map((tag: string) => (
|
||||
<div class="badge badge-primary font-bold">
|
||||
<Icon name="mdi:tag" class="text-lg" />
|
||||
<Icon name="tag" class="text-lg" />
|
||||
{tag}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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");
|
||||
@@ -11,26 +11,71 @@ const sortedPosts = posts.sort(
|
||||
(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="w-full p-4 sm:p-8">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{
|
||||
sortedPosts.length === 0 && (
|
||||
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>
|
||||
|
||||
@@ -1,28 +1,190 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import ProjectCard from "../components/ProjectCard.astro";
|
||||
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="w-full p-4 sm:p-8">
|
||||
<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>
|
||||
<div
|
||||
class="flex flex-row flex-wrap justify-center gap-4 sm:gap-6 max-w-6xl mx-auto"
|
||||
|
||||
<ul
|
||||
class="flex flex-col bg-base-100 rounded-box shadow-md border border-base-content/20 divide-y divide-base-content/20"
|
||||
>
|
||||
{
|
||||
config.projects.map((project) => (
|
||||
<ProjectCard project={project} />
|
||||
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>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
</ul>
|
||||
{
|
||||
config.projects.length === 0 && (
|
||||
sortedProjects.length === 0 && (
|
||||
<p class="text-center text-gray-500 mt-12">
|
||||
No projects available yet. Check back soon!
|
||||
</p>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
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 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";
|
||||
@@ -95,7 +95,12 @@ if (!data) {
|
||||
}
|
||||
---
|
||||
|
||||
<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
|
||||
@@ -113,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}
|
||||
@@ -141,7 +146,7 @@ if (!data) {
|
||||
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 break-words">
|
||||
<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"}
|
||||
@@ -157,7 +162,7 @@ if (!data) {
|
||||
data.skills.length > 0 &&
|
||||
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 break-words">
|
||||
<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"}
|
||||
@@ -180,7 +185,7 @@ if (!data) {
|
||||
data.experience.length > 0 &&
|
||||
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 break-words">
|
||||
<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 ||
|
||||
"Experience"}
|
||||
@@ -212,7 +217,7 @@ 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" />
|
||||
<Icon name="link" />
|
||||
Website
|
||||
</a>
|
||||
)}
|
||||
@@ -229,7 +234,7 @@ if (!data) {
|
||||
data.education.length > 0 &&
|
||||
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 break-words">
|
||||
<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 ||
|
||||
"Education"}
|
||||
@@ -271,7 +276,7 @@ if (!data) {
|
||||
data.volunteer.length > 0 &&
|
||||
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 break-words">
|
||||
<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 ||
|
||||
"Volunteer Work"}
|
||||
@@ -303,7 +308,7 @@ if (!data) {
|
||||
data.awards.length > 0 &&
|
||||
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 break-words">
|
||||
<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 ||
|
||||
"Awards & Recognition"}
|
||||
|
||||
@@ -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 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="w-full 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"
|
||||
>
|
||||
{config.talks.map((talk) => <TalkCard talk={talk} />)}
|
||||
</div>
|
||||
|
||||
{
|
||||
config.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>
|
||||
|
||||
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,38 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "atridotdad";
|
||||
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(100% 0 0);
|
||||
--color-primary: oklch(78% 0.154 211.53);
|
||||
--color-primary-content: oklch(0% 0 0);
|
||||
--color-secondary: oklch(68% 0.169 237.323);
|
||||
--color-secondary-content: oklch(0% 0 0);
|
||||
--color-accent: oklch(60% 0.126 221.723);
|
||||
--color-accent-content: oklch(0% 0 0);
|
||||
--color-neutral: oklch(45% 0.085 224.283);
|
||||
--color-neutral-content: oklch(100% 0 0);
|
||||
--color-info: oklch(74% 0.16 232.661);
|
||||
--color-info-content: oklch(29% 0.066 243.157);
|
||||
--color-success: oklch(70% 0.14 182.503);
|
||||
--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: 2rem;
|
||||
--radius-field: 2rem;
|
||||
--radius-box: 2rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1.5px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
@plugin "daisyui" {
|
||||
themes: dracula --default;
|
||||
}
|
||||
|
||||
106
src/types.ts
106
src/types.ts
@@ -1,11 +1,6 @@
|
||||
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;
|
||||
import type { GiteaRepoInfo } from "./utils/gitea";
|
||||
import type { IconName } from "./config/icons";
|
||||
|
||||
export interface Talk {
|
||||
id: string;
|
||||
@@ -19,16 +14,19 @@ export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
link: string;
|
||||
tags?: string[];
|
||||
webLink?: string;
|
||||
status?: string;
|
||||
iosLink?: string;
|
||||
androidLink?: string;
|
||||
gitLink?: string;
|
||||
giteaInfo?: GiteaRepoInfo;
|
||||
}
|
||||
|
||||
export interface SocialLink {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
icon: IconType;
|
||||
icon: IconName;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
@@ -36,7 +34,7 @@ export interface TechLink {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
icon: IconType;
|
||||
icon: IconName;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
@@ -45,7 +43,7 @@ export interface NavigationItem {
|
||||
name: string;
|
||||
path: string;
|
||||
tooltip: string;
|
||||
icon: IconType;
|
||||
icon: IconName;
|
||||
enabled?: boolean;
|
||||
isActive?: (path: string) => boolean;
|
||||
}
|
||||
@@ -60,7 +58,7 @@ export type ResumeSectionKey =
|
||||
| "awards";
|
||||
|
||||
export interface ResumeConfig {
|
||||
tomlFile: string; // Can be a file path or raw TOML content
|
||||
tomlFile: string;
|
||||
layout?: {
|
||||
leftColumn?: ResumeSectionKey[];
|
||||
rightColumn?: ResumeSectionKey[];
|
||||
@@ -91,6 +89,57 @@ export interface ResumeConfig {
|
||||
};
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -114,6 +163,28 @@ export interface HomepageSections {
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -124,6 +195,15 @@ export interface SiteConfig {
|
||||
url: string;
|
||||
author: string;
|
||||
};
|
||||
openGraph: OpenGraphConfig;
|
||||
pageOpenGraph: {
|
||||
home: PageOpenGraph;
|
||||
posts: PageOpenGraph;
|
||||
projects: PageOpenGraph;
|
||||
talks: PageOpenGraph;
|
||||
resume: PageOpenGraph;
|
||||
};
|
||||
giteaDomains?: string[];
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
@@ -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