initial commit
All checks were successful
Generate a build and push to Cloudflare Pages / Build and Deploy to Cloudflare Pages (push) Successful in 1m24s

This commit is contained in:
2026-03-25 18:53:21 -04:00
parent 2d19da4ef5
commit d21806dfd1
532 changed files with 11274 additions and 72849 deletions

View File

@@ -1 +1,4 @@
NEON_DATABASE_URL=your_database_url_here
# Cloudflare Turnstile
# For local dev, use test key: 1x00000000000000000000AA
# For production, set your real sitekey in Cloudflare Pages env vars
PUBLIC_TURNSTILE_SITEKEY=1x00000000000000000000AA

View File

@@ -0,0 +1,49 @@
name: Generate a build and push to Cloudflare Pages
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
name: Build and Deploy to Cloudflare Pages
steps:
- name: git-checkout
uses: actions/checkout@v5
- name: pnpm-setup
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Create .env file
run: echo "PUBLIC_TURNSTILE_SITEKEY=${{ secrets.TURNSTILE_SITEKEY }}" > .env
- name: Build
run: pnpm build
- name: Deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ./dist --project-name=portfolio

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
shamefully-hoist=true

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 David Cojocaru
Copyright (c) 2026 Patrick Jaroszewski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -41,7 +41,7 @@ Before you begin, ensure you have the following installed:
1. **Clone the repository**
```bash
git clone https://github.com/cojocaru-david/portfolio.git
git clone https://git.jaroszew.ski/patrick/portfolio.git
cd portfolio
```

View File

@@ -1,13 +1,13 @@
import { defineConfig } from 'astro/config'
import mdx from '@astrojs/mdx'
import react from '@astrojs/react'
import icon from 'astro-icon'
import expressiveCode from 'astro-expressive-code'
import { rehypeHeadingIds } from '@astrojs/markdown-remark'
import rehypeExternalLinks from 'rehype-external-links'
import rehypePrettyCode from 'rehype-pretty-code'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import remarkEmoji from 'remark-emoji'
import remarkMath from 'remark-math'
import remarkSectionize from 'remark-sectionize'
@@ -17,7 +17,11 @@ import { pluginCollapsibleSections } from '@expressive-code/plugin-collapsible-s
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers'
import tailwindcss from "@tailwindcss/vite";
import vercel from '@astrojs/vercel';
import Icons from 'unplugin-icons/vite';
import svelte from '@astrojs/svelte';
import { fileURLToPath } from 'url';
import path from 'path';
function rehypeDemoteH1AndStripTitle() {
return (tree: any) => {
@@ -46,7 +50,7 @@ function rehypeDemoteH1AndStripTitle() {
}
export default defineConfig({
site: 'https://www.cojocarudavid.me',
site: 'https://patrick.jaroszew.ski',
integrations: [expressiveCode({
themes: ['catppuccin-latte', 'ayu-dark'],
@@ -62,26 +66,22 @@ export default defineConfig({
},
},
},
}), mdx(), react(), icon()],
}), svelte(), mdx(), icon()],
vite: {
plugins: [tailwindcss() as any],
plugins: [
tailwindcss() as any,
Icons({ compiler: 'svelte' }),
],
resolve: {
alias: {
'@': path.resolve(path.dirname(fileURLToPath(import.meta.url)), './src'),
'$lib': path.resolve(path.dirname(fileURLToPath(import.meta.url)), './src/lib')
}
},
optimizeDeps: {
exclude: ["satori", "satori-html"],
include: [
"react",
"react-dom",
"clsx",
"framer-motion",
"lucide-react",
"lodash.debounce",
"@radix-ui/react-icons",
"@radix-ui/react-avatar",
"@radix-ui/react-dropdown-menu",
"@radix-ui/react-scroll-area",
"@radix-ui/react-separator",
"@radix-ui/react-slot"
]
include: ["clsx"]
},
},
@@ -107,6 +107,48 @@ export default defineConfig({
],
rehypeDemoteH1AndStripTitle,
rehypeHeadingIds,
[
rehypeAutolinkHeadings,
{
behavior: 'append',
properties: {
className: ['heading-anchor'],
ariaLabel: 'Link to this section',
},
content: {
type: 'element',
tagName: 'svg',
properties: {
className: ['anchor-icon'],
xmlns: 'http://www.w3.org/2000/svg',
width: '16',
height: '16',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round'
},
children: [
{
type: 'element',
tagName: 'path',
properties: {
d: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'
}
},
{
type: 'element',
tagName: 'path',
properties: {
d: 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'
}
}
]
}
}
],
[
rehypePrettyCode,
{
@@ -119,6 +161,4 @@ export default defineConfig({
],
remarkPlugins: [remarkMath, remarkEmoji, remarkSectionize],
},
adapter: vercel()
})

12619
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,76 +12,66 @@
],
"repository": {
"type": "git",
"url": "https://github.com/cojocaru-david/portfolio.git"
"url": "https://git.jaroszew.ski/patrick/portfolio.git"
},
"bugs": {
"url": "https://github.com/cojocaru-david/portfolio/issues"
"url": "https://git.jaroszew.ski/issues"
},
"homepage": "https://cojocarudavid.me",
"name": "cojocarudavid.me",
"homepage": "https://patrick.jaroszew.ski",
"name": "patrick.jaroszew.ski",
"type": "module",
"version": "1.1.0",
"version": "1.0.0",
"private": false,
"scripts": {
"dev": "astro dev --port 3010",
"start": "astro preview",
"build": "astro check && astro build",
"build": "npm run generate:og && astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"generate:og": "tsx scripts/generate-og-image.ts",
"prettier": "prettier --write **/*.{ts,tsx,css,astro} --ignore-path .gitignore"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/markdown-remark": "^6.3.5",
"@astrojs/mdx": "^4.3.3",
"@astrojs/react": "^4.3.0",
"@astrojs/rss": "^4.0.12",
"@astrojs/vercel": "^8.2.5",
"@expressive-code/plugin-collapsible-sections": "^0.41.3",
"@expressive-code/plugin-line-numbers": "^0.41.3",
"@fingerprintjs/fingerprintjs": "^4.6.2",
"@iconify-json/line-md": "^1.2.11",
"@iconify-json/lucide": "^1.2.62",
"@astrojs/check": "^0.9.8",
"@astrojs/markdown-remark": "^7.0.1",
"@astrojs/mdx": "^5.0.2",
"@astrojs/rss": "^4.0.17",
"@astrojs/svelte": "^8.0.3",
"@expressive-code/plugin-collapsible-sections": "^0.41.7",
"@expressive-code/plugin-line-numbers": "^0.41.7",
"@iconify-json/fa6-brands": "^1.2.6",
"@iconify-json/line-md": "^1.2.16",
"@iconify-json/lucide": "^1.2.97",
"@iconify-json/mdi": "^1.2.3",
"@neondatabase/serverless": "^1.0.1",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@iconify-json/simple-icons": "^1.2.73",
"@iconify/svelte": "^5.2.1",
"@resvg/resvg-js": "^2.6.2",
"@tailwindcss/vite": "^4.1.11",
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@vercel/routing-utils": "^5.1.1",
"@vercel/speed-insights": "^1.2.0",
"astro": "^5.12.9",
"astro-expressive-code": "^0.41.3",
"@tailwindcss/vite": "^4.2.1",
"astro": "^6.0.8",
"astro-expressive-code": "^0.41.7",
"astro-icon": "^1.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.12",
"fuse.js": "^7.1.0",
"lodash.debounce": "^4.0.8",
"lucide-react": "^0.539.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-icons": "^5.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-document": "^7.0.3",
"rehype-external-links": "^3.0.0",
"rehype-pretty-code": "^0.14.1",
"remark-emoji": "^5.0.1",
"rehype-pretty-code": "^0.14.3",
"remark-emoji": "^5.0.2",
"remark-math": "^6.0.0",
"remark-sectionize": "^2.1.0",
"satori": "^0.16.2",
"satori-html": "^0.3.2",
"tailwind-merge": "^3.3.1",
"typescript": "^5.9.2"
"svelte": "^5.53.10",
"tailwind-merge": "^3.5.0",
"typescript": "^5.9.3"
},
"devDependencies": {
"@types/lodash.debounce": "^4.0.9",
"prettier": "^3.6.2"
"@vercel/og": "^0.11.1",
"prettier": "^3.8.1",
"sharp": "^0.34.5",
"tsx": "^4.21.0",
"unplugin-icons": "^23.0.1"
},
"prettier": {
"semi": false,

6129
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 875 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,5 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="350" height="266"><svg width="350" height="266" viewBox="0 0 350 266" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0L216.667 266C290.305 266 350 206.454 350 133C350 59.5461 290.305 0 216.667 0H0ZM231.629 231.641C279.76 224.438 316.667 183.018 316.667 133C316.667 77.9096 271.895 33.25 216.667 33.25H161.805C185.557 59.7215 200 94.6783 200 133C200 150.331 197.04 166.991 191.595 182.492L231.629 231.641ZM165.386 150.315C166.23 144.669 166.667 138.887 166.667 133C166.667 90.6673 144.007 53.6139 110.126 33.25H70.0323L165.386 150.315Z" fill="#171717"></path>
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
@media (prefers-color-scheme: dark) { :root { filter: contrast(0.3846153846153846) brightness(4.5); } }
</style></svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="43.385681mm"
height="58.634415mm"
viewBox="0 0 43.385681 58.634415"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<path
id="text11"
style="font-style:italic;font-weight:bold;font-size:173.597px;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:41.1467"
d="m 29.991968,30.003904 c -0.889705,0.1094 -1.802233,0.16433 -2.735233,0.16433 h -6.756177 l -3.023071,14.29319 -0.0062,0.0232 c -0.290305,1.28564 -0.467849,2.02416 -1.044898,3.20497 -0.110528,0.22617 -0.523296,0.96336 -1.058333,1.50017 -0.0052,0.005 -0.01082,0.0105 -0.01602,0.0155 l -0.0021,0.002 v 0.002 l -0.0016,0.002 -0.01602,0.0155 c -0.01026,0.01 -0.02067,0.0195 -0.03101,0.0294 -0.01108,0.0105 -0.01575,0.0146 -0.02945,0.0274 h -5.17e-4 v 5.2e-4 h -5.17e-4 c -3.47e-4,5.4e-4 -0.0012,0.002 -0.0016,0.002 v 5.1e-4 l -5.17e-4,5.2e-4 h -5.17e-4 l -0.0041,0.004 c -0.0047,0.005 -0.0108,0.01 -0.0155,0.014 l -0.0217,0.0176 c -1.090905,0.99713 -2.538121,1.35282 -3.597713,1.5074 -1.601745,0.23363 -4.113534,0.21787 -5.648234,0.17777 -1.160812,-0.0303 -2.392544,-0.17342 -3.290755,-0.25942 -0.166746,-0.016 -0.518669,-0.0569 -0.746208,-0.0842 l -0.312642,-0.0377 -1.631424942275,7.67189 C 1.33539,58.427024 2.812366,58.550644 4.234278,58.587834 c 2.991756,0.0782 7.888243,0.10921 11.010697,-0.34623 2.065576,-0.30134 4.886391,-0.99503 7.013009,-2.93884 l 0.04237,-0.0341 c 0.0092,-0.008 0.02129,-0.0184 0.03049,-0.0274 l 0.0078,-0.008 h 0.001 l 0.001,-10e-4 5.17e-4,-5.1e-4 c 8.24e-4,-0.001 0.0023,-0.004 0.0031,-0.005 h 5.17e-4 v -5.2e-4 h 10e-4 c 0.02671,-0.025 0.03576,-0.0327 0.05736,-0.0532 0.02015,-0.0195 0.04046,-0.0379 0.06046,-0.0574 l 0.03101,-0.0305 0.0031,-0.004 v -0.004 l 0.0041,-0.004 c 0.01014,-0.01 0.02093,-0.0199 0.03101,-0.03 1.043005,-1.04647 1.847976,-2.48347 2.06344,-2.92437 1.124903,-2.30188 1.471162,-3.74196 2.037085,-6.2482 l 0.01189,-0.045 z" />
<g
id="layer1"
transform="translate(-17.866609,-107.09884)">
<path
id="text7-4"
style="font-style:italic;font-size:63.5px;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:5.551"
d="m 30.327566,107.09883 c 0,0 -9.81187,45.93971 -9.834025,45.93725 l -0.264583,1.25367 c 0,0 0.44063,0.0522 0.661458,0.0734 1.014959,0.0972 2.029653,0.21933 3.04891,0.24598 1.683714,0.044 3.749394,0.0472 5.050854,-0.14263 0.86763,-0.12657 1.466021,-0.36783 1.751831,-0.63458 0,0 0.0068,-0.007 0.01034,-0.0103 0.0037,-0.003 0.01137,-0.01 0.01137,-0.01 0.0501,-0.0438 0.284787,-0.35696 0.38424,-0.56047 0.374418,-0.76617 0.528159,-1.34067 0.775896,-2.43779 L 35.5319,133.76852 h 9.591146 c 4.741333,0 8.614812,-1.60873 11.620479,-4.82606 3.005667,-3.25967 4.508769,-7.13314 4.508769,-11.62048 0,-6.81566 -3.915937,-10.22315 -11.747604,-10.22315 z m 9.499162,6.38308 h 5.806364 c 3.965247,0 5.947441,1.49112 5.947441,4.47311 0,2.05974 -0.793083,3.79691 -2.379183,5.21105 -1.553054,1.41415 -3.651208,2.1208 -6.294707,2.1208 h -5.578987 z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 949 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -1,3 +1,26 @@
<svg width="350" height="266" viewBox="0 0 350 266" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0L216.667 266C290.305 266 350 206.454 350 133C350 59.5461 290.305 0 216.667 0H0ZM231.629 231.641C279.76 224.438 316.667 183.018 316.667 133C316.667 77.9096 271.895 33.25 216.667 33.25H161.805C185.557 59.7215 200 94.6783 200 133C200 150.331 197.04 166.991 191.595 182.492L231.629 231.641ZM165.386 150.315C166.23 144.669 166.667 138.887 166.667 133C166.667 90.6673 144.007 53.6139 110.126 33.25H70.0323L165.386 150.315Z" fill="white"/>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="43.385681mm"
height="58.634415mm"
viewBox="0 0 43.385681 58.634415"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<path
id="text11"
style="font-style:italic;font-weight:bold;font-size:173.597px;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:41.1467"
d="m 29.991968,30.003904 c -0.889705,0.1094 -1.802233,0.16433 -2.735233,0.16433 h -6.756177 l -3.023071,14.29319 -0.0062,0.0232 c -0.290305,1.28564 -0.467849,2.02416 -1.044898,3.20497 -0.110528,0.22617 -0.523296,0.96336 -1.058333,1.50017 -0.0052,0.005 -0.01082,0.0105 -0.01602,0.0155 l -0.0021,0.002 v 0.002 l -0.0016,0.002 -0.01602,0.0155 c -0.01026,0.01 -0.02067,0.0195 -0.03101,0.0294 -0.01108,0.0105 -0.01575,0.0146 -0.02945,0.0274 h -5.17e-4 v 5.2e-4 h -5.17e-4 c -3.47e-4,5.4e-4 -0.0012,0.002 -0.0016,0.002 v 5.1e-4 l -5.17e-4,5.2e-4 h -5.17e-4 l -0.0041,0.004 c -0.0047,0.005 -0.0108,0.01 -0.0155,0.014 l -0.0217,0.0176 c -1.090905,0.99713 -2.538121,1.35282 -3.597713,1.5074 -1.601745,0.23363 -4.113534,0.21787 -5.648234,0.17777 -1.160812,-0.0303 -2.392544,-0.17342 -3.290755,-0.25942 -0.166746,-0.016 -0.518669,-0.0569 -0.746208,-0.0842 l -0.312642,-0.0377 -1.631424942275,7.67189 C 1.33539,58.427024 2.812366,58.550644 4.234278,58.587834 c 2.991756,0.0782 7.888243,0.10921 11.010697,-0.34623 2.065576,-0.30134 4.886391,-0.99503 7.013009,-2.93884 l 0.04237,-0.0341 c 0.0092,-0.008 0.02129,-0.0184 0.03049,-0.0274 l 0.0078,-0.008 h 0.001 l 0.001,-10e-4 5.17e-4,-5.1e-4 c 8.24e-4,-0.001 0.0023,-0.004 0.0031,-0.005 h 5.17e-4 v -5.2e-4 h 10e-4 c 0.02671,-0.025 0.03576,-0.0327 0.05736,-0.0532 0.02015,-0.0195 0.04046,-0.0379 0.06046,-0.0574 l 0.03101,-0.0305 0.0031,-0.004 v -0.004 l 0.0041,-0.004 c 0.01014,-0.01 0.02093,-0.0199 0.03101,-0.03 1.043005,-1.04647 1.847976,-2.48347 2.06344,-2.92437 1.124903,-2.30188 1.471162,-3.74196 2.037085,-6.2482 l 0.01189,-0.045 z" />
<g
id="layer1"
transform="translate(-17.866609,-107.09884)">
<path
id="text7-4"
style="font-style:italic;font-size:63.5px;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:5.551"
d="m 30.327566,107.09883 c 0,0 -9.81187,45.93971 -9.834025,45.93725 l -0.264583,1.25367 c 0,0 0.44063,0.0522 0.661458,0.0734 1.014959,0.0972 2.029653,0.21933 3.04891,0.24598 1.683714,0.044 3.749394,0.0472 5.050854,-0.14263 0.86763,-0.12657 1.466021,-0.36783 1.751831,-0.63458 0,0 0.0068,-0.007 0.01034,-0.0103 0.0037,-0.003 0.01137,-0.01 0.01137,-0.01 0.0501,-0.0438 0.284787,-0.35696 0.38424,-0.56047 0.374418,-0.76617 0.528159,-1.34067 0.775896,-2.43779 L 35.5319,133.76852 h 9.591146 c 4.741333,0 8.614812,-1.60873 11.620479,-4.82606 3.005667,-3.25967 4.508769,-7.13314 4.508769,-11.62048 0,-6.81566 -3.915937,-10.22315 -11.747604,-10.22315 z m 9.499162,6.38308 h 5.806364 c 3.965247,0 5.947441,1.49112 5.947441,4.47311 0,2.05974 -0.793083,3.79691 -2.379183,5.21105 -1.553054,1.41415 -3.651208,2.1208 -6.294707,2.1208 h -5.578987 z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 593 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -1,15 +1,15 @@
{
"name": "Cojocaru David",
"short_name": "CD",
"name": "Patrick Jaroszewski",
"short_name": "PJ",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"

BIN
public/static/bullseye.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
public/static/chitai.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

BIN
public/static/cue.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
public/static/homelab.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
public/static/resume.pdf Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,154 @@
import { ImageResponse } from '@vercel/og'
import { readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
async function generateOGImage() {
console.log('🎨 Generating OG image...')
const clashFont = await readFile(
join(process.cwd(), 'public/fonts/ClashDisplay/ClashDisplay-Semibold.woff')
)
const lexendFont = await readFile(
join(process.cwd(), 'public/fonts/Lexend/Lexend-Regular.woff')
)
const logoImage = await readFile(
join(process.cwd(), 'public/logo.svg')
)
const logoDataUri = `data:image/svg+xml;base64,${logoImage.toString('base64')}`
const imageResponse = new ImageResponse(
{
type: 'div',
props: {
style: {
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#2d2d2d',
backgroundImage: 'linear-gradient(135deg, rgba(235, 219, 147, 0.40) 0%, transparent 50%)',
padding: '80px',
},
children: [
{
type: 'div',
props: {
style: {
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-start',
gap: '48px',
maxWidth: '1100px',
},
children: [
{
type: 'img',
props: {
src: logoDataUri,
alt: 'Patrick Jaroszewski Logo',
style: {
width: 183,
height: 247,
flexShrink: 0,
},
},
},
{
type: 'div',
props: {
style: {
display: 'flex',
flexDirection: 'column',
gap: '20px',
maxWidth: '820px',
},
children: [
{
type: 'div',
props: {
style: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
},
children: [
{
type: 'div',
props: {
style: {
fontFamily: 'Lexend',
fontSize: 28,
fontWeight: 400,
color: '#c4c4c4',
},
children: 'Hello, I\'m',
},
},
{
type: 'div',
props: {
style: {
fontFamily: 'ClashDisplay',
fontSize: 60,
fontWeight: 600,
color: '#f2f2f2',
letterSpacing: '-1.5px',
lineHeight: 1.1,
},
children: 'Patrick Jaroszewski',
},
},
],
},
},
{
type: 'div',
props: {
style: {
fontFamily: 'Lexend',
fontSize: 22,
fontWeight: 400,
color: '#c4c4c4',
lineHeight: 1.6,
},
children: 'Full Stack Developer & DevOps Engineer passionate about building scalable web applications and robust infrastructure. A relentless learner driven by curiosity — skilled in modern web technologies, self-hosted infrastructure, and automation.',
},
},
],
},
},
],
},
},
],
},
},
{
width: 1200,
height: 630,
fonts: [
{ name: 'ClashDisplay', data: clashFont, weight: 600 },
{ name: 'Lexend', data: lexendFont, weight: 400 },
],
}
)
// Get the image as an ArrayBuffer
const arrayBuffer = await imageResponse.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
// Write to public directory
const outputPath = join(process.cwd(), 'public/ogImage.png')
await writeFile(outputPath, buffer)
console.log('✅ OG image generated successfully at public/ogImage.png')
console.log(` Size: ${(buffer.length / 1024).toFixed(2)} KB`)
}
// Run the generator
generateOGImage().catch((error) => {
console.error('❌ Failed to generate OG image:', error)
process.exit(1)
})

24
shell.nix Normal file
View File

@@ -0,0 +1,24 @@
{ pkgs ? import <nixpkgs> { config.allowUnfree = true; } }:
pkgs.mkShell {
buildInputs = with pkgs; [
# Node.js ecosystem
nodejs_24
nodePackages.pnpm
claude-code
];
shellHook = ''
echo "Node.js development environment"
echo "Node version: $(node --version)"
echo "PNPM version: $(pnpm --version)"
export PATH="$PWD/node_modules/.bin:$PATH"
# Install dependencies if package.json exists
if [ -f "package.json" ] && [ ! -d "node_modules" ]; then
echo "Installing npm dependencies..."
pnpm install
fi
'';
}

View File

@@ -0,0 +1,5 @@
---
import AdBlockDiagram from './svelte/AdBlockDiagram.svelte';
---
<AdBlockDiagram client:visible />

View File

@@ -1,12 +1,4 @@
---
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { Icon } from 'astro-icon/components'
import { SITE } from '@/consts'
@@ -45,37 +37,63 @@ const breadcrumbStructuredData = {
<!-- Breadcrumb Structured Data -->
<script type="application/ld+json" is:inline set:html={JSON.stringify(breadcrumbStructuredData)} />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/" aria-label="Home" title="Home">
<nav aria-label="breadcrumb" data-slot="breadcrumb">
<ol
data-slot="breadcrumb-list"
class="text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5"
>
<li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
<a
href="/"
aria-label="Home"
title="Home"
data-slot="breadcrumb-link"
class="hover:text-foreground transition-colors"
>
<span class="sr-only">Home</span>
<Icon name="lucide:home" class="size-4" />
</BreadcrumbLink>
</BreadcrumbItem>
</a>
</li>
{
items.map((item, index) => (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
class="[&>svg]:size-3.5"
>
<Icon name="lucide:chevron-right" class="size-3.5" />
</li>
<li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
{index === items.length - 1 ? (
<BreadcrumbPage>
<span class="flex items-center gap-x-2">
{item.icon && <Icon name={item.icon} class="size-4" />}
{item.label}
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
class="text-foreground font-normal max-w-[200px] sm:max-w-none"
>
<span class="flex items-center gap-x-2 overflow-hidden">
{item.icon && <Icon name={item.icon} class="size-4 shrink-0" />}
<span class="truncate">{item.label}</span>
</span>
</span>
</BreadcrumbPage>
) : (
<BreadcrumbLink href={item.href}>
<a
href={item.href}
data-slot="breadcrumb-link"
class="hover:text-foreground transition-colors"
>
<span class="flex items-center gap-x-2">
{item.icon && <Icon name={item.icon} class="size-4" />}
{item.label}
</span>
</BreadcrumbLink>
</a>
)}
</BreadcrumbItem>
</li>
</>
))
}
</BreadcrumbList>
</Breadcrumb>
</ol>
</nav>

View File

@@ -0,0 +1,5 @@
---
import DnsLookupDiagram from './svelte/DnsLookupDiagram.svelte';
---
<DnsLookupDiagram client:visible />

View File

@@ -2,5 +2,5 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Cojocaru David" />
<meta name="apple-mobile-web-app-title" content="Patrick Jaroszewski" />
<link rel="manifest" href="/site.webmanifest" />

View File

@@ -1,5 +1,4 @@
---
import { Separator } from '@/components/ui/separator'
import { SOCIAL_LINKS } from '@/consts'
import Link from './Link.astro'
import SocialIcons from './SocialIcons.astro'
@@ -12,12 +11,12 @@ import SocialIcons from './SocialIcons.astro'
<SocialIcons links={SOCIAL_LINKS} />
<div class="flex flex-wrap items-center justify-center gap-x-2 text-center">
<span class="text-muted-foreground text-sm" aria-label="copyright">
2020 - {new Date().getFullYear()} &copy; All rights reserved.
2025 - {new Date().getFullYear()} &copy; All rights reserved.
</span>
<Separator orientation="vertical" className="hidden h-4! sm:block" />
<div class="bg-border hidden h-4 w-px shrink-0 sm:block" role="separator" aria-orientation="vertical"></div>
<p class="text-muted-foreground text-sm" aria-label="open-source description">
<Link
href="https://github.com/cojocaru-david/portfolio"
href="https://git.jaroszew.ski/patrick/portfolio"
class="text-foreground"
external
underline>Open-source</Link

View File

@@ -1,196 +0,0 @@
---
const { post } = Astro.props;
import { Button } from '@/components/ui/button';
import { Icon } from 'astro-icon/components';
---
<div id="feedback" class="flex flex-row items-center gap-4" data-post-slug={post.id}>
<Button
id="like-btn"
variant="outline"
className="flex flex-row items-center transition-all"
data-post-id={post.id}
disabled
>
<Icon name="mdi:thumb-up-outline" class="text-lg" />
Vote Up
<span id="like-count" class="text-sm text-neutral-500">0</span>
</Button>
<Button
id="dislike-btn"
variant="outline"
className="flex flex-row items-center transition-all"
data-post-id={post.id}
disabled
>
<Icon name="mdi:thumb-down-outline" class="text-lg" />
Vote Down
<span id="dislike-count" class="text-sm text-neutral-500">0</span>
</Button>
</div>
<style>
.button-voted {
border: 2px solid white !important;
box-shadow: 0 0 5px rgba(255, 255, 255, 0.5);
}
</style>
<script>
import FingerprintJS from '@fingerprintjs/fingerprintjs';
const likeBtn = document.getElementById('like-btn') as HTMLButtonElement;
const dislikeBtn = document.getElementById('dislike-btn') as HTMLButtonElement;
const likeCount = document.getElementById('like-count') as HTMLElement;
const dislikeCount = document.getElementById('dislike-count') as HTMLElement;
if (!likeBtn || !dislikeBtn || !likeCount || !dislikeCount) {
console.error('Required DOM elements not found');
throw new Error('Failed to initialize feedback component');
}
let fingerprintId: string | null = null;
async function fetchPostFeedback(postId: string) {
try {
const response = await fetch(`/api/like/${postId}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.success) {
likeCount.textContent = String(data?.data?.likes || 0);
dislikeCount.textContent = String(data?.data?.dislikes || 0);
return data.data;
} else {
console.error('Failed to fetch post feedback:', data.error);
return { likes: 0, dislikes: 0 };
}
} catch (error) {
console.error('Error fetching post feedback:', error);
return { likes: 0, dislikes: 0 };
}
}
async function initialize() {
try {
const fp = await FingerprintJS.load();
const result = await fp.get();
fingerprintId = result.visitorId;
const postId = likeBtn.dataset.postId;
if (!postId) {
console.error('Post ID not found on like button');
return;
}
// Fetch current like/dislike counts
await fetchPostFeedback(postId);
const hasActed = localStorage.getItem(`action_${postId}`);
if (hasActed) {
const voteType = localStorage.getItem(`vote_type_${postId}`);
if (voteType === 'like') {
likeBtn.classList.add('button-voted');
} else if (voteType === 'dislike') {
dislikeBtn.classList.add('button-voted');
}
likeBtn.disabled = true;
dislikeBtn.disabled = true;
} else {
likeBtn.disabled = false;
dislikeBtn.disabled = false;
}
} catch (err) {
console.error('Error getting fingerprint:', err);
likeBtn.disabled = true;
dislikeBtn.disabled = true;
}
}
initialize();
likeBtn.addEventListener('click', async () => {
if (!fingerprintId) {
console.error('Fingerprint ID not available');
alert('Unable to process like. Please try again later.');
return;
}
const postId = likeBtn.dataset.postId;
if (!postId) {
console.error('Post ID not found');
alert('Unable to process like. Missing post ID.');
return;
}
try {
const response = await fetch(`/api/like/${postId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fingerprintId }),
});
const data = await response.json();
if (data.success) {
likeCount.textContent = String(data?.data?.likes);
dislikeCount.textContent = String(data?.data?.dislikes);
likeBtn.disabled = true;
dislikeBtn.disabled = true;
localStorage.setItem(`action_${postId}`, 'true');
localStorage.setItem(`vote_type_${postId}`, 'like');
likeBtn.classList.add('button-voted');
} else {
console.error('Failed to update likes:', data.error);
alert(data.error || 'Failed to like the post');
}
} catch (error) {
console.error('Error liking post:', error);
alert('An error occurred while liking the post');
}
});
dislikeBtn.addEventListener('click', async () => {
if (!fingerprintId) {
console.error('Fingerprint ID not available');
alert('Unable to process dislike. Please try again later.');
return;
}
const postId = dislikeBtn.dataset.postId;
if (!postId) {
console.error('Post ID not found');
alert('Unable to process dislike. Missing post ID.');
return;
}
try {
const response = await fetch(`/api/dislike/${postId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fingerprintId }),
});
const data = await response.json();
if (data.success) {
likeCount.textContent = String(data?.data?.likes);
dislikeCount.textContent = String(data?.data?.dislikes);
likeBtn.disabled = true;
dislikeBtn.disabled = true;
localStorage.setItem(`action_${postId}`, 'true');
localStorage.setItem(`vote_type_${postId}`, 'dislike');
dislikeBtn.classList.add('button-voted');
} else {
console.error('Failed to update dislikes:', data.error);
alert(data.error || 'Failed to dislike the post');
}
} catch (error) {
console.error('Error disliking post:', error);
alert('An error occurred while disliking the post');
}
});
document.addEventListener("astro:page-load", () => {
const postId = likeBtn.dataset.postId;
if (postId) {
fetchPostFeedback(postId);
}
});
</script>

View File

@@ -1,13 +1,13 @@
---
import Link from '@/components/Link.astro'
import { buttonVariants } from '@/components/ui/button'
import { buttonVariants } from '@/lib/button-variants'
import { cn } from '@/lib/utils'
import { Icon } from 'astro-icon/components'
const { prevPost, nextPost } = Astro.props
---
<nav class="col-start-2 grid grid-cols-1 gap-4 sm:grid-cols-2">
<nav class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Link
href={prevPost ? `/blog/${prevPost.id}` : '#'}
class={cn(
@@ -21,9 +21,9 @@ const { prevPost, nextPost } = Astro.props
name="lucide:arrow-left"
class="mr-2 size-4 transition-transform group-hover:-translate-x-1"
/>
<div class="flex flex-col items-start overflow-hidden text-wrap">
<div class="flex flex-col items-start overflow-hidden min-w-0 flex-1">
<span class="text-muted-foreground text-left text-xs">Previous Post</span>
<span class="w-full text-left text-sm text-ellipsis">
<span class="w-full text-left text-sm overflow-hidden text-ellipsis whitespace-nowrap">
{prevPost?.data.title || 'No previous post!'}
</span>
</div>
@@ -37,9 +37,9 @@ const { prevPost, nextPost } = Astro.props
)}
aria-disabled={!nextPost}
>
<div class="flex flex-col items-end overflow-hidden text-wrap">
<div class="flex flex-col items-end overflow-hidden min-w-0 flex-1">
<span class="text-muted-foreground text-right text-xs">Next Post</span>
<span class="w-full text-right text-sm text-ellipsis">
<span class="w-full text-right text-sm overflow-hidden text-ellipsis whitespace-nowrap">
{nextPost?.data.title || 'No next post!'}
</span>
</div>

View File

@@ -1,6 +1,6 @@
---
import Link from '@/components/Link.astro'
import { buttonVariants } from '@/components/ui/button'
import { buttonVariants } from '@/lib/button-variants'
import { ICON_MAP } from '@/consts'
import { cn } from '@/lib/utils'
import type { SocialLink } from '@/types'
@@ -21,7 +21,6 @@ const { links } = Astro.props
href={href}
aria-label={label}
title={label}
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
class={cn(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'group text-2xl size-14 lg:size-9 lg:text-base',

View File

@@ -1,5 +1,5 @@
---
import { ScrollArea } from '@/components/ui/scroll-area'
import ScrollArea from '../components/svelte/ui/ScrollArea.svelte'
import { cn } from '@/lib/utils'
import type { MarkdownHeading } from 'astro'
import { Icon } from 'astro-icon/components'
@@ -32,13 +32,12 @@ function cleanupHeading(text: string): string {
<details
open
class="group col-start-2 rounded-xl border p-4 xl:sticky xl:top-26 xl:col-start-1 xl:mr-8 xl:ml-auto xl:h-[calc(100vh-5rem)] xl:max-w-fit xl:rounded-none xl:border-none xl:p-0"
class="group rounded-xl border p-4"
>
<summary
class="flex cursor-pointer items-center justify-between text-xl font-medium group-open:pb-4"
>
<div>
<hr class="mb-8 hidden md:block" />
<h2
id="skills-title"
class="font-custom flex items-center gap-x-2 text-2xl font-bold text-neutral-900 dark:text-white"
@@ -55,13 +54,16 @@ function cleanupHeading(text: string): string {
</div>
</summary>
<hr class="mb-2 hidden md:block" />
<ScrollArea
client:load
className="flex max-h-64 flex-col overflow-y-auto xl:max-h-[calc(100vh-8rem)]"
type="always"
>
<ul
class="flex list-none flex-col gap-y-2 px-4 xl:mr-8"
class="flex list-none flex-col gap-y-2"
id="table-of-contents"
>
{

View File

@@ -1,64 +0,0 @@
---
import { Button } from '@/components/ui/button'
import { Icon } from 'astro-icon/components'
---
<Button id="theme-toggle" variant="secondary" size="icon" title="Toggle theme">
<Icon
name="lucide:sun"
class="size-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90"
/>
<Icon
name="lucide:moon"
class="absolute size-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0"
/>
<span class="sr-only">Toggle theme</span>
</Button>
<script>
function handleToggleClick() {
const element = document.documentElement
element.classList.add('disable-transitions')
element.classList.toggle('dark')
window.getComputedStyle(element).getPropertyValue('opacity')
requestAnimationFrame(() => {
element.classList.remove('disable-transitions')
})
const isDark = element.classList.contains('dark')
localStorage.setItem('theme', isDark ? 'dark' : 'light')
}
function initThemeToggle() {
const themeToggle = document.getElementById('theme-toggle')
if (themeToggle) {
themeToggle.addEventListener('click', handleToggleClick)
}
}
initThemeToggle()
document.addEventListener('astro:after-swap', () => {
const storedTheme = localStorage.getItem('theme')
const element = document.documentElement
element.classList.add('disable-transitions')
window.getComputedStyle(element).getPropertyValue('opacity')
if (storedTheme === 'dark') {
element.classList.add('dark')
} else {
element.classList.remove('dark')
}
requestAnimationFrame(() => {
element.classList.remove('disable-transitions')
})
initThemeToggle()
})
</script>

View File

@@ -1,38 +0,0 @@
import { Badge } from '@/components/ui/badge'
import { Hash } from 'lucide-react'
import type { CollectionEntry } from 'astro:content'
const BlogCardJSX = ({ entry }: { entry: CollectionEntry<'blog'> }) => {
return (
<div className="hover:bg-secondary/50 rounded-xl border p-4 transition-colors duration-300 ease-in-out">
<a
href={`/${entry.collection}/${entry.id}`}
className="flex flex-col gap-4 sm:flex-row"
>
<div className="grow">
<h3 className="mb-1 text-lg font-medium">{entry.data.title}</h3>
<p className="text-muted-foreground mb-2 text-sm">
{entry.data.description}
</p>
{entry.data.tags && (
<div className="flex flex-wrap gap-2">
{entry.data.tags.map((tag, index) => (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-x-1"
>
<Hash size={12} />
{tag}
</Badge>
))}
</div>
)}
</div>
</a>
</div>
)
}
export default BlogCardJSX

File diff suppressed because one or more lines are too long

View File

@@ -1,30 +0,0 @@
import { cn } from '@/lib/utils';
import React from 'react';
interface Props {
href: string;
external?: boolean;
className?: string;
underline?: boolean;
[key: string]: any;
}
const Link: React.FC<Props> = ({ href, external, className, underline, children, ...rest }) => {
return (
<a
href={href}
target={external ? '_blank' : '_self'}
className={cn(
'inline-block transition-colors duration-300 ease-in-out',
underline &&
'underline decoration-muted-foreground underline-offset-[3px] hover:decoration-foreground',
className
)}
{...rest}
>
{children}
</a>
);
};
export default Link;

View File

@@ -1,233 +0,0 @@
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import Link from './link'
import ThemeToggle from './theme-toggle'
import { NAV_LINKS, SITE } from '../../consts'
import { cn } from '@/lib/utils'
import debounce from 'lodash.debounce'
import Logo from '../ui/logo'
import { Button } from '@/components/ui/button'
import { Menu, X } from 'lucide-react'
import { Separator } from '../ui/separator'
const Navbar = () => {
const [scrollLevel, setScrollLevel] = useState(0)
const [isScrolled, setIsScrolled] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [activePath, setActivePath] = useState("/")
useEffect(() => {
setActivePath(window.location.pathname)
const handleRouteChange = () => {
setActivePath(window.location.pathname)
}
window.addEventListener('popstate', handleRouteChange)
return () => {
window.removeEventListener('popstate', handleRouteChange)
}
}, [])
useEffect(() => {
const handleResize = debounce(() => {
const isMobileView = window.matchMedia('(max-width: 768px)').matches
setIsMobile(isMobileView)
if (!isMobileView && mobileMenuOpen) {
setMobileMenuOpen(false)
}
}, 100)
handleResize()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [mobileMenuOpen])
useEffect(() => {
const handleScroll = debounce(() => {
const scrollY = window.scrollY
setScrollLevel(
scrollY > 500 ? 4 : scrollY > 300 ? 3 : scrollY > 150 ? 2 : scrollY > 0 ? 1 : 0
)
setIsScrolled(scrollY > 0)
}, 50)
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll)
}
}, [])
useEffect(() => {
if (mobileMenuOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [mobileMenuOpen])
const sizeVariants: Record<number, { width: string }> = {
0: { width: '100%' },
1: { width: '90%' },
2: { width: '80%' },
3: { width: '70%' },
4: { width: '50%' },
}
return (
<>
<motion.header
aria-label="Navigation"
role="banner"
layout={!isMobile}
initial={sizeVariants[0]}
animate={isMobile ? sizeVariants[0] : sizeVariants[scrollLevel]}
className={cn(
'fixed left-1/2 z-30 -translate-x-1/2 transform backdrop-blur-lg',
'bg-background/80 border-0',
'rounded-none shadow-none transition-all duration-300 ease-in-out',
'border border-transparent w-full',
isScrolled && !isMobile && 'rounded-full',
isScrolled && !isMobile && 'backdrop-blur-md',
isScrolled && !isMobile && 'border-foreground/10',
isScrolled && !isMobile && 'border',
isScrolled && !isMobile && 'bg-background/80',
isScrolled && !isMobile && 'max-w-[calc(100vw-5rem)]',
!isMobile && 'top-2 lg:top-4 xl:top-6',
isMobile && 'top-0',
isMobile && 'rounded-none',
isMobile && 'border-0',
isMobile && 'shadow-none',
isMobile && 'border-0'
)}
>
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 p-4">
<Link
href="/"
className="font-custom flex shrink-0 items-center gap-2 text-xl font-bold"
aria-label="Home"
title="Home"
>
<Logo className="h-8 w-8" />
<span className={
'transition-opacity duration-200 ease-in-out text-foreground/90 dark:text-white'}>
{SITE.title}
</span>
</Link>
<div className="flex items-center gap-2 md:gap-4">
<nav className="hidden items-center gap-6 md:flex" aria-label="Main navigation">
{NAV_LINKS.map((item) => {
const isActive = activePath.startsWith(item.href) && item.href !== "/";
return (
<motion.div
key={item.href}
whileHover={{ scale: 1.05 }}
className="relative"
>
<Link
href={item.href}
className={cn(
"text-sm font-medium capitalize transition-colors duration-200",
"relative py-1 px-1",
"after:absolute after:bottom-0 after:left-0 after:h-[2px] after:w-0 after:bg-primary after:transition-all after:duration-300",
"hover:after:w-full hover:text-foreground",
isActive
? "text-foreground after:w-full after:bg-primary"
: "text-foreground/70"
)}
onClick={() => setActivePath(item.href)}
>
{item.label}
</Link>
</motion.div>
);
})}
</nav>
<ThemeToggle />
{isMobile && (
<Button
variant="ghost"
size="icon"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label={mobileMenuOpen ? "Close menu" : "Open menu"}
className={
"ml-1 h-9 w-9 rounded-full p-0 transition-colors duration-200 ease-in-out"
}
>
{mobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
)}
</div>
</div>
</motion.header>
<AnimatePresence>
{mobileMenuOpen && (
<motion.div
key="mobile-menu"
initial="closed"
animate="open"
exit="closed"
className="fixed inset-0 z-20 flex flex-col items-center justify-start bg-background border-0 shadow-none"
>
<div className="flex flex-col items-center justify-start h-full pt-24 w-full p-6">
<nav className="flex flex-col items-center justify-start gap-1 w-full">
{NAV_LINKS.map((item, i) => (
<motion.div
key={item.href}
custom={i}
className="w-full text-start"
>
<Link
href={item.href}
onClick={() => setMobileMenuOpen(false)}
className="dark:text-white text-lg font-bold font-custom capitalize dark:hover:text-white/80 transition-colors inline-block py-2 relative group"
>
{item.label}
<span className="absolute left-0 bottom-0 w-0 h-0.5 bg-neutral-900 dark:bg-white group-hover:w-full transition-all duration-300 ease-in-out"></span>
</Link>
</motion.div>
))}
</nav>
<motion.div
custom={NAV_LINKS.length + 1}
className="mt-auto flex flex-col items-center gap-6"
>
<div className="flex flex-wrap items-center justify-center gap-x-2 text-center">
<span className="text-muted-foreground text-sm" aria-label="copyright">
2020 - {new Date().getFullYear()} &copy; All rights reserved.
</span>
<Separator orientation="vertical" className="hidden h-4! sm:block" />
<p className="text-muted-foreground text-sm" aria-label="open-source description">
<Link
href="https://github.com/cojocaru-david/portfolio"
class="text-foreground"
external
underline>Open-source</Link
> under MIT license
</p>
</div>
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
)
}
export default Navbar

View File

@@ -1,115 +0,0 @@
import Fuse from 'fuse.js'
import { useState, useMemo, useCallback } from 'react'
import { Input } from '@/components/ui/input'
import BlogCardJSX from './blog-card'
import debounce from 'lodash.debounce'
import { cn } from '@/lib/utils'
const options = {
keys: ['data.title', 'data.description', 'data.tags'],
includeMatches: true,
minMatchCharLength: 3,
threshold: 0.3,
distance: 100,
sortFn: (a, b) => a.score - b.score,
}
function Search({ searchList, initialPosts }) {
const [query, setQuery] = useState('')
const [filteredPosts, setFilteredPosts] = useState(initialPosts)
const processedSearchList = useMemo(
() =>
searchList.map((item) => ({
...item,
data: {
...item.data,
title: String(item.data.title || '').toLowerCase(),
description: String(item.data.description || '').toLowerCase(),
tags: Array.isArray(item.data.tags)
? item.data.tags.map((tag) => String(tag).toLowerCase())
: [],
},
})),
[searchList],
)
const fuse = useMemo(
() => new Fuse(processedSearchList, options),
[processedSearchList],
)
const handleOnSearch = useCallback(
debounce((searchQuery) => {
if (searchQuery.length > 2) {
const results = fuse
.search(searchQuery.toLowerCase())
.map((result) => result.item)
setFilteredPosts(results)
} else {
setFilteredPosts(initialPosts)
}
}, 100),
[fuse, initialPosts],
)
const handleInputChange = (event) => {
const searchQuery = event.target.value
setQuery(searchQuery)
handleOnSearch(searchQuery)
}
return (
<div>
<div>
<label
htmlFor="search"
className="text-foreground mb-2 block text-sm font-medium dark:text-white"
>
Search
</label>
<Input
type="text"
value={query}
onChange={handleInputChange}
name="search"
id="search"
autoComplete="off"
autoCorrect="off"
placeholder="Search posts"
className="w-full outline-none focus:ring-0 dark:bg-neutral-900 dark:text-white"
/>
</div>
<hr className="my-6 border-neutral-200 dark:border-neutral-700" />
<div className={cn('flex items-center justify-between', 'mb-4', !query && 'hidden')}>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
{filteredPosts.length} posts found
</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Search results for: <strong>{query}</strong>
</p>
</div>
<div className="mt-6">
<ul className="flex flex-col gap-4">
{filteredPosts.slice(0, 50).map((post, index) => (
<li key={post.id || post.slug || index}>
<BlogCardJSX entry={post} />
</li>
))}
</ul>
{filteredPosts.length === 0 && (
<div className="mt-12 text-center">
<p className="text-neutral-600 dark:text-neutral-400">
No posts found matching your search criteria.
</p>
</div>
)}
</div>
</div>
)
}
export default Search

View File

@@ -1,112 +0,0 @@
import { useEffect } from 'react'
import { technologies, type Technologies, type Category } from '../../consts'
import { InfiniteScroll } from '../ui/infinite-scroll'
import { type IconType } from 'react-icons'
import { FaQuestionCircle } from 'react-icons/fa'
import {
SiHtml5,
SiJavascript,
SiCss3,
SiPhp,
SiAstro,
SiTailwindcss,
SiGit,
SiDigitalocean,
SiCloudflare,
SiNetlify,
SiUbuntu,
SiLua,
SiGo,
SiNodedotjs,
SiApache,
SiNginx,
SiMysql,
SiMongodb,
SiDiscord,
SiSpotify,
SiBrave,
} from 'react-icons/si'
import { FileCode, LucideAppWindow, Code } from 'lucide-react'
const iconMap: { [key: string]: IconType } = {
'mdi:language-html5': SiHtml5,
'mdi:language-javascript': SiJavascript,
'mdi:language-css3': SiCss3,
'mdi:language-php': SiPhp,
'simple-icons:astro': SiAstro,
'mdi:tailwind': SiTailwindcss,
'mdi:git': SiGit,
'mdi:digital-ocean': SiDigitalocean,
'cib:cloudflare': SiCloudflare,
'cib:netlify': SiNetlify,
'mdi:ubuntu': SiUbuntu,
'mdi:language-lua': SiLua,
'mdi:language-go': SiGo,
'mdi:nodejs': SiNodedotjs,
'cib:apache': SiApache,
'cib:nginx': SiNginx,
'cib:mysql': SiMysql,
'cib:mongodb': SiMongodb,
'mdi:discord': SiDiscord,
'mdi:spotify': SiSpotify,
'cib:brave': SiBrave,
'mdi:visual-studio-code': FileCode,
'mdi:windows': LucideAppWindow,
'mdi:visual-studio': Code,
}
const categories = Object.keys(technologies)
const groupSize = Math.ceil(categories.length / 3)
const categoryGroups = [
categories.slice(0, groupSize),
categories.slice(groupSize, groupSize * 2),
categories.slice(groupSize * 2),
]
const Skills: React.FC = () => {
useEffect(() => {
document.querySelectorAll('.tech-badge').forEach((badge) => {
badge.classList.add('tech-badge-visible')
})
}, [])
return (
<div className="z-30 mt-12 flex w-full flex-col max-w-[calc(100vw-5rem)] mx-auto lg:max-w-full">
<div className="space-y-2">
{categoryGroups.map((group, groupIndex) => (
<InfiniteScroll
key={groupIndex}
duration={50000}
direction={groupIndex % 2 === 0 ? 'normal' : 'reverse'}
showFade={true}
className="flex flex-row justify-center"
>
{group.flatMap((category) =>
technologies[category as keyof Technologies].map(
(tech: Category, techIndex: number) => {
const IconComponent = iconMap[tech.logo] || FaQuestionCircle
return (
<div
key={`${category}-${techIndex}`}
className="tech-badge repo-card border-border bg-card text-muted-foreground mr-5 flex items-center gap-3 rounded-full border p-3 shadow-sm backdrop-blur-sm transition-all duration-300 hover:shadow-md"
data-tech-name={`${category}-${techIndex}`}
>
<span className="bg-muted flex h-10 w-10 items-center justify-center rounded-full p-2 text-lg shadow-inner">
<IconComponent className="tech-icon text-primary" />
</span>
<span className="text-foreground font-medium">
{tech.text}
</span>
</div>
)
},
),
)}
</InfiniteScroll>
))}
</div>
</div>
)
}
export default Skills

View File

@@ -1,95 +0,0 @@
import { Button } from '@/components/ui/button'
import { SunIcon, MoonIcon } from 'lucide-react'
import { useEffect } from 'react'
export const prerender = true
export const dynamic = 'force-dynamic'
const ThemeToggle: React.FC = () => {
useEffect(() => {
const theme = (() => {
const localStorageTheme = localStorage?.getItem('theme') ?? ''
if (['dark', 'light'].includes(localStorageTheme)) {
return localStorageTheme
}
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark'
}
return 'light'
})()
if (theme === 'light') {
document.documentElement.classList.remove('dark')
} else {
document.documentElement.classList.add('dark')
}
window.localStorage.setItem('theme', theme)
const handleToggleClick = () => {
const element = document.documentElement
element.classList.add('disable-transitions')
element.classList.toggle('dark')
window.getComputedStyle(element).getPropertyValue('opacity')
requestAnimationFrame(() => {
element.classList.remove('disable-transitions')
})
const isDark = element.classList.contains('dark')
localStorage.setItem('theme', isDark ? 'dark' : 'light')
}
const initThemeToggle = () => {
const themeToggle = document.getElementById('theme-toggle')
if (themeToggle) {
themeToggle.addEventListener('click', handleToggleClick)
}
}
initThemeToggle()
const handleAfterSwap = () => {
const storedTheme = localStorage.getItem('theme')
const element = document.documentElement
element.classList.add('disable-transitions')
window.getComputedStyle(element).getPropertyValue('opacity')
if (storedTheme === 'dark') {
element.classList.add('dark')
} else {
element.classList.remove('dark')
}
requestAnimationFrame(() => {
element.classList.remove('disable-transitions')
})
initThemeToggle()
}
document.addEventListener('astro:after-swap', handleAfterSwap)
return () => {
document.removeEventListener('astro:after-swap', handleAfterSwap)
}
}, [])
return (
<Button
id="theme-toggle"
variant="secondary"
size="icon"
title="Toggle theme"
>
<SunIcon className="size-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<MoonIcon className="absolute size-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
)
}
export default ThemeToggle

View File

@@ -0,0 +1,731 @@
<script lang="ts">
import { onMount } from 'svelte';
// ── Types ──────────────────────────────────────────────────────────────────
type NodeId = 'browser' | 'dns-server' | 'website' | 'ad-server';
interface NodeLayout {
x: number;
y: number;
w: number;
h: number;
title: string;
sub: string;
}
interface Step {
label: string;
desc: string;
from: NodeId;
to: NodeId;
lineType: 'dns' | 'http' | 'blocked';
isReturn: boolean;
packetLabel?: string;
blockedInAdBlockMode?: boolean;
skipInAdBlockMode?: boolean;
hitNode?: NodeId;
hitText?: string;
hitColor?: 'blue' | 'green' | 'red';
}
// ── Constants ──────────────────────────────────────────────────────────────
const SITE_DOMAIN = 'news.com';
const AD_DOMAIN = 'ads.tracker.net';
const SITE_IP = '93.184.216.34';
const AD_IP = '203.0.113.50';
const BLOCKED_IP = '0.0.0.0';
// Desktop layout - Diamond shape
// Website at top, Browser left, DNS right, Ad server bottom
const NODES_H: Record<NodeId, NodeLayout> = {
'browser': { x: 20, y: 100, w: 120, h: 60, title: 'Browser', sub: 'Your device' },
'dns-server': { x: 380, y: 100, w: 120, h: 60, title: 'DNS Server', sub: 'dnsmasq' },
'website': { x: 200, y: 10, w: 120, h: 60, title: 'news.com', sub: SITE_IP },
'ad-server': { x: 200, y: 190, w: 120, h: 60, title: 'Ad Server', sub: AD_DOMAIN },
};
// Mobile layout - 2x2 grid
// Top row: Browser (left), DNS Server (right)
// Bottom row: Website (left), Ad Server (right)
const NODES_V: Record<NodeId, NodeLayout> = {
'browser': { x: 10, y: 10, w: 110, h: 50, title: 'Browser', sub: 'Your device' },
'dns-server': { x: 150, y: 10, w: 110, h: 50, title: 'DNS Server', sub: 'dnsmasq' },
'website': { x: 10, y: 90, w: 110, h: 50, title: 'news.com', sub: SITE_IP },
'ad-server': { x: 150, y: 90, w: 110, h: 50, title: 'Ad Server', sub: AD_DOMAIN },
};
// ── Reactive layout ────────────────────────────────────────────────────────
let isVertical = $state(false);
let NODES = $derived(isVertical ? NODES_V : NODES_H);
let viewBoxWidth = $derived(isVertical ? 270 : 520);
let viewBoxHeight = $derived(isVertical ? 175 : 340);
function checkLayout() {
if (typeof window !== 'undefined') {
isVertical = window.innerWidth < 640;
}
}
onMount(() => {
checkLayout();
window.addEventListener('resize', checkLayout);
return () => window.removeEventListener('resize', checkLayout);
});
// ── State ──────────────────────────────────────────────────────────────────
let adBlockEnabled = $state(false);
let current = $state(-1);
let busy = $state(false);
let done = $state(false);
// Colors
const COLOR_DNS = 'oklch(0.65 0.19 240)'; // Blue
const COLOR_HTTP = 'oklch(0.65 0.19 145)'; // Green
const COLOR_BLOCKED = 'oklch(0.65 0.19 25)'; // Red
// ── Helper functions ───────────────────────────────────────────────────────
function ncx(id: NodeId) { return NODES[id].x + NODES[id].w / 2; }
function ncy(id: NodeId) { return NODES[id].y + NODES[id].h / 2; }
function nright(id: NodeId) { return NODES[id].x + NODES[id].w; }
function nleft(id: NodeId) { return NODES[id].x; }
function ntop(id: NodeId) { return NODES[id].y; }
function nbottom(id: NodeId) { return NODES[id].y + NODES[id].h; }
// ── Steps ──────────────────────────────────────────────────────────────────
const STEPS: Step[] = [
{
label: 'Step 1 — DNS query for main site',
desc: `You navigate to ${SITE_DOMAIN}. The browser needs to resolve this domain to an IP address, so it sends a DNS query to your DNS server.`,
from: 'browser', to: 'dns-server',
lineType: 'dns', isReturn: false,
packetLabel: `DNS: ${SITE_DOMAIN}?`,
hitNode: 'dns-server', hitText: 'Looking up...', hitColor: 'blue',
},
{
label: 'Step 2 — DNS response',
desc: `The DNS server looks up ${SITE_DOMAIN} and responds with the IP address ${SITE_IP}. This is a legitimate site, so it resolves normally.`,
from: 'dns-server', to: 'browser',
lineType: 'dns', isReturn: true,
packetLabel: SITE_IP,
hitNode: 'browser', hitText: `Resolved: ${SITE_IP}`, hitColor: 'green',
},
{
label: 'Step 3 — HTTP request to site',
desc: `The browser now knows the IP. It opens a TCP connection to ${SITE_IP} and sends an HTTP GET request for the homepage.`,
from: 'browser', to: 'website',
lineType: 'http', isReturn: false,
packetLabel: 'GET /',
hitNode: 'website', hitText: 'Request received', hitColor: 'blue',
},
{
label: 'Step 4 — Site returns HTML',
desc: `The server responds with the HTML page. Embedded in this HTML is a script tag: <script src="https://${AD_DOMAIN}/tracker.js">. The browser needs to fetch this too.`,
from: 'website', to: 'browser',
lineType: 'http', isReturn: true,
packetLabel: 'HTML + JS',
hitNode: 'browser', hitText: 'Page loaded!', hitColor: 'green',
},
{
label: 'Step 5 — DNS query for ad domain',
desc: `To load the ad script, the browser must first resolve ${AD_DOMAIN}. It sends another DNS query to your DNS server.`,
from: 'browser', to: 'dns-server',
lineType: 'dns', isReturn: false,
packetLabel: `DNS: ${AD_DOMAIN}?`,
hitNode: 'dns-server', hitText: 'Checking block list...', hitColor: 'blue',
},
{
label: 'Step 6 — DNS response (blocked!)',
desc: `The DNS server checks its block list and finds ${AD_DOMAIN}. Instead of resolving it, the server returns ${BLOCKED_IP} — a dead end. The request stops here.`,
from: 'dns-server', to: 'browser',
lineType: 'blocked', isReturn: true,
packetLabel: BLOCKED_IP,
blockedInAdBlockMode: true,
hitNode: 'browser', hitText: 'BLOCKED!', hitColor: 'red',
},
{
label: 'Step 6 — DNS response (allowed)',
desc: `The DNS server resolves ${AD_DOMAIN} to ${AD_IP}. Without a block list, the ad domain resolves like any other.`,
from: 'dns-server', to: 'browser',
lineType: 'dns', isReturn: true,
packetLabel: AD_IP,
skipInAdBlockMode: true,
hitNode: 'browser', hitText: `Resolved: ${AD_IP}`, hitColor: 'green',
},
{
label: 'Step 7 — HTTP request to ad server',
desc: `The browser connects to the ad server and requests the tracking script. This script will set cookies, fingerprint your browser, and load display ads.`,
from: 'browser', to: 'ad-server',
lineType: 'http', isReturn: false,
packetLabel: 'GET /tracker.js',
skipInAdBlockMode: true,
hitNode: 'ad-server', hitText: 'Tracking you...', hitColor: 'red',
},
{
label: 'Step 8 — Ad content returned',
desc: `The ad server sends back JavaScript, tracking pixels, and ad creatives. Your data is collected, bandwidth is consumed, and ads clutter the page.`,
from: 'ad-server', to: 'browser',
lineType: 'http', isReturn: true,
packetLabel: 'Ads + Trackers',
skipInAdBlockMode: true,
hitNode: 'browser', hitText: 'Ads loaded :(', hitColor: 'red',
},
];
// Filter steps based on ad-block mode
let activeSteps = $derived.by(() => {
return STEPS.filter(step => {
if (adBlockEnabled && step.skipInAdBlockMode) return false;
if (!adBlockEnabled && step.blockedInAdBlockMode) return false;
return true;
});
});
let totalSteps = $derived(activeSteps.length);
// ── Line state tracking ────────────────────────────────────────────────────
interface LineState {
from: NodeId;
to: NodeId;
color: string;
isReturn: boolean;
progress: number;
}
let completedLines = $state<LineState[]>([]);
let animatingLine = $state<LineState | null>(null);
let litNodes = $state<Record<NodeId, string>>({
'browser': '', 'dns-server': '', 'website': '', 'ad-server': ''
});
let showBlockedIndicator = $state(false);
let badge = $state<{ node: NodeId; text: string; color: 'blue' | 'green' | 'red' } | null>(null);
let dot = $state<{ x: number; y: number; color: string; visible: boolean; label: string }>({
x: 0, y: 0, color: '', visible: false, label: ''
});
let stepLabel = $state('');
let desc = $state('');
// ── Line geometry ──────────────────────────────────────────────────────────
function getLineEndpoints(from: NodeId, to: NodeId, isReturn: boolean) {
let x1: number, y1: number, x2: number, y2: number;
if (isVertical) {
// Mobile 2x2 grid layout
// Top row: Browser (left), DNS Server (right)
// Bottom row: Website (left), Ad Server (right)
const offset = isReturn ? 6 : -6;
if ((from === 'browser' && to === 'dns-server') || (from === 'dns-server' && to === 'browser')) {
// Horizontal line across top row
x1 = from === 'browser' ? nright(from) : nleft(from);
y1 = ncy(from) + offset;
x2 = to === 'browser' ? nright(to) : nleft(to);
y2 = ncy(to) + offset;
} else if ((from === 'browser' && to === 'website') || (from === 'website' && to === 'browser')) {
// Vertical line down left column
const xOff = isReturn ? 10 : -10;
x1 = ncx(from) + xOff;
y1 = from === 'browser' ? nbottom(from) : ntop(from);
x2 = ncx(to) + xOff;
y2 = to === 'browser' ? nbottom(to) : ntop(to);
} else if ((from === 'browser' && to === 'ad-server') || (from === 'ad-server' && to === 'browser')) {
// Diagonal from top-left to bottom-right
x1 = from === 'browser' ? nright(from) : nleft(from);
y1 = from === 'browser' ? nbottom(from) : ntop(from);
x2 = to === 'browser' ? nright(to) : nleft(to);
y2 = to === 'browser' ? nbottom(to) : ntop(to);
} else {
x1 = ncx(from); y1 = ncy(from);
x2 = ncx(to); y2 = ncy(to);
}
} else {
// Desktop diamond layout
if ((from === 'browser' && to === 'dns-server') || (from === 'dns-server' && to === 'browser')) {
// Horizontal line between browser and DNS
const offset = isReturn ? 8 : -8;
x1 = from === 'browser' ? nright(from) : nleft(from);
y1 = ncy(from) + offset;
x2 = to === 'browser' ? nright(to) : nleft(to);
y2 = ncy(to) + offset;
} else if ((from === 'browser' && to === 'website') || (from === 'website' && to === 'browser')) {
// Diagonal: browser to website (top)
x1 = from === 'browser' ? nright(from) - 20 : ncx(from);
y1 = from === 'browser' ? ntop(from) : nbottom(from);
x2 = to === 'browser' ? nright(to) - 20 : ncx(to);
y2 = to === 'browser' ? ntop(to) : nbottom(to);
} else if ((from === 'browser' && to === 'ad-server') || (from === 'ad-server' && to === 'browser')) {
// Diagonal: browser to ad-server (bottom)
x1 = from === 'browser' ? nright(from) - 20 : ncx(from);
y1 = from === 'browser' ? nbottom(from) : ntop(from);
x2 = to === 'browser' ? nright(to) - 20 : ncx(to);
y2 = to === 'browser' ? nbottom(to) : ntop(to);
} else {
x1 = ncx(from); y1 = ncy(from);
x2 = ncx(to); y2 = ncy(to);
}
}
return { x1, y1, x2, y2 };
}
function getLineColor(type: 'dns' | 'http' | 'blocked') {
switch (type) {
case 'dns': return COLOR_DNS;
case 'http': return COLOR_HTTP;
case 'blocked': return COLOR_BLOCKED;
}
}
function getLineLength(from: NodeId, to: NodeId, isReturn: boolean) {
const { x1, y1, x2, y2 } = getLineEndpoints(from, to, isReturn);
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}
// ── Animation ──────────────────────────────────────────────────────────────
function ease(t: number) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
function animateStep(step: Step, cb: () => void) {
const { from, to, lineType, isReturn, packetLabel } = step;
const color = getLineColor(lineType);
const { x1, y1, x2, y2 } = getLineEndpoints(from, to, isReturn);
dot = { x: x1, y: y1, color, visible: true, label: packetLabel || '' };
litNodes = { ...litNodes, [from]: color };
badge = null;
// Start animating line
animatingLine = { from, to, color, isReturn, progress: 0 };
let t0: number | null = null;
const duration = 600;
function frame(ts: number) {
if (!t0) t0 = ts;
const raw = Math.min((ts - t0) / duration, 1);
const t = ease(raw);
const px = x1 + (x2 - x1) * t;
const py = y1 + (y2 - y1) * t;
dot = { x: px, y: py, color, visible: true, label: packetLabel || '' };
// Update line progress
animatingLine = { from, to, color, isReturn, progress: t };
if (raw < 1) {
requestAnimationFrame(frame);
} else {
dot = { ...dot, visible: false };
litNodes = { ...litNodes, [to]: color };
// Move to completed lines
completedLines = [...completedLines, { from, to, color, isReturn, progress: 1 }];
animatingLine = null;
if (step.blockedInAdBlockMode && adBlockEnabled) {
showBlockedIndicator = true;
}
// Show badge
if (step.hitNode && step.hitText && step.hitColor) {
badge = { node: step.hitNode, text: step.hitText, color: step.hitColor };
}
setTimeout(() => {
cb();
}, 200);
}
}
requestAnimationFrame(frame);
}
// ── Badge positioning ──────────────────────────────────────────────────────
function badgePos(id: NodeId) {
const n = NODES[id];
const w = isVertical ? 100 : 140;
const h = isVertical ? 20 : 24;
// Always position badge below the node
return {
x: n.x + (n.w - w) / 2,
y: n.y + n.h + 4,
cx: ncx(id),
cy: n.y + n.h + 4 + h / 2,
w,
h
};
}
// ── Step logic ─────────────────────────────────────────────────────────────
function getStateAtStep(stepIndex: number) {
const nodes: Record<NodeId, string> = {
'browser': '', 'dns-server': '', 'website': '', 'ad-server': ''
};
const lines: LineState[] = [];
let blocked = false;
let lastBadge: { node: NodeId; text: string; color: 'blue' | 'green' | 'red' } | null = null;
for (let i = 0; i <= stepIndex; i++) {
const step = activeSteps[i];
const color = getLineColor(step.lineType);
nodes[step.from] = color;
nodes[step.to] = color;
lines.push({ from: step.from, to: step.to, color, isReturn: step.isReturn, progress: 1 });
if (step.blockedInAdBlockMode && adBlockEnabled) {
blocked = true;
}
if (step.hitNode && step.hitText && step.hitColor) {
lastBadge = { node: step.hitNode, text: step.hitText, color: step.hitColor };
}
}
return { nodes, lines, blocked, badge: lastBadge };
}
function applyStateAtStep(stepIndex: number) {
const state = getStateAtStep(stepIndex);
litNodes = state.nodes;
completedLines = state.lines;
animatingLine = null;
showBlockedIndicator = state.blocked;
badge = state.badge;
const step = activeSteps[stepIndex];
stepLabel = step.label;
desc = step.desc;
}
function advance() {
if (busy || done) return;
current++;
busy = true;
badge = null;
const step = activeSteps[current];
stepLabel = step.label;
desc = step.desc;
animateStep(step, () => {
busy = false;
if (current === totalSteps - 1) done = true;
});
}
function goBack() {
if (busy || current < 0) return;
current--;
done = false;
if (current < 0) {
reset();
} else {
applyStateAtStep(current);
}
}
function reset() {
current = -1;
busy = false;
done = false;
stepLabel = '';
showBlockedIndicator = false;
badge = null;
dot = { x: 0, y: 0, color: '', visible: false, label: '' };
litNodes = { 'browser': '', 'dns-server': '', 'website': '', 'ad-server': '' };
completedLines = [];
animatingLine = null;
desc = adBlockEnabled
? `Ad-blocker is ON. Press "Start" to see how blocked requests work.`
: `Ad-blocker is OFF. Press "Start" to see a typical page load with ads.`;
}
function toggleAdBlock() {
adBlockEnabled = !adBlockEnabled;
reset();
}
// Initialize description
$effect(() => {
if (current === -1) {
desc = adBlockEnabled
? `Ad-blocker is ON. Press "Start" to see how blocked requests work.`
: `Ad-blocker is OFF. Press "Start" to see a typical page load with ads.`;
}
});
// ── Render line with progress ──────────────────────────────────────────────
function renderLine(line: LineState) {
const { x1, y1, x2, y2 } = getLineEndpoints(line.from, line.to, line.isReturn);
const length = getLineLength(line.from, line.to, line.isReturn);
const drawn = length * line.progress;
const dashArray = `${drawn} ${length - drawn}`;
return { x1, y1, x2, y2, dashArray };
}
</script>
<!-- ── Markup ──────────────────────────────────────────────────────────────── -->
<div class="mx-auto my-8 w-full max-w-2xl font-sans">
<svg
width="100%"
viewBox="0 0 {viewBoxWidth} {viewBoxHeight}"
aria-label="Interactive ad-blocking diagram"
class="overflow-visible"
>
<defs>
<marker id="arr-dns-ab" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto">
<path d="M2 1L8 5L2 9" fill="none" stroke={COLOR_DNS} stroke-width="1.5" stroke-linecap="round"/>
</marker>
<marker id="arr-http-ab" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto">
<path d="M2 1L8 5L2 9" fill="none" stroke={COLOR_HTTP} stroke-width="1.5" stroke-linecap="round"/>
</marker>
<marker id="arr-blocked-ab" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto">
<path d="M2 1L8 5L2 9" fill="none" stroke={COLOR_BLOCKED} stroke-width="1.5" stroke-linecap="round"/>
</marker>
</defs>
<!-- Completed lines -->
{#each completedLines as line}
{@const { x1, y1, x2, y2 } = getLineEndpoints(line.from, line.to, line.isReturn)}
<line
{x1} {y1} {x2} {y2}
stroke={line.color}
stroke-width="2"
marker-end={line.color === COLOR_HTTP ? 'url(#arr-http-ab)' : line.color === COLOR_BLOCKED ? 'url(#arr-blocked-ab)' : 'url(#arr-dns-ab)'}
/>
{/each}
<!-- Animating line (draws progressively, no arrow until complete) -->
{#if animatingLine}
{@const rendered = renderLine(animatingLine)}
<line
x1={rendered.x1} y1={rendered.y1}
x2={rendered.x2} y2={rendered.y2}
stroke={animatingLine.color}
stroke-width="2"
stroke-dasharray={rendered.dashArray}
/>
{/if}
<!-- Nodes -->
{#each Object.entries(NODES) as [id, n]}
{@const nodeId = id as NodeId}
{@const isAdServer = nodeId === 'ad-server'}
{@const isBlocked = isAdServer && adBlockEnabled && showBlockedIndicator}
{@const lit = litNodes[nodeId]}
<g class="transition-opacity duration-300" opacity={isBlocked ? 0.4 : 1}>
<rect
x={n.x} y={n.y} width={n.w} height={n.h} rx="8"
fill="var(--card)"
stroke={isBlocked ? COLOR_BLOCKED : lit || 'var(--border)'}
stroke-width={lit || isBlocked ? 2 : 0.5}
stroke-dasharray={isBlocked ? '4,2' : 'none'}
/>
<!-- Icon based on node type -->
{#if nodeId === 'browser'}
<g transform="translate({n.x + n.w/2}, {n.y + (isVertical ? 18 : 22)})">
<rect x="-10" y="-10" width="20" height="14" rx="2" fill="none" stroke="var(--muted-foreground)" stroke-width="1.2"/>
<line x1="0" y1="4" x2="0" y2="8" stroke="var(--muted-foreground)" stroke-width="1.2"/>
<line x1="-5" y1="8" x2="5" y2="8" stroke="var(--muted-foreground)" stroke-width="1.2"/>
</g>
{:else if nodeId === 'dns-server'}
<g transform="translate({n.x + n.w/2}, {n.y + (isVertical ? 18 : 22)})">
<circle cx="0" cy="0" r="9" fill="none" stroke={adBlockEnabled ? COLOR_HTTP : 'var(--muted-foreground)'} stroke-width="1.2"/>
{#if adBlockEnabled}
<text x="0" y="4" font-size="12" font-weight="bold" fill={COLOR_HTTP} text-anchor="middle"></text>
{:else}
<text x="0" y="3" font-size="8" font-weight="500" fill="var(--muted-foreground)" text-anchor="middle">DNS</text>
{/if}
</g>
{:else if nodeId === 'website'}
<g transform="translate({n.x + n.w/2}, {n.y + (isVertical ? 18 : 22)})">
<rect x="-10" y="-8" width="20" height="16" rx="2" fill="none" stroke="var(--muted-foreground)" stroke-width="1"/>
<line x1="-10" y1="-4" x2="10" y2="-4" stroke="var(--muted-foreground)" stroke-width="0.8"/>
<circle cx="-6" cy="-6" r="1.5" fill="var(--muted-foreground)"/>
</g>
{:else if nodeId === 'ad-server'}
<g transform="translate({n.x + n.w/2}, {n.y + (isVertical ? 18 : 22)})">
<rect x="-10" y="-8" width="20" height="16" rx="2" fill="none" stroke={isBlocked ? COLOR_BLOCKED : 'var(--muted-foreground)'} stroke-width="1"/>
<text x="0" y="4" font-size="9" font-weight="bold" fill={isBlocked ? COLOR_BLOCKED : 'var(--muted-foreground)'} text-anchor="middle">AD</text>
</g>
{/if}
<text x={n.x + n.w/2} y={n.y + n.h - (isVertical ? 6 : 8)} class="node-title" text-anchor="middle">{n.title}</text>
</g>
{/each}
<!-- Travelling dot + packet label -->
{#if dot.visible}
<circle cx={dot.x} cy={dot.y} r="5" fill={dot.color}/>
{#if dot.label}
{@const pw = dot.label.length * 5.5 + 14}
<rect x={dot.x - pw/2} y={dot.y - 24} width={pw} height="18" rx="4" fill={dot.color} opacity="0.95"/>
<text x={dot.x} y={dot.y - 12} class="packet-label">{dot.label}</text>
{/if}
{/if}
<!-- Badge -->
{#if badge}
{@const bp = badgePos(badge.node)}
<g class="badge-pop">
<rect
x={bp.x} y={bp.y} width={bp.w} height={bp.h} rx={isVertical ? 4 : 6}
fill={badge.color === 'green' ? 'oklch(0.95 0.05 145)'
: badge.color === 'red' ? 'oklch(0.95 0.05 25)'
: 'oklch(0.95 0.05 240)'}
stroke={badge.color === 'green' ? 'oklch(0.75 0.15 145)'
: badge.color === 'red' ? 'oklch(0.75 0.15 25)'
: 'oklch(0.75 0.15 240)'}
stroke-width="0.5"
/>
<text
x={bp.cx} y={bp.cy}
text-anchor="middle"
font-size={isVertical ? 8 : 10}
font-weight="500"
fill={badge.color === 'green' ? 'oklch(0.40 0.15 145)'
: badge.color === 'red' ? 'oklch(0.40 0.15 25)'
: 'oklch(0.40 0.15 240)'}
>{badge.text}</text>
</g>
{/if}
<!-- Step label (desktop only, positioned at very bottom) -->
{#if stepLabel && !isVertical}
<text x={viewBoxWidth / 2} y={viewBoxHeight - 12} class="step-label">{stepLabel}</text>
{/if}
</svg>
<!-- Mobile step label -->
{#if isVertical && stepLabel}
<p class="mt-2 text-center text-xs font-medium text-muted-foreground">{stepLabel}</p>
{/if}
<!-- Description -->
<p class="mb-2 mt-2 h-20 overflow-y-auto rounded-lg bg-muted px-3 py-2 text-sm leading-relaxed text-muted-foreground sm:mb-3 sm:mt-4 sm:h-auto sm:min-h-16 sm:px-3.5 sm:py-2.5">
{desc}
</p>
<!-- Controls -->
<div class="flex flex-wrap items-center gap-2 pb-2 sm:pb-0">
<!-- Back button -->
<button
onclick={goBack}
disabled={busy || current < 0}
class="inline-flex h-8 w-16 items-center justify-center gap-1 whitespace-nowrap rounded-full border bg-background text-xs font-medium shadow-xs transition-all hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 sm:h-9 sm:w-20 sm:text-sm dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
>
← Back
</button>
<!-- Next/Start button -->
{#if !done}
<button
onclick={advance}
disabled={busy}
class="inline-flex h-8 w-16 items-center justify-center gap-1 whitespace-nowrap rounded-full border bg-background text-xs font-medium shadow-xs transition-all hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 sm:h-9 sm:w-20 sm:text-sm dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
>
{current < 0 ? 'Start' : 'Next →'}
</button>
{:else}
<button
onclick={reset}
class="inline-flex h-8 w-16 items-center justify-center gap-1 whitespace-nowrap rounded-full border bg-background text-xs font-medium shadow-xs transition-all hover:bg-accent hover:text-accent-foreground sm:h-9 sm:w-20 sm:text-sm dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
>
Restart
</button>
{/if}
<!-- Reset button -->
<button
onclick={reset}
disabled={current < 0}
class="inline-flex h-8 w-8 items-center justify-center whitespace-nowrap rounded-full border bg-background text-xs font-medium shadow-xs transition-all hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 sm:h-9 sm:w-9 sm:text-sm dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
title="Reset"
>
</button>
<!-- Step counter -->
<span class="text-xs text-muted-foreground">
{Math.max(current + 1, 0)} / {totalSteps}
</span>
<!-- Spacer -->
<div class="flex-1"></div>
<!-- Ad-blocker toggle -->
<div class="flex items-center gap-1.5 sm:gap-2">
<span class="text-xs text-muted-foreground hidden sm:inline">Blocker:</span>
<button
onclick={toggleAdBlock}
class="relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 sm:h-6 sm:w-11"
class:bg-green-500={adBlockEnabled}
class:bg-gray-300={!adBlockEnabled}
class:dark:bg-green-600={adBlockEnabled}
class:dark:bg-gray-600={!adBlockEnabled}
aria-pressed={adBlockEnabled}
aria-label="Toggle ad-blocker"
>
<span
class="inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow-md transition-transform duration-200 sm:h-4 sm:w-4"
style="transform: translateX({adBlockEnabled ? (isVertical ? '1.125rem' : '1.5rem') : '0.125rem'})"
></span>
</button>
<span class="text-xs font-semibold" class:text-green-600={adBlockEnabled} class:dark:text-green-400={adBlockEnabled} class:text-gray-500={!adBlockEnabled}>
{adBlockEnabled ? 'ON' : 'OFF'}
</span>
</div>
</div>
</div>
<!-- ── Styles ─────────────────────────────────────────────────────────────── -->
<style>
.node-title {
font-size: 10px;
font-weight: 500;
fill: var(--foreground);
font-family: var(--font-sans);
}
.packet-label {
font-size: 9px;
font-family: var(--font-mono);
fill: white;
text-anchor: middle;
}
.step-label {
font-size: 11px;
fill: var(--muted-foreground);
text-anchor: middle;
font-family: var(--font-sans);
}
.badge-pop {
animation: badge-pop 0.28s ease forwards;
}
@keyframes badge-pop {
0% { opacity: 0; transform: translateY(5px); }
65% { opacity: 1; transform: translateY(-1px); }
100% { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { cn } from '@/lib/utils'
interface Props {
src?: string
alt?: string
fallback: string
class?: string
}
let { src, alt = '', fallback, class: className }: Props = $props()
let imageError = $state(false)
let imageLoaded = $state(false)
function handleError() {
imageError = true
}
function handleLoad() {
imageLoaded = true
}
</script>
<div
data-slot="avatar"
class={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className
)}
>
{#if src && !imageError}
<img
data-slot="avatar-image"
{src}
{alt}
class={cn('aspect-square size-full object-cover', !imageLoaded && 'opacity-0')}
onload={handleLoad}
onerror={handleError}
/>
{/if}
{#if !src || imageError || !imageLoaded}
<span
data-slot="avatar-fallback"
class={cn(
'bg-muted flex size-full items-center justify-center rounded-full text-xs font-medium',
src && imageLoaded && 'hidden'
)}
>
{fallback}
</span>
{/if}
</div>

View File

@@ -0,0 +1,32 @@
<script lang="ts" module>
export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline'
</script>
<script lang="ts">
import { cn } from '@/lib/utils'
import type { Snippet } from 'svelte'
interface Props {
variant?: BadgeVariant
class?: string
children: Snippet
}
let { variant = 'default', class: className, children }: Props = $props()
const baseClasses = 'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit max-w-full whitespace-nowrap [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none [&>svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] transition-[color,box-shadow] overflow-hidden text-ellipsis'
const variantClasses: Record<BadgeVariant, string> = {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-white focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70',
outline: 'text-foreground'
}
</script>
<span
data-slot="badge"
class={cn(baseClasses, variantClasses[variant], className)}
>
{@render children()}
</span>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import Badge from './Badge.svelte'
import IconHash from '~icons/lucide/hash'
interface BlogEntry {
id: string
collection: string
data: {
title: string
description: string
tags?: string[]
}
}
interface Props {
entry: BlogEntry
}
let { entry }: Props = $props()
</script>
<div class="hover:bg-secondary/50 rounded-xl border p-4 transition-colors duration-300 ease-in-out">
<a
href={`/${entry.collection}/${entry.id}`}
class="flex flex-col gap-4 sm:flex-row"
>
<div class="grow min-w-0">
<h3 class="mb-1 text-lg font-medium wrap-break-word">{entry.data.title}</h3>
<p class="text-muted-foreground mb-2 text-sm">
{entry.data.description}
</p>
{#if entry.data.tags}
<div class="flex flex-wrap gap-2">
{#each entry.data.tags as tag}
<Badge variant="secondary" class="flex items-center gap-x-1">
<IconHash class="size-3" />
{tag}
</Badge>
{/each}
</div>
{/if}
</div>
</a>
</div>

View File

@@ -0,0 +1,187 @@
<script lang="ts">
/// <reference types="../../types/turnstile" />
import { onMount } from 'svelte'
import IconSend from '~icons/lucide/send'
import IconCheck from '~icons/lucide/check-circle'
import IconAlertCircle from '~icons/lucide/alert-circle'
let formState = $state<'idle' | 'submitting' | 'success' | 'error'>('idle')
let errorMessage = $state('')
let turnstileWidgetId: string | null = $state(null)
let turnstileContainer: HTMLElement
// Get Turnstile sitekey from environment variable
// Dev: uses test key from .env
// Prod: uses real key from Cloudflare Pages env vars
const TURNSTILE_SITEKEY = import.meta.env.PUBLIC_TURNSTILE_SITEKEY || '1x00000000000000000000AA'
onMount(() => {
// Wait for Turnstile script to load
const interval = setInterval(() => {
if (typeof window.turnstile !== 'undefined') {
clearInterval(interval)
// Render Turnstile widget
turnstileWidgetId = window.turnstile.render(turnstileContainer, {
sitekey: TURNSTILE_SITEKEY,
theme: 'auto',
size: 'normal',
appearance: 'execute',
})
}
}, 100)
return () => {
clearInterval(interval)
if (turnstileWidgetId !== null && typeof window.turnstile !== 'undefined') {
window.turnstile.remove(turnstileWidgetId)
}
}
})
async function handleSubmit(event: Event) {
event.preventDefault()
formState = 'submitting'
errorMessage = ''
// Get Turnstile token
let turnstileToken = ''
if (turnstileWidgetId !== null && typeof window.turnstile !== 'undefined') {
turnstileToken = window.turnstile.getResponse(turnstileWidgetId)
if (!turnstileToken) {
formState = 'error'
errorMessage = 'Please complete the verification challenge.'
return
}
}
const form = event.target as HTMLFormElement
const formData = new FormData(form)
// Add Turnstile token to form data
const data = {
...Object.fromEntries(formData),
'cf-turnstile-response': turnstileToken,
}
try {
const response = await fetch('https://submit-form.com/JXY5zUICN', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(data),
})
if (response.ok) {
formState = 'success'
form.reset()
// Reset Turnstile widget
if (turnstileWidgetId !== null && typeof window.turnstile !== 'undefined') {
window.turnstile.reset(turnstileWidgetId)
}
// Reset to idle after 5 seconds
setTimeout(() => {
formState = 'idle'
}, 5000)
} else {
formState = 'error'
errorMessage = 'Failed to send message. Please try again.'
// Reset Turnstile on error
if (turnstileWidgetId !== null && typeof window.turnstile !== 'undefined') {
window.turnstile.reset(turnstileWidgetId)
}
}
} catch (error) {
formState = 'error'
errorMessage = 'Network error. Please check your connection and try again.'
// Reset Turnstile on error
if (turnstileWidgetId !== null && typeof window.turnstile !== 'undefined') {
window.turnstile.reset(turnstileWidgetId)
}
}
}
</script>
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label for="name" class="text-sm font-medium text-foreground">
Name <span class="text-destructive">*</span>
</label>
<input
type="text"
id="name"
name="name"
placeholder="Your name"
required
disabled={formState === 'submitting'}
class="w-full rounded-md border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200"
/>
</div>
<div class="flex flex-col gap-2">
<label for="email" class="text-sm font-medium text-foreground">
Email <span class="text-destructive">*</span>
</label>
<input
type="email"
id="email"
name="email"
placeholder="your.email@example.com"
required
disabled={formState === 'submitting'}
class="w-full rounded-md border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200"
/>
</div>
<div class="flex flex-col gap-2">
<label for="message" class="text-sm font-medium text-foreground">
Message <span class="text-destructive">*</span>
</label>
<textarea
id="message"
name="message"
placeholder="Tell me about the opportunity..."
required
rows="5"
disabled={formState === 'submitting'}
class="w-full resize-y rounded-md border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200 min-h-[120px]"
></textarea>
</div>
<!-- Turnstile Widget -->
<div bind:this={turnstileContainer} class="flex justify-center"></div>
{#if formState === 'success'}
<div class="flex items-center gap-2 rounded-md bg-primary/10 border border-primary/20 px-4 py-3 text-sm text-primary">
<IconCheck class="size-5 shrink-0" />
<p>Message sent successfully! I'll get back to you soon.</p>
</div>
{/if}
{#if formState === 'error'}
<div class="flex items-center gap-2 rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
<IconAlertCircle class="size-5 shrink-0" />
<p>{errorMessage}</p>
</div>
{/if}
<button
type="submit"
disabled={formState === 'submitting'}
class="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-6 py-2.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200"
>
{#if formState === 'submitting'}
<span class="inline-block size-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
Sending...
{:else}
<IconSend class="size-4" />
Send Message
{/if}
</button>
</form>

View File

@@ -0,0 +1,795 @@
<script lang="ts">
import { onMount } from 'svelte';
// ── Types ──────────────────────────────────────────────────────────────────
type NodeId = 'device' | 'browser-cache' | 'os-cache' | 'resolver' | 'google-server';
interface Step {
label: string;
desc: string;
from: NodeId;
to: NodeId;
isReturn: boolean;
hitNode?: NodeId;
hitText?: string;
hitColor?: 'blue' | 'green' | 'red';
packetLabel?: string;
cacheAt?: NodeId;
lineId: string;
showHostsCheck?: boolean;
}
interface NodeLayout {
x: number;
y: number;
w: number;
h: number;
title: string;
sub: string;
}
// ── Constants ──────────────────────────────────────────────────────────────
const DOMAIN = 'google.com';
const IP = '142.250.80.46';
// Horizontal layout (desktop)
const NODES_H: Record<NodeId, NodeLayout> = {
'device': { x: 20, y: 60, w: 140, h: 60, title: 'Your device', sub: 'Browser' },
'browser-cache': { x: 200, y: 60, w: 140, h: 60, title: 'Browser cache', sub: 'TTL-based' },
'os-cache': { x: 380, y: 60, w: 140, h: 60, title: 'OS layer', sub: 'hosts + cache' },
'resolver': { x: 560, y: 60, w: 140, h: 60, title: 'Resolver', sub: '1.1.1.1' },
'google-server': { x: 20, y: 200, w: 140, h: 60, title: 'Google server', sub: IP },
};
// Two-column mobile layout: device + google on left, DNS chain on right
const NODES_V: Record<NodeId, NodeLayout> = {
// Left column
'device': { x: 8, y: 8, w: 130, h: 50, title: 'Your device', sub: 'Browser' },
'google-server': { x: 8, y: 148, w: 130, h: 50, title: 'Google', sub: IP },
// Right column (DNS chain)
'browser-cache': { x: 158, y: 8, w: 130, h: 50, title: 'Browser cache', sub: 'TTL-based' },
'os-cache': { x: 158, y: 78, w: 130, h: 50, title: 'OS layer', sub: 'hosts + cache' },
'resolver': { x: 158, y: 148, w: 130, h: 50, title: 'Resolver', sub: '1.1.1.1' },
};
const DNS_ORDER: NodeId[] = ['device', 'browser-cache', 'os-cache', 'resolver'];
// ── Reactive layout ────────────────────────────────────────────────────────
let isVertical = $state(false);
let NODES = $derived(isVertical ? NODES_V : NODES_H);
let viewBoxWidth = $derived(isVertical ? 296 : 720);
let viewBoxHeight = $derived(isVertical ? 206 : 300);
function checkLayout() {
if (typeof window !== 'undefined') {
isVertical = window.innerWidth < 640;
}
}
onMount(() => {
checkLayout();
window.addEventListener('resize', checkLayout);
return () => window.removeEventListener('resize', checkLayout);
});
// ── Helper functions ───────────────────────────────────────────────────────
function ncx(id: NodeId) { return NODES[id].x + NODES[id].w / 2; }
function ncy(id: NodeId) { return NODES[id].y + NODES[id].h / 2; }
function nright(id: NodeId) { return NODES[id].x + NODES[id].w; }
function nleft(id: NodeId) { return NODES[id].x; }
function ntop(id: NodeId) { return NODES[id].y; }
function nbottom(id: NodeId) { return NODES[id].y + NODES[id].h; }
// ── Line definitions ───────────────────────────────────────────────────────
interface LineConfig {
id: string;
from: NodeId;
to: NodeId;
}
const LINES: LineConfig[] = [
{ id: 'dns-0', from: 'device', to: 'browser-cache' },
{ id: 'dns-1', from: 'browser-cache', to: 'os-cache' },
{ id: 'dns-2', from: 'os-cache', to: 'resolver' },
{ id: 'http', from: 'device', to: 'google-server' },
];
function getLineEndpoints(lineId: string, reverse: boolean = false) {
const line = LINES.find(l => l.id === lineId);
if (!line) return { x1: 0, y1: 0, x2: 0, y2: 0 };
const { from, to } = line;
let pts: { x1: number; y1: number; x2: number; y2: number };
if (isVertical) {
// Two-column mobile layout
if (lineId === 'dns-0') {
// device → browser-cache: horizontal (right of device to left of browser-cache)
pts = {
x1: nright(from), y1: ncy(from),
x2: nleft(to), y2: ncy(to)
};
} else if (lineId === 'http') {
// device → google-server: vertical down left column
pts = {
x1: ncx(from), y1: nbottom(from),
x2: ncx(to), y2: ntop(to)
};
} else {
// dns-1, dns-2: vertical down right column
pts = {
x1: ncx(from), y1: nbottom(from),
x2: ncx(to), y2: ntop(to)
};
}
} else {
// Desktop horizontal layout
if (lineId === 'http') {
pts = {
x1: ncx(from), y1: nbottom(from),
x2: ncx(to), y2: ntop(to)
};
} else {
pts = {
x1: nright(from), y1: ncy(from),
x2: nleft(to), y2: ncy(to)
};
}
}
return reverse ? { x1: pts.x2, y1: pts.y2, x2: pts.x1, y2: pts.y1 } : pts;
}
function getLineLength(lineId: string) {
const { x1, y1, x2, y2 } = getLineEndpoints(lineId);
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}
// ── Steps ──────────────────────────────────────────────────────────────────
const STEPS: Step[] = [
{
label: 'Step 1 / 10 — browser cache',
desc: `The browser checks its own DNS cache for "${DOMAIN}". This is the first time visiting, so nothing is cached yet.`,
from: 'device', to: 'browser-cache',
isReturn: false, lineId: 'dns-0',
hitNode: 'browser-cache', hitText: 'Cache miss', hitColor: 'red',
packetLabel: DOMAIN,
},
{
label: 'Step 2 / 10 — /etc/hosts & OS cache',
desc: `The OS first checks /etc/hosts — a static file where you can manually map domains (this is how ad-blocking works: blocked domains point to 0.0.0.0). Then it checks its DNS cache. Nothing found.`,
from: 'browser-cache', to: 'os-cache',
isReturn: false, lineId: 'dns-1',
hitNode: 'os-cache', hitText: 'Not found locally', hitColor: 'red',
packetLabel: DOMAIN,
showHostsCheck: true,
},
{
label: 'Step 3 / 10 — query resolver',
desc: `Nothing locally. The query goes to the recursive resolver at 1.1.1.1, which contacts authoritative DNS servers on your behalf.`,
from: 'os-cache', to: 'resolver',
isReturn: false, lineId: 'dns-2',
hitNode: 'resolver', hitText: `Resolved: ${IP}`, hitColor: 'blue',
packetLabel: DOMAIN,
},
{
label: 'Step 4 / 10 — response to OS',
desc: `The resolver returns ${IP}. The OS stores this in its DNS cache (not /etc/hosts — that file is only for manual entries). The cache respects the TTL.`,
from: 'resolver', to: 'os-cache',
isReturn: true, lineId: 'dns-2',
hitNode: 'os-cache', hitText: 'Stored in OS cache', hitColor: 'green',
packetLabel: IP, cacheAt: 'os-cache',
},
{
label: 'Step 5 / 10 — response to browser',
desc: `The OS passes the IP to the browser, which stores it in its own cache for even faster subsequent lookups.`,
from: 'os-cache', to: 'browser-cache',
isReturn: true, lineId: 'dns-1',
hitNode: 'browser-cache', hitText: 'Stored in browser', hitColor: 'green',
packetLabel: IP, cacheAt: 'browser-cache',
},
{
label: 'Step 6 / 10 — DNS complete!',
desc: `The browser receives ${IP}. DNS resolution complete! The browser can now open a TCP connection to Google's server.`,
from: 'browser-cache', to: 'device',
isReturn: true, lineId: 'dns-0',
hitNode: 'device', hitText: 'Ready to connect!', hitColor: 'green',
packetLabel: IP,
},
{
label: 'Step 7 / 10 — connect to server',
desc: `Using the resolved IP, the browser connects to ${IP} and requests the webpage. The server responds with HTML, CSS, and JavaScript.`,
from: 'device', to: 'google-server',
isReturn: false, lineId: 'http',
hitNode: 'google-server', hitText: 'Connected!', hitColor: 'green',
packetLabel: 'GET /',
},
{
label: 'Step 8 / 10 — second visit',
desc: `You visit "${DOMAIN}" again. The browser checks its cache...`,
from: 'device', to: 'browser-cache',
isReturn: false, lineId: 'dns-0',
hitNode: 'browser-cache', hitText: 'Cache hit!', hitColor: 'green',
packetLabel: DOMAIN,
},
{
label: 'Step 9 / 10 — instant response',
desc: `Cache hit! The IP is returned immediately — no network lookup, no /etc/hosts check, nothing leaves your machine.`,
from: 'browser-cache', to: 'device',
isReturn: true, lineId: 'dns-0',
hitNode: 'device', hitText: 'Got IP instantly!', hitColor: 'green',
packetLabel: IP,
},
{
label: 'Step 10 / 10 — connect to server',
desc: `With the cached IP, the browser connects immediately. DNS caching makes repeat visits much faster!`,
from: 'device', to: 'google-server',
isReturn: false, lineId: 'http',
hitNode: 'google-server', hitText: 'Connected!', hitColor: 'green',
packetLabel: 'GET /',
},
];
// ── Reactive state ─────────────────────────────────────────────────────────
let litNodes = $state<Record<NodeId, string>>({
'device': '', 'browser-cache': '', 'os-cache': '', 'resolver': '', 'google-server': ''
});
let cachedNodes = $state<Record<NodeId, boolean>>({
'device': false, 'browser-cache': false, 'os-cache': false, 'resolver': false, 'google-server': false
});
let hostsFileChecking = $state(false);
let lineStates = $state<Record<string, { visible: boolean; progress: number; reverse: boolean; color: string }>>({
'dns-0': { visible: false, progress: 0, reverse: false, color: '' },
'dns-1': { visible: false, progress: 0, reverse: false, color: '' },
'dns-2': { visible: false, progress: 0, reverse: false, color: '' },
'http': { visible: false, progress: 0, reverse: false, color: '' },
});
let badge = $state<{ node: NodeId; text: string; color: 'blue' | 'green' | 'red' } | null>(null);
let dot = $state<{ x: number; y: number; color: string; visible: boolean }>({ x: 0, y: 0, color: '', visible: false });
let packet = $state<{ label: string; visible: boolean }>({ label: '', visible: false });
let current = $state(-1);
let busy = $state(false);
let done = $state(false);
let stepLabel = $state('');
let desc = $state(`Press "Start" to walk through a DNS lookup for ${DOMAIN}.`);
const COLOR_PRIMARY = 'oklch(0.65 0.19 240)';
const COLOR_SUCCESS = 'oklch(0.65 0.19 145)';
// ── Animation ──────────────────────────────────────────────────────────────
function ease(t: number) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
function animateStep(
lineId: string,
reverse: boolean,
color: string,
packetLbl: string | undefined,
ms: number,
cb: () => void
) {
const { x1, y1, x2, y2 } = getLineEndpoints(lineId, reverse);
dot = { x: x1, y: y1, color, visible: true };
packet = { label: packetLbl ?? '', visible: !!packetLbl };
lineStates = {
...lineStates,
[lineId]: { visible: true, progress: 0, reverse, color }
};
let t0: number | null = null;
function frame(ts: number) {
if (!t0) t0 = ts;
const raw = Math.min((ts - t0) / ms, 1);
const t = ease(raw);
const px = x1 + (x2 - x1) * t;
const py = y1 + (y2 - y1) * t;
dot = { x: px, y: py, color, visible: true };
lineStates = {
...lineStates,
[lineId]: { visible: true, progress: t, reverse, color }
};
if (raw < 1) {
requestAnimationFrame(frame);
} else {
dot = { ...dot, visible: false };
packet = { ...packet, visible: false };
cb();
}
}
requestAnimationFrame(frame);
}
// ── Step logic ─────────────────────────────────────────────────────────────
function getStateAtStep(stepIndex: number) {
// Calculate what the state should be at a given step
const nodes: Record<NodeId, string> = {
'device': '', 'browser-cache': '', 'os-cache': '', 'resolver': '', 'google-server': ''
};
const cached: Record<NodeId, boolean> = {
'device': false, 'browser-cache': false, 'os-cache': false, 'resolver': false, 'google-server': false
};
const lines: Record<string, { visible: boolean; progress: number; reverse: boolean; color: string }> = {
'dns-0': { visible: false, progress: 0, reverse: false, color: '' },
'dns-1': { visible: false, progress: 0, reverse: false, color: '' },
'dns-2': { visible: false, progress: 0, reverse: false, color: '' },
'http': { visible: false, progress: 0, reverse: false, color: '' },
};
for (let i = 0; i <= stepIndex; i++) {
// Reset before step 8 (second lookup)
if (i === 7) {
Object.keys(nodes).forEach(k => nodes[k as NodeId] = '');
Object.keys(lines).forEach(k => {
lines[k] = { visible: false, progress: 0, reverse: false, color: '' };
});
}
const step = STEPS[i];
const color = step.isReturn ? COLOR_SUCCESS : COLOR_PRIMARY;
nodes[step.from] = color;
nodes[step.to] = color;
if (step.cacheAt) {
cached[step.cacheAt] = true;
}
lines[step.lineId] = { visible: true, progress: 1, reverse: step.isReturn, color };
}
return { nodes, cached, lines };
}
function applyStateAtStep(stepIndex: number) {
const state = getStateAtStep(stepIndex);
litNodes = state.nodes;
cachedNodes = state.cached;
lineStates = state.lines;
const step = STEPS[stepIndex];
stepLabel = step.label;
desc = step.desc;
if (step.hitNode && step.hitText && step.hitColor) {
badge = { node: step.hitNode, text: step.hitText, color: step.hitColor };
} else {
badge = null;
}
}
function runStep(step: Step, animate: boolean = true) {
busy = true;
badge = null;
stepLabel = step.label;
desc = step.desc;
hostsFileChecking = false;
const color = step.isReturn ? COLOR_SUCCESS : COLOR_PRIMARY;
litNodes = { ...litNodes, [step.from]: color };
if (step.showHostsCheck) {
hostsFileChecking = true;
}
const duration = animate ? 600 : 0;
if (!animate) {
// Instant state update
applyStateAtStep(current);
busy = false;
if (current === STEPS.length - 1) done = true;
return;
}
animateStep(step.lineId, step.isReturn, color, step.packetLabel, duration, () => {
litNodes = { ...litNodes, [step.to]: color };
hostsFileChecking = false;
if (step.cacheAt) {
cachedNodes = { ...cachedNodes, [step.cacheAt]: true };
}
if (step.hitNode && step.hitText && step.hitColor) {
badge = { node: step.hitNode, text: step.hitText, color: step.hitColor };
}
setTimeout(() => {
busy = false;
if (current === STEPS.length - 1) done = true;
}, 200);
});
}
function advance() {
if (busy || done) return;
current++;
// Reset before step 8 (second lookup)
if (current === 7) {
litNodes = { 'device': '', 'browser-cache': '', 'os-cache': '', 'resolver': '', 'google-server': '' };
lineStates = {
'dns-0': { visible: false, progress: 0, reverse: false, color: '' },
'dns-1': { visible: false, progress: 0, reverse: false, color: '' },
'dns-2': { visible: false, progress: 0, reverse: false, color: '' },
'http': { visible: false, progress: 0, reverse: false, color: '' },
};
badge = null;
}
runStep(STEPS[current]);
}
function goBack() {
if (busy || current < 0) return;
current--;
done = false;
if (current < 0) {
// Go back to initial state
reset();
} else {
// Apply the state at the previous step instantly
applyStateAtStep(current);
}
}
function reset() {
current = -1;
busy = false;
done = false;
stepLabel = '';
badge = null;
hostsFileChecking = false;
cachedNodes = { 'device': false, 'browser-cache': false, 'os-cache': false, 'resolver': false, 'google-server': false };
dot = { x: 0, y: 0, color: '', visible: false };
packet = { label: '', visible: false };
litNodes = { 'device': '', 'browser-cache': '', 'os-cache': '', 'resolver': '', 'google-server': '' };
lineStates = {
'dns-0': { visible: false, progress: 0, reverse: false, color: '' },
'dns-1': { visible: false, progress: 0, reverse: false, color: '' },
'dns-2': { visible: false, progress: 0, reverse: false, color: '' },
'http': { visible: false, progress: 0, reverse: false, color: '' },
};
desc = `Press "Start" to walk through a DNS lookup for ${DOMAIN}.`;
}
// ── Badge geometry ─────────────────────────────────────────────────────────
function badgePos(id: NodeId) {
const n = NODES[id];
const w = 160;
if (isVertical) {
return {
x: n.x + n.w + 8, y: n.y + (n.h - 28) / 2,
cx: n.x + n.w + 8 + w / 2, cy: n.y + n.h / 2 + 4, w
};
} else {
return {
x: n.x + (n.w - w) / 2, y: n.y + n.h + 8,
cx: ncx(id), cy: n.y + n.h + 8 + 16, w
};
}
}
// ── Render helpers ─────────────────────────────────────────────────────────
function renderLine(lineId: string) {
const state = lineStates[lineId];
if (!state.visible) return null;
const length = getLineLength(lineId);
const { x1, y1, x2, y2 } = getLineEndpoints(lineId, state.reverse);
const drawn = length * state.progress;
const dashArray = `${drawn} ${length - drawn}`;
return { x1, y1, x2, y2, dashArray, color: state.color };
}
</script>
<!-- ── Markup ──────────────────────────────────────────────────────────────── -->
<div class="mx-auto my-8 w-full max-w-3xl font-sans">
<svg
width="100%"
viewBox="0 0 {viewBoxWidth} {viewBoxHeight}"
aria-label="Interactive DNS lookup flow diagram"
class="overflow-visible"
>
<defs>
<marker id="arr-blue" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M2 1L8 5L2 9" fill="none" stroke={COLOR_PRIMARY} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</marker>
<marker id="arr-green" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M2 1L8 5L2 9" fill="none" stroke={COLOR_SUCCESS} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</marker>
</defs>
<!-- Animated lines -->
{#each LINES as line}
{@const rendered = renderLine(line.id)}
{@const state = lineStates[line.id]}
{#if rendered}
<line
x1={rendered.x1} y1={rendered.y1}
x2={rendered.x2} y2={rendered.y2}
stroke={rendered.color}
stroke-width="2"
stroke-dasharray={rendered.dashArray}
marker-end={state.progress === 1 ? (rendered.color === COLOR_SUCCESS ? 'url(#arr-green)' : 'url(#arr-blue)') : undefined}
/>
{/if}
{/each}
<!-- Nodes -->
{#each [...DNS_ORDER, 'google-server'] as id}
{@const n = NODES[id as NodeId]}
{@const lit = litNodes[id as NodeId]}
{@const cached = cachedNodes[id as NodeId]}
<g>
<rect
x={n.x} y={n.y} width={n.w} height={n.h} rx="8"
fill="var(--card)"
stroke={lit || 'var(--border)'}
stroke-width={lit ? 2 : 0.5}
/>
<!-- Centered content -->
{#if id === 'device'}
<g transform="translate({n.x + n.w/2}, {n.y + n.h/2 - 4})">
<rect x="-10" y="-10" width="20" height="14" rx="2" fill="none" stroke="var(--muted-foreground)" stroke-width="1.2"/>
<line x1="0" y1="4" x2="0" y2="8" stroke="var(--muted-foreground)" stroke-width="1.2"/>
<line x1="-5" y1="8" x2="5" y2="8" stroke="var(--muted-foreground)" stroke-width="1.2"/>
</g>
<text x={n.x + n.w/2} y={n.y + n.h - 8} class="node-title" text-anchor="middle">{n.title}</text>
{:else if id === 'browser-cache'}
<g transform="translate({n.x + n.w/2}, {n.y + n.h/2 - 6})">
{#each [-6, 0, 6] as dy}
<rect x="-9" y={dy - 2} width="18" height="4" rx="1.5"
fill="none"
stroke={cached && dy === 0 ? COLOR_SUCCESS : 'var(--muted-foreground)'}
stroke-width="1"/>
{/each}
{#if cached}
<circle cx="0" cy="0" r="3" fill={COLOR_SUCCESS}/>
{/if}
</g>
<text x={n.x + n.w/2} y={n.y + n.h - 8} class="node-title" text-anchor="middle">{n.title}</text>
{:else if id === 'os-cache'}
<g transform="translate({n.x + n.w/2}, {n.y + n.h/2 - 6})">
<!-- Left: /etc/hosts file icon -->
<g transform="translate(-14, 0)" class={hostsFileChecking ? 'hosts-checking' : ''}>
<rect x="-6" y="-8" width="12" height="15" rx="1.5"
fill="none"
stroke={hostsFileChecking ? COLOR_PRIMARY : 'var(--muted-foreground)'}
stroke-width={hostsFileChecking ? 1.5 : 1}
/>
<line x1="-3" y1="-3" x2="3" y2="-3" stroke={hostsFileChecking ? COLOR_PRIMARY : 'var(--muted-foreground)'} stroke-width="0.7"/>
<line x1="-3" y1="1" x2="3" y2="1" stroke={hostsFileChecking ? COLOR_PRIMARY : 'var(--muted-foreground)'} stroke-width="0.7"/>
<line x1="-3" y1="5" x2="1" y2="5" stroke={hostsFileChecking ? COLOR_PRIMARY : 'var(--muted-foreground)'} stroke-width="0.7"/>
</g>
<!-- Right: OS cache (cylinder/database icon) -->
<g transform="translate(14, 0)">
<ellipse cx="0" cy="-6" rx="7" ry="3"
fill="none"
stroke={cached ? COLOR_SUCCESS : 'var(--muted-foreground)'}
stroke-width="1"/>
<path d="M-7,-6 L-7,4 A7,3 0 0,0 7,4 L7,-6"
fill="none"
stroke={cached ? COLOR_SUCCESS : 'var(--muted-foreground)'}
stroke-width="1"/>
{#if cached}
<circle cx="0" cy="0" r="3" fill={COLOR_SUCCESS}/>
{/if}
</g>
</g>
<!-- Label -->
<text
x={n.x + n.w/2}
y={n.y + n.h - 8}
class="node-title"
class:hosts-label-checking={hostsFileChecking}
text-anchor="middle"
>{hostsFileChecking ? 'checking /etc/hosts...' : n.title}</text>
{:else if id === 'resolver'}
<g transform="translate({n.x + n.w/2}, {n.y + n.h/2 - 4})">
<circle cx="0" cy="0" r="11" fill="none" stroke="var(--muted-foreground)" stroke-width="1"/>
<ellipse cx="0" cy="0" rx="4.5" ry="11" fill="none" stroke="var(--muted-foreground)" stroke-width="0.8"/>
<line x1="-11" y1="0" x2="11" y2="0" stroke="var(--muted-foreground)" stroke-width="0.8"/>
</g>
<text x={n.x + n.w/2} y={n.y + n.h - 8} class="node-title" text-anchor="middle">{n.title}</text>
{:else if id === 'google-server'}
<g transform="translate({n.x + n.w/2}, {n.y + n.h/2 - 4})">
<rect x="-12" y="-10" width="24" height="8" rx="2" fill="none" stroke="var(--muted-foreground)" stroke-width="1"/>
<rect x="-12" y="2" width="24" height="8" rx="2" fill="none" stroke="var(--muted-foreground)" stroke-width="1"/>
<circle cx="-7" cy="-6" r="1.5" fill="var(--muted-foreground)"/>
<circle cx="-7" cy="6" r="1.5" fill="var(--muted-foreground)"/>
</g>
<text x={n.x + n.w/2} y={n.y + n.h - 8} class="node-title" text-anchor="middle">{n.title}</text>
{/if}
</g>
{/each}
<!-- Travelling dot + packet label -->
{#if dot.visible}
<circle cx={dot.x} cy={dot.y} r="5" fill={dot.color}/>
{#if packet.visible && packet.label}
{@const pw = packet.label.length * 6.4 + 12}
<rect x={dot.x - pw/2} y={dot.y - 22} width={pw} height="16" rx="4" fill={dot.color} opacity="0.92"/>
<text x={dot.x} y={dot.y - 11} class="packet-label">{packet.label}</text>
{/if}
{/if}
<!-- Badge (desktop) -->
{#if badge && !isVertical}
{@const bp = badgePos(badge.node)}
<g class="badge">
<rect
x={bp.x} y={bp.y} width={bp.w} height="28" rx="6"
fill={badge.color === 'green' ? 'oklch(0.95 0.05 145)'
: badge.color === 'red' ? 'oklch(0.95 0.05 25)'
: 'oklch(0.95 0.05 240)'}
stroke={badge.color === 'green' ? 'oklch(0.75 0.15 145)'
: badge.color === 'red' ? 'oklch(0.75 0.15 25)'
: 'oklch(0.75 0.15 240)'}
stroke-width="0.5"
/>
<text
x={bp.cx} y={bp.cy}
text-anchor="middle"
font-size="11"
font-weight="500"
fill={badge.color === 'green' ? 'oklch(0.40 0.15 145)'
: badge.color === 'red' ? 'oklch(0.40 0.15 25)'
: 'oklch(0.40 0.15 240)'}
>{badge.text}</text>
</g>
{/if}
<!-- Step label (desktop) -->
{#if stepLabel && !isVertical}
<text x={viewBoxWidth / 2} y={viewBoxHeight - 8} class="step-label">{stepLabel}</text>
{/if}
</svg>
<!-- Mobile info section - fixed height to prevent layout shift -->
{#if isVertical}
<div class="mt-2 flex h-12 flex-col justify-center">
{#if badge}
{@const badgeClasses = badge.color === 'green'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: badge.color === 'red'
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'}
<div class="rounded-md px-2 py-1 text-center text-xs font-medium {badgeClasses}">
{badge.text}
</div>
{/if}
{#if stepLabel}
<p class="mt-1 text-center text-xs text-muted-foreground">{stepLabel}</p>
{/if}
</div>
{/if}
<!-- Description - fixed height on mobile to prevent layout shift -->
<p class="mb-2 mt-2 h-20 overflow-y-auto rounded-lg bg-muted px-3 py-2 text-sm leading-relaxed text-muted-foreground sm:mb-3 sm:mt-4 sm:h-auto sm:min-h-16 sm:px-3.5 sm:py-2.5">
{desc}
</p>
<!-- Controls -->
<div class="flex items-center gap-2 pb-2 sm:pb-0">
<!-- Back button -->
<button
onclick={goBack}
disabled={busy || current < 0}
class="inline-flex h-9 w-20 items-center justify-center gap-1 whitespace-nowrap rounded-full border bg-background text-sm font-medium shadow-xs transition-all hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
>
← Back
</button>
<!-- Next/Start button -->
{#if !done}
<button
onclick={advance}
disabled={busy}
class="inline-flex h-9 w-20 items-center justify-center gap-1 whitespace-nowrap rounded-full border bg-background text-sm font-medium shadow-xs transition-all hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
>
{current < 0 ? 'Start' : 'Next →'}
</button>
{:else}
<button
onclick={reset}
class="inline-flex h-9 w-20 items-center justify-center gap-1 whitespace-nowrap rounded-full border bg-background text-sm font-medium shadow-xs transition-all hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
>
Restart
</button>
{/if}
<!-- Reset button -->
<button
onclick={reset}
disabled={current < 0}
class="inline-flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-full border bg-background text-sm font-medium shadow-xs transition-all hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
title="Reset"
>
</button>
<!-- Step counter -->
<span class="ml-auto text-xs text-muted-foreground">
{Math.max(current + 1, 0)} / {STEPS.length}
</span>
</div>
</div>
<!-- ── Styles ─────────────────────────────────────────────────────────────── -->
<style>
.node-title {
font-size: 11px;
font-weight: 500;
fill: var(--foreground);
font-family: var(--font-sans);
}
.packet-label {
font-size: 10px;
font-family: var(--font-mono);
fill: white;
text-anchor: middle;
}
.step-label {
font-size: 11px;
fill: var(--muted-foreground);
text-anchor: middle;
font-family: var(--font-sans);
}
.badge {
animation: badge-pop 0.28s ease forwards;
}
@keyframes badge-pop {
0% { opacity: 0; transform: translateY(5px); }
65% { opacity: 1; transform: translateY(-1px); }
100% { opacity: 1; transform: translateY(0); }
}
.hosts-checking {
animation: hosts-pulse 0.5s ease-in-out infinite alternate;
}
.hosts-label-checking {
fill: oklch(0.65 0.19 240);
font-weight: 600;
animation: hosts-text-pulse 0.5s ease-in-out infinite alternate;
}
@keyframes hosts-pulse {
from { stroke-width: 1.5; }
to { stroke-width: 2.5; }
}
@keyframes hosts-text-pulse {
from { opacity: 0.7; }
to { opacity: 1; }
}
</style>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { cn } from '@/lib/utils'
import type { Snippet } from 'svelte'
interface Props {
href: string
external?: boolean
class?: string
underline?: boolean
children: Snippet
[key: string]: unknown
}
let { href, external = false, class: className, underline = false, children, ...rest }: Props = $props()
</script>
<a
{href}
target={external ? '_blank' : '_self'}
rel={external ? 'noopener noreferrer' : undefined}
class={cn(
'inline-block transition-colors duration-300 ease-in-out',
underline && 'underline decoration-muted-foreground underline-offset-[3px] hover:decoration-foreground',
className
)}
{...rest}
>
{@render children()}
</a>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { cn } from '@/lib/utils'
interface Props {
class?: string
}
let { class: className }: Props = $props()
</script>
<svg
viewBox="0 0 43.385681 58.634415"
xmlns="http://www.w3.org/2000/svg"
class={cn(
'w-full h-auto text-neutral-900 dark:text-neutral-100 transition-colors duration-300',
className
)}
role="img"
aria-label="Logo"
aria-hidden="true"
focusable="false"
>
<path
d="m 29.991968,30.003904 c -0.889705,0.1094 -1.802233,0.16433 -2.735233,0.16433 h -6.756177 l -3.023071,14.29319 -0.0062,0.0232 c -0.290305,1.28564 -0.467849,2.02416 -1.044898,3.20497 -0.110528,0.22617 -0.523296,0.96336 -1.058333,1.50017 -0.0052,0.005 -0.01082,0.0105 -0.01602,0.0155 l -0.0021,0.002 v 0.002 l -0.0016,0.002 -0.01602,0.0155 c -0.01026,0.01 -0.02067,0.0195 -0.03101,0.0294 -0.01108,0.0105 -0.01575,0.0146 -0.02945,0.0274 h -5.17e-4 v 5.2e-4 h -5.17e-4 c -3.47e-4,5.4e-4 -0.0012,0.002 -0.0016,0.002 v 5.1e-4 l -5.17e-4,5.2e-4 h -5.17e-4 l -0.0041,0.004 c -0.0047,0.005 -0.0108,0.01 -0.0155,0.014 l -0.0217,0.0176 c -1.090905,0.99713 -2.538121,1.35282 -3.597713,1.5074 -1.601745,0.23363 -4.113534,0.21787 -5.648234,0.17777 -1.160812,-0.0303 -2.392544,-0.17342 -3.290755,-0.25942 -0.166746,-0.016 -0.518669,-0.0569 -0.746208,-0.0842 l -0.312642,-0.0377 -1.631424942275,7.67189 C 1.33539,58.427024 2.812366,58.550644 4.234278,58.587834 c 2.991756,0.0782 7.888243,0.10921 11.010697,-0.34623 2.065576,-0.30134 4.886391,-0.99503 7.013009,-2.93884 l 0.04237,-0.0341 c 0.0092,-0.008 0.02129,-0.0184 0.03049,-0.0274 l 0.0078,-0.008 h 0.001 l 0.001,-10e-4 5.17e-4,-5.1e-4 c 8.24e-4,-0.001 0.0023,-0.004 0.0031,-0.005 h 5.17e-4 v -5.2e-4 h 10e-4 c 0.02671,-0.025 0.03576,-0.0327 0.05736,-0.0532 0.02015,-0.0195 0.04046,-0.0379 0.06046,-0.0574 l 0.03101,-0.0305 0.0031,-0.004 v -0.004 l 0.0041,-0.004 c 0.01014,-0.01 0.02093,-0.0199 0.03101,-0.03 1.043005,-1.04647 1.847976,-2.48347 2.06344,-2.92437 1.124903,-2.30188 1.471162,-3.74196 2.037085,-6.2482 l 0.01189,-0.045 z"
fill="currentColor"
/>
<g transform="translate(-17.866609,-107.09884)">
<path
d="m 30.327566,107.09883 c 0,0 -9.81187,45.93971 -9.834025,45.93725 l -0.264583,1.25367 c 0,0 0.44063,0.0522 0.661458,0.0734 1.014959,0.0972 2.029653,0.21933 3.04891,0.24598 1.683714,0.044 3.749394,0.0472 5.050854,-0.14263 0.86763,-0.12657 1.466021,-0.36783 1.751831,-0.63458 0,0 0.0068,-0.007 0.01034,-0.0103 0.0037,-0.003 0.01137,-0.01 0.01137,-0.01 0.0501,-0.0438 0.284787,-0.35696 0.38424,-0.56047 0.374418,-0.76617 0.528159,-1.34067 0.775896,-2.43779 L 35.5319,133.76852 h 9.591146 c 4.741333,0 8.614812,-1.60873 11.620479,-4.82606 3.005667,-3.25967 4.508769,-7.13314 4.508769,-11.62048 0,-6.81566 -3.915937,-10.22315 -11.747604,-10.22315 z m 9.499162,6.38308 h 5.806364 c 3.965247,0 5.947441,1.49112 5.947441,4.47311 0,2.05974 -0.793083,3.79691 -2.379183,5.21105 -1.553054,1.41415 -3.651208,2.1208 -6.294707,2.1208 h -5.578987 z"
fill="currentColor"
/>
</g>
</svg>

View File

@@ -0,0 +1,246 @@
<script lang="ts">
import { onMount } from 'svelte'
import { slide, fade } from 'svelte/transition'
import { cn } from '@/lib/utils'
import { NAV_LINKS, SITE } from '@/consts'
import Logo from './Logo.svelte'
import Link from './Link.svelte'
import ThemeToggle from './ThemeToggle.svelte'
// Icons from unplugin-icons (bundled at build time, no flash)
import IconDownload from '~icons/lucide/download'
import IconMenu from '~icons/lucide/menu'
import IconX from '~icons/lucide/x'
let scrollLevel = $state(0)
let isScrolled = $state(false)
let isMobile = $state(false)
let mobileMenuOpen = $state(false)
let activePath = $state('/')
// Width transitions from padded full-width down to content width (56rem)
// Using max() to ensure width never goes below 56rem at any scroll level
const widthMap: Record<number, string> = {
0: 'calc(100% - 3rem)', // Padded from edges at top
1: 'max(calc(100% - 4rem), 56rem)',
2: 'max(calc(100% - 6rem), 56rem)',
3: 'max(calc(100% - 8rem), 56rem)',
4: '56rem', // Matches actual content width
}
let headerWidth = $derived(isMobile ? '100%' : widthMap[scrollLevel])
onMount(() => {
activePath = window.location.pathname
const handleRouteChange = () => {
activePath = window.location.pathname
}
let resizeTimer: ReturnType<typeof setTimeout>
const handleResize = () => {
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
const isMobileView = window.matchMedia('(max-width: 768px)').matches
isMobile = isMobileView
if (!isMobileView && mobileMenuOpen) {
mobileMenuOpen = false
}
}, 100)
}
let scrollTimer: ReturnType<typeof setTimeout>
const handleScroll = () => {
clearTimeout(scrollTimer)
scrollTimer = setTimeout(() => {
const scrollY = window.scrollY
scrollLevel = scrollY > 500 ? 4 : scrollY > 300 ? 3 : scrollY > 150 ? 2 : scrollY > 0 ? 1 : 0
isScrolled = scrollY > 0
}, 50)
}
handleResize()
handleScroll()
window.addEventListener('popstate', handleRouteChange)
window.addEventListener('resize', handleResize)
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('popstate', handleRouteChange)
window.removeEventListener('resize', handleResize)
window.removeEventListener('scroll', handleScroll)
clearTimeout(resizeTimer)
clearTimeout(scrollTimer)
}
})
$effect(() => {
if (typeof document !== 'undefined') {
if (mobileMenuOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
}
})
function toggleMobileMenu() {
mobileMenuOpen = !mobileMenuOpen
}
function closeMobileMenu() {
mobileMenuOpen = false
}
function handleNavClick(href: string) {
activePath = href
}
</script>
<header
aria-label="Navigation"
role="banner"
style="width: {headerWidth};"
class={cn(
'fixed left-1/2 z-30 -translate-x-1/2 transform backdrop-blur-lg',
'bg-background/80 border-0',
'rounded-none shadow-none transition-all duration-300 ease-in-out',
'border border-transparent',
isScrolled && !isMobile && 'rounded-full',
isScrolled && !isMobile && 'backdrop-blur-md',
isScrolled && !isMobile && 'border-foreground/10',
isScrolled && !isMobile && 'border',
isScrolled && !isMobile && 'bg-background/80',
isScrolled && !isMobile && 'max-w-[min(90vw,calc(100vw-2rem))]',
!isMobile && 'top-2 lg:top-4 xl:top-6',
isMobile && 'top-0',
isMobile && 'rounded-none',
isMobile && 'border-0',
isMobile && 'shadow-none'
)}
>
<div class="mx-auto flex max-w-7xl items-center justify-between gap-4 p-4">
<a
href="/"
class="font-custom flex shrink-0 items-center gap-2 text-xl font-bold"
aria-label="Home"
title="Home"
>
<Logo class="h-8 w-8 mr-2" />
<span class="transition-opacity duration-200 ease-in-out text-foreground/90 dark:text-white">
{SITE.title}
</span>
</a>
<div class="flex items-center gap-2 md:gap-4">
<nav class="hidden items-center gap-6 md:flex" aria-label="Main navigation">
{#each NAV_LINKS as item}
{@const isActive = activePath.startsWith(item.href) && item.href !== '/'}
<div class="relative hover:scale-105 transition-transform">
<a
href={item.href}
class={cn(
'text-sm font-medium capitalize transition-colors duration-200',
'relative py-1 px-1',
'after:absolute after:bottom-0 after:left-0 after:h-[2px] after:w-0 after:bg-primary after:transition-all after:duration-300',
'hover:after:w-full hover:text-foreground',
isActive
? 'text-foreground after:w-full after:bg-primary'
: 'text-foreground/70'
)}
onclick={() => handleNavClick(item.href)}
>
{item.label}
</a>
</div>
{/each}
<a
href="/static/resume.pdf"
download
class="flex items-center gap-2 rounded-full border border-foreground/20 px-4 py-1.5 text-sm font-medium transition-all duration-200 hover:border-foreground/40 hover:bg-foreground/5"
>
Resume
<IconDownload class="size-4" />
</a>
</nav>
<ThemeToggle />
{#if isMobile}
<button
onclick={toggleMobileMenu}
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
class="ml-1 h-9 w-9 rounded-full p-0 transition-colors duration-200 ease-in-out inline-flex items-center justify-center hover:bg-accent hover:text-accent-foreground cursor-pointer"
>
{#if mobileMenuOpen}
<IconX class="h-5 w-5" />
{:else}
<IconMenu class="h-5 w-5" />
{/if}
</button>
{/if}
</div>
</div>
</header>
{#if mobileMenuOpen}
<div
class="fixed inset-0 z-20 flex flex-col items-center justify-start bg-background border-0 shadow-none"
transition:fade={{ duration: 200 }}
>
<div class="flex flex-col items-center justify-start h-full pt-24 w-full p-6">
<nav class="flex flex-col items-center justify-start gap-1 w-full">
{#each NAV_LINKS as item, i}
<div
class="w-full text-start"
transition:slide={{ delay: i * 50, duration: 200 }}
>
<a
href={item.href}
onclick={closeMobileMenu}
class="dark:text-white text-lg font-bold font-custom capitalize dark:hover:text-white/80 transition-colors inline-block py-2 relative group"
>
{item.label}
<span class="absolute left-0 bottom-0 w-0 h-0.5 bg-neutral-900 dark:bg-white group-hover:w-full transition-all duration-300 ease-in-out"></span>
</a>
</div>
{/each}
<div
class="w-full text-start mt-4"
transition:slide={{ delay: NAV_LINKS.length * 50, duration: 200 }}
>
<a
href="/static/resume.pdf"
download
onclick={closeMobileMenu}
class="inline-flex items-center gap-2 rounded-full border border-foreground/20 px-5 py-2 text-lg font-bold font-custom transition-all duration-200 hover:border-foreground/40 hover:bg-foreground/5"
>
Resume
<IconDownload class="size-5" />
</a>
</div>
</nav>
<div class="mt-auto flex flex-col items-center gap-6">
<div class="flex flex-wrap items-center justify-center gap-x-2 text-center">
<span class="text-muted-foreground text-sm" aria-label="copyright">
2025 - {new Date().getFullYear()} &copy; All rights reserved.
</span>
<div class="hidden h-4 w-px bg-border sm:block"></div>
<p class="text-muted-foreground text-sm" aria-label="open-source description">
<Link
href="https://git.jaroszew.ski/patrick/portfolio"
class="text-foreground"
external
underline
>
Open-source
</Link>
under MIT license
</p>
</div>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import IconCalendar from '~icons/lucide/calendar'
interface Props {
id: string
name: string
description: string
image: string
tags?: string[]
endDate?: Date | null
}
let { id, name, description, image, tags = [], endDate = null }: Props = $props()
// svelte-ignore state_referenced_locally
const displayYear = endDate ? new Date(endDate).getFullYear() : 'Ongoing'
</script>
<div class="group h-full w-full transition-all duration-300 hover:translate-y-[-4px]">
<a
class="flex flex-col h-full w-full rounded-2xl overflow-hidden bg-card hover:shadow-lg transition-all duration-300 border border-card-foreground/10"
href={`/projects/${id}`}
aria-label="Project link"
>
<div class="aspect-16/9 w-full overflow-hidden">
<img
alt={name}
src={image}
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
</div>
<div class="flex flex-col justify-between p-5 grow">
<div>
<h3 class="text-xl font-medium text-foreground mb-2">{name}</h3>
<p class="text-sm text-muted-foreground line-clamp-2 mb-4">
{description || "An innovative project showcasing creativity and technical skills"}
</p>
</div>
<div class="flex flex-wrap justify-between items-center mt-auto pt-3 border-t border-border/40">
<div class="flex flex-wrap gap-2">
{#each tags.slice(0, 2) as tag}
<span class="text-xs px-2 py-1 rounded-full bg-secondary/80 text-primary font-medium">{tag}</span>
{/each}
{#if tags.length > 2}
<span class="text-xs px-2 py-1 rounded-full bg-secondary/80 text-primary font-medium">+{tags.length - 2}</span>
{/if}
</div>
<p class="text-xs font-medium text-muted-foreground flex items-center">
<IconCalendar class="h-3 w-3 mr-1" />
{displayYear}
</p>
</div>
</div>
</a>
</div>

View File

@@ -0,0 +1,126 @@
<script lang="ts">
import Fuse from 'fuse.js'
import BlogCard from './BlogCard.svelte'
import { cn } from '@/lib/utils'
interface BlogEntry {
id: string
collection: string
data: {
title: string
description: string
tags?: string[]
}
}
interface Props {
searchList: BlogEntry[]
initialPosts: BlogEntry[]
}
let { searchList, initialPosts }: Props = $props()
let query = $state('')
let filteredPosts = $state(initialPosts)
const options = {
keys: ['data.title', 'data.description', 'data.tags'],
includeMatches: true,
minMatchCharLength: 3,
threshold: 0.3,
distance: 100,
sortFn: (a: { score: number }, b: { score: number }) => a.score - b.score,
}
const processedSearchList = $derived(
searchList.map((item) => ({
...item,
data: {
...item.data,
title: String(item.data.title || '').toLowerCase(),
description: String(item.data.description || '').toLowerCase(),
tags: Array.isArray(item.data.tags)
? item.data.tags.map((tag) => String(tag).toLowerCase())
: [],
},
}))
)
const fuse = $derived(new Fuse(processedSearchList, options))
let debounceTimer: ReturnType<typeof setTimeout>
function handleSearch(searchQuery: string) {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
if (searchQuery.length > 2) {
const results = fuse
.search(searchQuery.toLowerCase())
.map((result) => result.item)
// Map back to original items to preserve original casing
filteredPosts = results.map(result =>
searchList.find(item => item.id === result.id) || result
) as BlogEntry[]
} else {
filteredPosts = initialPosts
}
}, 100)
}
function handleInputChange(event: Event) {
const target = event.target as HTMLInputElement
query = target.value
handleSearch(query)
}
</script>
<div>
<div>
<label
for="search"
class="text-foreground mb-2 block text-sm font-medium dark:text-white"
>
Search
</label>
<input
type="text"
value={query}
oninput={handleInputChange}
name="search"
id="search"
autocomplete="off"
autocorrect="off"
placeholder="Search posts"
class="border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] dark:bg-neutral-900 dark:text-white"
/>
</div>
<hr class="my-6 border-neutral-200 dark:border-neutral-700" />
<div class={cn('flex items-center justify-between', 'mb-4', !query && 'hidden')}>
<h2 class="text-lg font-semibold text-neutral-900 dark:text-white">
{filteredPosts.length} posts found
</h2>
<p class="text-sm text-neutral-500 dark:text-neutral-400">
Search results for: <strong>{query}</strong>
</p>
</div>
<div class="mt-6">
<ul class="flex flex-col gap-4">
{#each filteredPosts.slice(0, 50) as post, index (post.id || index)}
<li>
<BlogCard entry={post} />
</li>
{/each}
</ul>
{#if filteredPosts.length === 0}
<div class="mt-12 text-center">
<p class="text-neutral-600 dark:text-neutral-400">
No posts found matching your search criteria.
</p>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { Component } from 'svelte'
interface Props {
name: string
icon: Component
}
let { name, icon: IconComponent }: Props = $props()
</script>
<div
class="border-border bg-card text-muted-foreground flex items-center gap-3 rounded-full border p-3 shadow-sm backdrop-blur-sm transition-all duration-300 hover:shadow-md"
>
<span
class="bg-muted flex h-10 w-10 items-center justify-center rounded-full p-2 text-lg shadow-inner"
>
<IconComponent class="text-primary h-5 w-5" />
</span>
<span class="text-foreground font-medium overflow-hidden text-ellipsis whitespace-nowrap">{name}</span>
</div>

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import SkillsMarquee from './SkillsMarquee.svelte'
import type { Component } from 'svelte'
// Dev icons
import IconHtml5 from '~icons/simple-icons/html5'
import IconCss3 from '~icons/simple-icons/css3'
import IconJavascript from '~icons/simple-icons/javascript'
import IconTypescript from '~icons/simple-icons/typescript'
import IconSvelte from '~icons/simple-icons/svelte'
import IconPython from '~icons/simple-icons/python'
import IconJava from '~icons/fa6-brands/java'
import IconC from '~icons/simple-icons/c'
import IconNodejs from '~icons/simple-icons/nodedotjs'
import IconPostgresql from '~icons/simple-icons/postgresql'
import IconMongodb from '~icons/simple-icons/mongodb'
import IconGit from '~icons/simple-icons/git'
import IconVscode from '~icons/simple-icons/visualstudiocode'
// Ops icons
import IconLinux from '~icons/simple-icons/linux'
import IconNixos from '~icons/simple-icons/nixos'
import IconBash from '~icons/simple-icons/gnubash'
import IconDocker from '~icons/simple-icons/docker'
import IconLxc from '~icons/simple-icons/linuxcontainers'
import IconProxmox from '~icons/simple-icons/proxmox'
import IconAnsible from '~icons/simple-icons/ansible'
import IconNginx from '~icons/simple-icons/nginx'
import IconCaddy from '~icons/simple-icons/caddy'
import IconWireguard from '~icons/simple-icons/wireguard'
import IconCloudflare from '~icons/simple-icons/cloudflare'
import IconOpnsense from '~icons/simple-icons/opnsense'
import IconGitea from '~icons/simple-icons/gitea'
interface Skill {
name: string
icon: Component
}
const devSkills: Skill[] = [
{ name: 'HTML', icon: IconHtml5 },
{ name: 'CSS', icon: IconCss3 },
{ name: 'JavaScript', icon: IconJavascript },
{ name: 'TypeScript', icon: IconTypescript },
{ name: 'Svelte', icon: IconSvelte },
{ name: 'Python', icon: IconPython },
{ name: 'Java', icon: IconJava },
{ name: 'C', icon: IconC },
{ name: 'Node.js', icon: IconNodejs },
{ name: 'PostgreSQL', icon: IconPostgresql },
{ name: 'MongoDB', icon: IconMongodb },
{ name: 'Git', icon: IconGit },
{ name: 'VS Code', icon: IconVscode },
]
const opsSkills: Skill[] = [
{ name: 'Linux', icon: IconLinux },
{ name: 'NixOS', icon: IconNixos },
{ name: 'Bash', icon: IconBash },
{ name: 'Docker', icon: IconDocker },
{ name: 'LXC', icon: IconLxc },
{ name: 'Proxmox', icon: IconProxmox },
{ name: 'Ansible', icon: IconAnsible },
{ name: 'Nginx', icon: IconNginx },
{ name: 'Caddy', icon: IconCaddy },
{ name: 'WireGuard', icon: IconWireguard },
{ name: 'Cloudflare', icon: IconCloudflare },
{ name: 'OPNsense', icon: IconOpnsense },
{ name: 'Gitea', icon: IconGitea },
]
</script>
<div class="space-y-6">
<SkillsMarquee title="Dev" skills={devSkills} duration="50s" maxPerRow={7} pauseOnHover />
<SkillsMarquee title="Ops" skills={opsSkills} duration="50s" maxPerRow={7} reverse pauseOnHover />
</div>

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import SkillBadge from './SkillBadge.svelte'
import { Marquee } from '@/components/ui/marquee'
import type { Component } from 'svelte'
interface Skill {
name: string
icon: Component
}
interface Props {
title: string
skills: Skill[]
direction?: 'left' | 'up'
reverse?: boolean
fade?: boolean
pauseOnHover?: boolean
duration?: string
gap?: string
maxPerRow?: number
class?: string
}
let {
title,
skills,
direction = 'left',
reverse = false,
fade = true,
pauseOnHover = false,
duration = '20s',
gap = '1rem',
maxPerRow,
class: className = ''
}: Props = $props()
// Smart chunking - balances rows to avoid sparse last row
function balancedChunk<T>(array: T[], max: number): T[][] {
const total = array.length
// If fits in one row, use one row
if (total <= max) return [array]
// Calculate number of rows needed
const numRows = Math.ceil(total / max)
// Calculate ideal items per row (distribute evenly)
const basePerRow = Math.floor(total / numRows)
const remainder = total % numRows
const chunks: T[][] = []
let index = 0
for (let i = 0; i < numRows; i++) {
// Distribute remainder across first rows
const rowSize = basePerRow + (i < remainder ? 1 : 0)
chunks.push(array.slice(index, index + rowSize))
index += rowSize
}
return chunks
}
let rows = $derived(
maxPerRow ? balancedChunk(skills, maxPerRow) : [skills]
)
</script>
<section class="space-y-4">
<h3 class="text-foreground font-custom text-xl font-bold">{title}</h3>
<div class="space-y-3">
{#each rows as rowSkills, rowIndex (rowIndex)}
<Marquee
{direction}
{fade}
reverse={rowIndex % 2 === 0 ? reverse : !reverse}
{pauseOnHover}
{duration}
{gap}
class={className}
>
{#each rowSkills as skill (skill.name)}
<SkillBadge name={skill.name} icon={skill.icon} />
{/each}
</Marquee>
{/each}
</div>
</section>

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import { onMount } from 'svelte'
import IconSun from '~icons/lucide/sun'
import IconMoon from '~icons/lucide/moon'
let mounted = $state(false)
onMount(() => {
mounted = true
const theme = (() => {
const localStorageTheme = localStorage?.getItem('theme') ?? ''
if (['dark', 'light'].includes(localStorageTheme)) {
return localStorageTheme
}
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark'
}
return 'light'
})()
if (theme === 'light') {
document.documentElement.classList.remove('dark')
} else {
document.documentElement.classList.add('dark')
}
window.localStorage.setItem('theme', theme)
const handleAfterSwap = () => {
const storedTheme = localStorage.getItem('theme')
const element = document.documentElement
element.classList.add('disable-transitions')
window.getComputedStyle(element).getPropertyValue('opacity')
if (storedTheme === 'dark') {
element.classList.add('dark')
} else {
element.classList.remove('dark')
}
requestAnimationFrame(() => {
element.classList.remove('disable-transitions')
})
}
document.addEventListener('astro:after-swap', handleAfterSwap)
return () => {
document.removeEventListener('astro:after-swap', handleAfterSwap)
}
})
function toggleTheme() {
const element = document.documentElement
element.classList.toggle('dark')
const isDark = element.classList.contains('dark')
localStorage.setItem('theme', isDark ? 'dark' : 'light')
}
function handleToggle(event: MouseEvent) {
const button = event.currentTarget as HTMLButtonElement
const rect = button.getBoundingClientRect()
const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2
// Check if View Transitions API is supported
if (!document.startViewTransition) {
toggleTheme()
return
}
// Set CSS custom properties for the animation origin
document.documentElement.style.setProperty('--toggle-x', `${x}px`)
document.documentElement.style.setProperty('--toggle-y', `${y}px`)
// Start the view transition
document.startViewTransition(() => {
toggleTheme()
})
}
</script>
<button
onclick={(e) => handleToggle(e)}
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 bg-secondary text-secondary-foreground hover:bg-secondary/80 size-9 cursor-pointer"
title="Toggle theme"
>
<IconSun class="size-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<IconMoon class="absolute size-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span class="sr-only">Toggle theme</span>
</button>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import TimelineItem from './TimelineItem.svelte'
export interface Award {
name: string
description?: string
}
export interface TimelineEntry {
school: string
degree: string
minor?: string
gpa?: string
location?: string
startYear: number
startMonth?: string
endYear?: number
endMonth?: string
expected?: boolean
courses?: string[]
awards?: Award[]
}
interface Props {
entries: TimelineEntry[]
}
let { entries }: Props = $props()
</script>
<div class="relative">
{#each entries as entry}
<TimelineItem
school={entry.school}
degree={entry.degree}
minor={entry.minor}
gpa={entry.gpa}
location={entry.location}
startYear={entry.startYear}
startMonth={entry.startMonth}
endYear={entry.endYear}
endMonth={entry.endMonth}
expected={entry.expected}
courses={entry.courses}
awards={entry.awards}
/>
{/each}
</div>

View File

@@ -0,0 +1,151 @@
<script lang="ts">
import Badge from './Badge.svelte'
import IconHash from '~icons/lucide/hash'
import IconCalendar from '~icons/lucide/calendar'
import IconMapPin from '~icons/lucide/map-pin'
import IconAward from '~icons/lucide/award'
interface Award {
name: string
description?: string
}
interface Props {
school: string
degree: string
minor?: string
gpa?: string
location?: string
startYear: number
startMonth?: string
endYear?: number
endMonth?: string
expected?: boolean
courses?: string[]
awards?: Award[]
}
let {
school,
degree,
minor,
gpa,
location,
startYear,
startMonth,
endYear,
endMonth,
expected = false,
courses = [],
awards = [],
}: Props = $props()
const dateDisplay = $derived(() => {
const start = startMonth ? `${startMonth} ${startYear}` : `${startYear}`
if (endYear) {
const end = endMonth ? `${endMonth} ${endYear}` : `${endYear}`
return `${start} - ${end}`
}
if (expected) {
return `${start} - Present (Expected)`
}
return `${start} - Present`
})
</script>
<div class="relative pl-8 pb-8 last:pb-0">
<!-- Timeline line -->
<div class="absolute left-[7px] top-3 bottom-0 w-0.5 bg-border last:hidden"></div>
<!-- Timeline marker -->
<div class="absolute left-0 top-1.5 size-4 rounded-full border-2 border-primary bg-background"></div>
<!-- Content card -->
<div class="timeline-card rounded-xl border bg-card p-5 transition-colors duration-300 hover:bg-secondary/30">
<!-- Header row: school/degree left, date/location right -->
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2 mb-4">
<div>
<h3 class="text-lg font-medium text-foreground">{school}</h3>
<div class="flex items-center gap-2 flex-wrap">
<p class="text-sm text-muted-foreground">{degree}</p>
{#if gpa}
<span class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">{gpa} GPA</span>
{/if}
</div>
{#if minor}
<p class="text-sm text-muted-foreground">Minor in {minor}</p>
{/if}
</div>
<div class="flex flex-col sm:items-end gap-0.5 text-sm text-muted-foreground shrink-0">
<div class="flex items-center gap-1.5">
<IconCalendar class="size-3" />
<span class="font-medium">{dateDisplay()}</span>
</div>
{#if location}
<div class="flex items-center gap-1.5">
<IconMapPin class="size-3" />
<span class="text-sm">{location}</span>
</div>
{/if}
</div>
</div>
<!-- Courses and Awards grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Courses -->
{#if courses.length > 0}
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Core Courses</p>
<div class="flex flex-wrap gap-2">
{#each courses as course}
<Badge variant="secondary" class="flex items-center gap-x-1">
<IconHash class="size-3" />
{course}
</Badge>
{/each}
</div>
</div>
{/if}
<!-- Awards -->
{#if awards.length > 0}
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Awards & Scholarships</p>
<ul class="text-sm space-y-2">
{#each awards as award}
<li class="flex items-start gap-2">
<IconAward class="size-3 text-primary shrink-0 mt-1" />
<div class="flex flex-col">
<span class="text-foreground font-medium">{award.name}</span>
{#if award.description}
<span class="text-xs text-muted-foreground">{award.description}</span>
{/if}
</div>
</li>
{/each}
</ul>
</div>
{/if}
</div>
</div>
</div>
<style>
.timeline-card {
position: relative;
overflow: hidden;
}
.timeline-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(315deg, color-mix(in oklch, var(--color-primary) 6%, transparent) 0%, transparent 50%);
pointer-events: none;
}
:global(.dark) .timeline-card::before {
background: linear-gradient(135deg, color-mix(in oklch, var(--color-primary) 6%, transparent) 0%, transparent 50%);
}
</style>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { cn } from '$lib/utils'
import { buttonVariants, type ButtonVariants } from '$lib/button-variants'
import type { Snippet } from 'svelte'
import type { HTMLButtonAttributes } from 'svelte/elements'
interface Props extends HTMLButtonAttributes {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: string
children?: Snippet
}
let {
variant = 'default',
size = 'default',
class: className,
children,
...rest
}: Props = $props()
</script>
<button
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{...rest}
>
{#if children}
{@render children()}
{/if}
</button>

View File

@@ -0,0 +1,139 @@
<script lang="ts">
import { cn } from '$lib/utils'
import { buttonVariants } from '$lib/button-variants'
interface Props {
currentPage: number
totalPages: number
baseUrl: string
}
let { currentPage, totalPages, baseUrl }: Props = $props()
function getPageUrl(page: number): string {
if (page === 1) return baseUrl
return `${baseUrl}${page}`
}
function getVisiblePages(): number[] {
const maxVisiblePages = 6
const pages: number[] = []
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i)
}
} else {
const startPage = Math.max(1, currentPage - 2)
const endPage = Math.min(totalPages, currentPage + 2)
if (startPage > 1) pages.push(1)
if (startPage > 2) pages.push(-1) // ellipsis marker
for (let i = startPage; i <= endPage; i++) {
pages.push(i)
}
if (endPage < totalPages - 1) pages.push(-1) // ellipsis marker
if (endPage < totalPages) pages.push(totalPages)
}
return pages
}
let visiblePages = $derived(getVisiblePages())
</script>
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
class="mx-auto flex w-full justify-center"
>
<ul
data-slot="pagination-content"
class="flex flex-row flex-wrap items-center gap-1"
>
<!-- Previous Button -->
<li data-slot="pagination-item">
<a
href={currentPage > 1 ? getPageUrl(currentPage - 1) : undefined}
aria-label="Go to previous page"
data-slot="pagination-link"
data-disabled={currentPage === 1 ? true : undefined}
class={cn(
buttonVariants({ variant: 'ghost', size: 'default' }),
'gap-1 px-2.5 sm:pl-2.5',
currentPage === 1 && 'pointer-events-none opacity-50'
)}
>
<!-- ChevronLeft icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<path d="m15 18-6-6 6-6"/>
</svg>
<span class="hidden sm:block">Previous</span>
</a>
</li>
<!-- Page Numbers -->
{#each visiblePages as page, index}
{#if page === -1}
<!-- Ellipsis -->
<li data-slot="pagination-item">
<span
aria-hidden="true"
data-slot="pagination-ellipsis"
class="flex size-9 items-center justify-center"
>
<!-- MoreHorizontal icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<circle cx="12" cy="12" r="1"/>
<circle cx="19" cy="12" r="1"/>
<circle cx="5" cy="12" r="1"/>
</svg>
<span class="sr-only">More pages</span>
</span>
</li>
{:else}
<!-- Page Number -->
<li data-slot="pagination-item">
<a
href={getPageUrl(page)}
aria-current={page === currentPage ? 'page' : undefined}
data-slot="pagination-link"
data-active={page === currentPage ? true : undefined}
class={cn(
buttonVariants({
variant: page === currentPage ? 'outline' : 'ghost',
size: 'icon',
})
)}
>
{page}
</a>
</li>
{/if}
{/each}
<!-- Next Button -->
<li data-slot="pagination-item">
<a
href={currentPage < totalPages ? getPageUrl(currentPage + 1) : undefined}
aria-label="Go to next page"
data-slot="pagination-link"
data-disabled={currentPage === totalPages ? true : undefined}
class={cn(
buttonVariants({ variant: 'ghost', size: 'default' }),
'gap-1 px-2.5 sm:pr-2.5',
currentPage === totalPages && 'pointer-events-none opacity-50'
)}
>
<span class="hidden sm:block">Next</span>
<!-- ChevronRight icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<path d="m9 18 6-6-6-6"/>
</svg>
</a>
</li>
</ul>
</nav>

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import { cn } from '$lib/utils'
import type { Snippet } from 'svelte'
interface Props {
class?: string
className?: string
orientation?: 'vertical' | 'horizontal' | 'both'
type?: 'auto' | 'always' | 'scroll' | 'hover'
fadeMask?: boolean
children?: Snippet
}
let {
class: classFromClass,
className,
orientation = 'vertical',
type = 'auto',
fadeMask = true,
children,
...rest
}: Props = $props()
// Support both class and className props
const finalClass = classFromClass || className
// Track scroll position for dynamic fade masks
let viewportEl: HTMLDivElement | null = $state(null)
let canScrollUp = $state(false)
let canScrollDown = $state(false)
function checkScroll() {
if (!viewportEl) return
const { scrollTop, scrollHeight, clientHeight } = viewportEl
canScrollUp = scrollTop > 5
canScrollDown = scrollTop < scrollHeight - clientHeight - 5
}
$effect(() => {
if (!viewportEl || !fadeMask) return
// Initial check
checkScroll()
// Use ResizeObserver to detect content changes
const resizeObserver = new ResizeObserver(checkScroll)
resizeObserver.observe(viewportEl)
if (viewportEl.firstElementChild) {
resizeObserver.observe(viewportEl.firstElementChild)
}
return () => resizeObserver.disconnect()
})
</script>
<div
data-slot="scroll-area"
class={cn('relative', finalClass)}
{...rest}
>
<div
bind:this={viewportEl}
onscroll={checkScroll}
data-slot="scroll-area-viewport"
data-fade-top={fadeMask && orientation !== 'horizontal' && canScrollUp}
data-fade-bottom={fadeMask && orientation !== 'horizontal' && canScrollDown}
class={cn(
'size-full rounded-[inherit]',
orientation === 'vertical' && 'overflow-y-auto overflow-x-hidden',
orientation === 'horizontal' && 'overflow-x-auto overflow-y-hidden',
orientation === 'both' && 'overflow-auto',
type === 'always' && 'scrollbar-thin',
type === 'hover' && 'scrollbar-thin hover:scrollbar-thumb-border',
type === 'auto' && 'scrollbar-thin'
)}
>
{#if children}
{@render children()}
{/if}
</div>
</div>
<style>
/* Fade mask - only bottom (at top of scroll) */
[data-slot="scroll-area-viewport"][data-fade-top="false"][data-fade-bottom="true"] {
mask-image: linear-gradient(
to bottom,
black,
black calc(100% - 20px),
transparent
);
-webkit-mask-image: linear-gradient(
to bottom,
black,
black calc(100% - 20px),
transparent
);
}
/* Fade mask - only top (at bottom of scroll) */
[data-slot="scroll-area-viewport"][data-fade-top="true"][data-fade-bottom="false"] {
mask-image: linear-gradient(
to bottom,
transparent,
black 20px,
black
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent,
black 20px,
black
);
}
/* Fade mask - both top and bottom (in the middle) */
[data-slot="scroll-area-viewport"][data-fade-top="true"][data-fade-bottom="true"] {
mask-image: linear-gradient(
to bottom,
transparent,
black 20px,
black calc(100% - 20px),
transparent
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent,
black 20px,
black calc(100% - 20px),
transparent
);
}
/* More visible scrollbar */
[data-slot="scroll-area-viewport"] {
scrollbar-width: thin;
scrollbar-color: var(--muted-foreground) var(--muted);
}
[data-slot="scroll-area-viewport"]::-webkit-scrollbar {
width: 10px;
height: 10px;
}
[data-slot="scroll-area-viewport"]::-webkit-scrollbar-track {
background: var(--muted);
border-radius: 9999px;
}
[data-slot="scroll-area-viewport"]::-webkit-scrollbar-thumb {
background-color: var(--muted-foreground);
border-radius: 9999px;
border: 2px solid var(--muted);
background-clip: content-box;
}
[data-slot="scroll-area-viewport"]::-webkit-scrollbar-thumb:hover {
background-color: var(--foreground);
}
</style>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { cn } from '$lib/utils'
interface Props {
orientation?: 'horizontal' | 'vertical'
decorative?: boolean
class?: string
}
let {
orientation = 'horizontal',
decorative = true,
class: className,
...rest
}: Props = $props()
</script>
<div
data-slot="separator-root"
role={decorative ? 'none' : 'separator'}
aria-orientation={decorative ? undefined : orientation}
data-orientation={orientation}
class={cn(
'bg-border shrink-0',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className
)}
{...rest}
></div>

View File

@@ -1,74 +0,0 @@
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cn } from '@/lib/utils'
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full', className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className,
)}
{...props}
/>
)
}
const AvatarComponent: React.FC<AvatarComponentProps> = ({
src,
alt,
fallback,
className,
}) => {
return (
<Avatar className={className}>
<AvatarImage src={src} alt={alt} />
<AvatarFallback>{fallback}</AvatarFallback>
</Avatar>
)
}
export default AvatarComponent
interface AvatarComponentProps {
src?: string
alt?: string
fallback: string
className?: string
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -1,46 +0,0 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70',
outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span'
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -1,108 +0,0 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cn } from '@/lib/utils'
import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons'
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
className,
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-item"
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'a'
return (
<Comp
data-slot="breadcrumb-link"
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn('text-foreground font-normal', className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<DotsHorizontalIcon className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -1,255 +0,0 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -1,125 +0,0 @@
'use client'
import React, { useRef, useEffect, useState } from 'react'
import { motion, useAnimationControls } from 'framer-motion'
import { cn } from '@/lib/utils'
interface InfiniteScrollProps {
className?: string
duration?: number
direction?: 'normal' | 'reverse'
containerColor?: string
showFade?: boolean
children: React.ReactNode
pauseOnHover?: boolean
}
export function InfiniteScroll({
className,
duration = 15000,
direction = 'normal',
containerColor = '#ffffff',
showFade = true,
children,
pauseOnHover = true,
}: InfiniteScrollProps) {
const [contentWidth, setContentWidth] = useState<number>(0)
const [isPaused, setIsPaused] = useState(false)
const scrollerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const controls = useAnimationControls()
const elapsedTimeRef = useRef(0)
const lastTimeRef = useRef(0)
useEffect(() => {
const content = contentRef.current
if (!content) return
const updateWidth = () => {
const width = content.offsetWidth
setContentWidth(width)
}
updateWidth()
window.addEventListener('resize', updateWidth)
return () => window.removeEventListener('resize', updateWidth)
}, [children])
useEffect(() => {
if (!contentWidth) return
const startX = direction === 'normal' ? 0 : -contentWidth
const endX = direction === 'normal' ? -contentWidth : 0
if (!isPaused) {
const remainingDuration = duration - elapsedTimeRef.current
const progress = elapsedTimeRef.current / duration
const currentX =
direction === 'normal'
? startX + (endX - startX) * progress
: endX + (startX - endX) * (1 - progress)
controls.set({ x: currentX })
controls.start({
x: endX,
transition: {
duration: remainingDuration / 1000,
ease: 'linear',
repeat: Infinity,
repeatType: 'loop',
repeatDelay: 0,
},
})
lastTimeRef.current = Date.now()
}
}, [controls, direction, duration, contentWidth, isPaused])
const handleMouseEnter = () => {
if (!pauseOnHover) return
const currentTime = Date.now()
const deltaTime = currentTime - lastTimeRef.current
elapsedTimeRef.current = (elapsedTimeRef.current + deltaTime) % duration
setIsPaused(true)
controls.stop()
}
const handleMouseLeave = () => {
if (!pauseOnHover) return
lastTimeRef.current = Date.now()
setIsPaused(false)
}
return (
<div
className={cn(
'relative flex shrink-0 flex-col gap-4 overflow-hidden py-3 sm:py-2 sm:gap-2',
className,
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="flex">
<motion.div
ref={scrollerRef}
className="flex shrink-0"
animate={controls}
>
<div ref={contentRef} className="flex shrink-0">
{children}
</div>
<div className="flex shrink-0">{children}</div>
<div className="flex shrink-0">{children}</div>
</motion.div>
</div>
{showFade && (
<div
className="from-background to-background pointer-events-none absolute inset-0 bg-linear-to-r via-transparent sm:bg-gradient-to-r"
style={{ '--container-color': containerColor } as React.CSSProperties}
/>
)}
</div>
)
}

View File

@@ -1,21 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -1,27 +0,0 @@
import { cn } from "@/lib/utils"
function Logo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 350 266"
xmlns="http://www.w3.org/2000/svg"
className={cn(
"w-full h-auto text-neutral-900 dark:text-neutral-100 transition-colors duration-300",
className
)}
role="img"
aria-label="Logo"
aria-hidden="true"
focusable="false"
>
<path
d="M0 0h216.667C290.305 0 350 59.546 350 133s-59.695 133-133.333 133L0 0zm231.629 231.641c48.131-7.203 85.038-48.623 85.038-98.641 0-55.09-44.772-99.75-100-99.75h-54.862C185.557 59.722 200 94.678 200 133c0 17.331-2.96 33.991-8.405 49.492l40.034 49.149zm-66.243-81.326c.844-5.646 1.281-11.428 1.281-17.315 0-42.333-22.66-79.386-56.541-99.75H70.032l95.354 117.065z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
)
}
export default Logo

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { cn } from '@/lib/utils'
type Direction = 'left' | 'up'
interface Props {
direction?: Direction
pauseOnHover?: boolean
reverse?: boolean
fade?: boolean
innerClassName?: string
numberOfCopies?: number
duration?: string
gap?: string
class?: string
children?: import('svelte').Snippet
}
let {
direction = 'left',
pauseOnHover = false,
reverse = false,
fade = false,
innerClassName = '',
numberOfCopies = 2,
duration = '20s',
gap = '1rem',
class: className = '',
children
}: Props = $props()
let isPaused = $state(false)
function handleMouseEnter() {
if (pauseOnHover) isPaused = true
}
function handleMouseLeave() {
if (pauseOnHover) isPaused = false
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={cn(
'group relative flex overflow-hidden',
direction === 'left' ? 'flex-row' : 'flex-col',
className
)}
style="gap: {gap};"
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
>
{#each Array(numberOfCopies) as _, i (i)}
<div
class={cn(
'flex justify-around shrink-0',
direction === 'left' ? 'flex-row' : 'flex-col',
innerClassName
)}
style="--gap: {gap}; gap: {gap}; animation: {direction === 'left' ? 'marquee-left' : 'marquee-up'} {duration} linear infinite {reverse ? 'reverse' : ''}; animation-play-state: {isPaused ? 'paused' : 'running'};"
>
{#if children}
{@render children()}
{/if}
</div>
{/each}
{#if fade}
<div
class={cn(
'pointer-events-none absolute inset-0',
direction === 'left'
? 'bg-[linear-gradient(to_right,var(--color-background)_0%,transparent_15%,transparent_85%,var(--color-background)_100%)]'
: 'bg-[linear-gradient(to_bottom,var(--color-background)_0%,transparent_15%,transparent_85%,var(--color-background)_100%)]'
)}
></div>
{/if}
</div>

View File

@@ -0,0 +1,2 @@
import Marquee from "./Marquee.svelte";
export { Marquee }

View File

@@ -1,221 +0,0 @@
import * as React from 'react'
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button, buttonVariants } from '@/components/ui/button'
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="pagination-content"
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
isDisabled?: boolean
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<'a'>
function PaginationLink({
className,
isActive,
isDisabled,
size = 'icon',
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? 'page' : undefined}
data-slot="pagination-link"
data-active={isActive}
data-disabled={isDisabled}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
isDisabled && 'pointer-events-none opacity-50',
className,
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
isDisabled,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
isDisabled={isDisabled}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
isDisabled,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
isDisabled={isDisabled}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
const PaginationComponent: React.FC<PaginationProps> = ({
currentPage,
totalPages,
baseUrl,
}) => {
const getPageUrl = (page: number) => {
if (page === 1) return baseUrl
return `${baseUrl}${page}`
}
const getVisiblePages = () => {
const maxVisiblePages = 6
const pages: number[] = []
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i)
}
} else {
const startPage = Math.max(1, currentPage - 2)
const endPage = Math.min(totalPages, currentPage + 2)
if (startPage > 1) pages.push(1)
if (startPage > 2) pages.push(-1)
for (let i = startPage; i <= endPage; i++) {
pages.push(i)
}
if (endPage < totalPages - 1) pages.push(-1)
if (endPage < totalPages) pages.push(totalPages)
}
return pages
}
const visiblePages = getVisiblePages()
return (
<Pagination>
<PaginationContent className="flex-wrap">
<PaginationItem>
<PaginationPrevious
href={currentPage > 1 ? getPageUrl(currentPage - 1) : undefined}
isDisabled={currentPage === 1}
/>
</PaginationItem>
{visiblePages.map((page, index) =>
page === -1 ? (
<PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={page}>
<PaginationLink
href={getPageUrl(page)}
isActive={page === currentPage}
>
{page}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext
href={
currentPage < totalPages ? getPageUrl(currentPage + 1) : undefined
}
isDisabled={currentPage === totalPages}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}
interface PaginationProps {
currentPage: number
totalPages: number
baseUrl: string
}
export default PaginationComponent
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@@ -1,56 +0,0 @@
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -1,26 +0,0 @@
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -1,14 +1,14 @@
import type { IconMap, SocialLink, Site } from '@/types'
export const SITE: Site = {
title: 'Cojocaru David',
title: 'Patrick Jaroszewski',
description:
"Junior Full Stack Developer specializing in modern web technologies. Expert in React, Node.js, TypeScript, and cloud development. Read my latest tech tutorials, project insights, and programming tips on web development, DevOps, and software engineering best practices.",
href: 'https://www.cojocarudavid.me',
author: 'Cojocaru David',
"Full Stack Developer & DevOps Engineer passionate about building scalable web applications and robust infrastructure. A passionate learner driven by curiosity — skilled in modern web technologies, self-hosted infrastructure, and automation. Exploring new tech, sharing what I learn, and open to software engineering and DevOps opportunities.",
href: 'https://patrick.jaroszew.ski',
author: 'Patrick Jaroszewski',
locale: 'en-US',
location: 'Romania',
email: 'contact@cojocarudavid.me'
location: 'Canada',
email: 'patrick@jaroszew.ski'
}
export const NAV_LINKS: SocialLink[] = [
@@ -24,25 +24,25 @@ export const NAV_LINKS: SocialLink[] = [
href: '/blog',
label: 'blog',
},
{
href: '/#contact',
label: 'contact',
},
]
export const SOCIAL_LINKS: SocialLink[] = [
{
href: 'https://github.com/cojocaru-david?ref=personal-website',
label: 'GitHub',
href: 'https://git.jaroszew.ski/patrick',
label: 'Gitea',
},
{
href: 'mailto:contact@cojocarudavid.me',
href: 'mailto:patrick@jaroszew.ski',
label: 'Email',
},
{
href: 'tel:+40764132266',
href: 'tel:+14168334441',
label: 'Phone',
},
{
href: 'https://www.instagram.com/david._.cojo?ref=personal-website',
label: 'Instagram',
},
{
href: '/rss.xml',
label: 'RSS',
@@ -51,8 +51,7 @@ export const SOCIAL_LINKS: SocialLink[] = [
export const ICON_MAP: IconMap = {
Website: 'lucide:globe',
GitHub: 'lucide:github',
Instagram: 'lucide:instagram',
Gitea: 'mdi:git',
Phone: 'lucide:phone',
Email: 'lucide:mail',
RSS: 'lucide:rss',

View File

@@ -1,122 +0,0 @@
---
title: "10 bespoke digital marketing tools that will make your technology company stand "
description: "Discover 10 bespoke digital marketing tools that will make your technology company stand with this in-depth guide, providing actionable insights and practical tips to boost your knowledge and results."
date: 2025-08-14
tags:
- "bespoke"
- "digital"
- "marketing"
- "tools"
- "that"
- "will"
- "make"
- "your"
- "technology"
- "company"
- "stand"
authors:
- "Cojocaru David"
- "ChatGPT"
slug: "10-bespoke-digital-marketing-tools-that-will-make-your-technology-company-stand-out"
updatedDate: 2025-05-02
---
# 10 Bespoke Digital Marketing Tools to Make Your Tech Company Stand Out
In today's competitive tech landscape, generic marketing strategies fall short. To truly stand out, your technology company needs **bespoke digital marketing tools** designed for precision, scalability, and measurable impact. This guide explores 10 specialized tools from AI-driven personalization to predictive lead scoring that will elevate your campaigns, enhance engagement, and drive sustainable growth.
## 1. AI-Powered Personalization Platforms
Tech buyers demand tailored experiences. AI personalization tools analyze user behavior in real-time to deliver hyper-relevant content, emails, and ads.
### Key Benefits:
- **Dynamic content adaptation**: Adjusts messaging based on individual preferences.
- **Predictive analytics**: Forecasts engagement trends for proactive campaign optimization.
- **Automated A/B testing**: Continuously refines campaigns for maximum ROI.
## 2. Advanced SEO & Keyword Research Tools
Ranking higher in search results is critical for visibility. These tools offer deep insights into keywords, backlinks, and competitor strategies.
### Why It's Essential:
- **Real-time rank tracking**: Monitor keyword performance and adapt quickly.
- **Competitor gap analysis**: Uncover untapped opportunities in your niche.
- **Automated SEO audits**: Identify and fix technical issues before they impact rankings.
## 3. Interactive Content Creation Platforms
Static content no longer captivates audiences. Interactive tools like quizzes, calculators, and AR demos boost engagement and conversions.
### Top Picks:
- **Quiz builders**: Educate users while capturing leads.
- **ROI calculators**: Showcase value to potential customers.
- **AR product previews**: Offer virtual "try before you buy" experiences.
## 4. Precision Programmatic Advertising
AI-driven ad platforms ensure your budget targets the right audience at the optimal time.
### Core Advantages:
- **Real-time bidding (RTB)**: Dynamically optimize ad spend.
- **Granular segmentation**: Target users by behavior, location, and intent.
- **Cross-channel management**: Sync campaigns seamlessly across platforms.
## 5. Customer Journey Mapping Software
Visualize every touchpoint to refine marketing and eliminate friction in the buyer's journey.
### Must-Have Features:
- **Multi-touch attribution**: Measure how each interaction drives conversions.
- **Funnel visualization**: Identify and address drop-off points.
- **Behavioral triggers**: Automate personalized follow-ups.
> _"The best marketing tools don't just collect data they turn insights into action."_
## 6. Intelligent Chatbots & Conversational AI
Automate support, qualify leads, and guide users 24/7 with AI-powered chatbots.
### Use Cases:
- **Instant customer service**: Resolve queries without human intervention.
- **Lead qualification**: Engage visitors with interactive forms.
- **Personalized recommendations**: Suggest products based on user behavior.
## 7. Data-Driven Email Marketing Automation
Segment audiences and personalize emails at scale to boost open rates and conversions.
### Key Features:
- **Behavioral triggers**: Send emails based on user actions (e.g., cart abandonment).
- **Send-time optimization**: AI schedules emails for peak engagement.
- **Performance analytics**: Track opens, clicks, and conversions in real-time.
## 8. Influencer Relationship Management (IRM)
Build impactful partnerships with tech influencers to amplify your brand.
### Why It Works:
- **Performance tracking**: Measure campaign success with detailed metrics.
- **ROI analysis**: Quantify the value of influencer collaborations.
- **Automated outreach**: Streamline communication with influencers.
## 9. Social Listening & Sentiment Analysis
Monitor brand mentions, trends, and competitor activity to stay ahead.
### Capabilities:
- **Real-time alerts**: Get instant notifications about brand conversations.
- **Competitor benchmarking**: Compare sentiment against industry rivals.
- **Trend forecasting**: Spot emerging opportunities before competitors.
## 10. Predictive Lead Scoring
Prioritize high-intent leads using AI-driven scoring models.
### How It Helps:
- **Machine learning models**: Score leads based on engagement and fit.
- **CRM integration**: Sync scores with your sales pipeline.
- **Dynamic adjustments**: Update scores as lead behavior evolves.
> _"In marketing, the right tools are force multipliers they turn effort into exponential results."_
#marketing #technology #growth #digitalmarketing #SEO

View File

@@ -1,91 +0,0 @@
---
title: "10 essential cybersecurity tips for beginners"
description: "Discover 10 essential cybersecurity tips for beginners with this in-depth guide, providing actionable insights and practical tips to boost your knowledge and results."
date: 2025-08-14
tags:
- "essential"
- "cybersecurity"
- "tips"
- "beginners"
authors:
- "Cojocaru David"
- "ChatGPT"
slug: "10-essential-cybersecurity-tips-for-beginners"
updatedDate: 2025-05-02
---
# 10 Essential Cybersecurity Tips for Beginners
Wondering how to stay safe online as a beginner? Cybersecurity doesn't have to be complicated. These **10 essential cybersecurity tips** will help you protect your data, privacy, and devices from common threats even if you're just starting out. From strong passwords to spotting scams, this guide covers the basics in simple, actionable steps.
## 1. Create Strong, Unique Passwords
Weak passwords are the easiest way for hackers to access your accounts. Follow these best practices:
### Key Password Rules
- **Use 12+ characters** longer passwords are harder to crack.
- **Mix letters, numbers, and symbols** (e.g., `H7$kLm2#pQ!`).
- **Avoid personal details** like birthdays or pet names.
- **Try a password manager** (LastPass, Bitwarden) to generate and store passwords securely.
_"Passwords are like underwear: change them often, keep them private, and never share them."_
## 2. Enable Two-Factor Authentication (2FA)
2FA adds a second verification step, blocking hackers even if they steal your password.
### Best 2FA Methods
- **Authentication apps** (Google Authenticator, Authy).
- **Text message codes** (less secure but better than nothing).
- **Biometrics** (fingerprint or face ID).
Enable 2FA for email, banking, and social media first.
## 3. Update Software Regularly
Outdated apps and devices have security flaws hackers exploit.
### How to Stay Updated
- Turn on **automatic updates** for your OS and apps.
- Check for **router and smart device firmware updates** monthly.
- Delete unused apps to reduce attack risks.
## 4. Spot and Avoid Phishing Scams
Phishing emails or messages trick you into sharing sensitive data.
### Red Flags to Watch For
- Suspicious sender addresses (e.g., `support@amaz0n.com`).
- Urgent threats ("Your account will be closed!").
- Links to fake login pages.
**Never** share passwords or bank details via email.
## 5. Secure Your Wi-Fi Network
An unsecured Wi-Fi network lets hackers snoop on your traffic.
### Quick Wi-Fi Fixes
- Change the **default router username/password**.
- Use **WPA3 encryption** (or WPA2 if unavailable).
- Set up a **guest network** for visitors.
## 6. Back Up Your Data
Ransomware or crashes can wipe your files backups save you.
### Backup Strategies
- **Cloud storage** (Google Drive, iCloud).
- **External hard drives** for local copies.
- **Automate backups** so you never forget.
## 7. Limit Personal Info Online
Oversharing makes you a target for scams and identity theft.
### Privacy Tips
- Tighten **social media privacy settings**.
- Avoid posting travel plans or home addresses.
- Skip sketchy online quizzes asking for personal data.
#cybersecurity #onlinesafety #beginners #dataprotection #phishing

View File

@@ -1,119 +0,0 @@
---
title: "10 essential libraries for python developers"
description: "Discover 10 essential libraries for python developers with this in-depth guide, providing actionable insights and practical tips to boost your knowledge and results."
date: 2025-08-14
tags:
- "essential"
- "libraries"
- "python"
- "developers"
authors:
- "Cojocaru David"
- "ChatGPT"
slug: "10-essential-libraries-for-python-developers"
updatedDate: 2025-05-02
---
# 10 Essential Python Libraries Every Developer Should Know in 2024
Looking for the best Python libraries to boost your coding efficiency? Python's vast ecosystem offers powerful tools for data science, web development, machine learning, and more. Here are **10 must-know Python libraries** that will supercharge your projects, whether you're a beginner or an experienced developer.
> *"Python's 'batteries-included' philosophy means you can achieve incredible things with the right libraries."* Guido van Rossum
## 1. NumPy: The Foundation for Numerical Computing
NumPy is the backbone of numerical computing in Python. It provides fast, memory-efficient array operations, making it indispensable for scientific computing, data analysis, and machine learning.
### Why Use NumPy?
- **Blazing-fast computations** - Optimized C/Fortran backend for performance.
- **Broadcasting support** - Automatically handles operations on arrays of different shapes.
- **Seamless integration** - Works perfectly with Pandas, SciPy, and Matplotlib.
```python
import numpy as np
arr = np.array([1, 2, 3])
print(arr * 2) # Output: [2 4 6]
```
## 2. Pandas: The Ultimate Data Analysis Tool
Pandas simplifies data manipulation with its **DataFrame** structure, ideal for cleaning, transforming, and analyzing structured data.
### Key Features
- **Handle missing data** - Easily filter, fill, or drop null values.
- **Aggregation & grouping** - Summarize data with `groupby()` and `agg()`.
- **Built-in visualization** - Works smoothly with Matplotlib and Seaborn.
## 3. Matplotlib: Python's Go-To Plotting Library
Matplotlib lets you create **static, interactive, and animated visualizations**, from simple line charts to complex 3D plots.
### Best Use Cases
- **Customizable plots** - Adjust colors, labels, and styles.
- **Jupyter Notebook support** - Display plots inline for interactive analysis.
- **Wide variety of charts** - Line, bar, scatter, histograms, and more.
## 4. Requests: Effortless HTTP Requests
Requests simplifies API interactions with an intuitive syntax for sending **GET, POST, PUT, and DELETE** requests.
### Why Developers Love It
- **JSON auto-parsing** - Converts responses to Python dictionaries.
- **Session management** - Maintains persistent connections for efficiency.
- **Simple error handling** - Built-in status code checks.
## 5. Flask: Lightweight Web Development
Flask is a **minimalist web framework** perfect for small to medium projects. It's flexible, easy to learn, and great for prototyping.
### Top Advantages
- **Simple routing** - Map URLs to Python functions effortlessly.
- **Jinja2 templating** - Render dynamic HTML pages.
- **Extensible** - Add features via Flask extensions.
## 6. Django: The Full-Stack Powerhouse
Django follows a **"batteries-included"** approach, offering everything for secure, scalable web apps ORM, authentication, and admin panels.
### Why Choose Django?
- **Built-in ORM** - Interact with databases using Python, not SQL.
- **Admin dashboard** - Auto-generates a UI for data management.
- **Security-first** - Protects against XSS, CSRF, and SQL injection.
## 7. Scikit-learn: Machine Learning Made Simple
Scikit-learn provides **ready-to-use ML algorithms** for classification, regression, clustering, and model evaluation.
### Key Features
- **Supervised & unsupervised learning** - From linear regression to K-means.
- **Model tuning** - Hyperparameter optimization with GridSearchCV.
- **Evaluation metrics** - Accuracy, precision, recall, and more.
## 8. TensorFlow: Google's Deep Learning Giant
TensorFlow is the go-to library for **building neural networks**, supporting GPU acceleration and deployment across devices.
### Why It's Essential
- **Keras integration** - High-level API for quick prototyping.
- **Scalability** - Train models on CPUs, GPUs, or TPUs.
- **Production-ready** - Export models for mobile and edge devices.
## 9. BeautifulSoup: Web Scraping Simplified
BeautifulSoup parses HTML/XML, making **web scraping** effortless ideal for data extraction from websites.
### Why Use It?
- **Handles messy HTML** - Parses poorly formatted pages.
- **Flexible navigation** - Search by tags, attributes, or CSS selectors.
- **Lightweight** - No heavy dependencies.
## 10. PyTorch: The Researcher's Favorite
PyTorch is known for its **dynamic computation graphs**, making it a top choice for deep learning research.
### Why Developers Prefer It
- **Pythonic syntax** - Feels like native Python coding.
- **Dynamic graphs** - Modify networks on the fly.
- **Strong community** - Extensive tutorials and research support.
#Python #DataScience #WebDev #MachineLearning #Programming

View File

@@ -1,173 +0,0 @@
---
title: "10 essential linux commands for aspiring sysadmins"
description: "Discover 10 essential linux commands for aspiring sysadmins with this in-depth guide, providing actionable insights and practical tips to boost your knowledge and results."
date: 2025-08-14
tags:
- "essential"
- "linux"
- "commands"
- "aspiring"
- "sysadmins"
authors:
- "Cojocaru David"
- "ChatGPT"
slug: "10-essential-linux-commands-for-aspiring-sysadmins"
updatedDate: 2025-05-02
---
# 10 Essential Linux Commands Every Aspiring SysAdmin Should Master
Mastering Linux commands is non-negotiable for aspiring system administrators. Whether you're troubleshooting servers, managing files, or automating tasks, these **10 essential Linux commands** will give you the foundational skills to work efficiently. Below, we break down each command with practical examples and key options to help you gain confidence in the terminal.
## 1. `ls` - List Directory Contents
The `ls` command displays files and directories, giving you a quick snapshot of your current location.
### Key Options:
- `ls -l`: Detailed view (permissions, owner, size, modification date).
- `ls -a`: Shows hidden files (starting with `.`).
- `ls -h`: Human-readable file sizes (KB, MB, GB).
- `ls -t`: Sorts by modification time (newest first).
Example:
```
ls -lath
```
Combines multiple flags for a comprehensive directory overview.
## 2. `cd` - Change Directory
Navigate the filesystem effortlessly with `cd`.
### Common Uses:
- `cd /path/to/dir`: Move to an absolute path.
- `cd ..`: Go up one directory.
- `cd ~`: Return to your home directory.
- `cd -`: Switch back to the previous directory.
Example:
```
cd /var/log
```
Jumps to the system logs directory.
## 3. `grep` - Search Text Patterns
Find specific text in files quickly with `grep`.
### Useful Flags:
- `grep -i`: Case-insensitive search.
- `grep -r`: Recursive search (includes subdirectories).
- `grep -v`: Exclude matching lines.
- `grep -n`: Show line numbers.
Example:
```
grep -i "error" /var/log/syslog
```
Searches for "error" in system logs, ignoring case.
## 4. `chmod` - Change File Permissions
Control file access with `chmod` for better security.
### Permission Basics:
- `chmod 755 file`: Owner gets `rwx`, group/others get `rx`.
- `chmod +x script.sh`: Makes a script executable.
- `chmod u=rwx,g=rx,o=r file`: Symbolic permission assignment.
Example:
```
chmod 644 config.conf
```
Sets read/write for owner, read-only for others.
## 5. `sudo` - Execute Commands as Superuser
Run administrative tasks safely with `sudo`.
### Best Practices:
- Limit `sudo` usage to reduce risks.
- `sudo -u user command`: Run as a specific user.
Example:
```
sudo apt update
```
Updates package lists (requires root).
## 6. `df` - Check Disk Space Usage
Monitor storage with `df`.
### Helpful Options:
- `df -h`: Human-readable sizes.
- `df -T`: Shows filesystem types.
Example:
```
df -hT
```
Displays disk usage and filesystem types.
## 7. `top` - Monitor System Processes
Get real-time system performance insights.
### Key Features:
- Press `P` to sort by CPU usage.
- Press `M` to sort by memory usage.
- Press `1` to view per-core stats.
Example:
```
top
```
Launches the interactive process viewer.
## 8. `tar` - Archive Files
Bundle and compress files efficiently.
### Common Commands:
- `tar -czvf backup.tar.gz /home/user`: Creates a compressed archive.
- `tar -xvzf backup.tar.gz`: Extracts a gzipped archive.
Example:
```
tar -czvf logs.tar.gz /var/log
```
Compresses log files into a single archive.
## 9. `ssh` - Secure Remote Access
Connect to remote servers securely.
### Basic Usage:
- `ssh user@hostname`: Standard remote login.
- `ssh -p port user@host`: Custom port connection.
Example:
```
ssh admin@192.168.1.100
```
Logs into a server as `admin`.
## 10. `systemctl` - Manage System Services
Control background services with `systemctl`.
### Essential Commands:
- `systemctl start nginx`: Starts the Nginx service.
- `systemctl status nginx`: Checks service status.
- `systemctl enable nginx`: Auto-starts on boot.
Example:
```
systemctl restart nginx
```
Restarts the Nginx web server.
> *"The Linux philosophy is 'Do one thing and do it well.'"* Linus Torvalds
#Linux #SysAdmin #CommandLine #DevOps #LinuxCommands

View File

@@ -1,133 +0,0 @@
---
title: "10 essential metrics for measuring app performance"
description: "Discover 10 essential metrics for measuring app performance with this in-depth guide, providing actionable insights and practical tips to boost your knowledge and results."
date: 2025-08-14
tags:
- "essential"
- "metrics"
- "measuring"
- "performance"
authors:
- "Cojocaru David"
- "ChatGPT"
slug: "10-essential-metrics-for-measuring-app-performance"
updatedDate: 2025-05-02
---
# 10 Essential Metrics for Measuring App Performance
Wondering which metrics truly matter for your app's success? Tracking the right app performance metrics is key to improving user experience, boosting retention, and increasing revenue. In this guide, we break down the **10 essential metrics** every app developer, marketer, or product manager should monitor along with actionable tips to optimize them.
## 1. User Retention Rate
User retention rate measures how many users return to your app after their first visit. A high retention rate means your app delivers lasting value, while a low rate signals potential issues.
### Key Insights
- **Formula**: (Returning Users ÷ Total Installs) × 100
- **Benchmark**: Aim for 40%+ retention after 30 days.
- **Improvement Tips**:
- Optimize onboarding for a smooth first experience.
- Fix bugs quickly to ensure stability.
- Add features that solve real user problems.
## 2. Churn Rate
Churn rate tracks users who abandon your app. High churn indicates dissatisfaction or poor engagement.
### How to Reduce Churn
- **Formula**: (Lost Users ÷ Total Users at Period Start) × 100
- **Strategies**:
- Offer incentives (e.g., discounts, rewards).
- Simplify navigation and UX.
- Collect feedback to address pain points.
## 3. Daily & Monthly Active Users (DAU/MAU)
DAU and MAU reveal engagement frequency. The **DAU/MAU ratio** shows app "stickiness."
### Ideal Targets
- **Strong Ratio**: 20% or higher.
- **Optimization Tips**:
- Use push notifications wisely.
- Personalize content for relevance.
- Add gamification to encourage repeat use.
## 4. Session Length
Longer sessions often mean deeper engagement and higher conversion potential.
### Benchmarks & Fixes
- **Average Goal**: 2-5 minutes per session.
- **Enhancements**:
- Deliver high-value content.
- Speed up load times.
- Simplify navigation.
## 5. Crash Rate
Frequent crashes drive users away. Stability is non-negotiable.
### Critical Checks
- **Formula**: (Crashes ÷ Total Sessions) × 100
- **Target**: Keep crashes below 1%.
- **Solutions**:
- Use crash reporting tools (e.g., Firebase).
- Test updates rigorously.
- Optimize code quality.
## 6. Load Time
Slow apps frustrate users. Speed directly impacts retention.
### Performance Goals
- **Ideal Load Time**: Under 2 seconds.
- **Optimizations**:
- Compress images.
- Use caching and CDNs.
- Reduce unnecessary API calls.
## 7. Conversion Rate
Measures how many users complete key actions (e.g., purchases, sign-ups).
### Boosting Conversions
- **Formula**: (Conversions ÷ Total Users) × 100
- **Tactics**:
- Streamline checkout flows.
- A/B test CTAs and layouts.
- Highlight benefits clearly.
## 8. Average Revenue Per User (ARPU)
ARPU shows monetization effectiveness.
### Growth Strategies
- **Formula**: Total Revenue ÷ Active Users
- **Tips**:
- Upsell premium features.
- Optimize ad placements.
- Offer in-app purchases.
## 9. Net Promoter Score (NPS)
NPS gauges user loyalty by asking, "Would you recommend this app?"
### Scoring & Action
- **Scale**: -100 to 100 (50+ is excellent).
- **Improvements**:
- Act on feedback promptly.
- Enhance customer support.
- Follow up with users.
## 10. App Store Ratings & Reviews
Positive reviews build trust and visibility.
### Management Tips
- Respond to all reviews (good and bad).
- Prompt happy users to leave ratings.
- Resolve complaints quickly.
> _"You can't manage what you don't measure."_ - Peter Drucker
#appperformance #mobileapps #datadriven #userexperience #metrics

View File

@@ -1,133 +0,0 @@
---
title: "10 essential plugins for your next.js project"
description: "Discover 10 essential plugins for your next.js project with this in-depth guide, providing actionable insights and practical tips to boost your knowledge and results."
date: 2025-08-14
tags:
- "essential"
- "plugins"
- "your"
- "nextjs"
- "project"
authors:
- "Cojocaru David"
- "ChatGPT"
slug: "10-essential-plugins-for-your-nextjs-project"
updatedDate: 2025-05-02
---
# 10 Essential Plugins for Your Next.js Project
Looking for the best plugins to enhance your Next.js project? These 10 essential tools will streamline development, boost performance, and add powerful features helping you build faster, optimize better, and scale efficiently.
## Why Plugins Matter for Next.js
Next.js is a powerful React framework, but plugins unlock its full potential. They save time, improve SEO, optimize performance, and simplify complex tasks like authentication and internationalization. Here's a curated list of must-have plugins for any Next.js developer.
## 1. Next SEO: Boost Your Search Rankings
Search engine optimization is critical for visibility. `Next SEO` simplifies adding metadata, Open Graph tags, and structured data to your pages.
### Key Features:
- **Auto-generated SEO tags:** Titles, descriptions, and canonical URLs.
- **Dynamic support:** Works with SSR and SSG pages.
- **Easy setup:** Minimal configuration required.
```javascript
import { NextSeo } from "next-seo";
const Home = () => (
<>
<NextSeo
title="My Page - Example Website"
description="A Next.js page with enhanced SEO features."
canonical="https://www.example.com/"
openGraph={{
url: "https://www.example.com/",
title: "My Page - Example Website",
description: "A Next.js page with enhanced SEO features.",
}}
/>
{/* Your content */}
</>
);
```
## 2. next-sitemap: Automate Sitemap Creation
A sitemap helps search engines index your site. `next-sitemap` generates XML sitemaps during builds.
### Key Features:
- **Supports all routes:** Static and dynamic pages.
- **Customizable:** Configure `sitemap.xml` and `robots.txt`.
- **SSG/ISR compatible:** Works with Next.js static features.
## 3. next-pwa: Turn Your App into a PWA
Progressive Web Apps (PWAs) improve engagement with offline access and faster loads.
### Key Features:
- **Zero-config setup:** Quick PWA conversion.
- **Offline caching:** Reliable performance without internet.
- **Service worker support:** Optimized resource loading.
## 4. next-auth: Simplify Authentication
Secure user authentication is easier with `next-auth`.
### Key Features:
- **Multiple providers:** OAuth, email/password, and more.
- **Session management:** Built-in and secure.
- **Database integration:** Works with MongoDB, PostgreSQL, etc.
## 5. next-translate: Add Multilingual Support
Reach global audiences with `next-translate` for i18n.
### Key Features:
- **JSON translations:** Easy-to-manage language files.
- **Dynamic routing:** Auto-detects user language.
- **Lightweight:** Minimal performance impact.
## 6. next-bundle-analyzer: Optimize Performance
Large bundles slow down apps. `next-bundle-analyzer` visualizes dependencies.
### Key Features:
- **Interactive treemap:** Spot bloated dependencies.
- **Faster loads:** Trim unnecessary code.
## 7. next-images: Optimize Image Loading
Images impact performance. `next-images` simplifies optimization.
### Key Features:
- **Multiple formats:** JPG, PNG, SVG, etc.
- **Auto-optimization:** No manual tweaking needed.
## 8. next-fonts: Prevent Layout Shifts
Font loading can disrupt layouts. `next-fonts` fixes this.
### Key Features:
- **Self-hosted fonts:** Faster loads.
- **Preloading:** Avoids unstyled text flashes.
## 9. next-compose-plugins: Organize Configs
Managing plugins gets messy. `next-compose-plugins` cleans it up.
### Key Features:
- **Simplified config:** Easier-to-read setup.
- **Plugin chaining:** Apply multiple transformations.
## 10. next-offline: Advanced Offline Support
`next-offline` extends PWA capabilities with better caching.
### Key Features:
- **Smart caching:** Prioritizes critical resources.
- **Reliable offline mode:** Works with spotty connections.
> _"Plugins are like shortcuts in a long journey - they help you reach your destination faster and with less effort."_
#NextJS #WebDevelopment #SEO #Plugins #Performance

View File

@@ -1,103 +0,0 @@
---
title: "10 essential skills for a career in devops"
description: "Discover 10 essential skills for a career in devops with this in-depth guide, providing actionable insights and practical tips to boost your knowledge and results."
date: 2025-08-14
tags:
- "essential"
- "skills"
- "career"
- "devops"
authors:
- "Cojocaru David"
- "ChatGPT"
slug: "10-essential-skills-for-a-career-in-devops"
updatedDate: 2025-05-02
---
# 10 Essential DevOps Skills to Boost Your Career in 2024
Want to build a successful DevOps career? Start by mastering these **10 essential DevOps skills** from Linux and cloud computing to CI/CD and automation. Whether you're a beginner or looking to upskill, this guide breaks down the must-have technical and soft skills to thrive in DevOps.
## 1. Linux and Scripting Mastery
A strong Linux foundation is non-negotiable for DevOps engineers. Most cloud environments and DevOps tools run on Linux, so fluency in command-line operations, file systems, and permissions is critical.
- **Key Skill:** Learn Bash, Python, or PowerShell for automation.
- **Pro Tip:** Master package managers like `apt` and `yum` for efficient software management.
- **Real-World Use:** Troubleshoot server issues permissions, networking, and performance bottlenecks.
## 2. Cloud Computing Expertise
AWS, Azure, and Google Cloud dominate DevOps workflows. Understanding cloud infrastructure, services, and deployment models is essential.
- **Core Concept:** Master Infrastructure as Code (IaC) with Terraform or AWS CloudFormation.
- **Deployment Models:** Explore VMs, containers (Docker), and serverless computing.
- **Security First:** Implement cloud security best practices (IAM, encryption, network hardening).
## 3. CI/CD Pipeline Automation
CI/CD pipelines accelerate software delivery by automating testing and deployment.
- **Top Tools:** Jenkins, GitLab CI, GitHub Actions.
- **Pipeline Scripting:** Write CI/CD workflows in YAML or Groovy.
- **Automated Testing:** Integrate unit, integration, and end-to-end tests.
## 4. Containerization and Kubernetes
Containers ensure consistency, while Kubernetes manages them at scale.
- **Docker Basics:** Build, run, and manage container images.
- **Kubernetes Mastery:** Deploy, scale, and monitor containerized apps.
- **Helm for Efficiency:** Simplify Kubernetes deployments with Helm charts.
## 5. Infrastructure as Code (IaC)
IaC replaces manual setups with automated, version-controlled infrastructure.
- **Terraform or CloudFormation:** Define and provision infrastructure via code.
- **Configuration Management:** Use Ansible, Puppet, or Chef for server consistency.
- **Git Integration:** Version-control IaC scripts for collaboration and rollbacks.
## 6. Monitoring and Logging
Proactive monitoring prevents outages and optimizes performance.
- **Key Tools:** Prometheus (metrics), Grafana (visualization), ELK Stack (logs).
- **Log Analysis:** Identify trends and troubleshoot faster.
- **Alerting:** Set up real-time alerts for critical issues.
## 7. Git and Version Control
Git enables seamless collaboration across DevOps teams.
- **Git Commands:** Master branching, merging, and conflict resolution.
- **Workflows:** Adopt GitFlow or GitHub Flow for structured development.
- **CI/CD Integration:** Automate builds and deployments with Git hooks.
## 8. Networking and Security
Secure infrastructure starts with networking and security fundamentals.
- **Networking Basics:** Firewalls, VPNs, load balancers, and DNS.
- **Security Practices:** Implement IAM, encryption, and compliance (GDPR, HIPAA).
- **Shift-Left Security:** Embed security into CI/CD pipelines.
## 9. Collaboration and Communication
DevOps success hinges on teamwork and clear communication.
- **Cross-Functional Collaboration:** Bridge gaps between dev, ops, and QA.
- **Tools:** Slack (chat), Jira (ticketing), Confluence (documentation).
- **Documentation:** Keep processes and runbooks up to date.
## 10. Problem-Solving and Adaptability
DevOps evolves fast stay agile and solution-focused.
- **Debugging Skills:** Break down complex issues systematically.
- **Stay Updated:** Follow industry blogs, attend conferences, join DevOps communities.
- **Continuous Learning:** Embrace new tools and methodologies.
> _"DevOps is a mindset automate relentlessly, collaborate fearlessly, and iterate endlessly. Master these skills, and you'll future-proof your career."_
#DevOps #CloudComputing #Automation #CareerGrowth #TechSkills

View File

@@ -1,101 +0,0 @@
---
title: "10 essential tips for securing your home wi-fi"
description: "Discover 10 essential tips for securing your home wi-fi with this in-depth guide, providing actionable insights and practical tips to boost your knowledge and results."
date: 2025-08-14
tags:
- "essential"
- "tips"
- "securing"
- "your"
- "home"
authors:
- "Cojocaru David"
- "ChatGPT"
slug: "10-essential-tips-for-securing-your-home-wi-fi"
updatedDate: 2025-05-02
---
# 10 Essential Tips for Securing Your Home Wi-Fi Network
A secure home Wi-Fi network is your first defense against hackers, data theft, and unauthorized access. Weak security leaves your personal information, devices, and internet speed vulnerable. Follow these 10 essential tips to lock down your Wi-Fi and protect your digital life.
## 1. Change Your Router's Default Login Credentials
Default usernames and passwords (like "admin") are easy targets for attackers. Always customize them for better security.
- Access your router's admin panel (usually via `192.168.1.1` or `192.168.0.1`).
- Replace default credentials with a strong, unique combination.
- Avoid personal details (e.g., birthdays, pet names) in passwords.
## 2. Use a Strong Wi-Fi Password
A weak password invites intruders. Strengthen yours with these best practices:
- Minimum **12 characters**, mixing letters, numbers, and symbols.
- Avoid common phrases (e.g., "password123").
- Update your password every **3-6 months**.
- Use a password manager for secure storage.
## 3. Enable WPA3 Encryption
Encryption scrambles data to prevent eavesdropping. **WPA3** is the gold standard.
- Navigate to your router's wireless security settings.
- Select **WPA3-Personal** (or **WPA2** if unavailable).
- Never use outdated options like **WEP** or an open network.
## 4. Turn Off WPS (Wi-Fi Protected Setup)
WPS simplifies connections but has known security flaws.
- Disable WPS in your router's admin panel.
- Manually enter passwords for new devices instead.
## 5. Update Your Router's Firmware
Manufacturers release updates to fix vulnerabilities.
- Check for firmware updates in the admin panel.
- Enable **automatic updates** if available.
- Reboot the router after installing updates.
## 6. Hide Your Wi-Fi Network (SSID)
A hidden network is less visible to hackers.
- Disable **SSID Broadcast** in wireless settings.
- Manually enter the network name when connecting new devices.
## 7. Set Up a Guest Network
Keep visitors separate from your main network.
- Enable the guest network feature.
- Assign a unique password.
- Restrict access to sensitive files and devices.
## 8. Activate Your Router's Firewall
A firewall blocks malicious traffic.
- Ensure the firewall is **enabled** in security settings.
- Configure it to block suspicious incoming requests.
## 9. Disable Remote Management
Remote access can be exploited by hackers.
- Turn off remote management in the admin panel.
- Only enable it temporarily if absolutely needed.
## 10. Monitor Connected Devices
Regular checks prevent unauthorized access.
- Review connected devices in the router's admin panel.
- Block unfamiliar devices immediately.
- Change your Wi-Fi password if suspicious activity occurs.
> "Your Wi-Fi network is the gateway to your digital home. Secure it like you would your front door."
#WiFiSecurity #CyberSafety #HomeNetwork #Privacy #TechTips

View File

@@ -1,159 +0,0 @@
---
title: "10 essential tools for api testing"
description: "Discover 10 essential tools for api testing with this in-depth guide, providing actionable insights and practical tips to boost your knowledge and results."
date: 2025-08-14
tags:
- "essential"
- "tools"
- "testing"
authors:
- "Cojocaru David"
- "ChatGPT"
slug: "10-essential-tools-for-api-testing"
updatedDate: 2025-05-02
---
# 10 Essential Tools for API Testing: Boost Efficiency and Reliability
Looking for the best API testing tools to streamline your development workflow? This guide covers **10 essential tools for API testing**, from debugging and automation to performance and security testing. Whether you're a developer, QA engineer, or DevOps professional, these tools will help you deliver robust, high-performing APIs with confidence.
## Why API Testing Is Crucial for Modern Development
API testing ensures seamless communication between applications by verifying functionality, security, and performance. Without proper testing, APIs can introduce bugs, security risks, and poor user experiences.
### Key Benefits of API Testing
- **Faster feedback** than UI testing, catching issues early.
- **Improved security** by validating authentication and data handling.
- **Better scalability** under high traffic loads.
- **Cost savings** by reducing late-stage bug fixes.
## 1. Postman: The All-in-One API Testing Solution
Postman is a favorite for its intuitive interface, automation capabilities, and collaboration features.
### Why Choose Postman?
- Supports **REST, SOAP, and GraphQL** APIs.
- Built-in JavaScript scripting for test automation.
- Team collaboration with shared workspaces.
- Environment variables for flexible testing.
Example GET request:
```
GET https://api.example.com/users
```
## 2. SoapUI: Advanced Testing for SOAP & REST APIs
SoapUI excels in functional, security, and load testing for complex API workflows.
### Key Features
- Drag-and-drop test creation.
- Data-driven testing with external datasets.
- Detailed reporting for performance analysis.
## 3. Swagger (OpenAPI): Design, Document & Test APIs
Swagger simplifies API design and testing with interactive documentation.
### Why Use Swagger?
- Auto-generates **up-to-date API docs**.
- Live API testing in-browser.
- OpenAPI standard for interoperability.
## 4. JMeter: Performance Testing for APIs
Apache JMeter simulates high traffic to test API scalability.
### Why JMeter?
- Supports **HTTP, HTTPS, FTP** protocols.
- Extensible with plugins.
- Detailed performance metrics.
## 5. RestAssured: Java-Based API Testing Simplified
RestAssured offers a fluent syntax for REST API validation in Java.
### Key Advantages
- Integrates with Java projects.
- Supports BDD (Behavior-Driven Development).
- Easy JSON/XML response handling.
Example test:
```java
given()
.param("userId", "1")
.when()
.get("/users")
.then()
.statusCode(200);
```
## 6. Karate: No-Code API Testing with Gherkin Syntax
Karate combines API testing, mocking, and performance checks in one tool.
### Why Karate?
- No Java knowledge needed.
- Built-in assertions and reporting.
- Parallel test execution.
## 7. Katalon Studio: Unified Automation for APIs, Web & Mobile
Katalon Studio supports **no-code and scripted** API testing.
### Key Features
- CI/CD pipeline integration.
- SOAP and REST API testing.
- Cross-platform compatibility.
## 8. Insomnia: Lightweight API Debugging
Insomnia focuses on debugging and API design with a clean interface.
### Why Insomnia?
- Environment variables for dynamic testing.
- GraphQL support.
- Plugin ecosystem for customization.
## 9. Paw: Advanced API Testing for Mac Users
Paw is a Mac-exclusive tool with dynamic request building.
### Key Benefits
- Automatic code generation.
- Real-time testing with dynamic values.
- Sleek, user-friendly UI.
## 10. Fiddler: Web Debugging Proxy for API Traffic
Fiddler captures and analyzes HTTP/HTTPS traffic for troubleshooting.
### Why Fiddler?
- Inspect requests/responses in real-time.
- Simulate network conditions.
- Identify performance bottlenecks.
## How to Choose the Right API Testing Tool
Match tools to your needs:
- **Postman** for general testing & collaboration.
- **JMeter** for load/performance testing.
- **RestAssured** for Java-based automation.
- **Swagger** for API documentation & design.
- **SoapUI** for complex SOAP/REST workflows.
> _"A well-tested API is the bedrock upon which seamless digital experiences are built."_
#apitesting #testautomation #devtools #qa #restapi

View File

@@ -1,125 +0,0 @@
---
title: "10 essential tools for managing cloud infrastructure"
description: "Discover 10 essential tools for managing cloud infrastructure with this in-depth guide, providing actionable insights and practical tips to boost your knowledge and results."
date: 2025-08-14
tags:
- "essential"
- "tools"
- "managing"
- "cloud"
- "infrastructure"
authors:
- "Cojocaru David"
- "ChatGPT"
slug: "10-essential-tools-for-managing-cloud-infrastructure"
updatedDate: 2025-05-02
---
# 10 Essential Tools for Managing Cloud Infrastructure
Looking for the best tools to manage your cloud infrastructure efficiently? Whether you're deploying applications, scaling resources, or enhancing security, the right tools can automate, monitor, and optimize your cloud environment. Here are **10 essential cloud management tools** that streamline operations, boost productivity, and keep your infrastructure running smoothly.
## 1. Terraform
Terraform by HashiCorp is a leading **Infrastructure as Code (IaC)** tool that lets teams define and provision cloud resources using declarative configuration files. It supports multiple cloud providers, including AWS, Azure, and Google Cloud, making it ideal for hybrid and multi-cloud setups.
### Key Features
- **Multi-Cloud Support:** Manage resources across different cloud platforms seamlessly.
- **State Tracking:** Monitor infrastructure changes and dependencies for reliable deployments.
- **Reusable Modules:** Simplify provisioning with modular, reusable configurations.
**Example Terraform Snippet (AWS EC2 Instance):**
```hcl
resource "aws_instance" "web_server" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
tags = {
Name = "WebServer"
}
}
```
## 2. AWS CloudFormation
AWS CloudFormation is a native **cloud orchestration tool** that automates infrastructure management using JSON or YAML templates. It ensures consistency and repeatability across AWS environments.
### Advantages
- **Template-Based Deployments:** Define infrastructure as code for predictable setups.
- **Rollback Functionality:** Revert to stable configurations if deployments fail.
- **AWS Integration:** Works seamlessly with other AWS services for scaling and management.
## 3. Kubernetes (K8s)
Kubernetes is the go-to **container orchestration platform** for cloud-native applications. It automates deployment, scaling, and load balancing, ensuring high availability.
### Why Use Kubernetes?
- **Self-Healing:** Restarts failed containers automatically.
- **Scalability:** Add containers dynamically to handle traffic spikes.
- **Multi-Cloud Flexibility:** Avoid vendor lock-in by running apps across clouds.
## 4. Ansible
Ansible is a powerful **automation tool** that simplifies configuration management and deployments using YAML-based playbooks.
### Benefits
- **Agentless Design:** No need to install agents on target systems.
- **Idempotency:** Ensures tasks run only when necessary.
- **Extensive Modules:** Pre-built integrations for cloud platforms and services.
## 5. Prometheus
Prometheus is an open-source **monitoring tool** for cloud-native apps, offering real-time metrics and alerting.
### Key Capabilities
- **Time-Series Database:** Stores and retrieves metrics efficiently.
- **PromQL:** Query language for custom dashboards and analysis.
- **Alertmanager:** Configurable alerts for proactive issue resolution.
## 6. Docker
Docker popularized **containerization**, packaging apps and dependencies into portable containers for consistent deployments.
### Why Docker?
- **Lightweight:** Uses minimal system resources.
- **Consistency:** Runs the same way across all environments.
- **Docker Hub:** Access thousands of pre-built images.
## 7. Pulumi
Pulumi is a modern **IaC tool** that lets you define infrastructure using Python, JavaScript, or Go.
### Advantages
- **Familiar Languages:** Use existing coding skills for infrastructure.
- **Multi-Cloud Support:** Manage resources across providers with one codebase.
- **State Management:** Track infrastructure changes reliably.
## 8. Datadog
Datadog is a **cloud monitoring platform** offering visibility into infrastructure, apps, and logs.
### Features
- **Unified Dashboards:** Metrics, logs, and traces in one place.
- **AI Alerts:** Detects anomalies automatically.
- **APM:** Optimizes application performance.
## 9. Helm
Helm is the **package manager for Kubernetes**, simplifying app deployments with reusable charts.
### Why Helm?
- **Templating:** Customize apps for different environments.
- **Chart Repositories:** Share and discover pre-built charts.
- **Rollbacks:** Revert deployments if issues arise.
## 10. Cloudflare
Cloudflare enhances **cloud security and performance** with CDN, DDoS protection, and DNS management.
### Key Offerings
- **Global CDN:** Faster content delivery with edge caching.
- **WAF:** Blocks common web attacks.
- **Zero Trust:** Strict access controls for security.
> _"The cloud is not just about technology; it's about transforming how we manage infrastructure, enabling agility and driving business value."_
#CloudComputing #DevOps #InfrastructureAsCode #CloudSecurity #Automation

Some files were not shown because too many files have changed in this diff Show More