Merge pull request 'Migrate to Astro' (#1) from fresh-2.0-migrate into main
Reviewed-on: atridad/atri.dad#1
This commit is contained in:
35
.github/workflows/deploy.yml
vendored
35
.github/workflows/deploy.yml
vendored
@ -1,35 +0,0 @@
|
|||||||
name: Docker Deploy
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
|
|
||||||
- name: Login to Container Registry
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
registry: ${{ secrets.REPO_HOST }}
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.DEPLOY_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.sha }}
|
|
||||||
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
|
|
31
.gitignore
vendored
31
.gitignore
vendored
@ -1,11 +1,24 @@
|
|||||||
# dotenv environment variable files
|
# build output
|
||||||
.env
|
dist/
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# Fresh build directory
|
# generated types
|
||||||
_fresh/
|
.astro/
|
||||||
# npm dependencies
|
|
||||||
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# jetbrains setting folder
|
||||||
|
.idea/
|
||||||
|
4
.vscode/extensions.json
vendored
Normal file
4
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode"],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
11
.vscode/launch.json
vendored
Normal file
11
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "./node_modules/.bin/astro dev",
|
||||||
|
"name": "Development server",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"deno.enable": true
|
|
||||||
}
|
|
40
Dockerfile
40
Dockerfile
@ -1,35 +1,27 @@
|
|||||||
FROM denoland/deno:alpine AS builder
|
FROM node:lts-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install build dependencies for native modules
|
RUN npm i -g pnpm
|
||||||
RUN apk add --no-cache build-base python3
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
RUN pnpm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
# Create node_modules directory and install dependencies
|
FROM node:lts-alpine AS runtime
|
||||||
RUN deno cache -r main.ts
|
|
||||||
|
|
||||||
# Build Fresh application in a more controlled way (without task)
|
|
||||||
RUN deno run -A dev.ts build || deno run -A --unstable-worker-options --node-modules-dir main.ts build
|
|
||||||
|
|
||||||
FROM denoland/deno:alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy the Deno cache and node_modules
|
RUN npm i -g pnpm
|
||||||
COPY --from=builder /deno-dir/ /deno-dir/
|
|
||||||
COPY --from=builder /app/node_modules/ /app/node_modules/
|
|
||||||
|
|
||||||
# Copy application code
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/ /app/
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
# Ensure static assets directories permissions are set correctly
|
RUN pnpm install --prod
|
||||||
RUN chmod -R 755 /app/static /app/_fresh
|
|
||||||
|
|
||||||
ENV DENO_DEPLOYMENT=production
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=4321
|
||||||
|
EXPOSE 4321
|
||||||
|
|
||||||
EXPOSE 8000
|
CMD ["node", "./dist/server/entry.mjs"]
|
||||||
|
|
||||||
# Run with appropriate flags for static file serving
|
|
||||||
CMD ["run", "--allow-net", "--allow-read", "--allow-env", "--node-modules-dir", "main.ts"]
|
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
# Personal Site
|
# Personal Site
|
||||||
|
|
||||||
Re-written with Deno + Fresh :)
|
Re-written with Astro + Preact :)
|
27
astro.config.mjs
Normal file
27
astro.config.mjs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
import preact from '@astrojs/preact';
|
||||||
|
|
||||||
|
import node from '@astrojs/node';
|
||||||
|
|
||||||
|
import icon from 'astro-icon';
|
||||||
|
|
||||||
|
|
||||||
|
import mdx from '@astrojs/mdx';
|
||||||
|
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()]
|
||||||
|
},
|
||||||
|
|
||||||
|
integrations: [preact(), icon(), mdx()],
|
||||||
|
|
||||||
|
adapter: node({
|
||||||
|
mode: 'standalone'
|
||||||
|
}),
|
||||||
|
});
|
@ -1,15 +0,0 @@
|
|||||||
export default function HomeButtonLinks() {
|
|
||||||
return (
|
|
||||||
<div class="flex flex-row gap-4 text-3xl">
|
|
||||||
<a
|
|
||||||
href="/resume"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="React"
|
|
||||||
class="btn btn-dash btn-primary"
|
|
||||||
>
|
|
||||||
Resumé
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,37 +0,0 @@
|
|||||||
import { LuArrowRight, LuClock } from "@preact-icons/lu";
|
|
||||||
import { Post } from "../lib/posts.ts";
|
|
||||||
|
|
||||||
export default function PostCard(props: { post: Post }) {
|
|
||||||
const { post } = props;
|
|
||||||
return (
|
|
||||||
<div class="card bg-accent shadow-lg w-full sm:w-[calc(50%-1rem)] md:w-96 min-w-[280px] max-w-sm shrink">
|
|
||||||
<div class="card-body p-4 sm:p-6">
|
|
||||||
<h2 class="card-title text-base-100 justify-center text-center break-words">
|
|
||||||
{post.title}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p class="text-center text-base-100 break-words">{post.blurb}</p>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-center text-base-100 opacity-75 gap-2 text-sm sm:text-base">
|
|
||||||
<LuClock class="flex-shrink-0" />
|
|
||||||
<span>
|
|
||||||
{post.publishedAt!.toLocaleDateString("en-us", {
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-4">
|
|
||||||
<a
|
|
||||||
href={`/post/${post.slug}`}
|
|
||||||
class="btn btn-circle btn-sm sm:btn-md btn-primary text-accent"
|
|
||||||
>
|
|
||||||
<LuArrowRight class="text-lg" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,37 +0,0 @@
|
|||||||
import { LuLink } from "@preact-icons/lu";
|
|
||||||
|
|
||||||
interface Project {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
link: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectCard(props: { project: Project }) {
|
|
||||||
const { project } = props;
|
|
||||||
return (
|
|
||||||
<div class="card bg-accent shadow-lg w-full sm:w-[calc(50%-1rem)] md:w-96 min-w-[280px] max-w-sm shrink">
|
|
||||||
<div class="card-body p-6">
|
|
||||||
<h2 class="card-title text-xl md:text-2xl font-bold justify-center text-center break-words text-base-100">
|
|
||||||
{project.name}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p class="text-center break-words my-4 text-base-100">
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-4 ">
|
|
||||||
<a
|
|
||||||
href={project.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="btn btn-circle btn-secondary text-accent"
|
|
||||||
aria-label={`Visit ${project.name}`}
|
|
||||||
>
|
|
||||||
<LuLink class="text-lg" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,44 +0,0 @@
|
|||||||
import { LuMail } from "@preact-icons/lu";
|
|
||||||
import { SiBluesky, SiForgejo, SiRss } from "@preact-icons/si";
|
|
||||||
|
|
||||||
export default function SocialLinks() {
|
|
||||||
return (
|
|
||||||
<div class="flex flex-row gap-4 text-xl sm:text-3xl">
|
|
||||||
<a
|
|
||||||
href="mailto:me@atri.dad"
|
|
||||||
aria-label="Email me"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<LuMail />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="/feed"
|
|
||||||
aria-label="RSS Feed"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<SiRss />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://git.atri.dad/atridad"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Forgejo (Git)"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<SiForgejo />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://bsky.app/profile/atri.dad"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Bluesky Profile"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<SiBluesky />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,85 +0,0 @@
|
|||||||
import {
|
|
||||||
SiDeno,
|
|
||||||
SiDocker,
|
|
||||||
SiGo,
|
|
||||||
SiPostgresql,
|
|
||||||
SiReact,
|
|
||||||
SiRedis,
|
|
||||||
SiTypescript,
|
|
||||||
} from "@preact-icons/si";
|
|
||||||
|
|
||||||
export default function TechLinks() {
|
|
||||||
return (
|
|
||||||
<div class="flex flex-row gap-4 text-xl sm:text-3xl">
|
|
||||||
<a
|
|
||||||
href="https://react.dev/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="React"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<SiReact />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://www.typescriptlang.org/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="TypeScript"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<SiTypescript />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://deno.com/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Deno"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<SiDeno />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://go.dev/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Go"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<SiGo />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://www.postgresql.org/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="PostgreSQL"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<SiPostgresql />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://redis.io/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Redis"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<SiRedis />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://www.docker.com/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Docker"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<SiDocker />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
32
deno.json
32
deno.json
@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"nodeModulesDir": "auto",
|
|
||||||
"lock": false,
|
|
||||||
"tasks": {
|
|
||||||
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
|
|
||||||
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
|
|
||||||
"manifest": "deno task cli manifest $(pwd)",
|
|
||||||
"start": "deno run -A --watch=static/,routes/ dev.ts",
|
|
||||||
"build": "deno run -A dev.ts build",
|
|
||||||
"preview": "deno run -A main.ts",
|
|
||||||
"update": "deno run -A -r https://fresh.deno.dev/update ."
|
|
||||||
},
|
|
||||||
"lint": { "rules": { "tags": ["fresh", "recommended"] } },
|
|
||||||
"exclude": ["**/_fresh/*"],
|
|
||||||
"imports": {
|
|
||||||
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
|
|
||||||
"@deno/gfm": "jsr:@deno/gfm@^0.11.0",
|
|
||||||
"@pakornv/fresh-plugin-tailwindcss": "jsr:@pakornv/fresh-plugin-tailwindcss@^1.0.2",
|
|
||||||
"@preact-icons/lu": "jsr:@preact-icons/lu@^1.0.13",
|
|
||||||
"@preact-icons/si": "jsr:@preact-icons/si@^1.0.13",
|
|
||||||
"@std/front-matter": "jsr:@std/front-matter@^1.0.9",
|
|
||||||
"@std/path": "jsr:@std/path@^1.0.9",
|
|
||||||
"@tailwindcss/typography": "npm:@tailwindcss/typography@^0.5.16",
|
|
||||||
"daisyui": "npm:daisyui@^5.0.35",
|
|
||||||
"preact": "npm:preact@10.26.6",
|
|
||||||
"@preact/signals": "npm:@preact/signals@1.2.2",
|
|
||||||
"@preact/signals-core": "npm:@preact/signals-core@1.5.1",
|
|
||||||
"$std/": "https://deno.land/std@0.216.0/",
|
|
||||||
"tailwindcss": "npm:tailwindcss@^4.1.5"
|
|
||||||
},
|
|
||||||
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" }
|
|
||||||
}
|
|
8
dev.ts
8
dev.ts
@ -1,8 +0,0 @@
|
|||||||
#!/usr/bin/env -S deno run -A --watch=static/,routes/
|
|
||||||
|
|
||||||
import dev from "$fresh/dev.ts";
|
|
||||||
import config from "./fresh.config.ts";
|
|
||||||
|
|
||||||
import "$std/dotenv/load.ts";
|
|
||||||
|
|
||||||
await dev(import.meta.url, "./main.ts", config);
|
|
@ -1,10 +1,8 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: ${IMAGE:-ghcr.io/yourusername/your-fresh-project:latest}
|
image: ${IMAGE}
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- DENO_DEPLOYMENT=production
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:8000"
|
- "${APP_PORT}:4321"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
restart: unless-stopped
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
import { defineConfig } from "$fresh/server.ts";
|
|
||||||
import tailwind from "@pakornv/fresh-plugin-tailwindcss";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [tailwind()],
|
|
||||||
});
|
|
43
fresh.gen.ts
43
fresh.gen.ts
@ -1,43 +0,0 @@
|
|||||||
// DO NOT EDIT. This file is generated by Fresh.
|
|
||||||
// This file SHOULD be checked into source version control.
|
|
||||||
// This file is automatically updated during development when running `dev.ts`.
|
|
||||||
|
|
||||||
import * as $_404 from "./routes/_404.tsx";
|
|
||||||
import * as $_app from "./routes/_app.tsx";
|
|
||||||
import * as $_layout from "./routes/_layout.tsx";
|
|
||||||
import * as $api_chat from "./routes/api/chat.ts";
|
|
||||||
import * as $api_ping from "./routes/api/ping.ts";
|
|
||||||
import * as $chat from "./routes/chat.tsx";
|
|
||||||
import * as $index from "./routes/index.tsx";
|
|
||||||
import * as $post_slug_ from "./routes/post/[slug].tsx";
|
|
||||||
import * as $posts from "./routes/posts.tsx";
|
|
||||||
import * as $projects from "./routes/projects.tsx";
|
|
||||||
import * as $resume from "./routes/resume.tsx";
|
|
||||||
import * as $Chat from "./islands/Chat.tsx";
|
|
||||||
import * as $NavigationBar from "./islands/NavigationBar.tsx";
|
|
||||||
import * as $ScrollUpButton from "./islands/ScrollUpButton.tsx";
|
|
||||||
import type { Manifest } from "$fresh/server.ts";
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
routes: {
|
|
||||||
"./routes/_404.tsx": $_404,
|
|
||||||
"./routes/_app.tsx": $_app,
|
|
||||||
"./routes/_layout.tsx": $_layout,
|
|
||||||
"./routes/api/chat.ts": $api_chat,
|
|
||||||
"./routes/api/ping.ts": $api_ping,
|
|
||||||
"./routes/chat.tsx": $chat,
|
|
||||||
"./routes/index.tsx": $index,
|
|
||||||
"./routes/post/[slug].tsx": $post_slug_,
|
|
||||||
"./routes/posts.tsx": $posts,
|
|
||||||
"./routes/projects.tsx": $projects,
|
|
||||||
"./routes/resume.tsx": $resume,
|
|
||||||
},
|
|
||||||
islands: {
|
|
||||||
"./islands/Chat.tsx": $Chat,
|
|
||||||
"./islands/NavigationBar.tsx": $NavigationBar,
|
|
||||||
"./islands/ScrollUpButton.tsx": $ScrollUpButton,
|
|
||||||
},
|
|
||||||
baseUrl: import.meta.url,
|
|
||||||
} satisfies Manifest;
|
|
||||||
|
|
||||||
export default manifest;
|
|
161
islands/Chat.tsx
161
islands/Chat.tsx
@ -1,161 +0,0 @@
|
|||||||
import { useEffect, useState } from "preact/hooks";
|
|
||||||
|
|
||||||
interface ChatMessage {
|
|
||||||
text: string;
|
|
||||||
sender: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Chat() {
|
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
||||||
const [newMessage, setNewMessage] = useState("");
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [socket, setSocket] = useState<WebSocket | null>(null);
|
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
|
||||||
const [userCount, setUserCount] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!username) {
|
|
||||||
const randomNum = Math.floor(Math.random() * 10000);
|
|
||||||
setUsername(`HumanGuest${randomNum}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsProtocol = globalThis.location.protocol === "https:"
|
|
||||||
? "wss:"
|
|
||||||
: "ws:";
|
|
||||||
const ws = new WebSocket(
|
|
||||||
`${wsProtocol}//${globalThis.location.host}/api/chat`,
|
|
||||||
);
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
console.log("Connected to chat");
|
|
||||||
setIsConnected(true);
|
|
||||||
setSocket(ws);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data.type === "user_count") {
|
|
||||||
setUserCount(data.count);
|
|
||||||
} else {
|
|
||||||
setMessages((prev) => [...prev, data]);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom on new message
|
|
||||||
const chatBox = document.getElementById("chat-messages");
|
|
||||||
if (chatBox) {
|
|
||||||
setTimeout(() => {
|
|
||||||
chatBox.scrollTop = chatBox.scrollHeight;
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error processing message:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
console.log("Disconnected from chat");
|
|
||||||
setIsConnected(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ws.close();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sendMessage = (e: Event) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!newMessage.trim() || !socket) return;
|
|
||||||
|
|
||||||
const messageData = {
|
|
||||||
text: newMessage,
|
|
||||||
sender: username,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.send(JSON.stringify(messageData));
|
|
||||||
setNewMessage("");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="w-full max-w-4xl mx-auto bg-[#1E2127] rounded-lg shadow-lg overflow-hidden border border-gray-800 flex flex-col h-[70vh]">
|
|
||||||
{/* Header */}
|
|
||||||
<div class="p-4 bg-secondary text-white">
|
|
||||||
<h2 class="text-2xl font-bold">Live Chat</h2>
|
|
||||||
<p class="text-sm">
|
|
||||||
{isConnected
|
|
||||||
? `${userCount} online • Messages are not saved`
|
|
||||||
: "Connecting..."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="chat-messages"
|
|
||||||
class="flex-grow overflow-y-auto bg-[#1E2127] text-gray-300 p-4"
|
|
||||||
>
|
|
||||||
{messages.length === 0
|
|
||||||
? (
|
|
||||||
<p class="text-center text-gray-500 py-8">
|
|
||||||
No messages yet.
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
messages.map((msg, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
class={`mb-3 max-w-[85%] ${
|
|
||||||
msg.sender === username ? "ml-auto" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class={`px-4 py-2 rounded-lg ${
|
|
||||||
msg.sender === username
|
|
||||||
? "bg-secondary text-white rounded-br-none"
|
|
||||||
: "bg-gray-800 text-gray-200 rounded-bl-none"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div class="flex justify-between items-baseline mb-1">
|
|
||||||
<span class="font-bold text-sm">
|
|
||||||
{msg.sender === username ? "You" : msg.sender}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs opacity-70 ml-2">
|
|
||||||
{new Date(msg.timestamp).toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="break-words">{msg.text}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-3 border-t border-gray-800 pb-6 md:pb-3">
|
|
||||||
<form onSubmit={sendMessage} class="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newMessage}
|
|
||||||
onChange={(e) => setNewMessage(e.currentTarget.value)}
|
|
||||||
placeholder="Type your message..."
|
|
||||||
class="w-full pl-4 pr-20 py-3 bg-gray-800 text-white rounded-lg border-0 focus:outline-none focus:ring-1 focus:ring-secondary placeholder-gray-500"
|
|
||||||
disabled={!isConnected}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="absolute right-0 top-0 h-full bg-secondary text-white px-5 rounded-r-lg font-medium"
|
|
||||||
disabled={!isConnected || !newMessage.trim()}
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<p class="mt-2 text-xs text-gray-500">
|
|
||||||
You are connected as{" "}
|
|
||||||
<span class="font-medium text-gray-400">{username}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
45
lib/posts.ts
45
lib/posts.ts
@ -1,45 +0,0 @@
|
|||||||
import { extractYaml } from "@std/front-matter";
|
|
||||||
import { join } from "@std/path";
|
|
||||||
|
|
||||||
const POSTS_DIR = "./posts";
|
|
||||||
|
|
||||||
interface FrontMatter {
|
|
||||||
title: string;
|
|
||||||
published_at: string;
|
|
||||||
blurb: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Post {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
publishedAt: Date | null;
|
|
||||||
blurb: string;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPost(slug: string): Promise<Post> {
|
|
||||||
const text = await Deno.readTextFile(join(POSTS_DIR, `${slug}.md`));
|
|
||||||
const { attrs, body } = extractYaml<FrontMatter>(text);
|
|
||||||
const post = {
|
|
||||||
slug,
|
|
||||||
title: attrs.title,
|
|
||||||
publishedAt: attrs.published_at ? new Date(attrs.published_at) : null,
|
|
||||||
blurb: attrs.blurb,
|
|
||||||
content: body,
|
|
||||||
};
|
|
||||||
return post;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPosts(): Promise<Post[]> {
|
|
||||||
const files = Deno.readDir(POSTS_DIR);
|
|
||||||
const promises = [];
|
|
||||||
for await (const file of files) {
|
|
||||||
if (file.name.startsWith(".")) continue;
|
|
||||||
const slug = file.name.replace(".md", "");
|
|
||||||
promises.push(getPost(slug));
|
|
||||||
}
|
|
||||||
const posts = (await Promise.all(promises) as Post[])
|
|
||||||
.filter((post) => post.publishedAt instanceof Date);
|
|
||||||
posts.sort((a, b) => b.publishedAt!.getTime() - a.publishedAt!.getTime());
|
|
||||||
return posts;
|
|
||||||
}
|
|
13
main.ts
13
main.ts
@ -1,13 +0,0 @@
|
|||||||
/// <reference no-default-lib="true" />
|
|
||||||
/// <reference lib="dom" />
|
|
||||||
/// <reference lib="dom.iterable" />
|
|
||||||
/// <reference lib="dom.asynciterable" />
|
|
||||||
/// <reference lib="deno.ns" />
|
|
||||||
|
|
||||||
import "$std/dotenv/load.ts";
|
|
||||||
|
|
||||||
import { start } from "$fresh/server.ts";
|
|
||||||
import manifest from "./fresh.gen.ts";
|
|
||||||
import config from "./fresh.config.ts";
|
|
||||||
|
|
||||||
await start(manifest, config);
|
|
29
package.json
Normal file
29
package.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/mdx": "^4.2.6",
|
||||||
|
"@astrojs/node": "^9.2.1",
|
||||||
|
"@astrojs/preact": "^4.0.11",
|
||||||
|
"@preact/signals": "^2.0.4",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
|
"astro": "^5.7.13",
|
||||||
|
"astro-icon": "^1.1.5",
|
||||||
|
"lucide-preact": "^0.511.0",
|
||||||
|
"preact": "^10.26.6",
|
||||||
|
"tailwindcss": "^4.1.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify-json/mdi": "^1.2.3",
|
||||||
|
"@iconify-json/simple-icons": "^1.2.34",
|
||||||
|
"daisyui": "^5.0.35"
|
||||||
|
}
|
||||||
|
}
|
5429
pnpm-lock.yaml
generated
Normal file
5429
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,58 +0,0 @@
|
|||||||
---
|
|
||||||
title: Re-write it in Deno!
|
|
||||||
published_at: 2025-04-25T15:00:00.000Z
|
|
||||||
---
|
|
||||||
|
|
||||||
So... all new site! I use this site as something of an experiment for whatever
|
|
||||||
technology interests me. One recently got my attention:
|
|
||||||
|
|
||||||
_Deno!_
|
|
||||||
|
|
||||||
Yes... Deno the friendly re-imagining of Node.js by Ryan Dahl, the the creator
|
|
||||||
of Node.js! Now given my recent dive into Golang and HTMX, this might seem odd.
|
|
||||||
Let me explain:
|
|
||||||
|
|
||||||
## Built in TSX and Typescript support!
|
|
||||||
|
|
||||||
Deno has built in Typescript support, which has been huge for me. Lets table the
|
|
||||||
whole "Bun and new Node can do that too" discussion for now. Its simply magical
|
|
||||||
to have a Typescript codebase without a single tsconfig in sight!
|
|
||||||
|
|
||||||
## Security!
|
|
||||||
|
|
||||||
Look, the secrity model of Deno is incredible. The idea that the runtime will
|
|
||||||
default-deny permissions unless you as the developer enables them is an awesome
|
|
||||||
move.
|
|
||||||
|
|
||||||
## Imports
|
|
||||||
|
|
||||||
Being able to seamlessly pull from JSR and NPM is amazing! Having those two
|
|
||||||
ecosystems work without the frustrating package.json dance is refreshing. I
|
|
||||||
mean, just being able to import directly from URLs? Game changer!
|
|
||||||
|
|
||||||
## Built-in tooling
|
|
||||||
|
|
||||||
The tooling that ships with Deno is first-class! Formatter, linter, test runner,
|
|
||||||
doc generator... all built right in! No more spending half a day configuring
|
|
||||||
eslint, prettier, and jest just to get started on a project. Just use
|
|
||||||
`deno fmt`, `deno lint`, `deno test` and you're good to go.
|
|
||||||
|
|
||||||
## Web standards first
|
|
||||||
|
|
||||||
Fetch, web streams, event listeners and so on all work just like they do in the
|
|
||||||
browser. Coming from Node where you have to remember different APIs between
|
|
||||||
back-end and front-end JS, this is absolutely a breath of fresh air.
|
|
||||||
|
|
||||||
## What about Golang? GOTH Stack?
|
|
||||||
|
|
||||||
I still love Go as a language and will continue to use it for certain things.
|
|
||||||
Its incredibly fast and the concurrency model is awesome, but sometimes you just
|
|
||||||
want to put together a quick web application and JS frameworks are just much
|
|
||||||
easier to reach for.
|
|
||||||
|
|
||||||
## Whats next?
|
|
||||||
|
|
||||||
I'm rebuilding this entire site with Deno Fresh, which is their web framework.
|
|
||||||
It has their island architecture which means minimal JS sent to the client. Will
|
|
||||||
I stick with it? Who knows! All I know is this has definitely been a blast of a
|
|
||||||
re-write.
|
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@ -274,8 +274,8 @@
|
|||||||
"href": "https://git.atri.dad/atridad",
|
"href": "https://git.atri.dad/atridad",
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
"icon": "forgejo",
|
"icon": "gitea",
|
||||||
"network": "Forgejo",
|
"network": "Gitea",
|
||||||
"visible": true,
|
"visible": true,
|
||||||
"username": "atridad"
|
"username": "atridad"
|
||||||
}
|
}
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
@ -1,12 +0,0 @@
|
|||||||
import { Head } from "$fresh/runtime.ts";
|
|
||||||
|
|
||||||
export default function Error404() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>404 - Page not found</title>
|
|
||||||
</Head>
|
|
||||||
<h1>404 Page Not Found</h1>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
import { type PageProps } from "$fresh/server.ts";
|
|
||||||
export default function App({ Component }: PageProps) {
|
|
||||||
return (
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Atridad Lahiji</title>
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico"></link>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<Component />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,27 +0,0 @@
|
|||||||
import { PageProps } from "$fresh/server.ts";
|
|
||||||
import { Head } from "$fresh/runtime.ts";
|
|
||||||
import NavigationBar from "../islands/NavigationBar.tsx";
|
|
||||||
import ScrollUpButton from "../islands/ScrollUpButton.tsx";
|
|
||||||
|
|
||||||
export default function Layout({ Component, url }: PageProps) {
|
|
||||||
const currentPath = url.pathname;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<title>Atridad Lahiji</title>
|
|
||||||
</Head>
|
|
||||||
<body class="flex flex-col min-h-screen">
|
|
||||||
<main class="flex-grow flex flex-col gap-4 items-center justify-center">
|
|
||||||
<Component />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<NavigationBar currentPath={currentPath} />
|
|
||||||
<ScrollUpButton />
|
|
||||||
</body>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,85 +0,0 @@
|
|||||||
import { FreshContext } from "$fresh/server.ts";
|
|
||||||
|
|
||||||
const chatConnections = new Set<WebSocket>();
|
|
||||||
|
|
||||||
// HTML sanitization
|
|
||||||
function sanitizeText(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process and sanitize message object
|
|
||||||
function processChatMessage(message: string): string {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(message);
|
|
||||||
|
|
||||||
if (typeof parsed.text === "string") {
|
|
||||||
parsed.text = sanitizeText(parsed.text.trim().slice(0, 2000));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof parsed.sender === "string") {
|
|
||||||
parsed.sender = sanitizeText(parsed.sender.trim().slice(0, 50));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed.timestamp) {
|
|
||||||
parsed.timestamp = new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(parsed);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Invalid message format:", error);
|
|
||||||
return JSON.stringify({
|
|
||||||
text: "Error: Invalid message format",
|
|
||||||
sender: "System",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast current user count to all clients
|
|
||||||
function broadcastUserCount() {
|
|
||||||
const count = chatConnections.size;
|
|
||||||
const message = JSON.stringify({
|
|
||||||
type: "user_count",
|
|
||||||
count: count,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const client of chatConnections) {
|
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
|
||||||
client.send(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handler = (req: Request, _ctx: FreshContext): Response => {
|
|
||||||
const { socket, response } = Deno.upgradeWebSocket(req);
|
|
||||||
|
|
||||||
socket.onopen = () => {
|
|
||||||
chatConnections.add(socket);
|
|
||||||
console.log(`New connection: ${chatConnections.size} users connected`);
|
|
||||||
broadcastUserCount();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle messages
|
|
||||||
socket.onmessage = (event) => {
|
|
||||||
const sanitizedMessage = processChatMessage(event.data);
|
|
||||||
|
|
||||||
for (const client of chatConnections) {
|
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
|
||||||
client.send(sanitizedMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onclose = () => {
|
|
||||||
chatConnections.delete(socket);
|
|
||||||
console.log(`Connection closed: ${chatConnections.size} users connected`);
|
|
||||||
broadcastUserCount();
|
|
||||||
};
|
|
||||||
|
|
||||||
return response;
|
|
||||||
};
|
|
@ -1,5 +0,0 @@
|
|||||||
import { FreshContext } from "$fresh/server.ts";
|
|
||||||
|
|
||||||
export const handler = (_req: Request, _ctx: FreshContext): Response => {
|
|
||||||
return new Response("pong");
|
|
||||||
};
|
|
@ -1,25 +0,0 @@
|
|||||||
import Chat from "../islands/Chat.tsx";
|
|
||||||
|
|
||||||
export default function ChatPage() {
|
|
||||||
return (
|
|
||||||
<div class="min-h-screen p-4 pb-24">
|
|
||||||
<div class="flex items-center justify-center mb-6">
|
|
||||||
<h1 class="text-3xl font-bold text-secondary">Chat Room</h1>
|
|
||||||
<span class="ml-3 border border-pink-500 text-pink-500 rounded-full px-3 py-1 text-sm">
|
|
||||||
Demo
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="max-w-4xl mx-auto">
|
|
||||||
<Chat />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 text-center text-xs text-gray-500">
|
|
||||||
<p>
|
|
||||||
This is an ephemeral chat room. Messages are only visible to users
|
|
||||||
currently online and aren't stored after you leave.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,80 +0,0 @@
|
|||||||
import { render } from "@deno/gfm";
|
|
||||||
import { Handlers, PageProps } from "$fresh/server.ts";
|
|
||||||
|
|
||||||
import { getPost, Post } from "../../lib/posts.ts";
|
|
||||||
import { LuClock } from "@preact-icons/lu";
|
|
||||||
|
|
||||||
export const handler: Handlers<Post> = {
|
|
||||||
async GET(_req, ctx) {
|
|
||||||
try {
|
|
||||||
const post = await getPost(ctx.params.slug);
|
|
||||||
return post.publishedAt ? ctx.render(post) : ctx.renderNotFound();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
return ctx.renderNotFound();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PostPage(props: PageProps<Post>) {
|
|
||||||
const post = props.data;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="min-h-screen p-4 md:p-8">
|
|
||||||
<div class="max-w-3xl mx-auto">
|
|
||||||
<div class="p-4 md:p-8">
|
|
||||||
{/* Header section */}
|
|
||||||
<h1 class="text-4xl md:text-5xl font-bold text-primary mb-6">
|
|
||||||
{post.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-4 mb-6">
|
|
||||||
{/* Date with clock icon */}
|
|
||||||
<div class="flex items-center flex-row gap-2 text-base-content opacity-75">
|
|
||||||
<LuClock />
|
|
||||||
<time>
|
|
||||||
{post.publishedAt!.toLocaleDateString("en-us", {
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Back button */}
|
|
||||||
<a href="/posts" class="btn btn-outline btn-primary btn-sm">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5 mr-1"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M11 17l-5-5m0 0l5-5m-5 5h12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Back
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider line */}
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
{/* Content section */}
|
|
||||||
<div
|
|
||||||
class="max-w-none prose"
|
|
||||||
data-color-mode="dark"
|
|
||||||
data-dark-theme="dark"
|
|
||||||
// deno-lint-ignore react-no-danger
|
|
||||||
dangerouslySetInnerHTML={{ __html: render(post.content) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
import { Handlers, PageProps } from "$fresh/server.ts";
|
|
||||||
import { getPosts, Post } from "../lib/posts.ts";
|
|
||||||
import PostCard from "../components/PostCard.tsx";
|
|
||||||
|
|
||||||
export const handler: Handlers<Post[]> = {
|
|
||||||
async GET(_req, ctx) {
|
|
||||||
const posts = await getPosts();
|
|
||||||
return ctx.render(posts);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PostsPage(props: PageProps<Post[]>) {
|
|
||||||
const posts = props.data;
|
|
||||||
return (
|
|
||||||
<div class="min-h-screen p-4 sm:p-8">
|
|
||||||
<h1 class="text-3xl sm:text-4xl font-bold text-primary mb-6 sm:mb-8 text-center">
|
|
||||||
Posts
|
|
||||||
</h1>
|
|
||||||
<div class="flex flex-row flex-wrap justify-center gap-4 sm:gap-6 max-w-6xl mx-auto">
|
|
||||||
{posts.map((post) => <PostCard key={post.slug} post={post} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,60 +0,0 @@
|
|||||||
import ProjectCard from "../components/ProjectCard.tsx";
|
|
||||||
|
|
||||||
interface Project {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
link: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
|
||||||
const projects: Project[] = [
|
|
||||||
{
|
|
||||||
id: "bluesky-pds-manager",
|
|
||||||
name: "BlueSky PDS Manager",
|
|
||||||
description:
|
|
||||||
"A web-based BlueSky PDS Manager. Manage your invite codes and users with a simple web UI.",
|
|
||||||
link: "https://pdsman.atri.dad",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "pollo",
|
|
||||||
name: "Pollo",
|
|
||||||
description: "A dead-simple real-time voting tool.",
|
|
||||||
link: "https://git.atri.dad/atridad/pollo",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "goth-stack",
|
|
||||||
name: "GOTH Stack",
|
|
||||||
description:
|
|
||||||
"🚀 A Web Application Template Powered by HTMX + Go + Tailwind 🚀",
|
|
||||||
link: "https://git.atri.dad/atridad/goth.stack",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "himbot",
|
|
||||||
name: "Himbot",
|
|
||||||
description:
|
|
||||||
"A discord bot written in Go. Loosly named after my username online (HimbothySwaggins).",
|
|
||||||
link: "https://git.atri.dad/atridad/himbot",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "loadr",
|
|
||||||
name: "loadr",
|
|
||||||
description:
|
|
||||||
"A lightweight REST load testing tool with robust support for different verbs, token auth, and performance reports.",
|
|
||||||
link: "https://git.atri.dad/atridad/loadr",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="min-h-screen p-4 sm:p-8">
|
|
||||||
<h1 class="text-3xl sm:text-4xl font-bold text-secondary mb-6 sm:mb-8 text-center">
|
|
||||||
Projects
|
|
||||||
</h1>
|
|
||||||
<div class="flex flex-row flex-wrap justify-center gap-4 sm:gap-6 max-w-6xl mx-auto">
|
|
||||||
{projects.map((project) => (
|
|
||||||
<ProjectCard key={project.id} project={project} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,239 +0,0 @@
|
|||||||
import { Head } from "$fresh/runtime.ts";
|
|
||||||
import { Handlers, PageProps } from "$fresh/server.ts";
|
|
||||||
import {
|
|
||||||
LuMail,
|
|
||||||
LuGithub,
|
|
||||||
LuLinkedin,
|
|
||||||
LuGlobe,
|
|
||||||
LuGitBranch,
|
|
||||||
LuDownload,
|
|
||||||
} from "@preact-icons/lu";
|
|
||||||
|
|
||||||
interface ResumeData {
|
|
||||||
basics: {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
url?: { href: string };
|
|
||||||
};
|
|
||||||
sections: {
|
|
||||||
summary: { name: string; content: string };
|
|
||||||
profiles: { name: string; items: { network: string; username: string; url: { href: string } }[] };
|
|
||||||
skills: { name: string; items: { id: string; name: string; level: number }[] };
|
|
||||||
experience: { name: string; items: { id: string; company: string; position: string; date: string; location: string; summary: string; url?: { href: string } }[] };
|
|
||||||
education: { name: string; items: { id: string; institution: string; studyType: string; area: string; date: string; summary: string }[] };
|
|
||||||
volunteer: { name: string; items: { id: string; organization: string; position: string; date: string /* No summary here */ }[] };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handler: Handlers<ResumeData> = {
|
|
||||||
async GET(_req, ctx) {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(new URL("/files/resume.json", ctx.url).href);
|
|
||||||
if (!resp.ok) {
|
|
||||||
console.error(`Error fetching resume.json: ${resp.status} ${resp.statusText}`);
|
|
||||||
return ctx.render(undefined);
|
|
||||||
}
|
|
||||||
const resumeData: ResumeData = await resp.json();
|
|
||||||
const skillsSection = resumeData.sections.skills;
|
|
||||||
if (skillsSection && skillsSection.items) {
|
|
||||||
const tsSkill = skillsSection.items.find(s => s.name === "Typescrpt");
|
|
||||||
if (tsSkill) {
|
|
||||||
tsSkill.name = "Typescript";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ctx.render(resumeData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error processing resume data:", error);
|
|
||||||
return ctx.render(undefined);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ResumePage({ data }: PageProps<ResumeData | undefined>) {
|
|
||||||
if (!data) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head><title>Error Loading Resume</title></Head>
|
|
||||||
<div class="container mx-auto p-4 max-w-4xl text-center">
|
|
||||||
<h1 class="text-2xl font-bold text-error">Error loading resume data.</h1>
|
|
||||||
<p>Please try refreshing the page.</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { basics, sections } = data;
|
|
||||||
const { summary, profiles, skills, experience, education, volunteer } = sections;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{basics.name} - Resume</title>
|
|
||||||
<meta name="description" content={`${basics.name}'s professional resume.`} />
|
|
||||||
</Head>
|
|
||||||
<div class="container mx-auto p-4 max-w-4xl">
|
|
||||||
<h1 class="text-4xl font-bold mb-6 text-center">{basics.name}</h1>
|
|
||||||
|
|
||||||
{/* Contact Info */}
|
|
||||||
<div class="flex justify-center items-center flex-wrap gap-x-4 gap-y-2 mb-6">
|
|
||||||
{basics.email && (
|
|
||||||
<a href={`mailto:${basics.email}`} class="link link-hover inline-flex items-center gap-1">
|
|
||||||
<LuMail /> {basics.email}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{profiles.items.find(p => p.network === "GitHub") && (
|
|
||||||
<a href={profiles.items.find(p => p.network === "GitHub")!.url.href} target="_blank" rel="noopener noreferrer" class="link link-hover inline-flex items-center gap-1">
|
|
||||||
<LuGithub /> GitHub
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{profiles.items.find(p => p.network === "linkedin") && (
|
|
||||||
<a href={profiles.items.find(p => p.network === "linkedin")!.url.href} target="_blank" rel="noopener noreferrer" class="link link-hover inline-flex items-center gap-1">
|
|
||||||
<LuLinkedin /> LinkedIn
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Download Resume Button */}
|
|
||||||
<div class="text-center mb-8">
|
|
||||||
<a
|
|
||||||
href="/files/Atridad_Lahiji_Resume_Public.pdf"
|
|
||||||
download="Atridad_Lahiji_Resume.pdf"
|
|
||||||
class="btn btn-primary inline-flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<LuDownload /> Download Resume (PDF)
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary Card */}
|
|
||||||
{summary && (
|
|
||||||
<div class="card bg-base-200 shadow-xl mb-6">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-2xl">{summary.name || "Summary"}</h2>
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: summary.content }}></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Profiles Card */}
|
|
||||||
{profiles && profiles.items && profiles.items.length > 0 && (
|
|
||||||
<div class="card bg-base-200 shadow-xl mb-6">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-2xl">{profiles.name || "Profiles"}</h2>
|
|
||||||
<div class="flex flex-wrap gap-4">
|
|
||||||
{profiles.items.map((profile) => {
|
|
||||||
let IconComponent = LuGlobe;
|
|
||||||
const networkLower = profile.network.toLowerCase();
|
|
||||||
if (networkLower === "github") IconComponent = LuGithub;
|
|
||||||
else if (networkLower === "linkedin") IconComponent = LuLinkedin;
|
|
||||||
else if (networkLower === "forgejo") IconComponent = LuGitBranch;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={profile.network}
|
|
||||||
href={profile.url.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="link link-hover inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<IconComponent /> {profile.network} ({profile.username})
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Skills Card */}
|
|
||||||
{skills && skills.items && skills.items.length > 0 && (
|
|
||||||
<div class="card bg-base-200 shadow-xl mb-6">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-2xl">{skills.name || "Skills"}</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{skills.items.map((skill) => (
|
|
||||||
<div key={skill.id || skill.name}>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">{skill.name}</span>
|
|
||||||
</label>
|
|
||||||
<progress
|
|
||||||
class="progress progress-primary w-full"
|
|
||||||
value={skill.level * 20}
|
|
||||||
max="100"
|
|
||||||
>
|
|
||||||
</progress>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Experience Card */}
|
|
||||||
{experience && experience.items && experience.items.length > 0 && (
|
|
||||||
<div class="card bg-base-200 shadow-xl mb-6">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-2xl">{experience.name || "Experience"}</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
{experience.items.map((exp, index) => (
|
|
||||||
<div key={exp.id || index} class="collapse collapse-arrow bg-base-100">
|
|
||||||
<input type="radio" name="resume-accordion-experience" checked={index === 0} readOnly />
|
|
||||||
<div class="collapse-title text-xl font-medium">
|
|
||||||
{exp.position} at {exp.company} ({exp.date})
|
|
||||||
{exp.location && (
|
|
||||||
<span class="text-sm font-normal float-right pt-1">{exp.location}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div class="collapse-content">
|
|
||||||
{exp.url && exp.url.href && (
|
|
||||||
<a href={exp.url.href} target="_blank" rel="noopener noreferrer" class="link link-primary block mb-2">{exp.url.href}</a>
|
|
||||||
)}
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: exp.summary }}></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Education Card */}
|
|
||||||
{education && education.items && education.items.length > 0 && (
|
|
||||||
<div class="card bg-base-200 shadow-xl mb-6">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-2xl">{education.name || "Education"}</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
{education.items.map((edu, index) => (
|
|
||||||
<div key={edu.id || index}>
|
|
||||||
<h3 class="text-lg font-semibold">{edu.institution}</h3>
|
|
||||||
<p>{edu.studyType} - {edu.area} ({edu.date})</p>
|
|
||||||
{edu.summary && (
|
|
||||||
<div class="ml-4 text-sm mt-1" dangerouslySetInnerHTML={{ __html: edu.summary }}></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Volunteering Card */}
|
|
||||||
{volunteer && volunteer.items && volunteer.items.length > 0 && (
|
|
||||||
<div class="card bg-base-200 shadow-xl mb-6">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-2xl">{volunteer.name || "Volunteering"}</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
{volunteer.items.map((vol, index) => (
|
|
||||||
<div key={vol.id || index}>
|
|
||||||
<h3 class="text-lg font-semibold">{vol.organization}</h3>
|
|
||||||
<p>{vol.position} ({vol.date})</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,12 +1,6 @@
|
|||||||
import { useComputed, useSignal } from "@preact/signals";
|
import { useComputed, useSignal } from "@preact/signals";
|
||||||
import { useEffect } from "preact/hooks";
|
import { useEffect } from "preact/hooks";
|
||||||
import {
|
import { Home, NotebookPen, FileText, CodeXml } from 'lucide-preact';
|
||||||
LuCodeXml,
|
|
||||||
LuHouse,
|
|
||||||
LuMessageCircle,
|
|
||||||
LuNotebookPen,
|
|
||||||
LuFileText,
|
|
||||||
} from "@preact-icons/lu";
|
|
||||||
|
|
||||||
interface NavigationBarProps {
|
interface NavigationBarProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
@ -27,7 +21,7 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let scrollTimer: number | undefined;
|
let scrollTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
isScrolling.value = true;
|
isScrolling.value = true;
|
||||||
@ -59,7 +53,7 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
|
|||||||
<li class="mx-1">
|
<li class="mx-1">
|
||||||
<a href="/" class={currentPath === "/" ? "menu-active" : ""}>
|
<a href="/" class={currentPath === "/" ? "menu-active" : ""}>
|
||||||
<div class="tooltip" data-tip="Home">
|
<div class="tooltip" data-tip="Home">
|
||||||
<LuHouse class="text-xl" />
|
<Home />
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@ -70,7 +64,7 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
|
|||||||
class={isPostsPath(currentPath) ? "menu-active" : ""}
|
class={isPostsPath(currentPath) ? "menu-active" : ""}
|
||||||
>
|
>
|
||||||
<div class="tooltip" data-tip="Posts">
|
<div class="tooltip" data-tip="Posts">
|
||||||
<LuNotebookPen class="text-xl" />
|
<NotebookPen />
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@ -81,7 +75,7 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
|
|||||||
class={currentPath === "/resume" ? "menu-active" : ""}
|
class={currentPath === "/resume" ? "menu-active" : ""}
|
||||||
>
|
>
|
||||||
<div class="tooltip" data-tip="Resume">
|
<div class="tooltip" data-tip="Resume">
|
||||||
<LuFileText class="text-xl" />
|
<FileText />
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@ -92,18 +86,7 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
|
|||||||
class={currentPath.startsWith("/projects") ? "menu-active" : ""}
|
class={currentPath.startsWith("/projects") ? "menu-active" : ""}
|
||||||
>
|
>
|
||||||
<div class="tooltip" data-tip="Projects">
|
<div class="tooltip" data-tip="Projects">
|
||||||
<LuCodeXml class="text-xl" />
|
<CodeXml />
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="mx-1">
|
|
||||||
<a
|
|
||||||
href="/chat"
|
|
||||||
class={currentPath.startsWith("/chat") ? "menu-active" : ""}
|
|
||||||
>
|
|
||||||
<div class="tooltip" data-tip="Chat">
|
|
||||||
<LuMessageCircle class="text-xl" />
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
55
src/components/PostCard.astro
Normal file
55
src/components/PostCard.astro
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
import type { CollectionEntry } from 'astro:content';
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
export interface Props {
|
||||||
|
post: CollectionEntry<'posts'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { post } = Astro.props;
|
||||||
|
const { title, description: blurb, pubDate } = post.data;
|
||||||
|
const { slug } = post;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="card bg-accent shadow-lg w-full sm:w-[calc(50%-1rem)] md:w-96 min-w-[280px] max-w-sm shrink">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<h2 class="card-title text-base-100 justify-center text-center break-words font-bold text-lg mb-2">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-center text-base-100 break-words mb-3 text-base">
|
||||||
|
{blurb || 'No description available.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-center text-base-100 opacity-75 gap-2 text-sm mb-2">
|
||||||
|
<Icon name="mdi:clock" class="text-xl" />
|
||||||
|
<span>
|
||||||
|
{new Date(pubDate).toLocaleDateString("en-us", {
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{post.data.tags && post.data.tags.length > 0 && (
|
||||||
|
<div class="flex gap-2 flex-wrap mb-2 justify-center">
|
||||||
|
{post.data.tags.map((tag: string) => (
|
||||||
|
<span class="flex items-center flex-row gap-2 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-sm">
|
||||||
|
<Icon name="mdi:tag" class="text-lg" />
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="card-actions justify-end mt-2">
|
||||||
|
<a
|
||||||
|
href={`/post/${slug}`}
|
||||||
|
class="btn btn-circle btn-sm bg-base-100 hover:bg-base-200 text-accent"
|
||||||
|
aria-label={`Read more about ${title}`}
|
||||||
|
>
|
||||||
|
<Icon name="mdi:arrow-right" class="text-lg" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
40
src/components/ProjectCard.astro
Normal file
40
src/components/ProjectCard.astro
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { project } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="card bg-accent shadow-lg w-full sm:w-[calc(50%-1rem)] md:w-96 min-w-[280px] max-w-sm shrink">
|
||||||
|
<div class="card-body p-6">
|
||||||
|
<h2 class="card-title text-xl md:text-2xl font-bold justify-center text-center break-words text-base-100">
|
||||||
|
{project.name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-center break-words my-4 text-base-100">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end mt-4">
|
||||||
|
<a
|
||||||
|
href={project.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn btn-circle btn-sm bg-base-100 hover:bg-base-200 text-accent"
|
||||||
|
aria-label={`Visit ${project.name}`}
|
||||||
|
>
|
||||||
|
<Icon name="mdi:link" class="text-lg" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -1,26 +1,26 @@
|
|||||||
import { useSignal } from "@preact/signals";
|
import { useSignal } from "@preact/signals";
|
||||||
import { useEffect } from "preact/hooks";
|
import { useEffect } from "preact/hooks";
|
||||||
import { LuArrowUp } from "@preact-icons/lu";
|
import { ArrowUp } from 'lucide-preact';
|
||||||
|
|
||||||
export default function ScrollUpButton() {
|
export default function ScrollUpButton() {
|
||||||
const isVisible = useSignal(false);
|
const isVisible = useSignal(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkScroll = () => {
|
const checkScroll = () => {
|
||||||
isVisible.value = globalThis.scrollY > 300;
|
isVisible.value = window.scrollY > 300;
|
||||||
};
|
};
|
||||||
|
|
||||||
checkScroll();
|
checkScroll();
|
||||||
|
|
||||||
globalThis.addEventListener("scroll", checkScroll);
|
window.addEventListener("scroll", checkScroll);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
globalThis.removeEventListener("scroll", checkScroll);
|
window.removeEventListener("scroll", checkScroll);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scrollToTop = () => {
|
const scrollToTop = () => {
|
||||||
globalThis.scrollTo({
|
window.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
@ -39,7 +39,7 @@ export default function ScrollUpButton() {
|
|||||||
}`}
|
}`}
|
||||||
aria-label="Scroll to top"
|
aria-label="Scroll to top"
|
||||||
>
|
>
|
||||||
<LuArrowUp class="text-lg" />
|
<ArrowUp class="text-lg" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
41
src/components/SocialLinks.astro
Normal file
41
src/components/SocialLinks.astro
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-4 text-xl sm:text-3xl">
|
||||||
|
<a
|
||||||
|
href="mailto:me@atri.dad"
|
||||||
|
aria-label="Email me"
|
||||||
|
class="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:email" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/feed"
|
||||||
|
aria-label="RSS Feed"
|
||||||
|
class="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:rss" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://git.atri.dad/atridad"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Forgejo (Git)"
|
||||||
|
class="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="simple-icons:gitea" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://bsky.app/profile/atri.dad"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Bluesky Profile"
|
||||||
|
class="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="simple-icons:bluesky" />
|
||||||
|
</a>
|
||||||
|
</div>
|
74
src/components/TechLinks.astro
Normal file
74
src/components/TechLinks.astro
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
---
|
||||||
|
<div class="flex flex-row gap-4 text-xl sm:text-3xl">
|
||||||
|
<a
|
||||||
|
href="https://react.dev/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="React"
|
||||||
|
class="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="simple-icons:react" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://www.typescriptlang.org/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="TypeScript"
|
||||||
|
class="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="simple-icons:typescript" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://astro.build/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Deno"
|
||||||
|
class="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="simple-icons:astro" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://go.dev/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Go"
|
||||||
|
class="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="simple-icons:go" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://www.postgresql.org/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="PostgreSQL"
|
||||||
|
class="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="simple-icons:postgresql" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://redis.io/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Redis"
|
||||||
|
class="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="simple-icons:redis" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://www.docker.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Docker"
|
||||||
|
class="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="simple-icons:docker" />
|
||||||
|
</a>
|
||||||
|
</div>
|
16
src/content/config.ts
Normal file
16
src/content/config.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { defineCollection, z } from 'astro:content';
|
||||||
|
|
||||||
|
const postsCollection = defineCollection({
|
||||||
|
type: 'content',
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
pubDate: z.coerce.date(),
|
||||||
|
updatedDate: z.coerce.date().optional(),
|
||||||
|
tags: z.array(z.string()).default([]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
'posts': postsCollection,
|
||||||
|
};
|
@ -1,6 +1,8 @@
|
|||||||
---
|
---
|
||||||
title: Current List of Favourite Tools
|
title: "Current List of Favourite Tools"
|
||||||
published_at: 2025-01-28T15:00:00.000Z
|
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
|
I change what I use _constantly_ in order to find something that feels just
|
@ -1,10 +1,12 @@
|
|||||||
---
|
---
|
||||||
title: Welcome!
|
title: "Welcome"
|
||||||
published_at: 2024-10-20T15:00:00.000Z
|
description: "Welcome to my website! :)"
|
||||||
|
pubDate: "2024-10-20"
|
||||||
|
tags: ["meta"]
|
||||||
---
|
---
|
||||||
|
|
||||||
Welcome to my site! This is a place for me to share my thoughts and updates on
|
Welcome to my site! This is a place for me to share my thoughts and updates on
|
||||||
my projects. I hope you find something interesting here.
|
my projects. I hope you find something interesting here.
|
||||||
|
|
||||||
Feel free to reach out if you have any questions or comments. I'd love to hear
|
Feel free to reach out if you have any questions or comments. I'd love to hear
|
||||||
from you! I can be reached by email at [me@atri.dad](mailto:me@atri.dad).
|
from you! I can be reached by email at [me@atri.dad](mailto:me@atri.dad).
|
26
src/layouts/Layout.astro
Normal file
26
src/layouts/Layout.astro
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
import { ClientRouter } from "astro:transitions";
|
||||||
|
import NavigationBar from "../components/NavigationBar";
|
||||||
|
import ScrollUpButton from "../components/ScrollUpButton";
|
||||||
|
const currentPath = Astro.url.pathname;
|
||||||
|
import '../styles/global.css';
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
<title>Atridad Lahiji</title>
|
||||||
|
<ClientRouter />
|
||||||
|
</head>
|
||||||
|
<body class="flex flex-col min-h-screen">
|
||||||
|
<main class="flex-grow flex flex-col gap-4 items-center justify-center">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<NavigationBar client:load currentPath={currentPath} />
|
||||||
|
<ScrollUpButton client:load />
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -1,11 +1,11 @@
|
|||||||
import HomeButtonLinks from "../components/HomeButtonLinks.tsx";
|
---
|
||||||
import SocialLinks from "../components/SocialLinks.tsx";
|
import SocialLinks from '../components/SocialLinks.astro';
|
||||||
import TechLinks from "../components/TechLinks.tsx";
|
import TechLinks from '../components/TechLinks.astro';
|
||||||
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
---
|
||||||
|
|
||||||
export default function Home() {
|
<Layout>
|
||||||
return (
|
<img
|
||||||
<>
|
|
||||||
<img
|
|
||||||
src="/logo.webp"
|
src="/logo.webp"
|
||||||
alt="A drawing of Atridad Lahiji by Shelze!"
|
alt="A drawing of Atridad Lahiji by Shelze!"
|
||||||
height={150}
|
height={150}
|
||||||
@ -28,7 +28,5 @@ export default function Home() {
|
|||||||
|
|
||||||
<TechLinks />
|
<TechLinks />
|
||||||
|
|
||||||
<HomeButtonLinks />
|
<!-- <HomeButtonLinks /> -->
|
||||||
</>
|
</Layout>
|
||||||
);
|
|
||||||
}
|
|
62
src/pages/post/[...slug].astro
Normal file
62
src/pages/post/[...slug].astro
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import Layout from '../../layouts/Layout.astro';
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const posts = await getCollection('posts');
|
||||||
|
return posts.map((post: CollectionEntry<'posts'>) => ({
|
||||||
|
params: { slug: post.slug },
|
||||||
|
props: { post },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { post }: { post: CollectionEntry<'posts'> } = Astro.props;
|
||||||
|
const { Content } = await post.render();
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
<div class="min-h-screen p-4 md:p-8">
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="p-4 md:p-8">
|
||||||
|
<h1 class="text-4xl md:text-5xl font-bold text-primary mb-6">
|
||||||
|
{post.data.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-4 mb-6">
|
||||||
|
<div class="flex items-center flex-row gap-2 text-base-content opacity-75">
|
||||||
|
<Icon name="mdi:clock" class="text-xl" />
|
||||||
|
<time datetime={post.data.pubDate.toISOString()}>
|
||||||
|
{new Date(post.data.pubDate).toLocaleDateString('en-us', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Back button */}
|
||||||
|
<a href="/posts" class="btn btn-outline btn-primary btn-sm">
|
||||||
|
<Icon name="mdi:arrow-left" class="text-lg" />
|
||||||
|
Back
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{post.data.tags && post.data.tags.length > 0 && (
|
||||||
|
<div class="flex gap-2 flex-wrap mb-6">
|
||||||
|
{post.data.tags.map((tag: string) => (
|
||||||
|
<span class="flex items-center flex-row gap-2 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-sm">
|
||||||
|
<Icon name="mdi:tag" class="text-lg" />
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<article class="prose prose-lg dark:prose-invert max-w-none mt-6">
|
||||||
|
<Content />
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
30
src/pages/posts.astro
Normal file
30
src/pages/posts.astro
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
import { getCollection, type CollectionEntry } from "astro:content";
|
||||||
|
import PostCard from "../components/PostCard.astro";
|
||||||
|
|
||||||
|
// Get all posts from the content collection
|
||||||
|
const posts = await getCollection("posts");
|
||||||
|
|
||||||
|
// Sort posts by date, newest first
|
||||||
|
const sortedPosts = posts.sort(
|
||||||
|
(a: CollectionEntry<"posts">, b: CollectionEntry<"posts">) => new Date(b.data.pubDate).valueOf() - new Date(a.data.pubDate).valueOf()
|
||||||
|
);
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
<div class="min-h-screen p-4 sm:p-8">
|
||||||
|
<h1 class="text-3xl sm:text-4xl font-bold text-primary mb-6 sm:mb-8 text-center">
|
||||||
|
Posts
|
||||||
|
</h1>
|
||||||
|
<div class="flex flex-row flex-wrap justify-center gap-4 sm:gap-6 max-w-6xl mx-auto">
|
||||||
|
{sortedPosts.map((post: CollectionEntry<"posts">) => (
|
||||||
|
<PostCard post={post} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sortedPosts.length === 0 && (
|
||||||
|
<p class="text-center text-gray-500 mt-12">No posts available yet. Check back soon!</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
58
src/pages/projects.astro
Normal file
58
src/pages/projects.astro
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
import ProjectCard from "../components/ProjectCard.astro";
|
||||||
|
|
||||||
|
const projects = [
|
||||||
|
{
|
||||||
|
id: "bluesky-pds-manager",
|
||||||
|
name: "BlueSky PDS Manager",
|
||||||
|
description:
|
||||||
|
"A web-based BlueSky PDS Manager. Manage your invite codes and users with a simple web UI.",
|
||||||
|
link: "https://pdsman.atri.dad",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pollo",
|
||||||
|
name: "Pollo",
|
||||||
|
description: "A dead-simple real-time voting tool.",
|
||||||
|
link: "https://git.atri.dad/atridad/pollo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "goth-stack",
|
||||||
|
name: "GOTH Stack",
|
||||||
|
description:
|
||||||
|
"🚀 A Web Application Template Powered by HTMX + Go + Tailwind 🚀",
|
||||||
|
link: "https://git.atri.dad/atridad/goth.stack",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "himbot",
|
||||||
|
name: "Himbot",
|
||||||
|
description:
|
||||||
|
"A discord bot written in Go. Loosly named after my username online (HimbothySwaggins).",
|
||||||
|
link: "https://git.atri.dad/atridad/himbot",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "loadr",
|
||||||
|
name: "loadr",
|
||||||
|
description:
|
||||||
|
"A lightweight REST load testing tool with robust support for different verbs, token auth, and performance reports.",
|
||||||
|
link: "https://git.atri.dad/atridad/loadr",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
<div class="min-h-screen p-4 sm:p-8">
|
||||||
|
<h1 class="text-3xl sm:text-4xl font-bold text-secondary mb-6 sm:mb-8 text-center">
|
||||||
|
Projects
|
||||||
|
</h1>
|
||||||
|
<div class="flex flex-row flex-wrap justify-center gap-4 sm:gap-6 max-w-6xl mx-auto">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ProjectCard project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projects.length === 0 && (
|
||||||
|
<p class="text-center text-gray-500 mt-12">No projects available yet. Check back soon!</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
232
src/pages/resume.astro
Normal file
232
src/pages/resume.astro
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
import '../styles/global.css';
|
||||||
|
|
||||||
|
interface ResumeData {
|
||||||
|
basics: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
url?: { href: string };
|
||||||
|
};
|
||||||
|
sections: {
|
||||||
|
summary: { name: string; content: string };
|
||||||
|
profiles: { name: string; items: { network: string; username: string; url: { href: string } }[] };
|
||||||
|
skills: { name: string; items: { id: string; name: string; level: number }[] };
|
||||||
|
experience: { name: string; items: { id: string; company: string; position: string; date: string; location: string; summary: string; url?: { href: string } }[] };
|
||||||
|
education: { name: string; items: { id: string; institution: string; studyType: string; area: string; date: string; summary: string }[] };
|
||||||
|
volunteer: { name: string; items: { id: string; organization: string; position: string; date: string }[] };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let resumeData: ResumeData | undefined = undefined;
|
||||||
|
let fetchError: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let resumeJson: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
resumeJson = await readFile(join(process.cwd(), 'public', 'files', 'resume.json'), 'utf-8');
|
||||||
|
} catch (err) {
|
||||||
|
resumeJson = await readFile('/Users/atridad/Downloads/resume.json', 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
resumeData = JSON.parse(resumeJson);
|
||||||
|
|
||||||
|
if (resumeData && resumeData.sections && resumeData.sections.skills) {
|
||||||
|
const skillsSection = resumeData.sections.skills;
|
||||||
|
if (skillsSection.items) {
|
||||||
|
const tsSkill = skillsSection.items.find(s => s.name === "Typescrpt");
|
||||||
|
if (tsSkill) {
|
||||||
|
tsSkill.name = "Typescript";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing resume data:", error);
|
||||||
|
fetchError = "An error occurred while processing resume data. Please make sure the resume.json file is in the correct location.";
|
||||||
|
resumeData = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = resumeData;
|
||||||
|
---
|
||||||
|
|
||||||
|
{(!data || fetchError) && (
|
||||||
|
<Layout>
|
||||||
|
<div class="container mx-auto p-4 max-w-4xl text-center">
|
||||||
|
<h1 class="text-2xl font-bold text-red-600">Error loading resume data.</h1>
|
||||||
|
<p>{fetchError || "Please try refreshing the page."}</p>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && !fetchError && (
|
||||||
|
<Layout>
|
||||||
|
<div class="container mx-auto p-4 max-w-4xl">
|
||||||
|
<h1 class="text-4xl font-bold mb-6 text-center">{data.basics.name}</h1>
|
||||||
|
|
||||||
|
<div class="flex justify-center items-center flex-wrap gap-x-4 gap-y-2 mb-6">
|
||||||
|
{data.basics.email && (
|
||||||
|
<a href={`mailto:${data.basics.email}`} class="link link-hover inline-flex items-center gap-1">
|
||||||
|
<Icon name="mdi:email" /> {data.basics.email}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{data.sections.profiles.items.find(p => p.network === "GitHub") && (
|
||||||
|
<a href={data.sections.profiles.items.find(p => p.network === "GitHub")!.url.href} target="_blank" rel="noopener noreferrer" class="link link-hover inline-flex items-center gap-1">
|
||||||
|
<Icon name="simple-icons:github" /> GitHub
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{data.sections.profiles.items.find(p => p.network === "linkedin") && (
|
||||||
|
<a href={data.sections.profiles.items.find(p => p.network === "linkedin")!.url.href} target="_blank" rel="noopener noreferrer" class="link link-hover inline-flex items-center gap-1">
|
||||||
|
<Icon name="simple-icons:linkedin" /> LinkedIn
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<a
|
||||||
|
href="/files/Atridad_Lahiji_Resume.pdf"
|
||||||
|
download="Atridad_Lahiji_Resume.pdf"
|
||||||
|
class="btn btn-primary inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:download" /> Download Resume (PDF)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.sections.summary && (
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl">{data.sections.summary.name || "Summary"}</h2>
|
||||||
|
<div set:html={data.sections.summary.content}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.sections.profiles && data.sections.profiles.items && data.sections.profiles.items.length > 0 && (
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl">{data.sections.profiles.name || "Profiles"}</h2>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
{data.sections.profiles.items.map((profile) => {
|
||||||
|
let iconName = "mdi:web";
|
||||||
|
const networkLower = profile.network.toLowerCase();
|
||||||
|
if (networkLower === "github") iconName = "simple-icons:github";
|
||||||
|
else if (networkLower === "linkedin") iconName = "simple-icons:linkedin";
|
||||||
|
else if (networkLower === "gitea") iconName = "simple-icons:gitea";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={profile.url.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="link link-hover inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Icon name={iconName} /> {profile.network} ({profile.username})
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.sections.skills && data.sections.skills.items && data.sections.skills.items.length > 0 && (
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl">{data.sections.skills.name || "Skills"}</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{data.sections.skills.items.map((skill) => (
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">{skill.name}</span>
|
||||||
|
</label>
|
||||||
|
<progress
|
||||||
|
class="progress progress-primary w-full"
|
||||||
|
value={skill.level * 20}
|
||||||
|
max="100"
|
||||||
|
>
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.sections.experience && data.sections.experience.items && data.sections.experience.items.length > 0 && (
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl">{data.sections.experience.name || "Experience"}</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{data.sections.experience.items.map((exp, index) => (
|
||||||
|
<details class="collapse collapse-arrow bg-base-100" open={index === 0 ? true : undefined}>
|
||||||
|
<summary class="collapse-title text-xl font-medium">
|
||||||
|
{exp.position} at {exp.company} ({exp.date})
|
||||||
|
{exp.location && (
|
||||||
|
<span class="text-sm font-normal float-right pt-1">{exp.location}</span>
|
||||||
|
)}
|
||||||
|
</summary>
|
||||||
|
<div class="collapse-content">
|
||||||
|
{exp.url && exp.url.href && (
|
||||||
|
<a href={exp.url.href} target="_blank" rel="noopener noreferrer" class="link link-primary block mb-2">{exp.url.href}</a>
|
||||||
|
)}
|
||||||
|
<div class="mt-2">
|
||||||
|
<ul class="list">
|
||||||
|
{exp.summary.replace(/<\/?ul>|<\/?p>/g, '')
|
||||||
|
.split('<li>')
|
||||||
|
.filter(item => item.trim() !== '')
|
||||||
|
.map(item => (
|
||||||
|
<li class="list-row">
|
||||||
|
{item.replace('</li>', '')}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.sections.education && data.sections.education.items && data.sections.education.items.length > 0 && (
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl">{data.sections.education.name || "Education"}</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{data.sections.education.items.map((edu, index) => (
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold">{edu.institution}</h3>
|
||||||
|
<p>{edu.studyType} - {edu.area} ({edu.date})</p>
|
||||||
|
{edu.summary && (
|
||||||
|
<div class="ml-4 text-sm mt-1" set:html={edu.summary}></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.sections.volunteer && data.sections.volunteer.items && data.sections.volunteer.items.length > 0 && (
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl">{data.sections.volunteer.name || "Volunteering"}</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{data.sections.volunteer.items.map((vol, index) => (
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold">{vol.organization}</h3>
|
||||||
|
<p>{vol.position} ({vol.date})</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
)}
|
37
src/styles/global.css
Normal file
37
src/styles/global.css
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "daisyui";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
name: "chaoticbisexual";
|
||||||
|
default: true;
|
||||||
|
prefersdark: true;
|
||||||
|
color-scheme: "dark";
|
||||||
|
--color-base-100: oklch(25.33% 0.016 252.42);
|
||||||
|
--color-base-200: oklch(23.26% 0.014 253.1);
|
||||||
|
--color-base-300: oklch(21.15% 0.012 254.09);
|
||||||
|
--color-base-content: oklch(97.807% 0.029 256.847);
|
||||||
|
--color-primary: oklch(65% 0.241 354.308);
|
||||||
|
--color-primary-content: oklch(96% 0.018 272.314);
|
||||||
|
--color-secondary: oklch(60% 0.25 292.717);
|
||||||
|
--color-secondary-content: oklch(94% 0.028 342.258);
|
||||||
|
--color-accent: oklch(78% 0.154 211.53);
|
||||||
|
--color-accent-content: oklch(38% 0.063 188.416);
|
||||||
|
--color-neutral: oklch(40% 0.17 325.612);
|
||||||
|
--color-neutral-content: oklch(92% 0.004 286.32);
|
||||||
|
--color-info: oklch(74% 0.16 232.661);
|
||||||
|
--color-info-content: oklch(29% 0.066 243.157);
|
||||||
|
--color-success: oklch(76% 0.177 163.223);
|
||||||
|
--color-success-content: oklch(37% 0.077 168.94);
|
||||||
|
--color-warning: oklch(82% 0.189 84.429);
|
||||||
|
--color-warning-content: oklch(41% 0.112 45.904);
|
||||||
|
--color-error: oklch(71% 0.194 13.428);
|
||||||
|
--color-error-content: oklch(27% 0.105 12.094);
|
||||||
|
--radius-selector: 1rem;
|
||||||
|
--radius-field: 1rem;
|
||||||
|
--radius-box: 1rem;
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
--border: 1px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 1;
|
||||||
|
}
|
@ -1,37 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@plugin "daisyui";
|
|
||||||
@plugin "@tailwindcss/typography";
|
|
||||||
@plugin "daisyui/theme" {
|
|
||||||
name: "chaoticbisexual";
|
|
||||||
default: true;
|
|
||||||
prefersdark: true;
|
|
||||||
color-scheme: "dark";
|
|
||||||
--color-base-100: oklch(25.33% 0.016 252.42);
|
|
||||||
--color-base-200: oklch(23.26% 0.014 253.1);
|
|
||||||
--color-base-300: oklch(21.15% 0.012 254.09);
|
|
||||||
--color-base-content: oklch(97.807% 0.029 256.847);
|
|
||||||
--color-primary: oklch(65% 0.241 354.308);
|
|
||||||
--color-primary-content: oklch(96% 0.018 272.314);
|
|
||||||
--color-secondary: oklch(60% 0.25 292.717);
|
|
||||||
--color-secondary-content: oklch(94% 0.028 342.258);
|
|
||||||
--color-accent: oklch(78% 0.154 211.53);
|
|
||||||
--color-accent-content: oklch(38% 0.063 188.416);
|
|
||||||
--color-neutral: oklch(40% 0.17 325.612);
|
|
||||||
--color-neutral-content: oklch(92% 0.004 286.32);
|
|
||||||
--color-info: oklch(74% 0.16 232.661);
|
|
||||||
--color-info-content: oklch(29% 0.066 243.157);
|
|
||||||
--color-success: oklch(76% 0.177 163.223);
|
|
||||||
--color-success-content: oklch(37% 0.077 168.94);
|
|
||||||
--color-warning: oklch(82% 0.189 84.429);
|
|
||||||
--color-warning-content: oklch(41% 0.112 45.904);
|
|
||||||
--color-error: oklch(71% 0.194 13.428);
|
|
||||||
--color-error-content: oklch(27% 0.105 12.094);
|
|
||||||
--radius-selector: 1rem;
|
|
||||||
--radius-field: 1rem;
|
|
||||||
--radius-box: 1rem;
|
|
||||||
--size-selector: 0.25rem;
|
|
||||||
--size-field: 0.25rem;
|
|
||||||
--border: 1px;
|
|
||||||
--depth: 1;
|
|
||||||
--noise: 1;
|
|
||||||
}
|
|
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
".astro/types.d.ts",
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
"src/**/*.astro"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
Reference in New Issue
Block a user