mirror of
https://github.com/prdlk/website.git
synced 2026-08-02 17:31:41 +00:00
Merge pull request #1 from prdlk/feat/astro-migrate
Refactor into Prad Nukala's CV/portfolio site
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
WEBMENTION_API_KEY=
|
||||||
|
WEBMENTION_URL=
|
||||||
|
WEBMENTION_PINGBACK=#optional
|
||||||
+17
-27
@@ -1,38 +1,28 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# build output
|
||||||
|
dist/
|
||||||
|
.output/
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
data
|
node_modules/
|
||||||
node_modules
|
|
||||||
.pnp
|
|
||||||
.pnp.js
|
|
||||||
tmp/
|
|
||||||
|
|
||||||
# testing
|
# logs
|
||||||
coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
.next/
|
|
||||||
out/
|
|
||||||
build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
# local env files
|
|
||||||
|
# environment variables
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.production
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# turbo
|
# macOS-specific files
|
||||||
.turbo
|
.DS_Store
|
||||||
|
|
||||||
# vercel
|
# misc
|
||||||
|
*.pem
|
||||||
|
.cache
|
||||||
|
.astro
|
||||||
|
tmp/
|
||||||
.vercel
|
.vercel
|
||||||
|
.env*.local
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
*.min.js
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# cache-dirs
|
||||||
|
**/.cache
|
||||||
|
|
||||||
|
pnpm-lock.yaml
|
||||||
|
dist
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/** @type {import("@types/prettier").Options} */
|
||||||
|
export default {
|
||||||
|
printWidth: 100,
|
||||||
|
semi: true,
|
||||||
|
singleQuote: false,
|
||||||
|
tabWidth: 2,
|
||||||
|
useTabs: true,
|
||||||
|
plugins: ["prettier-plugin-astro", "prettier-plugin-tailwindcss" /* Must come last */],
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: "**/*.astro",
|
||||||
|
options: {
|
||||||
|
parser: "astro",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["*.mdx", "*.md"],
|
||||||
|
options: {
|
||||||
|
printWidth: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
Vendored
-23
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"explorer.excludeGitIgnore": false,
|
|
||||||
"workbench.colorCustomizations": {
|
|
||||||
"activityBar.activeBackground": "#95ee3a",
|
|
||||||
"activityBar.background": "#95ee3a",
|
|
||||||
"activityBar.foreground": "#15202b",
|
|
||||||
"activityBar.inactiveForeground": "#15202b99",
|
|
||||||
"activityBarBadge.background": "#3290ed",
|
|
||||||
"activityBarBadge.foreground": "#e7e7e7",
|
|
||||||
"commandCenter.border": "#15202b99",
|
|
||||||
"sash.hoverBorder": "#95ee3a",
|
|
||||||
"statusBar.background": "#7ce114",
|
|
||||||
"statusBar.foreground": "#15202b",
|
|
||||||
"statusBarItem.hoverBackground": "#62b210",
|
|
||||||
"statusBarItem.remoteBackground": "#7ce114",
|
|
||||||
"statusBarItem.remoteForeground": "#15202b",
|
|
||||||
"titleBar.activeBackground": "#7ce114",
|
|
||||||
"titleBar.activeForeground": "#15202b",
|
|
||||||
"titleBar.inactiveBackground": "#7ce11499",
|
|
||||||
"titleBar.inactiveForeground": "#15202b99"
|
|
||||||
},
|
|
||||||
"peacock.color": "#7ce114"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<!-- Generated: 2026-06-30 | Updated: 2026-06-30 -->
|
||||||
|
|
||||||
|
# prad.nu
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Personal CV / portfolio site for Prad Nukala (https://prad.nu). A static [Astro](https://astro.build) v6 site (forked from the Astro Cactus theme) organised into four content sections — **Projects**, **Experience**, **Writing**, **Speaking** — where Experience entries double as a cross-linking taxonomy. See `README.md` for the human-facing overview.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `package.json` | Dependencies and scripts (pnpm). |
|
||||||
|
| `astro.config.ts` | Astro integrations (MDX, sitemap, icon, robots, webmanifest, expressive-code), markdown remark/rehype pipeline, Satori OG fonts. |
|
||||||
|
| `tailwind.config.ts` | Tailwind v4 config (most styling is in `src/styles/global.css`). |
|
||||||
|
| `tsconfig.json` | TypeScript config; defines the `@/*` → `src/*` path alias. |
|
||||||
|
| `biome.json` | Biome linter/formatter config (`pnpm check` / `pnpm lint`). |
|
||||||
|
| `mise.toml` / `pnpm-workspace.yaml` | Toolchain + workspace config. |
|
||||||
|
| `README.md` | Project purpose, content model, frontmatter reference. |
|
||||||
|
|
||||||
|
## Subdirectories
|
||||||
|
| Directory | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `src/` | All application source (see `src/AGENTS.md`). |
|
||||||
|
| `public/` | Static assets served as-is (see `public/AGENTS.md`). |
|
||||||
|
|
||||||
|
## For AI Agents
|
||||||
|
|
||||||
|
### Working In This Directory
|
||||||
|
- Package manager is **pnpm**. Output is **static** (`output: 'static'`, `./dist`) — no SSR adapter.
|
||||||
|
- Import from source via the `@/` alias (e.g. `@/site.config`), not long relative paths.
|
||||||
|
- Most site-wide config (title, url, `socialLinks`, `streamOrigin`, `menuLinks`) lives in `src/site.config.ts`.
|
||||||
|
|
||||||
|
### Testing Requirements
|
||||||
|
- `pnpm build` is the primary correctness gate — it runs `astro check`-level content validation and fails on bad frontmatter, broken collection refs, or type errors.
|
||||||
|
- `pnpm check` runs `astro check` + Biome. `pnpm dev` serves locally for visual checks.
|
||||||
|
|
||||||
|
### Common Patterns
|
||||||
|
- Content lives in Content Collections; schema is defined once in `src/content.config.ts`.
|
||||||
|
- `is:inline` is required on `<script type="application/ld+json">` and similar non-bundled scripts.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### External
|
||||||
|
- `astro` v6, `tailwindcss` v4 — framework + styling.
|
||||||
|
- `@astrojs/mdx`, `@astrojs/rss`, `@astrojs/sitemap`, `astro-icon`, `astro-webmanifest`, `astro-robots-txt`, `astro-expressive-code`.
|
||||||
|
- `satori` + `sharp` — OG image generation. `pagefind` — static search. `@mux`? no — speaking uses Cloudflare Stream via an iframe (no player dep).
|
||||||
|
|
||||||
|
<!-- MANUAL: Any manually added notes below this line are preserved on regeneration -->
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
This is a personal portfolio/blog website built with Next.js 13.4.16 (App Router), TypeScript, and Tailwind CSS. It's based on the Tailwind UI "Spotlight" template and features a modern, responsive design with dark mode support.
|
|
||||||
|
|
||||||
## Essential Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Run development server (http://localhost:3000)
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Build for production
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Start production server
|
|
||||||
npm start
|
|
||||||
|
|
||||||
# Run linting
|
|
||||||
npm run lint
|
|
||||||
|
|
||||||
# Type checking (via build process)
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture & Key Patterns
|
|
||||||
|
|
||||||
### Directory Structure
|
|
||||||
- **`/src/app/`** - Next.js App Router pages and layouts
|
|
||||||
- Each page is in its own directory with `page.tsx` or `page.mdx`
|
|
||||||
- Articles are MDX files in subdirectories under `/articles/`
|
|
||||||
- RSS feed generation at `/feed.xml/`
|
|
||||||
|
|
||||||
- **`/src/components/`** - Reusable React components
|
|
||||||
- Layout components extend from `Layout.tsx`
|
|
||||||
- All components use TypeScript with proper type definitions
|
|
||||||
- Components use Tailwind CSS classes for styling
|
|
||||||
|
|
||||||
- **`/src/lib/`** - Utility functions
|
|
||||||
- `articles.ts` - Functions for loading and managing MDX articles
|
|
||||||
- `formatDate.ts` - Date formatting utilities
|
|
||||||
|
|
||||||
### Content Management
|
|
||||||
- Articles are written in MDX format in `/src/app/articles/[slug]/page.mdx`
|
|
||||||
- Each article directory can contain images alongside the MDX file
|
|
||||||
- Article metadata is defined in the MDX frontmatter
|
|
||||||
- The `ArticleLayout` component wraps all article content
|
|
||||||
|
|
||||||
### Styling Approach
|
|
||||||
- Tailwind CSS utility classes for all styling
|
|
||||||
- Dark mode support via `next-themes` package
|
|
||||||
- Typography plugin for prose content styling
|
|
||||||
- Custom Tailwind configuration in `tailwind.config.js`
|
|
||||||
- Code syntax highlighting with Prism CSS
|
|
||||||
|
|
||||||
### TypeScript Configuration
|
|
||||||
- Strict mode enabled
|
|
||||||
- Path alias `@/*` maps to `./src/*`
|
|
||||||
- All components and utilities should have proper TypeScript types
|
|
||||||
|
|
||||||
### Important Files
|
|
||||||
- `next.config.mjs` - Next.js configuration with MDX support
|
|
||||||
- `tailwind.config.js` - Tailwind CSS customization
|
|
||||||
- `.env.local` - Must set `NEXT_PUBLIC_SITE_URL` for production
|
|
||||||
|
|
||||||
## Development Guidelines
|
|
||||||
|
|
||||||
### Adding New Pages
|
|
||||||
1. Create a new directory under `/src/app/`
|
|
||||||
2. Add a `page.tsx` file with proper TypeScript types
|
|
||||||
3. Use the `SimpleLayout` or custom layout components
|
|
||||||
4. Follow existing page patterns for consistency
|
|
||||||
|
|
||||||
### Adding New Articles
|
|
||||||
1. Create a new directory under `/src/app/articles/[article-name]/`
|
|
||||||
2. Add a `page.mdx` file with frontmatter including title, description, author, and date
|
|
||||||
3. Place any images in the same directory
|
|
||||||
4. Articles automatically appear in the articles list and RSS feed
|
|
||||||
|
|
||||||
### Component Development
|
|
||||||
- Always use TypeScript with proper type definitions
|
|
||||||
- Follow existing component patterns for props and structure
|
|
||||||
- Use Tailwind CSS classes; avoid inline styles
|
|
||||||
- Components should be reusable and properly abstracted
|
|
||||||
|
|
||||||
### Environment Setup
|
|
||||||
Before running the project, create a `.env.local` file:
|
|
||||||
```
|
|
||||||
NEXT_PUBLIC_SITE_URL=https://your-domain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## No Testing Framework
|
|
||||||
This project does not currently have a testing framework configured. Consider adding Jest and React Testing Library if test coverage is needed.
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 Chris Williams
|
||||||
|
|
||||||
|
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.
|
||||||
-129
@@ -1,129 +0,0 @@
|
|||||||
# Tailwind UI License
|
|
||||||
|
|
||||||
## Personal License
|
|
||||||
|
|
||||||
Tailwind Labs Inc. grants you an on-going, non-exclusive license to use the Components and Templates.
|
|
||||||
|
|
||||||
The license grants permission to **one individual** (the Licensee) to access and use the Components and Templates.
|
|
||||||
|
|
||||||
You **can**:
|
|
||||||
|
|
||||||
- Use the Components and Templates to create unlimited End Products.
|
|
||||||
- Modify the Components and Templates to create derivative components and templates. Those components and templates are subject to this license.
|
|
||||||
- Use the Components and Templates to create unlimited End Products for unlimited Clients.
|
|
||||||
- Use the Components and Templates to create End Products where the End Product is sold to End Users.
|
|
||||||
- Use the Components and Templates to create End Products that are open source and freely available to End Users.
|
|
||||||
|
|
||||||
You **cannot**:
|
|
||||||
|
|
||||||
- Use the Components and Templates to create End Products that are designed to allow an End User to build their own End Products using the Components and Templates or derivatives of the Components and Templates.
|
|
||||||
- Re-distribute the Components and Templates or derivatives of the Components and Templates separately from an End Product, neither in code or as design assets.
|
|
||||||
- Share your access to the Components and Templates with any other individuals.
|
|
||||||
- Use the Components and Templates to produce anything that may be deemed by Tailwind Labs Inc, in their sole and absolute discretion, to be competitive or in conflict with the business of Tailwind Labs Inc.
|
|
||||||
|
|
||||||
### Example usage
|
|
||||||
|
|
||||||
Examples of usage **allowed** by the license:
|
|
||||||
|
|
||||||
- Creating a personal website by yourself.
|
|
||||||
- Creating a website or web application for a client that will be owned by that client.
|
|
||||||
- Creating a commercial SaaS application (like an invoicing app for example) where end users have to pay a fee to use the application.
|
|
||||||
- Creating a commercial self-hosted web application that is sold to end users for a one-time fee.
|
|
||||||
- Creating a web application where the primary purpose is clearly not to simply re-distribute the components (like a conference organization app that uses the components for its UI for example) that is free and open source, where the source code is publicly available.
|
|
||||||
|
|
||||||
Examples of usage **not allowed** by the license:
|
|
||||||
|
|
||||||
- Creating a repository of your favorite Tailwind UI components or templates (or derivatives based on Tailwind UI components or templates) and publishing it publicly.
|
|
||||||
- Creating a React or Vue version of Tailwind UI and making it available either for sale or for free.
|
|
||||||
- Create a Figma or Sketch UI kit based on the Tailwind UI component designs.
|
|
||||||
- Creating a "website builder" project where end users can build their own websites using components or templates included with or derived from Tailwind UI.
|
|
||||||
- Creating a theme, template, or project starter kit using the components or templates and making it available either for sale or for free.
|
|
||||||
- Creating an admin panel tool (like [Laravel Nova](https://nova.laravel.com/) or [ActiveAdmin](https://activeadmin.info/)) that is made available either for sale or for free.
|
|
||||||
|
|
||||||
In simple terms, use Tailwind UI for anything you like as long as it doesn't compete with Tailwind UI.
|
|
||||||
|
|
||||||
### Personal License Definitions
|
|
||||||
|
|
||||||
Licensee is the individual who has purchased a Personal License.
|
|
||||||
|
|
||||||
Components and Templates are the source code and design assets made available to the Licensee after purchasing a Tailwind UI license.
|
|
||||||
|
|
||||||
End Product is any artifact produced that incorporates the Components or Templates or derivatives of the Components or Templates.
|
|
||||||
|
|
||||||
End User is a user of an End Product.
|
|
||||||
|
|
||||||
Client is an individual or entity receiving custom professional services directly from the Licensee, produced specifically for that individual or entity. Customers of software-as-a-service products are not considered clients for the purpose of this document.
|
|
||||||
|
|
||||||
## Team License
|
|
||||||
|
|
||||||
Tailwind Labs Inc. grants you an on-going, non-exclusive license to use the Components and Templates.
|
|
||||||
|
|
||||||
The license grants permission for **up to 25 Employees and Contractors of the Licensee** to access and use the Components and Templates.
|
|
||||||
|
|
||||||
You **can**:
|
|
||||||
|
|
||||||
- Use the Components and Templates to create unlimited End Products.
|
|
||||||
- Modify the Components and Templates to create derivative components and templates. Those components and templates are subject to this license.
|
|
||||||
- Use the Components and Templates to create unlimited End Products for unlimited Clients.
|
|
||||||
- Use the Components and Templates to create End Products where the End Product is sold to End Users.
|
|
||||||
- Use the Components and Templates to create End Products that are open source and freely available to End Users.
|
|
||||||
|
|
||||||
You **cannot**:
|
|
||||||
|
|
||||||
- Use the Components or Templates to create End Products that are designed to allow an End User to build their own End Products using the Components or Templates or derivatives of the Components or Templates.
|
|
||||||
- Re-distribute the Components or Templates or derivatives of the Components or Templates separately from an End Product.
|
|
||||||
- Use the Components or Templates to create End Products that are the property of any individual or entity other than the Licensee or Clients of the Licensee.
|
|
||||||
- Use the Components or Templates to produce anything that may be deemed by Tailwind Labs Inc, in their sole and absolute discretion, to be competitive or in conflict with the business of Tailwind Labs Inc.
|
|
||||||
|
|
||||||
### Example usage
|
|
||||||
|
|
||||||
Examples of usage **allowed** by the license:
|
|
||||||
|
|
||||||
- Creating a website for your company.
|
|
||||||
- Creating a website or web application for a client that will be owned by that client.
|
|
||||||
- Creating a commercial SaaS application (like an invoicing app for example) where end users have to pay a fee to use the application.
|
|
||||||
- Creating a commercial self-hosted web application that is sold to end users for a one-time fee.
|
|
||||||
- Creating a web application where the primary purpose is clearly not to simply re-distribute the components or templates (like a conference organization app that uses the components or a template for its UI for example) that is free and open source, where the source code is publicly available.
|
|
||||||
|
|
||||||
Examples of use **not allowed** by the license:
|
|
||||||
|
|
||||||
- Creating a repository of your favorite Tailwind UI components or template (or derivatives based on Tailwind UI components or templates) and publishing it publicly.
|
|
||||||
- Creating a React or Vue version of Tailwind UI and making it available either for sale or for free.
|
|
||||||
- Creating a "website builder" project where end users can build their own websites using components or templates included with or derived from Tailwind UI.
|
|
||||||
- Creating a theme or template using the components or templates and making it available either for sale or for free.
|
|
||||||
- Creating an admin panel tool (like [Laravel Nova](https://nova.laravel.com/) or [ActiveAdmin](https://activeadmin.info/)) that is made available either for sale or for free.
|
|
||||||
- Creating any End Product that is not the sole property of either your company or a client of your company. For example your employees/contractors can't use your company Tailwind UI license to build their own websites or side projects.
|
|
||||||
|
|
||||||
### Team License Definitions
|
|
||||||
|
|
||||||
Licensee is the business entity who has purchased a Team License.
|
|
||||||
|
|
||||||
Components and Templates are the source code and design assets made available to the Licensee after purchasing a Tailwind UI license.
|
|
||||||
|
|
||||||
End Product is any artifact produced that incorporates the Components or Templates or derivatives of the Components or Templates.
|
|
||||||
|
|
||||||
End User is a user of an End Product.
|
|
||||||
|
|
||||||
Employee is a full-time or part-time employee of the Licensee.
|
|
||||||
|
|
||||||
Contractor is an individual or business entity contracted to perform services for the Licensee.
|
|
||||||
|
|
||||||
Client is an individual or entity receiving custom professional services directly from the Licensee, produced specifically for that individual or entity. Customers of software-as-a-service products are not considered clients for the purpose of this document.
|
|
||||||
|
|
||||||
## Enforcement
|
|
||||||
|
|
||||||
If you are found to be in violation of the license, access to your Tailwind UI account will be terminated, and a refund may be issued at our discretion. When license violation is blatant and malicious (such as intentionally redistributing the Components or Templates through private warez channels), no refund will be issued.
|
|
||||||
|
|
||||||
The copyright of the Components and Templates is owned by Tailwind Labs Inc. You are granted only the permissions described in this license; all other rights are reserved. Tailwind Labs Inc. reserves the right to pursue legal remedies for any unauthorized use of the Components or Templates outside the scope of this license.
|
|
||||||
|
|
||||||
## Liability
|
|
||||||
|
|
||||||
Tailwind Labs Inc.’s liability to you for costs, damages, or other losses arising from your use of the Components or Templates — including third-party claims against you — is limited to a refund of your license fee. Tailwind Labs Inc. may not be held liable for any consequential damages related to your use of the Components or Templates.
|
|
||||||
|
|
||||||
This Agreement is governed by the laws of the Province of Ontario and the applicable laws of Canada. Legal proceedings related to this Agreement may only be brought in the courts of Ontario. You agree to service of process at the e-mail address on your original order.
|
|
||||||
|
|
||||||
## Questions?
|
|
||||||
|
|
||||||
Unsure which license you need, or unsure if your use case is covered by our licenses?
|
|
||||||
|
|
||||||
Email us at [support@tailwindui.com](mailto:support@tailwindui.com) with your questions.
|
|
||||||
@@ -1,42 +1,154 @@
|
|||||||
# Spotlight
|
<!--toc:start-->
|
||||||
|
- [What this is](#what-this-is)
|
||||||
|
- [Experience as a taxonomy](#experience-as-a-taxonomy)
|
||||||
|
- [Tech](#tech)
|
||||||
|
- [Commands](#commands)
|
||||||
|
- [Content](#content)
|
||||||
|
- [Projects (`src/content/projects`)](#projects-srccontentprojects)
|
||||||
|
- [Experience (`src/content/experience`)](#experience-srccontentexperience)
|
||||||
|
- [Writing (`src/content/writing`)](#writing-srccontentwriting)
|
||||||
|
- [Speaking (`src/content/speaking`)](#speaking-srccontentspeaking)
|
||||||
|
- [Configure](#configure)
|
||||||
|
- [Deploy](#deploy)
|
||||||
|
- [Acknowledgment](#acknowledgment)
|
||||||
|
- [License](#license)
|
||||||
|
<!--toc:end-->
|
||||||
|
|
||||||
Spotlight is a [Tailwind UI](https://tailwindui.com) site template built using [Tailwind CSS](https://tailwindcss.com) and [Next.js](https://nextjs.org).
|
<div align="center">
|
||||||
|
<img alt="Prad Nukala" src="./public/avatar.png" width="96" />
|
||||||
|
</div>
|
||||||
|
<h1 align="center">pradnukala.com</h1>
|
||||||
|
|
||||||
## Getting started
|
<p align="center">The personal CV & portfolio site of <strong>Prad Nukala</strong> — engineer and founder.</p>
|
||||||
|
|
||||||
To get started with this template, first install the npm dependencies:
|
## What this is
|
||||||
|
|
||||||
```bash
|
This repo is my personal website at [www.pradnukala.com](https://www.pradnukala.com) (prad.nu redirects here). It's a static [Astro](https://astro.build) site (originally forked from the [Astro Cactus](https://github.com/chrismwilliams/astro-theme-cactus) theme) reworked into a portfolio organised around four sections:
|
||||||
npm install
|
|
||||||
|
| Section | Route | Purpose |
|
||||||
|
| ------------------ | -------------- | ------------------------------------------------------------ |
|
||||||
|
| **Projects** | `/projects` | Things I've built, with links to live sites and source. |
|
||||||
|
| **Experience** | `/experience` | Resume-style roles, which also act as a taxonomy (see below). |
|
||||||
|
| **Writing** | `/writing` | Essays, notes, and articles. |
|
||||||
|
| **Speaking** | `/speaking` | Talks, with an embedded YouTube player. |
|
||||||
|
|
||||||
|
### Experience as a taxonomy
|
||||||
|
|
||||||
|
Experience entries are the connective tissue of the site. Projects, writing, and talks each declare an `experiences` array in their frontmatter that references experience entries by id. Each experience page then renders its own write-up **plus** every project, post, and talk filed under it — so the content cross-links automatically.
|
||||||
|
|
||||||
|
## Tech
|
||||||
|
|
||||||
|
- [Astro](https://astro.build) v6 (static output, no adapter required)
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com) v4
|
||||||
|
- [Content Collections](https://docs.astro.build/en/guides/content-collections/) with type-checked frontmatter (`src/content.config.ts`)
|
||||||
|
- Embedded YouTube players for speaking videos
|
||||||
|
- [Satori](https://github.com/vercel/satori) OG images, [Pagefind](https://pagefind.app/) search, RSS, sitemap, and `Person` JSON-LD on the homepage for SEO
|
||||||
|
- Dark / light mode, accessible semantic markup
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :--------------- | :-------------------------------------------------- |
|
||||||
|
| `pnpm install` | Install dependencies |
|
||||||
|
| `pnpm dev` | Start the local dev server |
|
||||||
|
| `pnpm build` | Build the production site to `./dist/` |
|
||||||
|
| `pnpm postbuild` | Build the Pagefind static search index |
|
||||||
|
| `pnpm preview` | Preview the production build locally |
|
||||||
|
| `pnpm sync` | Generate types from the content config |
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
Content lives in `src/content/<collection>` as `.md`/`.mdx` files; the filename becomes the slug. The schema for each collection is defined in `src/content.config.ts`.
|
||||||
|
|
||||||
|
### Projects (`src/content/projects`)
|
||||||
|
|
||||||
|
| Property | Required | Notes |
|
||||||
|
| --------------- | :------: | -------------------------------------------------- |
|
||||||
|
| `title` | ✓ | Max 60 chars. |
|
||||||
|
| `startDate` | ✓ | Date. |
|
||||||
|
| `endDate` | | Omit for ongoing projects. Projects are sorted by `endDate`, most recent first (ongoing on top). |
|
||||||
|
| `description` | | Used for meta description. |
|
||||||
|
| `link` | | URL to the live project. |
|
||||||
|
| `repo` | | URL to the source repository. |
|
||||||
|
| `tags` | | Skills/technologies — double as tags for cross-project browsing at `/projects/tags/<tag>`. |
|
||||||
|
| `experiences` | | Array of experience ids this project is filed under. |
|
||||||
|
|
||||||
|
### Experience (`src/content/experience`)
|
||||||
|
|
||||||
|
| Property | Required | Notes |
|
||||||
|
| --------------- | :------: | -------------------------------------------------- |
|
||||||
|
| `title` | ✓ | Display name of the experience (also its id via filename). |
|
||||||
|
| `organization` | ✓ | Company / org. |
|
||||||
|
| `startDate` | ✓ | Date. |
|
||||||
|
| `endDate` | | Omit for current/ongoing. |
|
||||||
|
| `role` | | Job title. |
|
||||||
|
| `location` | | e.g. "Remote". |
|
||||||
|
| `url` | | Org website. |
|
||||||
|
| `description` | | Short summary. |
|
||||||
|
|
||||||
|
### Writing (`src/content/writing`)
|
||||||
|
|
||||||
|
| Property | Required | Notes |
|
||||||
|
| --------------- | :------: | -------------------------------------------------- |
|
||||||
|
| `title` | ✓ | Max 60 chars. |
|
||||||
|
| `description` | ✓ | Meta description. |
|
||||||
|
| `publishDate` | ✓ | Date. |
|
||||||
|
| `updatedDate` | | Date. |
|
||||||
|
| `coverImage` | | `{ src, alt }`. |
|
||||||
|
| `ogImage` | | Skip Satori generation with a custom OG image. |
|
||||||
|
| `tags` | | String array. |
|
||||||
|
| `experiences` | | Array of experience ids. |
|
||||||
|
| `externalUrl` | | If set, the entry links out to this URL (an article published on Fast Company, Medium, LinkedIn, …) instead of rendering a local page. |
|
||||||
|
| `publisher` | | Publisher name shown as a badge next to external entries (e.g. "Fast Company"). |
|
||||||
|
| `draft` | | `true` excludes the post from production builds. |
|
||||||
|
| `pinned` | | Pin to the top of `/writing`. |
|
||||||
|
|
||||||
|
To surface an article published elsewhere (PESOS), add a stub with no body:
|
||||||
|
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
title: "Why user-owned identity matters"
|
||||||
|
description: "A short summary of the piece."
|
||||||
|
publishDate: "2025-01-15"
|
||||||
|
externalUrl: "https://www.fastcompany.com/…"
|
||||||
|
publisher: "Fast Company"
|
||||||
|
tags: ["identity"]
|
||||||
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
Next, create a `.env.local` file in the root of your project and set the `NEXT_PUBLIC_SITE_URL` variable to your site's public URL:
|
It appears in `/writing`, RSS, and the `/skills` taxonomy, links straight to the publisher (new tab, with a `↗ Publisher` badge), and gets no local detail page — so there's no duplicate-content risk.
|
||||||
|
|
||||||
```
|
### Speaking (`src/content/speaking`)
|
||||||
NEXT_PUBLIC_SITE_URL=https://example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Next, run the development server:
|
| Property | Required | Notes |
|
||||||
|
| ---------------- | :------: | ------------------------------------------------- |
|
||||||
|
| `title` | ✓ | Max 60 chars. |
|
||||||
|
| `description` | ✓ | Meta description. |
|
||||||
|
| `publishDate` | ✓ | Date. |
|
||||||
|
| `youtubeId` | ✓ | YouTube video ID (the `v=` value). |
|
||||||
|
| `event` | | Conference / event name. |
|
||||||
|
| `tags` | | String array. |
|
||||||
|
| `experiences` | | Array of experience ids. |
|
||||||
|
| `draft` | | `true` excludes the talk from production builds. |
|
||||||
|
|
||||||
```bash
|
## Configure
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Finally, open [http://localhost:3000](http://localhost:3000) in your browser to view the website.
|
Most site-wide settings live in **`src/site.config.ts`**:
|
||||||
|
|
||||||
## Customizing
|
- `siteConfig` — title, author, description, `url` (used for canonical URLs and OG tags), locale.
|
||||||
|
- `socialLinks` — GitHub / X / LinkedIn / Medium / Discord / Telegram / email, shown in the footer and homepage intro.
|
||||||
|
- `menuLinks` — header/footer navigation.
|
||||||
|
|
||||||
You can start editing this template by modifying the files in the `/src` folder. The site will auto-update as you edit these files.
|
Replace `public/avatar.png` (header logo) and `public/social-card.png` (default OG image) with your own.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Static output, so it builds to `./dist/` with no adapter. See the [Astro deploy guides](https://docs.astro.build/en/guides/deploy/). For Cloudflare Workers, point the assets directory at `./dist` with a static [Wrangler config](https://developers.cloudflare.com/workers/framework-guides/web-apps/astro/).
|
||||||
|
|
||||||
|
## Acknowledgment
|
||||||
|
|
||||||
|
Built on the [Astro Cactus](https://github.com/chrismwilliams/astro-theme-cactus) theme by Chris Williams.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This site template is a commercial product and is licensed under the [Tailwind UI license](https://tailwindui.com/license).
|
MIT
|
||||||
|
|
||||||
## Learn more
|
|
||||||
|
|
||||||
To learn more about the technologies used in this site template, see the following resources:
|
|
||||||
|
|
||||||
- [Tailwind CSS](https://tailwindcss.com/docs) - the official Tailwind CSS documentation
|
|
||||||
- [Next.js](https://nextjs.org/docs) - the official Next.js documentation
|
|
||||||
- [Headless UI](https://headlessui.dev) - the official Headless UI documentation
|
|
||||||
- [MDX](https://mdxjs.com) - the MDX documentation
|
|
||||||
|
|||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import { rehypeHeadingIds } from "@astrojs/markdown-remark";
|
||||||
|
import mdx from "@astrojs/mdx";
|
||||||
|
import sitemap from "@astrojs/sitemap";
|
||||||
|
import tailwind from "@tailwindcss/vite";
|
||||||
|
import { defineConfig, envField } from "astro/config";
|
||||||
|
import expressiveCode from "astro-expressive-code";
|
||||||
|
import icon from "astro-icon";
|
||||||
|
import robotsTxt from "astro-robots-txt";
|
||||||
|
import webmanifest from "astro-webmanifest";
|
||||||
|
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
||||||
|
import rehypeExternalLinks from "rehype-external-links";
|
||||||
|
import rehypeUnwrapImages from "rehype-unwrap-images";
|
||||||
|
import remarkDirective from "remark-directive"; /* Handle ::: directives as nodes */
|
||||||
|
import { remarkAdmonitions } from "./src/plugins/remark-admonitions";
|
||||||
|
import { remarkGithubCard } from "./src/plugins/remark-github-card";
|
||||||
|
import { remarkReadingTime } from "./src/plugins/remark-reading-time";
|
||||||
|
import { expressiveCodeOptions, siteConfig } from "./src/site.config";
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
site: siteConfig.url,
|
||||||
|
image: {
|
||||||
|
domains: ["webmention.io"],
|
||||||
|
},
|
||||||
|
integrations: [
|
||||||
|
expressiveCode(expressiveCodeOptions),
|
||||||
|
icon(),
|
||||||
|
sitemap(),
|
||||||
|
mdx(),
|
||||||
|
robotsTxt(),
|
||||||
|
webmanifest({
|
||||||
|
// See: https://github.com/alextim/astro-lib/blob/main/packages/astro-webmanifest/README.md
|
||||||
|
name: siteConfig.title,
|
||||||
|
description: siteConfig.description,
|
||||||
|
lang: siteConfig.lang,
|
||||||
|
icon: "public/icon.svg", // the source for generating favicon & icons
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "icons/apple-touch-icon.png", // used in src/components/BaseHead.astro L:26
|
||||||
|
sizes: "180x180",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "icons/icon-192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "icons/icon-512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
start_url: "/",
|
||||||
|
background_color: "#1d1f21",
|
||||||
|
theme_color: "#2bbc8a",
|
||||||
|
display: "standalone",
|
||||||
|
config: {
|
||||||
|
insertFaviconLinks: false,
|
||||||
|
insertThemeColorMeta: false,
|
||||||
|
insertManifestLink: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
markdown: {
|
||||||
|
rehypePlugins: [
|
||||||
|
rehypeHeadingIds,
|
||||||
|
[rehypeAutolinkHeadings, { behavior: "wrap", properties: { className: ["not-prose"] } }],
|
||||||
|
[
|
||||||
|
rehypeExternalLinks,
|
||||||
|
{
|
||||||
|
rel: ["noreferrer", "noopener"],
|
||||||
|
target: "_blank",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rehypeUnwrapImages,
|
||||||
|
],
|
||||||
|
remarkPlugins: [remarkReadingTime, remarkDirective, remarkGithubCard, remarkAdmonitions],
|
||||||
|
remarkRehype: {
|
||||||
|
footnoteLabelProperties: {
|
||||||
|
className: [""],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwind(), rawFonts([".ttf", ".woff"])],
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
schema: {
|
||||||
|
WEBMENTION_API_KEY: envField.string({ context: "server", access: "secret", optional: true }),
|
||||||
|
WEBMENTION_URL: envField.string({ context: "client", access: "public", optional: true }),
|
||||||
|
WEBMENTION_PINGBACK: envField.string({ context: "client", access: "public", optional: true }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function rawFonts(ext: string[]) {
|
||||||
|
return {
|
||||||
|
name: "vite-plugin-raw-fonts",
|
||||||
|
// @ts-expect-error:next-line
|
||||||
|
transform(_, id) {
|
||||||
|
if (ext.some((e) => id.endsWith(e))) {
|
||||||
|
const buffer = fs.readFileSync(id);
|
||||||
|
return {
|
||||||
|
code: `export default ${JSON.stringify(buffer)}`,
|
||||||
|
map: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||||
|
"files": {
|
||||||
|
"includes": [
|
||||||
|
"**",
|
||||||
|
"!**/build",
|
||||||
|
"!**/dist",
|
||||||
|
"!**/node_modules",
|
||||||
|
"!**/.vercel",
|
||||||
|
"!**/.netlify",
|
||||||
|
"!**/.vscode",
|
||||||
|
"!**/.astro"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "tab",
|
||||||
|
"lineEnding": "lf",
|
||||||
|
"lineWidth": 100,
|
||||||
|
"expand": "auto"
|
||||||
|
},
|
||||||
|
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
||||||
|
"assist": {
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"a11y": {
|
||||||
|
"noSvgWithoutTitle": "off"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noParameterAssign": "error",
|
||||||
|
"useAsConstAssertion": "error",
|
||||||
|
"useDefaultParameterLast": "error",
|
||||||
|
"useEnumInitializers": "error",
|
||||||
|
"useSelfClosingElements": "error",
|
||||||
|
"useSingleVarDeclarator": "error",
|
||||||
|
"noUnusedTemplateLiteral": "error",
|
||||||
|
"useNumberNamespace": "error",
|
||||||
|
"noInferrableTypes": "error",
|
||||||
|
"noUselessElse": "error",
|
||||||
|
"useImportType": "error"
|
||||||
|
},
|
||||||
|
"suspicious": { "noExplicitAny": "warn" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"trailingCommas": "all",
|
||||||
|
"semicolons": "always",
|
||||||
|
"bracketSameLine": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"html": { "formatter": { "selfCloseVoidElements": "always" } },
|
||||||
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"tailwindDirectives": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": ["**/*.astro"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"useConst": "off",
|
||||||
|
"useImportType": "off"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"noUnusedVariables": "off",
|
||||||
|
"noUnusedImports": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { type MDXComponents } from 'mdx/types'
|
|
||||||
|
|
||||||
export function useMDXComponents(components: MDXComponents) {
|
|
||||||
return {
|
|
||||||
...components,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-5
@@ -1,5 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import rehypePrism from '@mapbox/rehype-prism'
|
|
||||||
import nextMDX from '@next/mdx'
|
|
||||||
import remarkGfm from 'remark-gfm'
|
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'mdx'],
|
|
||||||
redirects: async () => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: '/twitter',
|
|
||||||
destination: 'https://twitter.com/thisisprad',
|
|
||||||
permanent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/github',
|
|
||||||
destination: 'https://github.com/prnk28',
|
|
||||||
permanent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/linkedin',
|
|
||||||
destination: 'https://www.linkedin.com/in/pradn/',
|
|
||||||
permanent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/medium',
|
|
||||||
destination: 'https://medium.com/@prnk28',
|
|
||||||
permanent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/cal',
|
|
||||||
destination: 'https://cal.com/pradn',
|
|
||||||
permanent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/telegram',
|
|
||||||
destination: 'https://t.me/prnk28',
|
|
||||||
permanent: true,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const withMDX = nextMDX({
|
|
||||||
extension: /\.mdx?$/,
|
|
||||||
options: {
|
|
||||||
remarkPlugins: [remarkGfm],
|
|
||||||
rehypePlugins: [rehypePrism],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export default withMDX(nextConfig)
|
|
||||||
Generated
-17617
File diff suppressed because it is too large
Load Diff
+59
-36
@@ -1,43 +1,66 @@
|
|||||||
{
|
{
|
||||||
"name": "tailwindui-template",
|
"name": "prad.nu",
|
||||||
"version": "0.1.0",
|
"type": "module",
|
||||||
"private": true,
|
"version": "7.2.0",
|
||||||
|
"private": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"postinstall": "npm rebuild sharp --force",
|
||||||
"build": "next build",
|
"dev": "astro dev",
|
||||||
"start": "next start",
|
"start": "astro dev",
|
||||||
"lint": "next lint"
|
"build": "astro build",
|
||||||
|
"postbuild": "pagefind --site dist",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"check": "astro check && biome check",
|
||||||
|
"lint": "biome check --write",
|
||||||
|
"format": "prettier -w . --cache"
|
||||||
},
|
},
|
||||||
"browserslist": "defaults, not ie <= 11",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.7.13",
|
"@astrojs/markdown-remark": "^7.1.2",
|
||||||
"@mapbox/rehype-prism": "^0.8.0",
|
"@astrojs/mdx": "5.0.6",
|
||||||
"@mdx-js/loader": "^2.3.0",
|
"@astrojs/rss": "4.0.18",
|
||||||
"@mdx-js/react": "^2.3.0",
|
"@astrojs/sitemap": "3.7.2",
|
||||||
"@next/mdx": "13.4.16",
|
"@pagefind/component-ui": "^1.5.2",
|
||||||
"@tailwindcss/typography": "^0.5.4",
|
"@tailwindcss/vite": "4.3.0",
|
||||||
"@types/node": "20.4.7",
|
"@vercel/analytics": "^2.0.1",
|
||||||
"@types/react": "18.2.18",
|
"astro": "6.3.3",
|
||||||
"@types/react-dom": "18.2.7",
|
"astro-expressive-code": "^0.42.0",
|
||||||
"@types/webpack-env": "^1.18.1",
|
"astro-icon": "^1.1.5",
|
||||||
"autoprefixer": "^10.4.12",
|
"astro-robots-txt": "^1.0.0",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"astro-webmanifest": "^1.0.0",
|
||||||
"clsx": "^1.2.1",
|
"cssnano": "^8.0.1",
|
||||||
"fast-glob": "^3.2.11",
|
"hastscript": "^9.0.0",
|
||||||
"feed": "^4.2.2",
|
"mdast-util-directive": "^3.0.0",
|
||||||
"next": "13.4.16",
|
"mdast-util-to-markdown": "^2.1.2",
|
||||||
"next-themes": "^0.2.1",
|
"mdast-util-to-string": "^4.0.0",
|
||||||
"react": "18.2.0",
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
"react-dom": "18.2.0",
|
"rehype-external-links": "^3.0.0",
|
||||||
"remark-gfm": "^3.0.1",
|
"rehype-unwrap-images": "^1.0.0",
|
||||||
"tailwindcss": "^3.3.3",
|
"remark-directive": "^4.0.0",
|
||||||
"typescript": "5.1.6"
|
"satori": "0.26.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"unified": "^11.0.5",
|
||||||
|
"unist-util-visit": "^5.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "8.45.0",
|
"@astrojs/check": "^0.9.9",
|
||||||
"eslint-config-next": "13.4.16",
|
"@biomejs/biome": "^2.4.15",
|
||||||
"prettier": "^3.0.1",
|
"@iconify-json/mdi": "^1.2.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.2",
|
"@pagefind/default-ui": "^1.5.2",
|
||||||
"sharp": "^0.32.0"
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
}
|
"@types/hast": "^3.0.4",
|
||||||
|
"@types/mdast": "^4.0.4",
|
||||||
|
"@types/node": "^25.8.0",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"pagefind": "^1.5.2",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"prettier-plugin-astro": "0.14.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
|
"reading-time": "^1.5.0",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"sharp": "^0.34.5"
|
||||||
|
},
|
||||||
|
"trustedDependencies": ["@biomejs/biome", "@tailwindcss/oxide", "esbuild", "sharp"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/** @type {import('prettier').Options} */
|
|
||||||
module.exports = {
|
|
||||||
singleQuote: true,
|
|
||||||
semi: false,
|
|
||||||
plugins: ['prettier-plugin-tailwindcss'],
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<!-- Parent: ../AGENTS.md -->
|
||||||
|
<!-- Generated: 2026-06-30 | Updated: 2026-06-30 -->
|
||||||
|
|
||||||
|
# public
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Static assets copied verbatim to the site root at build time (served at `/`).
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `avatar.png` | Personal avatar used as the header logo (`src/components/layout/Header.astro`, referenced as `/avatar.png`) and in the homepage `Person` JSON-LD. |
|
||||||
|
| `icon.svg` | Source icon; `astro-webmanifest` generates favicons / apple-touch / manifest icons from it at build (referenced in `BaseHead.astro`). |
|
||||||
|
| `social-card.png` | Default Open Graph image when a page/post supplies no `ogImage`. |
|
||||||
|
|
||||||
|
## For AI Agents
|
||||||
|
|
||||||
|
### Working In This Directory
|
||||||
|
- Reference these with absolute root paths (`/avatar.png`), not the `@/` alias — they are not processed by Astro's asset pipeline.
|
||||||
|
- Replacing `icon.svg` changes all generated favicons/manifest icons; keep it roughly square (build warns otherwise).
|
||||||
|
|
||||||
|
<!-- MANUAL: -->
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
+936
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 527 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,50 @@
|
|||||||
|
<!-- Parent: ../AGENTS.md -->
|
||||||
|
<!-- Generated: 2026-06-30 | Updated: 2026-06-30 -->
|
||||||
|
|
||||||
|
# src
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
All application source: configuration, content collections, pages/routes, layouts, components, data helpers, styles, and build plugins.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `site.config.ts` | Site metadata (`siteConfig`), `socialLinks`, `menuLinks`, and Expressive Code options. Primary place to edit site-wide settings. |
|
||||||
|
| `content.config.ts` | Content Collection definitions + Zod schemas for `writing`, `projects`, `experience`, `speaking`. Experience refs are validated via `reference("experience")`. |
|
||||||
|
| `types.ts` | Shared TS types (`SiteConfig`, `SiteMeta`, webmention types, `AdmonitionType`). |
|
||||||
|
| `env.d.ts` | Ambient type declarations. |
|
||||||
|
|
||||||
|
## Subdirectories
|
||||||
|
| Directory | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `assets/` | Build-time assets, currently OG fonts (see `assets/AGENTS.md`). |
|
||||||
|
| `components/` | Reusable `.astro` components (see `components/AGENTS.md`). |
|
||||||
|
| `content/` | Markdown content for the four collections (see `content/AGENTS.md`). |
|
||||||
|
| `data/` | Collection query helpers (see `data/AGENTS.md`). |
|
||||||
|
| `layouts/` | Page shells (see `layouts/AGENTS.md`). |
|
||||||
|
| `pages/` | File-based routes + endpoints. **No `AGENTS.md` here** — any `.md` under `src/pages` becomes a public route, so docs for this dir live in this file (see "Routing & OG images" below). |
|
||||||
|
| `plugins/` | Custom remark plugins (see `plugins/AGENTS.md`). |
|
||||||
|
| `styles/` | Global CSS + block/component styles (see `styles/AGENTS.md`). |
|
||||||
|
| `utils/` | Pure helper functions (see `utils/AGENTS.md`). |
|
||||||
|
|
||||||
|
## For AI Agents
|
||||||
|
|
||||||
|
### Working In This Directory
|
||||||
|
- Collection **keys match their content dir and route**: `writing`→`/writing`, `projects`→`/projects`, `experience`→`/experience`, `speaking`→`/speaking`.
|
||||||
|
- When changing a collection schema in `content.config.ts`, run `pnpm build` to regenerate `astro:content` types and catch frontmatter mismatches.
|
||||||
|
|
||||||
|
### Routing & OG images (`src/pages`)
|
||||||
|
- Any `.md`/`.mdx`/`.astro` under `src/pages` becomes a route — do **not** put `AGENTS.md` or other docs there (they'd ship as public pages). Document `pages/` here instead.
|
||||||
|
- Each section has `[...page].astro` (paginated list) + `[...slug].astro` (detail); plus `rss.xml.ts` feeds and a dynamic OG-image endpoint.
|
||||||
|
- `og-image/[...slug].png.ts` generates per-entry social images for **all** collections (namespaced `/og-image/<type>/<id>.png`) via Satori + Sharp, cached by `_cacheUtil.ts` (bump `CACHE_VERSION` when changing `_ogMarkup.ts`). Files prefixed `_` are private modules, not routes.
|
||||||
|
- The `/experience/[...slug]` page is the taxonomy hub (`getContentForExperience`), listing related projects/writing/talks.
|
||||||
|
|
||||||
|
### Common Patterns
|
||||||
|
- The `experiences` frontmatter array (on projects/writing/speaking) holds `reference("experience")` entries, resolved with `getEntries`.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- Nearly everything imports `@/site.config` and `@/content.config`-derived collection types.
|
||||||
|
|
||||||
|
<!-- MANUAL: -->
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import { type Metadata } from 'next'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
import { Container } from '@/components/Container'
|
|
||||||
import {
|
|
||||||
GitHubIcon,
|
|
||||||
InstagramIcon,
|
|
||||||
LinkedInIcon,
|
|
||||||
MediumIcon,
|
|
||||||
TwitterIcon,
|
|
||||||
} from '@/components/SocialIcons'
|
|
||||||
import portraitImage from '@/images/portrait.jpg'
|
|
||||||
|
|
||||||
function SocialLink({
|
|
||||||
className,
|
|
||||||
href,
|
|
||||||
children,
|
|
||||||
icon: Icon,
|
|
||||||
}: {
|
|
||||||
className?: string
|
|
||||||
href: string
|
|
||||||
icon: React.ComponentType<{ className?: string }>
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<li className={clsx(className, 'flex')}>
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className="group flex text-sm font-medium text-zinc-800 transition hover:text-teal-500 dark:text-zinc-200 dark:hover:text-teal-500"
|
|
||||||
>
|
|
||||||
<Icon className="h-6 w-6 flex-none fill-zinc-500 transition group-hover:fill-teal-500" />
|
|
||||||
<span className="ml-4">{children}</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MailIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M6 5a3 3 0 0 0-3 3v8a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3V8a3 3 0 0 0-3-3H6Zm.245 2.187a.75.75 0 0 0-.99 1.126l6.25 5.5a.75.75 0 0 0 .99 0l6.25-5.5a.75.75 0 0 0-.99-1.126L12 12.251 6.245 7.187Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'About',
|
|
||||||
description:
|
|
||||||
'I\'m Prad Nukala, founder of Sonr and blockchain identity pioneer. Building the future of decentralized identity in New York City.',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function About() {
|
|
||||||
return (
|
|
||||||
<Container className="mt-16 sm:mt-32">
|
|
||||||
<div className="grid grid-cols-1 gap-y-16 lg:grid-cols-2 lg:grid-rows-[auto_1fr] lg:gap-y-12">
|
|
||||||
<div className="lg:pl-20">
|
|
||||||
<div className="max-w-xs px-2.5 lg:max-w-none">
|
|
||||||
<Image
|
|
||||||
src={portraitImage}
|
|
||||||
alt=""
|
|
||||||
sizes="(min-width: 1024px) 32rem, 20rem"
|
|
||||||
className="aspect-square rotate-3 rounded-2xl bg-zinc-100 object-cover dark:bg-zinc-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="lg:order-first lg:row-span-2">
|
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
|
||||||
I\'m Prad Nukala. I live in New York City, where I\'m building the future of digital identity.
|
|
||||||
</h1>
|
|
||||||
<div className="mt-6 space-y-7 text-base text-zinc-600 dark:text-zinc-400">
|
|
||||||
<p>
|
|
||||||
I\'ve been passionate about technology since the introduction of the App Store, where I made one
|
|
||||||
of the first 5,000 apps at age 12. This early start in mobile development shaped my understanding
|
|
||||||
of how technology can empower individuals and sparked my lifelong commitment to building tools that
|
|
||||||
put users first.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
My journey in blockchain began with a simple realization: the internet\'s identity layer is fundamentally
|
|
||||||
broken. Users have no control over their digital selves, leading to privacy breaches, data exploitation,
|
|
||||||
and a web that serves corporations over people. This inspired me to found Sonr in 2020, where we\'re
|
|
||||||
building an IBC-enabled blockchain specifically designed for decentralized identity.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
As a W3C Working Group Member for DIDs, WebAuthn, and WASM, I\'m actively shaping the standards that
|
|
||||||
will define the future of digital identity. My technical contributions speak to my commitment - with over
|
|
||||||
6,000 GitHub contributions from 2021-2023, I was ranked as the 4th most active contributor worldwide.
|
|
||||||
Before Sonr, I self-developed over 10 projects that garnered more than 1 million downloads combined.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Today, I lead Sonr with a clear mission: to create a peer-to-peer identity and asset management system
|
|
||||||
that leverages DID documents, WebAuthn, and IPFS. We\'ve raised $4.7M in funding and onboarded over 120
|
|
||||||
first-time blockchain developers to our platform. Our technology features passkey-based user accounts,
|
|
||||||
DKLS-MPC powered wallets (no seed phrases), and lightning-fast 600ms wallet generation. I believe the
|
|
||||||
future of the internet is one where users own their identity, control their data, and interact on their
|
|
||||||
own terms.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="lg:pl-20">
|
|
||||||
<ul role="list">
|
|
||||||
<SocialLink href="https://twitter.com/thisisprad" icon={TwitterIcon}>
|
|
||||||
Follow on Twitter
|
|
||||||
</SocialLink>
|
|
||||||
<SocialLink href="https://medium.com/@prnk28" icon={MediumIcon} className="mt-4">
|
|
||||||
Follow on Medium
|
|
||||||
</SocialLink>
|
|
||||||
<SocialLink href="https://github.com/prnk28" icon={GitHubIcon} className="mt-4">
|
|
||||||
Follow on GitHub
|
|
||||||
</SocialLink>
|
|
||||||
<SocialLink href="https://linkedin.com/in/pradn" icon={LinkedInIcon} className="mt-4">
|
|
||||||
Follow on LinkedIn
|
|
||||||
</SocialLink>
|
|
||||||
<SocialLink
|
|
||||||
href="mailto:prad.nukala@sonr.io"
|
|
||||||
icon={MailIcon}
|
|
||||||
className="mt-8 border-t border-zinc-100 pt-8 dark:border-zinc-700/40"
|
|
||||||
>
|
|
||||||
prad.nukala@sonr.io
|
|
||||||
</SocialLink>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { ArticleLayout } from '@/components/ArticleLayout'
|
|
||||||
|
|
||||||
export const article = {
|
|
||||||
author: 'Prad Nukala',
|
|
||||||
date: '2024-01-15',
|
|
||||||
title: 'Building Sonr: The Journey to Decentralized Identity',
|
|
||||||
description:
|
|
||||||
'How we\'re creating an IBC-enabled blockchain that puts users in control of their digital identity, eliminating passwords and seed phrases while maintaining true self-sovereignty.',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: article.title,
|
|
||||||
description: article.description,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (props) => <ArticleLayout article={article} {...props} />
|
|
||||||
|
|
||||||
When I founded Sonr in 2020, I had a simple but ambitious vision: create a world where people truly own their digital identity. After years of watching data breaches, privacy violations, and the centralization of user data, I knew there had to be a better way.
|
|
||||||
|
|
||||||
## The Problem with Digital Identity Today
|
|
||||||
|
|
||||||
The internet wasn\'t designed with identity in mind. We\'ve patched together solutions using usernames, passwords, and centralized databases, but these approaches are fundamentally broken:
|
|
||||||
|
|
||||||
- **No User Control**: Your identity lives on servers you don\'t control
|
|
||||||
- **Privacy Violations**: Companies monetize your personal data without consent
|
|
||||||
- **Security Nightmares**: Password breaches affect billions of users annually
|
|
||||||
- **Platform Lock-in**: Your identity is fragmented across countless services
|
|
||||||
|
|
||||||
## Enter Sonr: A New Paradigm
|
|
||||||
|
|
||||||
Sonr is an IBC-enabled blockchain specifically designed for decentralized identity. We\'re not just building another blockchain - we\'re creating the identity layer the internet should have had from the beginning.
|
|
||||||
|
|
||||||
### Key Innovations
|
|
||||||
|
|
||||||
1. **Passkey-Based Authentication**: No more passwords. Users authenticate with biometrics or device-based keys, making phishing virtually impossible.
|
|
||||||
|
|
||||||
2. **No Seed Phrases**: Using DKLS-MPC (Distributed Key Lifetime Security - Multi-Party Computation), we\'ve eliminated the need for users to manage complex seed phrases while maintaining true ownership.
|
|
||||||
|
|
||||||
3. **Lightning-Fast Performance**: 600ms wallet generation means users can onboard as quickly as traditional web apps.
|
|
||||||
|
|
||||||
4. **IBC Integration**: As part of the Cosmos ecosystem, Sonr identities work seamlessly across multiple blockchains.
|
|
||||||
|
|
||||||
## Technical Architecture
|
|
||||||
|
|
||||||
At its core, Sonr leverages several cutting-edge technologies:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Example: Creating a Sonr identity
|
|
||||||
const identity = await Sonr.createIdentity({
|
|
||||||
authentication: 'passkey',
|
|
||||||
storage: 'ipfs',
|
|
||||||
recovery: 'social'
|
|
||||||
});
|
|
||||||
|
|
||||||
// The user now has a DID (Decentralized Identifier)
|
|
||||||
console.log(identity.did); // did:sonr:abc123...
|
|
||||||
```
|
|
||||||
|
|
||||||
### DID Documents
|
|
||||||
Every Sonr user has a DID document stored on-chain that contains their public keys and service endpoints. This document is the source of truth for their identity.
|
|
||||||
|
|
||||||
### IPFS Integration
|
|
||||||
User data is stored on IPFS, ensuring it\'s distributed, permanent, and under user control. Only the user\'s keys can decrypt their personal information.
|
|
||||||
|
|
||||||
### UCAN Tokens
|
|
||||||
We use User Controlled Authorization Networks (UCAN) for fine-grained access control. Users can delegate specific permissions without sharing their private keys.
|
|
||||||
|
|
||||||
## Real-World Impact
|
|
||||||
|
|
||||||
Since launching, we\'ve:
|
|
||||||
- Raised $4.7M in funding to accelerate development
|
|
||||||
- Onboarded over 120 first-time blockchain developers
|
|
||||||
- Created tools that make blockchain accessible to mainstream developers
|
|
||||||
- Built partnerships across the Cosmos ecosystem
|
|
||||||
|
|
||||||
## The Road Ahead
|
|
||||||
|
|
||||||
We\'re currently preparing for our public testnet launch, with mainnet coming soon. Our goal is to onboard the next billion users to Web3 - not by forcing them to understand blockchain, but by making it invisible.
|
|
||||||
|
|
||||||
The future of the internet is one where:
|
|
||||||
- Users own their identity and data
|
|
||||||
- Privacy is default, not optional
|
|
||||||
- Security doesn\'t sacrifice usability
|
|
||||||
- Interoperability is built-in, not bolted-on
|
|
||||||
|
|
||||||
## Join the Revolution
|
|
||||||
|
|
||||||
If you\'re a developer interested in building on Sonr, check out our [documentation](https://docs.sonr.io) and [GitHub](https://github.com/sonr-io). We\'re always looking for contributors who share our vision of a more equitable internet.
|
|
||||||
|
|
||||||
The identity layer of the internet is broken, but together, we can fix it. Welcome to Sonr - where your identity truly belongs to you.
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import { ArticleLayout } from '@/components/ArticleLayout'
|
|
||||||
|
|
||||||
export const article = {
|
|
||||||
author: 'Prad Nukala',
|
|
||||||
date: '2023-09-10',
|
|
||||||
title: 'The Future of Digital Identity: Self-Sovereign and Interoperable',
|
|
||||||
description:
|
|
||||||
'Digital identity is broken. Here\'s how decentralized identifiers (DIDs), verifiable credentials, and blockchain technology will create a future where you truly own your identity.',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: article.title,
|
|
||||||
description: article.description,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (props) => <ArticleLayout article={article} {...props} />
|
|
||||||
|
|
||||||
Every day, billions of people prove who they are online using systems designed in the 1960s. Usernames and passwords were meant to be temporary solutions, yet here we are, six decades later, still typing "password123" and hoping for the best.
|
|
||||||
|
|
||||||
As someone who\'s been building technology since creating one of the first App Store apps, and now as a W3C Working Group Member for DIDs and WebAuthn, I\'ve seen firsthand how broken our identity systems are. It\'s time for a fundamental shift.
|
|
||||||
|
|
||||||
## The Current Identity Crisis
|
|
||||||
|
|
||||||
Today\'s digital identity landscape is a disaster:
|
|
||||||
|
|
||||||
- **2.5 billion** records were exposed in data breaches last year
|
|
||||||
- The average person has **100+ passwords** to manage
|
|
||||||
- Identity theft affects **1 in 15 people** annually
|
|
||||||
- Users have **zero control** over their personal data
|
|
||||||
|
|
||||||
But the real problem isn\'t just security - it\'s ownership. Your digital identity doesn\'t belong to you. It belongs to Facebook, Google, your bank, your employer, and dozens of other entities.
|
|
||||||
|
|
||||||
## Enter Self-Sovereign Identity
|
|
||||||
|
|
||||||
Self-sovereign identity (SSI) flips the script. Instead of companies owning your identity, you do. Here\'s how it works:
|
|
||||||
|
|
||||||
### Decentralized Identifiers (DIDs)
|
|
||||||
A DID is like a phone number for your identity - unique, permanent, and owned by you. Unlike usernames, DIDs aren\'t controlled by any company.
|
|
||||||
|
|
||||||
```
|
|
||||||
did:sonr:1234567890abcdef
|
|
||||||
```
|
|
||||||
|
|
||||||
This simple string represents your entire digital identity, secured by cryptography rather than corporate databases.
|
|
||||||
|
|
||||||
### Verifiable Credentials
|
|
||||||
Imagine your driver\'s license, but digital and cryptographically verifiable. That\'s a verifiable credential. Your university can issue you a degree credential, your employer can issue an employment credential, and you store them in your digital wallet.
|
|
||||||
|
|
||||||
The magic? You can prove things about yourself without revealing everything. Need to prove you\'re over 21? You don\'t need to share your birthdate - just a cryptographic proof that you meet the requirement.
|
|
||||||
|
|
||||||
## The Sonr Implementation
|
|
||||||
|
|
||||||
At Sonr, we\'re not just theorizing about SSI - we\'re building it. Our approach combines:
|
|
||||||
|
|
||||||
1. **IBC-Enabled Blockchain**: Your identity works across multiple chains, not locked to one ecosystem
|
|
||||||
2. **IPFS Storage**: Your data is distributed and permanent, not sitting in vulnerable databases
|
|
||||||
3. **WebAuthn Integration**: Authenticate with your fingerprint, not passwords
|
|
||||||
4. **UCAN Authorization**: Grant specific permissions without sharing your keys
|
|
||||||
|
|
||||||
## Real-World Applications
|
|
||||||
|
|
||||||
This isn\'t just technology for technology\'s sake. Here\'s how SSI will change everyday life:
|
|
||||||
|
|
||||||
### Healthcare
|
|
||||||
- Carry your medical records with you
|
|
||||||
- Share only relevant information with providers
|
|
||||||
- Maintain privacy while ensuring continuity of care
|
|
||||||
|
|
||||||
### Finance
|
|
||||||
- Instant KYC/AML compliance without repeated paperwork
|
|
||||||
- Prove creditworthiness without exposing financial details
|
|
||||||
- Access global financial services with one identity
|
|
||||||
|
|
||||||
### Education
|
|
||||||
- Verifiable diplomas that can\'t be faked
|
|
||||||
- Instant credential verification for employers
|
|
||||||
- Lifelong learning records that follow you
|
|
||||||
|
|
||||||
### Travel
|
|
||||||
- Digital passports that speed up border crossings
|
|
||||||
- Vaccination records that are instantly verifiable
|
|
||||||
- One identity for all your travel needs
|
|
||||||
|
|
||||||
## The Technical Foundation
|
|
||||||
|
|
||||||
Building SSI requires solving complex technical challenges:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Creating a verifiable credential
|
|
||||||
const credential = await Sonr.createCredential({
|
|
||||||
type: 'UniversityDegree',
|
|
||||||
subject: userDID,
|
|
||||||
claims: {
|
|
||||||
degree: 'Computer Science',
|
|
||||||
graduationDate: '2023-05-15'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Selective disclosure - prove graduation without revealing degree
|
|
||||||
const proof = await credential.generateProof({
|
|
||||||
reveal: ['graduationDate']
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Privacy by Design
|
|
||||||
|
|
||||||
Unlike current systems where privacy is an afterthought, SSI builds privacy in from the ground up:
|
|
||||||
|
|
||||||
- **Minimal Disclosure**: Share only what\'s necessary
|
|
||||||
- **User Consent**: Every data share requires explicit permission
|
|
||||||
- **Pseudonymity**: Use different identifiers for different contexts
|
|
||||||
- **Revocation**: Withdraw access to your data anytime
|
|
||||||
|
|
||||||
## The Path Forward
|
|
||||||
|
|
||||||
Transitioning to SSI won\'t happen overnight, but the momentum is building:
|
|
||||||
|
|
||||||
- W3C standards for DIDs and Verifiable Credentials are finalized
|
|
||||||
- Governments are piloting digital identity programs
|
|
||||||
- Major tech companies are adopting WebAuthn
|
|
||||||
- The EU\'s eIDAS 2.0 regulation mandates digital wallets by 2024
|
|
||||||
|
|
||||||
## Join the Identity Revolution
|
|
||||||
|
|
||||||
The future of identity isn\'t about better passwords or more secure databases. It\'s about fundamentally reimagining the relationship between individuals and their digital selves.
|
|
||||||
|
|
||||||
At Sonr, we\'re building this future today. Whether you\'re a developer wanting to integrate SSI, an organization looking to issue credentials, or just someone tired of password resets, there\'s a place for you in this revolution.
|
|
||||||
|
|
||||||
The question isn\'t whether self-sovereign identity will replace current systems - it\'s how quickly we can make it happen. The technology is here. The standards are set. Now we need builders, advocates, and early adopters to make it reality.
|
|
||||||
|
|
||||||
Your identity belongs to you. It\'s time we built an internet that respects that.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { type Metadata } from 'next'
|
|
||||||
|
|
||||||
import { Card } from '@/components/Card'
|
|
||||||
import { SimpleLayout } from '@/components/SimpleLayout'
|
|
||||||
import { type ArticleWithSlug, getAllArticles } from '@/lib/articles'
|
|
||||||
import { formatDate } from '@/lib/formatDate'
|
|
||||||
|
|
||||||
function Article({ article }: { article: ArticleWithSlug }) {
|
|
||||||
return (
|
|
||||||
<article className="md:grid md:grid-cols-4 md:items-baseline">
|
|
||||||
<Card className="md:col-span-3">
|
|
||||||
<Card.Title href={`/articles/${article.slug}`}>
|
|
||||||
{article.title}
|
|
||||||
</Card.Title>
|
|
||||||
<Card.Eyebrow
|
|
||||||
as="time"
|
|
||||||
dateTime={article.date}
|
|
||||||
className="md:hidden"
|
|
||||||
decorate
|
|
||||||
>
|
|
||||||
{formatDate(article.date)}
|
|
||||||
</Card.Eyebrow>
|
|
||||||
<Card.Description>{article.description}</Card.Description>
|
|
||||||
<Card.Cta>Read article</Card.Cta>
|
|
||||||
</Card>
|
|
||||||
<Card.Eyebrow
|
|
||||||
as="time"
|
|
||||||
dateTime={article.date}
|
|
||||||
className="mt-1 hidden md:block"
|
|
||||||
>
|
|
||||||
{formatDate(article.date)}
|
|
||||||
</Card.Eyebrow>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Articles',
|
|
||||||
description:
|
|
||||||
'All of my long-form thoughts on programming, leadership, product design, and more, collected in chronological order.',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ArticlesIndex() {
|
|
||||||
let articles = await getAllArticles()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SimpleLayout
|
|
||||||
title="Writing on software design, company building, and the blockchain industry."
|
|
||||||
intro="All of my long-form thoughts on programming, leadership, product design, and more, collected in chronological order."
|
|
||||||
>
|
|
||||||
<div className="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40">
|
|
||||||
<div className="flex max-w-3xl flex-col space-y-16">
|
|
||||||
{articles.map((article) => (
|
|
||||||
<Article key={article.slug} article={article} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SimpleLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { ArticleLayout } from '@/components/ArticleLayout'
|
|
||||||
|
|
||||||
export const article = {
|
|
||||||
author: 'Prad Nukala',
|
|
||||||
date: '2023-11-20',
|
|
||||||
title: 'Why Blockchain Needs Better UX (And How We\'re Fixing It)',
|
|
||||||
description:
|
|
||||||
'The biggest barrier to blockchain adoption isn\'t scalability or regulation - it\'s user experience. Here\'s how we\'re making Web3 as easy as Web2.',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: article.title,
|
|
||||||
description: article.description,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (props) => <ArticleLayout article={article} {...props} />
|
|
||||||
|
|
||||||
Ask any blockchain developer about the biggest challenge facing our industry, and you\'ll hear about scalability, regulation, or interoperability. But after years of building in this space and onboarding over 120 developers to blockchain, I\'ve learned the real barrier is much simpler: user experience.
|
|
||||||
|
|
||||||
## The UX Crisis in Web3
|
|
||||||
|
|
||||||
Let\'s be honest about the current state of blockchain UX:
|
|
||||||
|
|
||||||
- **Seed Phrases**: Asking users to write down 24 random words and never lose them is a non-starter for mainstream adoption
|
|
||||||
- **Gas Fees**: Imagine if every click on the internet cost money and required manual approval
|
|
||||||
- **Addresses**: 42-character hexadecimal strings are not how humans think about identity
|
|
||||||
- **Transaction Times**: Waiting minutes for confirmations kills any semblance of modern app experience
|
|
||||||
|
|
||||||
These aren\'t technical limitations - they\'re design choices we\'ve accepted as "the way blockchain works."
|
|
||||||
|
|
||||||
## Learning from History
|
|
||||||
|
|
||||||
When I created one of the first 5,000 apps on the App Store at age 12, the iPhone had just made computing accessible to everyone. Not by making people understand computers, but by hiding the complexity behind intuitive interfaces.
|
|
||||||
|
|
||||||
Blockchain needs its iPhone moment.
|
|
||||||
|
|
||||||
## The Sonr Approach
|
|
||||||
|
|
||||||
At Sonr, we\'ve rethought blockchain UX from first principles:
|
|
||||||
|
|
||||||
### 1. No Seed Phrases
|
|
||||||
Using DKLS-MPC technology, users never see or manage private keys. Account recovery uses familiar methods like email or social recovery - not a piece of paper in a safe.
|
|
||||||
|
|
||||||
### 2. Instant Onboarding
|
|
||||||
Our 600ms wallet generation means users can start using blockchain apps as quickly as they\'d sign up for any web service. No downloads, no extensions, no friction.
|
|
||||||
|
|
||||||
### 3. Passkey Authentication
|
|
||||||
Leveraging WebAuthn standards, users authenticate with their fingerprint or face - the same way they unlock their phones. It\'s more secure than passwords and infinitely more user-friendly.
|
|
||||||
|
|
||||||
### 4. Invisible Blockchain
|
|
||||||
Users shouldn\'t need to understand consensus mechanisms any more than they need to understand TCP/IP to browse the web. Blockchain should be infrastructure, not interface.
|
|
||||||
|
|
||||||
## Real Code, Real Solutions
|
|
||||||
|
|
||||||
Here\'s how simple it is to integrate Sonr:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Traditional Web3
|
|
||||||
const accounts = await ethereum.request({
|
|
||||||
method: 'eth_requestAccounts'
|
|
||||||
});
|
|
||||||
// User must have MetaMask installed
|
|
||||||
// User must understand gas fees
|
|
||||||
// User must protect seed phrase
|
|
||||||
|
|
||||||
// With Sonr
|
|
||||||
const user = await Sonr.authenticate();
|
|
||||||
// That\'s it. No wallet required.
|
|
||||||
// No seed phrases. Just works.
|
|
||||||
```
|
|
||||||
|
|
||||||
## The Broader Impact
|
|
||||||
|
|
||||||
Better UX isn\'t just about convenience - it\'s about accessibility and inclusion. When we make blockchain easier to use, we:
|
|
||||||
|
|
||||||
- **Reduce Scams**: Simpler interfaces mean fewer opportunities for user error
|
|
||||||
- **Increase Adoption**: Lower barriers mean more users and developers
|
|
||||||
- **Enable Innovation**: When basics are solved, builders can focus on novel applications
|
|
||||||
|
|
||||||
## Looking Forward
|
|
||||||
|
|
||||||
As a W3C Working Group Member, I\'m helping shape standards that will make these UX improvements universal. The future of blockchain isn\'t about teaching billions of people new concepts - it\'s about making those concepts invisible.
|
|
||||||
|
|
||||||
## The Challenge to Builders
|
|
||||||
|
|
||||||
If you\'re building in Web3, ask yourself:
|
|
||||||
- Would my parents be able to use this?
|
|
||||||
- Does it require reading documentation?
|
|
||||||
- Are there more than 3 steps to get started?
|
|
||||||
|
|
||||||
If the answer to any of these is yes, we have work to do.
|
|
||||||
|
|
||||||
## Join the UX Revolution
|
|
||||||
|
|
||||||
The blockchain industry has brilliant technologists, but we need more designers, more product thinkers, and more people who put users first. If you\'re interested in making blockchain accessible to everyone, [reach out](mailto:prad.nukala@sonr.io).
|
|
||||||
|
|
||||||
The future of the internet depends on making it usable by everyone, not just the technically sophisticated. Let\'s build that future together.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.5 KiB |
@@ -1,70 +0,0 @@
|
|||||||
import assert from 'assert'
|
|
||||||
import * as cheerio from 'cheerio'
|
|
||||||
import { Feed } from 'feed'
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
let siteUrl = process.env.NEXT_PUBLIC_SITE_URL
|
|
||||||
|
|
||||||
if (!siteUrl) {
|
|
||||||
throw Error('Missing NEXT_PUBLIC_SITE_URL environment variable')
|
|
||||||
}
|
|
||||||
|
|
||||||
let author = {
|
|
||||||
name: 'Prad Nukala',
|
|
||||||
email: 'spencer@planetaria.tech',
|
|
||||||
}
|
|
||||||
|
|
||||||
let feed = new Feed({
|
|
||||||
title: author.name,
|
|
||||||
description: 'Your blog description',
|
|
||||||
author,
|
|
||||||
id: siteUrl,
|
|
||||||
link: siteUrl,
|
|
||||||
image: `${siteUrl}/favicon.ico`,
|
|
||||||
favicon: `${siteUrl}/favicon.ico`,
|
|
||||||
copyright: `All rights reserved ${new Date().getFullYear()}`,
|
|
||||||
feedLinks: {
|
|
||||||
rss2: `${siteUrl}/feed.xml`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
let articleIds = require
|
|
||||||
.context('../articles', true, /\/page\.mdx$/)
|
|
||||||
.keys()
|
|
||||||
.filter((key) => key.startsWith('./'))
|
|
||||||
.map((key) => key.slice(2).replace(/\/page\.mdx$/, ''))
|
|
||||||
|
|
||||||
for (let id of articleIds) {
|
|
||||||
let url = String(new URL(`/articles/${id}`, req.url))
|
|
||||||
let html = await (await fetch(url)).text()
|
|
||||||
let $ = cheerio.load(html)
|
|
||||||
|
|
||||||
let publicUrl = `${siteUrl}/articles/${id}`
|
|
||||||
let article = $('article').first()
|
|
||||||
let title = article.find('h1').first().text()
|
|
||||||
let date = article.find('time').first().attr('datetime')
|
|
||||||
let content = article.find('[data-mdx-content]').first().html()
|
|
||||||
|
|
||||||
assert(typeof title === 'string')
|
|
||||||
assert(typeof date === 'string')
|
|
||||||
assert(typeof content === 'string')
|
|
||||||
|
|
||||||
feed.addItem({
|
|
||||||
title,
|
|
||||||
id: publicUrl,
|
|
||||||
link: publicUrl,
|
|
||||||
content,
|
|
||||||
author: [author],
|
|
||||||
contributor: [author],
|
|
||||||
date: new Date(date),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(feed.rss2(), {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/xml',
|
|
||||||
'cache-control': 's-maxage=31556952',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { type Metadata } from 'next'
|
|
||||||
|
|
||||||
import { Providers } from '@/app/providers'
|
|
||||||
import { Layout } from '@/components/Layout'
|
|
||||||
|
|
||||||
import '@/styles/tailwind.css'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: {
|
|
||||||
template: '%s - Prad Nukala',
|
|
||||||
default:
|
|
||||||
'Prad Nukala - Founder & CEO of Sonr, Blockchain Identity Pioneer',
|
|
||||||
},
|
|
||||||
description:
|
|
||||||
'I\'m Prad Nukala, founder and CEO of Sonr, an IBC-enabled blockchain for decentralized identity. W3C Working Group Member for DIDs, WebAuthn, and WASM. Building the future of self-sovereign identity and empowering users with control over their digital assets.',
|
|
||||||
alternates: {
|
|
||||||
types: {
|
|
||||||
'application/rss+xml': `${process.env.NEXT_PUBLIC_SITE_URL}/feed.xml`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<html lang="en" className="h-full antialiased" suppressHydrationWarning>
|
|
||||||
<body className="flex h-full bg-zinc-50 dark:bg-black">
|
|
||||||
<Providers>
|
|
||||||
<div className="flex w-full">
|
|
||||||
<Layout>{children}</Layout>
|
|
||||||
</div>
|
|
||||||
</Providers>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Button } from '@/components/Button'
|
|
||||||
import { Container } from '@/components/Container'
|
|
||||||
|
|
||||||
export default function NotFound() {
|
|
||||||
return (
|
|
||||||
<Container className="flex h-full items-center pt-16 sm:pt-32">
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<p className="text-base font-semibold text-zinc-400 dark:text-zinc-500">
|
|
||||||
404
|
|
||||||
</p>
|
|
||||||
<h1 className="mt-4 text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
|
||||||
Page not found
|
|
||||||
</h1>
|
|
||||||
<p className="mt-4 text-base text-zinc-600 dark:text-zinc-400">
|
|
||||||
Sorry, we couldn’t find the page you’re looking for.
|
|
||||||
</p>
|
|
||||||
<Button href="/" variant="secondary" className="mt-4">
|
|
||||||
Go back home
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
import Image, { type ImageProps } from 'next/image'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
import { Button } from '@/components/Button'
|
|
||||||
import { Card } from '@/components/Card'
|
|
||||||
import { Container } from '@/components/Container'
|
|
||||||
import {
|
|
||||||
GitHubIcon,
|
|
||||||
InstagramIcon,
|
|
||||||
LinkedInIcon,
|
|
||||||
MediumIcon,
|
|
||||||
TwitterIcon,
|
|
||||||
} from '@/components/SocialIcons'
|
|
||||||
import logoAirbnb from '@/images/logos/spacebolt.svg'
|
|
||||||
import logoFacebook from '@/images/logos/voluntree.svg'
|
|
||||||
import logoPlanetaria from '@/images/logos/sonr.svg'
|
|
||||||
import logoStarbucks from '@/images/logos/superball.svg'
|
|
||||||
import image1 from '@/images/photos/image-1.jpg'
|
|
||||||
import image2 from '@/images/photos/image-2.jpg'
|
|
||||||
import image3 from '@/images/photos/image-3.jpg'
|
|
||||||
import image4 from '@/images/photos/image-4.jpg'
|
|
||||||
import image5 from '@/images/photos/image-5.jpg'
|
|
||||||
import { type ArticleWithSlug, getAllArticles } from '@/lib/articles'
|
|
||||||
import { formatDate } from '@/lib/formatDate'
|
|
||||||
|
|
||||||
function MailIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M2.75 7.75a3 3 0 0 1 3-3h12.5a3 3 0 0 1 3 3v8.5a3 3 0 0 1-3 3H5.75a3 3 0 0 1-3-3v-8.5Z"
|
|
||||||
className="fill-zinc-100 stroke-zinc-400 dark:fill-zinc-100/10 dark:stroke-zinc-500"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="m4 6 6.024 5.479a2.915 2.915 0 0 0 3.952 0L20 6"
|
|
||||||
className="stroke-zinc-400 dark:stroke-zinc-500"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BriefcaseIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M2.75 9.75a3 3 0 0 1 3-3h12.5a3 3 0 0 1 3 3v8.5a3 3 0 0 1-3 3H5.75a3 3 0 0 1-3-3v-8.5Z"
|
|
||||||
className="fill-zinc-100 stroke-zinc-400 dark:fill-zinc-100/10 dark:stroke-zinc-500"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M3 14.25h6.249c.484 0 .952-.002 1.316.319l.777.682a.996.996 0 0 0 1.316 0l.777-.682c.364-.32.832-.319 1.316-.319H21M8.75 6.5V4.75a2 2 0 0 1 2-2h2.5a2 2 0 0 1 2 2V6.5"
|
|
||||||
className="stroke-zinc-400 dark:stroke-zinc-500"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ArrowDownIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
|
||||||
<path
|
|
||||||
d="M4.75 8.75 8 12.25m0 0 3.25-3.5M8 12.25v-8.5"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Article({ article }: { article: ArticleWithSlug }) {
|
|
||||||
return (
|
|
||||||
<Card as="article">
|
|
||||||
<Card.Title href={`/articles/${article.slug}`}>
|
|
||||||
{article.title}
|
|
||||||
</Card.Title>
|
|
||||||
<Card.Eyebrow as="time" dateTime={article.date} decorate>
|
|
||||||
{formatDate(article.date)}
|
|
||||||
</Card.Eyebrow>
|
|
||||||
<Card.Description>{article.description}</Card.Description>
|
|
||||||
<Card.Cta>Read article</Card.Cta>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SocialLink({
|
|
||||||
icon: Icon,
|
|
||||||
...props
|
|
||||||
}: React.ComponentPropsWithoutRef<typeof Link> & {
|
|
||||||
icon: React.ComponentType<{ className?: string }>
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Link className="group -m-1 p-1" {...props}>
|
|
||||||
<Icon className="h-6 w-6 fill-zinc-500 transition group-hover:fill-zinc-600 dark:fill-zinc-400 dark:group-hover:fill-zinc-300" />
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Newsletter() {
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
action="/thank-you"
|
|
||||||
className="rounded-2xl border border-zinc-100 p-6 dark:border-zinc-700/40"
|
|
||||||
>
|
|
||||||
<h2 className="flex text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
|
||||||
<MailIcon className="h-6 w-6 flex-none" />
|
|
||||||
<span className="ml-3">Stay up to date</span>
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
|
||||||
Get notified about updates on Sonr, blockchain technology insights, and decentralized identity developments.
|
|
||||||
</p>
|
|
||||||
<div className="mt-6 flex">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="Email address"
|
|
||||||
aria-label="Email address"
|
|
||||||
required
|
|
||||||
className="min-w-0 flex-auto appearance-none rounded-md border border-zinc-900/10 bg-white px-3 py-[calc(theme(spacing.2)-1px)] shadow-md shadow-zinc-800/5 placeholder:text-zinc-400 focus:border-teal-500 focus:outline-none focus:ring-4 focus:ring-teal-500/10 dark:border-zinc-700 dark:bg-zinc-700/[0.15] dark:text-zinc-200 dark:placeholder:text-zinc-500 dark:focus:border-teal-400 dark:focus:ring-teal-400/10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
<Button type="submit" className="ml-4 flex-none">
|
|
||||||
Join
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Role {
|
|
||||||
company: string
|
|
||||||
title: string
|
|
||||||
logo: ImageProps['src']
|
|
||||||
start: string | { label: string; dateTime: string }
|
|
||||||
end: string | { label: string; dateTime: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
function Role({ role }: { role: Role }) {
|
|
||||||
let startLabel =
|
|
||||||
typeof role.start === 'string' ? role.start : role.start.label
|
|
||||||
let startDate =
|
|
||||||
typeof role.start === 'string' ? role.start : role.start.dateTime
|
|
||||||
|
|
||||||
let endLabel = typeof role.end === 'string' ? role.end : role.end.label
|
|
||||||
let endDate = typeof role.end === 'string' ? role.end : role.end.dateTime
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className="flex gap-4">
|
|
||||||
<div className="relative mt-1 flex h-10 w-10 flex-none items-center justify-center rounded-full shadow-md shadow-zinc-800/5 ring-1 ring-zinc-900/5 dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0">
|
|
||||||
<Image src={role.logo} alt="" className="h-7 w-7 rounded-full" unoptimized />
|
|
||||||
</div>
|
|
||||||
<dl className="flex flex-auto flex-wrap gap-x-2">
|
|
||||||
<dt className="sr-only">Company</dt>
|
|
||||||
<dd className="w-full flex-none text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
||||||
{role.company}
|
|
||||||
</dd>
|
|
||||||
<dt className="sr-only">Role</dt>
|
|
||||||
<dd className="text-xs text-zinc-500 dark:text-zinc-400">
|
|
||||||
{role.title}
|
|
||||||
</dd>
|
|
||||||
<dt className="sr-only">Date</dt>
|
|
||||||
<dd
|
|
||||||
className="ml-auto text-xs text-zinc-400 dark:text-zinc-500"
|
|
||||||
aria-label={`${startLabel} until ${endLabel}`}
|
|
||||||
>
|
|
||||||
<time dateTime={startDate}>{startLabel}</time>{' '}
|
|
||||||
<span aria-hidden="true">—</span>{' '}
|
|
||||||
<time dateTime={endDate}>{endLabel}</time>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Resume() {
|
|
||||||
let resume: Array<Role> = [
|
|
||||||
{
|
|
||||||
company: 'Sonr',
|
|
||||||
title: 'Founder & CEO',
|
|
||||||
logo: logoPlanetaria,
|
|
||||||
start: '2020',
|
|
||||||
end: {
|
|
||||||
label: 'Present',
|
|
||||||
dateTime: new Date().getFullYear().toString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: 'W3C Working Group',
|
|
||||||
title: 'Member - DIDs, WebAuthn, WASM',
|
|
||||||
logo: logoAirbnb,
|
|
||||||
start: '2021',
|
|
||||||
end: {
|
|
||||||
label: 'Present',
|
|
||||||
dateTime: new Date().getFullYear().toString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: 'Open Source',
|
|
||||||
title: '4th Most GitHub Contributions Worldwide',
|
|
||||||
logo: logoFacebook,
|
|
||||||
start: '2021',
|
|
||||||
end: '2023',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: 'Early iOS Developer',
|
|
||||||
title: 'One of the first 5,000 App Store apps',
|
|
||||||
logo: logoStarbucks,
|
|
||||||
start: '2008',
|
|
||||||
end: '2012',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-zinc-100 p-6 dark:border-zinc-700/40">
|
|
||||||
<h2 className="flex text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
|
||||||
<BriefcaseIcon className="h-6 w-6 flex-none" />
|
|
||||||
<span className="ml-3">Experience</span>
|
|
||||||
</h2>
|
|
||||||
<ol className="mt-6 space-y-4">
|
|
||||||
{resume.map((role, roleIndex) => (
|
|
||||||
<Role key={roleIndex} role={role} />
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
<Button href="#" variant="secondary" className="group mt-6 w-full">
|
|
||||||
Download CV
|
|
||||||
<ArrowDownIcon className="h-4 w-4 stroke-zinc-400 transition group-active:stroke-zinc-600 dark:group-hover:stroke-zinc-50 dark:group-active:stroke-zinc-50" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Photos() {
|
|
||||||
let rotations = ['rotate-2', '-rotate-2', 'rotate-2', 'rotate-2', '-rotate-2']
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-16 sm:mt-20">
|
|
||||||
<div className="-my-4 flex justify-center gap-5 overflow-hidden py-4 sm:gap-8">
|
|
||||||
{[image1, image2, image3, image4, image5].map((image, imageIndex) => (
|
|
||||||
<div
|
|
||||||
key={image.src}
|
|
||||||
className={clsx(
|
|
||||||
'relative aspect-[9/10] w-44 flex-none overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-800 sm:w-72 sm:rounded-2xl',
|
|
||||||
rotations[imageIndex % rotations.length],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={image}
|
|
||||||
alt=""
|
|
||||||
sizes="(min-width: 640px) 18rem, 11rem"
|
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Home() {
|
|
||||||
let articles = (await getAllArticles()).slice(0, 4)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Container className="mt-9">
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
|
||||||
Blockchain pioneer, founder, and decentralized identity architect.
|
|
||||||
</h1>
|
|
||||||
<p className="mt-6 text-base text-zinc-600 dark:text-zinc-400">
|
|
||||||
I\'m Prad Nukala, founder and CEO of Sonr, an IBC-enabled blockchain for decentralized identity.
|
|
||||||
As a W3C Working Group Member for DIDs, WebAuthn, and WASM, I\'m building the future where users
|
|
||||||
control their digital identity and assets. With over 6000+ GitHub contributions, I\'m passionate
|
|
||||||
about making blockchain technology accessible to everyone.
|
|
||||||
</p>
|
|
||||||
<div className="mt-6 flex gap-6">
|
|
||||||
<SocialLink
|
|
||||||
href="https://twitter.com/thisisprad"
|
|
||||||
aria-label="Follow on Twitter"
|
|
||||||
icon={TwitterIcon}
|
|
||||||
/>
|
|
||||||
<SocialLink
|
|
||||||
href="https://medium.com/@prnk28"
|
|
||||||
aria-label="Follow on Medium"
|
|
||||||
icon={MediumIcon}
|
|
||||||
/>
|
|
||||||
<SocialLink
|
|
||||||
href="https://github.com/prnk28"
|
|
||||||
aria-label="Follow on GitHub"
|
|
||||||
icon={GitHubIcon}
|
|
||||||
/>
|
|
||||||
<SocialLink
|
|
||||||
href="https://linkedin.com/in/pradn"
|
|
||||||
aria-label="Follow on LinkedIn"
|
|
||||||
icon={LinkedInIcon}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
<Photos />
|
|
||||||
<Container className="mt-24 md:mt-28">
|
|
||||||
<div className="mx-auto grid max-w-xl grid-cols-1 gap-y-20 lg:max-w-none lg:grid-cols-2">
|
|
||||||
<div className="flex flex-col gap-16">
|
|
||||||
{articles.map((article) => (
|
|
||||||
<Article key={article.slug} article={article} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-10 lg:pl-16 xl:pl-24">
|
|
||||||
<Newsletter />
|
|
||||||
<Resume />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { type Metadata } from 'next'
|
|
||||||
import Image from 'next/image'
|
|
||||||
|
|
||||||
import { Card } from '@/components/Card'
|
|
||||||
import { SimpleLayout } from '@/components/SimpleLayout'
|
|
||||||
import logoSuperball from '@/images/logos/superball.svg'
|
|
||||||
import logoVoluntree from '@/images/logos/voluntree.svg'
|
|
||||||
import logoSpacebolt from '@/images/logos/spacebolt.svg'
|
|
||||||
import logoYue from '@/images/logos/yue.svg'
|
|
||||||
import logoSonr from '@/images/logos/sonr.svg'
|
|
||||||
|
|
||||||
const projects = [
|
|
||||||
{
|
|
||||||
name: 'Sonr',
|
|
||||||
created: '2020 - Present',
|
|
||||||
description:
|
|
||||||
'IBC-enabled blockchain for decentralized identity. Features passkey-based accounts, DKLS-MPC wallets, and 600ms wallet generation.',
|
|
||||||
link: { href: 'https://sonr.io', label: 'sonr.io' },
|
|
||||||
logo: logoSonr,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DID & WebAuthn Standards',
|
|
||||||
created: '2021 - Present',
|
|
||||||
description:
|
|
||||||
'Contributing to W3C Working Groups for DIDs, WebAuthn, and WASM standards that define the future of digital identity.',
|
|
||||||
link: { href: 'https://www.w3.org/TR/did-core/', label: 'w3.org' },
|
|
||||||
logo: logoYue,
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// name: 'ML Livestock Detection',
|
|
||||||
// description:
|
|
||||||
// 'Real-time video streaming library, optimized for interstellar transmission.',
|
|
||||||
// link: { href: '#', label: 'github.com' },
|
|
||||||
// logo: logoHelioStream,
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// name: 'AR Whiteboard',
|
|
||||||
// description:
|
|
||||||
// 'The operating system that powers our Sonr space shuttles.',
|
|
||||||
// link: { href: '#', label: 'github.com' },
|
|
||||||
// logo: logoCosmos,
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
name: 'Spacebolt',
|
|
||||||
created: '2017 - 2018',
|
|
||||||
description:
|
|
||||||
'Unity-based mobile game featuring innovative physics-based gameplay. Part of my journey in mobile development.',
|
|
||||||
link: { href: 'https://github.com/prnk28', label: 'github.com' },
|
|
||||||
logo: logoSpacebolt,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'VolunTree',
|
|
||||||
created: '2015 - 2016',
|
|
||||||
description:
|
|
||||||
'iOS app for tracking volunteer hours and connecting volunteers with organizations. Simplified community service engagement.',
|
|
||||||
link: { href: 'https://github.com/prnk28', label: 'github.com' },
|
|
||||||
logo: logoVoluntree,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Superball',
|
|
||||||
created: '2010 - 2011',
|
|
||||||
description:
|
|
||||||
'One of the first 10,000 apps published on the App Store. A physics-based game that started my journey at age 12.',
|
|
||||||
link: { href: 'https://github.com/prnk28', label: 'github.com' },
|
|
||||||
logo: logoSuperball,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function LinkIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
|
||||||
<path
|
|
||||||
d="M15.712 11.823a.75.75 0 1 0 1.06 1.06l-1.06-1.06Zm-4.95 1.768a.75.75 0 0 0 1.06-1.06l-1.06 1.06Zm-2.475-1.414a.75.75 0 1 0-1.06-1.06l1.06 1.06Zm4.95-1.768a.75.75 0 1 0-1.06 1.06l1.06-1.06Zm3.359.53-.884.884 1.06 1.06.885-.883-1.061-1.06Zm-4.95-2.12 1.414-1.415L12 6.344l-1.415 1.413 1.061 1.061Zm0 3.535a2.5 2.5 0 0 1 0-3.536l-1.06-1.06a4 4 0 0 0 0 5.656l1.06-1.06Zm4.95-4.95a2.5 2.5 0 0 1 0 3.535L17.656 12a4 4 0 0 0 0-5.657l-1.06 1.06Zm1.06-1.06a4 4 0 0 0-5.656 0l1.06 1.06a2.5 2.5 0 0 1 3.536 0l1.06-1.06Zm-7.07 7.07.176.177 1.06-1.06-.176-.177-1.06 1.06Zm-3.183-.353.884-.884-1.06-1.06-.884.883 1.06 1.06Zm4.95 2.121-1.414 1.414 1.06 1.06 1.415-1.413-1.06-1.061Zm0-3.536a2.5 2.5 0 0 1 0 3.536l1.06 1.06a4 4 0 0 0 0-5.656l-1.06 1.06Zm-4.95 4.95a2.5 2.5 0 0 1 0-3.535L6.344 12a4 4 0 0 0 0 5.656l1.06-1.06Zm-1.06 1.06a4 4 0 0 0 5.657 0l-1.061-1.06a2.5 2.5 0 0 1-3.535 0l-1.061 1.06Zm7.07-7.07-.176-.177-1.06 1.06.176.178 1.06-1.061Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Projects',
|
|
||||||
description: 'Blockchain innovations and decentralized identity solutions I\'ve built to empower users.',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Projects() {
|
|
||||||
return (
|
|
||||||
<SimpleLayout
|
|
||||||
title="Building the infrastructure for a decentralized future."
|
|
||||||
intro="From creating one of the first App Store apps at age 12 to founding Sonr, I\\'ve developed over 10 projects with 1M+ downloads. These represent my journey from mobile development to blockchain innovation, all focused on empowering users with better technology."
|
|
||||||
>
|
|
||||||
<ul
|
|
||||||
role="list"
|
|
||||||
className="grid grid-cols-1 gap-x-12 gap-y-16 sm:grid-cols-2 lg:grid-cols-3"
|
|
||||||
>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<Card as="li" key={project.name}>
|
|
||||||
<div className="relative z-10 flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-md shadow-zinc-800/5 ring-1 ring-zinc-900/5 dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0">
|
|
||||||
<Image
|
|
||||||
src={project.logo}
|
|
||||||
alt=""
|
|
||||||
className="h-8 w-8 rounded-full"
|
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 className="mt-6 text-base font-semibold text-zinc-800 dark:text-zinc-100">
|
|
||||||
<Card.Link href={project.link.href}>{project.name}</Card.Link>
|
|
||||||
</h2>
|
|
||||||
<Card.Description>{project.description}</Card.Description>
|
|
||||||
<p className="relative z-10 mt-6 flex text-sm font-medium text-zinc-400 transition group-hover:text-teal-500 dark:text-zinc-200">
|
|
||||||
<LinkIcon className="h-6 w-6 flex-none" />
|
|
||||||
<span className="ml-2">{project.link.label}</span>
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</SimpleLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { createContext, useEffect, useRef } from 'react'
|
|
||||||
import { usePathname } from 'next/navigation'
|
|
||||||
import { ThemeProvider, useTheme } from 'next-themes'
|
|
||||||
|
|
||||||
function usePrevious<T>(value: T) {
|
|
||||||
let ref = useRef<T>()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
ref.current = value
|
|
||||||
}, [value])
|
|
||||||
|
|
||||||
return ref.current
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThemeWatcher() {
|
|
||||||
let { resolvedTheme, setTheme } = useTheme()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let media = window.matchMedia('(prefers-color-scheme: dark)')
|
|
||||||
|
|
||||||
function onMediaChange() {
|
|
||||||
let systemTheme = media.matches ? 'dark' : 'light'
|
|
||||||
if (resolvedTheme === systemTheme) {
|
|
||||||
setTheme('system')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMediaChange()
|
|
||||||
media.addEventListener('change', onMediaChange)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
media.removeEventListener('change', onMediaChange)
|
|
||||||
}
|
|
||||||
}, [resolvedTheme, setTheme])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AppContext = createContext<{ previousPathname?: string }>({})
|
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
|
||||||
let pathname = usePathname()
|
|
||||||
let previousPathname = usePrevious(pathname)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppContext.Provider value={{ previousPathname }}>
|
|
||||||
<ThemeProvider attribute="class" disableTransitionOnChange>
|
|
||||||
<ThemeWatcher />
|
|
||||||
{children}
|
|
||||||
</ThemeProvider>
|
|
||||||
</AppContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { type Metadata } from 'next'
|
|
||||||
|
|
||||||
import { Card } from '@/components/Card'
|
|
||||||
import { Section } from '@/components/Section'
|
|
||||||
import { SimpleLayout } from '@/components/SimpleLayout'
|
|
||||||
|
|
||||||
function SpeakingSection({
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentPropsWithoutRef<typeof Section>) {
|
|
||||||
return (
|
|
||||||
<Section {...props}>
|
|
||||||
<div className="space-y-16">{children}</div>
|
|
||||||
</Section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Appearance({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
event,
|
|
||||||
cta,
|
|
||||||
href,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
event: string
|
|
||||||
cta: string
|
|
||||||
href: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card as="article">
|
|
||||||
<Card.Title as="h3" href={href}>
|
|
||||||
{title}
|
|
||||||
</Card.Title>
|
|
||||||
<Card.Eyebrow decorate>{event}</Card.Eyebrow>
|
|
||||||
<Card.Description>{description}</Card.Description>
|
|
||||||
<Card.Cta>{cta}</Card.Cta>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Speaking',
|
|
||||||
description:
|
|
||||||
'Speaking at prestigious institutions and conferences about blockchain, decentralized identity, and the future of the web.',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Speaking() {
|
|
||||||
return (
|
|
||||||
<SimpleLayout
|
|
||||||
title="Sharing the vision of decentralized identity with the world."
|
|
||||||
intro="From guest lecturing at MIT alongside Vitalik Buterin to speaking at blockchain conferences, I\'m passionate about educating developers and entrepreneurs about the future of digital identity and how blockchain technology can empower users."
|
|
||||||
>
|
|
||||||
<div className="space-y-20">
|
|
||||||
<SpeakingSection title="Guest Lectures & Conferences">
|
|
||||||
<Appearance
|
|
||||||
href="#"
|
|
||||||
title="The Future of Decentralized Identity"
|
|
||||||
description="Guest lecture at MIT Sloan School alongside Vitalik Buterin, Alex Mashinsky, Anthony Pompliano, and Yat Siu on blockchain innovation and decentralized identity."
|
|
||||||
event="MIT Sloan School of Management, 2023"
|
|
||||||
cta="Learn more"
|
|
||||||
/>
|
|
||||||
<Appearance
|
|
||||||
href="#"
|
|
||||||
title="Building IBC-Enabled Blockchains for Identity"
|
|
||||||
description="Technical deep-dive into Sonr\'s architecture, showcasing how we leverage IBC for cross-chain identity management and our innovative approach to wallet generation."
|
|
||||||
event="Cosmos Ecosystem Conference, 2023"
|
|
||||||
cta="Watch video"
|
|
||||||
/>
|
|
||||||
<Appearance
|
|
||||||
href="#"
|
|
||||||
title="WebAuthn and the Future of Authentication"
|
|
||||||
description="Presenting our work with W3C standards for DIDs and WebAuthn, demonstrating how passkey-based authentication can replace traditional passwords."
|
|
||||||
event="W3C Workshop on Web Authentication, 2022"
|
|
||||||
cta="View presentation"
|
|
||||||
/>
|
|
||||||
</SpeakingSection>
|
|
||||||
<SpeakingSection title="Podcasts & Interviews">
|
|
||||||
<Appearance
|
|
||||||
href="#"
|
|
||||||
title="From App Store Pioneer to Blockchain Innovator"
|
|
||||||
description="My journey from creating one of the first 5,000 iOS apps at age 12 to founding Sonr and becoming the 4th most active GitHub contributor worldwide."
|
|
||||||
event="The Blockchain Founders Podcast, 2023"
|
|
||||||
cta="Listen to podcast"
|
|
||||||
/>
|
|
||||||
<Appearance
|
|
||||||
href="#"
|
|
||||||
title="Raising $4.7M to Build Decentralized Identity"
|
|
||||||
description="How we raised funding for Sonr, onboarded 120+ first-time blockchain developers, and our vision for making blockchain accessible to everyone."
|
|
||||||
event="Web3 Startup Stories, 2022"
|
|
||||||
cta="Listen to podcast"
|
|
||||||
/>
|
|
||||||
<Appearance
|
|
||||||
href="#"
|
|
||||||
title="The Technical Architecture of Self-Sovereign Identity"
|
|
||||||
description="Deep technical discussion on DID documents, IPFS integration, UCAN tokens, and our approach to eliminating seed phrases with DKLS-MPC wallets."
|
|
||||||
event="Decentralized Tech Talks, 2022"
|
|
||||||
cta="Listen to podcast"
|
|
||||||
/>
|
|
||||||
</SpeakingSection>
|
|
||||||
</div>
|
|
||||||
</SimpleLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { type Metadata } from 'next'
|
|
||||||
|
|
||||||
import { SimpleLayout } from '@/components/SimpleLayout'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'You\'re subscribed',
|
|
||||||
description: 'Thanks for subscribing to my newsletter.',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ThankYou() {
|
|
||||||
return (
|
|
||||||
<SimpleLayout
|
|
||||||
title="Thanks for subscribing."
|
|
||||||
intro="I\'ll send you an email any time I publish a new blog post, release a new project, or have anything interesting to share that I think you\'d want to hear about. You can unsubscribe at any time, no hard feelings."
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import { Card } from '@/components/Card'
|
|
||||||
import { Section } from '@/components/Section'
|
|
||||||
import { SimpleLayout } from '@/components/SimpleLayout'
|
|
||||||
|
|
||||||
function ToolsSection({
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentPropsWithoutRef<typeof Section>) {
|
|
||||||
return (
|
|
||||||
<Section {...props}>
|
|
||||||
<ul role="list" className="space-y-16">
|
|
||||||
{children}
|
|
||||||
</ul>
|
|
||||||
</Section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Tool({
|
|
||||||
title,
|
|
||||||
href,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
href?: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card as="li">
|
|
||||||
<Card.Title as="h3" href={href}>
|
|
||||||
{title}
|
|
||||||
</Card.Title>
|
|
||||||
<Card.Description>{children}</Card.Description>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: 'Uses',
|
|
||||||
description: 'Tools and technologies I use for blockchain development, building Sonr, and staying productive.',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Uses() {
|
|
||||||
return (
|
|
||||||
<SimpleLayout
|
|
||||||
title="Tools for building the future of decentralized identity."
|
|
||||||
intro="From blockchain development to managing a startup, here are the tools and technologies that power my work at Sonr and help me maintain my position as one of the world\'s most active open-source contributors."
|
|
||||||
>
|
|
||||||
<div className="space-y-20">
|
|
||||||
<ToolsSection title="Workstation">
|
|
||||||
<Tool title='16" MacBook Pro, M2 Max, 96GB RAM'>
|
|
||||||
When you\'re compiling blockchain nodes and running multiple test networks locally,
|
|
||||||
RAM is everything. This machine handles everything I throw at it, from Rust compilation
|
|
||||||
to running full Cosmos SDK nodes.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="LG UltraFine 5K Display (x2)">
|
|
||||||
Dual 5K displays give me the screen real estate I need for coding, monitoring
|
|
||||||
blockchain metrics, and keeping up with the constant stream of GitHub notifications
|
|
||||||
from maintaining one of the world\'s most active contribution streaks.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="Keychron Q1 with Gateron Brown Switches">
|
|
||||||
Mechanical keyboards aren\'t just for gaming. The tactile feedback helps me maintain
|
|
||||||
accuracy during long coding sessions, crucial when every character matters in smart contracts.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="Herman Miller Embody Chair">
|
|
||||||
After years of 12+ hour coding days, investing in proper ergonomics isn\'t optional.
|
|
||||||
This chair has been perfect for those long sessions building Sonr\'s infrastructure.
|
|
||||||
</Tool>
|
|
||||||
</ToolsSection>
|
|
||||||
<ToolsSection title="Development Tools">
|
|
||||||
<Tool title="VS Code with Rust Analyzer">
|
|
||||||
Essential for Cosmos SDK development. The Rust analyzer extension is particularly
|
|
||||||
crucial for blockchain development where type safety can prevent costly errors.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="Warp Terminal">
|
|
||||||
The AI-powered terminal that understands context. Perfect for managing multiple
|
|
||||||
blockchain nodes, SSH sessions, and the complex command sequences involved in
|
|
||||||
Cosmos SDK development.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="Docker & Kubernetes">
|
|
||||||
Running local testnets and managing blockchain infrastructure requires serious
|
|
||||||
containerization. Docker for local development, K8s for production deployments.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="Lens IDE">
|
|
||||||
The best Kubernetes IDE I\'ve found. Essential for managing Sonr\'s infrastructure
|
|
||||||
across multiple environments and monitoring our validator nodes.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="Tendermint/CometBFT Tools">
|
|
||||||
The entire Cosmos SDK toolkit is essential for building IBC-enabled chains.
|
|
||||||
From Ignite CLI to CosmWasm, these tools form the backbone of Sonr\'s development.
|
|
||||||
</Tool>
|
|
||||||
</ToolsSection>
|
|
||||||
<ToolsSection title="Blockchain & Web3 Tools">
|
|
||||||
<Tool title="Keplr Wallet">
|
|
||||||
The premier wallet for Cosmos ecosystem development. Essential for testing
|
|
||||||
IBC transactions and interacting with Sonr\'s testnet.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="Remix IDE">
|
|
||||||
While Sonr uses Cosmos SDK, understanding EVM is crucial. Remix helps me
|
|
||||||
stay current with Ethereum development and test cross-chain scenarios.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="IPFS Desktop">
|
|
||||||
Since Sonr integrates IPFS for distributed storage, having a local IPFS
|
|
||||||
node is essential for development and testing.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="Postman">
|
|
||||||
API testing is crucial when building blockchain infrastructure. Postman
|
|
||||||
helps me test our REST and gRPC endpoints thoroughly.
|
|
||||||
</Tool>
|
|
||||||
</ToolsSection>
|
|
||||||
<ToolsSection title="Design & Documentation">
|
|
||||||
<Tool title="Figma">
|
|
||||||
Designing intuitive blockchain UX requires constant iteration. Figma helps
|
|
||||||
us prototype wallet interfaces and visualize complex identity flows.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="Excalidraw">
|
|
||||||
Perfect for sketching blockchain architectures and explaining complex
|
|
||||||
technical concepts. Most of our technical documentation starts here.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="Notion">
|
|
||||||
Our entire company knowledge base lives in Notion. From technical specs
|
|
||||||
to meeting notes, it\'s our single source of truth.
|
|
||||||
</Tool>
|
|
||||||
</ToolsSection>
|
|
||||||
<ToolsSection title="Productivity & Communication">
|
|
||||||
<Tool title="Raycast">
|
|
||||||
More than just an app launcher - it\'s my command center. Custom scripts
|
|
||||||
for blockchain operations, quick access to documentation, and AI features
|
|
||||||
for code generation.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="Linear">
|
|
||||||
Issue tracking designed for modern software teams. Perfect for managing
|
|
||||||
Sonr\'s development across multiple repositories and coordinating with our
|
|
||||||
distributed team.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="Cal.com">
|
|
||||||
Open-source scheduling that respects privacy. Important when you\'re building
|
|
||||||
privacy-first technology and need to practice what you preach.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="Discord">
|
|
||||||
Where the blockchain community lives. Essential for staying connected with
|
|
||||||
the Cosmos ecosystem and supporting our developer community.
|
|
||||||
</Tool>
|
|
||||||
<Tool title="GitHub Copilot">
|
|
||||||
AI pair programming at its finest. Particularly helpful when working with
|
|
||||||
new Cosmos SDK modules or implementing complex cryptographic functions.
|
|
||||||
</Tool>
|
|
||||||
</ToolsSection>
|
|
||||||
</div>
|
|
||||||
</SimpleLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!-- Parent: ../AGENTS.md -->
|
||||||
|
<!-- Generated: 2026-06-30 | Updated: 2026-06-30 -->
|
||||||
|
|
||||||
|
# assets
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Build-time assets imported by source code (processed by Astro/Vite), as opposed to `public/` which is served verbatim.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `roboto-mono-regular.ttf` | Font (weight 400) embedded into Satori-generated OG images. |
|
||||||
|
| `roboto-mono-700.ttf` | Font (weight 700) for OG image titles. |
|
||||||
|
|
||||||
|
## For AI Agents
|
||||||
|
|
||||||
|
### Working In This Directory
|
||||||
|
- These fonts are imported as raw buffers in `src/pages/og-image/[...slug].png.ts` (via the `vite-plugin-raw-fonts` config in `astro.config.ts`). Renaming requires updating that import.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- `src/pages/og-image/` — consumes these fonts.
|
||||||
|
|
||||||
|
<!-- MANUAL: -->
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,43 @@
|
|||||||
|
<!-- Parent: ../AGENTS.md -->
|
||||||
|
<!-- Generated: 2026-06-30 | Updated: 2026-06-30 -->
|
||||||
|
|
||||||
|
# components
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Reusable `.astro` components for layout, content rendering, theming, search, and SEO.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `BaseHead.astro` | All `<head>` tags: title, meta, canonical, Open Graph/Twitter, favicons, RSS auto-discovery, webmentions. |
|
||||||
|
| `ExperienceTags.astro` | Renders linked chips for an entry's `experiences` refs; resolves them with `getEntries` and links to `/experience/<id>`. Used by Masthead, Project, and the speaking page. |
|
||||||
|
| `YouTube.astro` | Responsive YouTube embed (`youtube-nocookie.com/embed/<id>`). Used on speaking detail pages. No external player dependency. |
|
||||||
|
| `FormattedDate.astro` | `<time>` element formatted per `siteConfig.date`. |
|
||||||
|
| `Paginator.astro` | Prev/next pagination links. |
|
||||||
|
| `Search.astro` | Pagefind search UI (client-side, works only after build). |
|
||||||
|
| `ThemeProvider.astro` / `ThemeToggle.astro` | Dark/light mode state + toggle button. |
|
||||||
|
| `SkipLink.astro` | Accessibility skip-to-content link. |
|
||||||
|
|
||||||
|
## Subdirectories
|
||||||
|
| Directory | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `blog/` | Writing/post rendering: masthead, preview, table of contents, webmentions (see `blog/AGENTS.md`). |
|
||||||
|
| `layout/` | Site chrome: header + footer (see `layout/AGENTS.md`). |
|
||||||
|
| `project/` | Project rendering (see `project/AGENTS.md`). |
|
||||||
|
|
||||||
|
## For AI Agents
|
||||||
|
|
||||||
|
### Working In This Directory
|
||||||
|
- Components consume Content Collection types like `CollectionEntry<"writing">`; keep prop types in sync with `src/content.config.ts`.
|
||||||
|
- A CSS `filter` (e.g. `grayscale`) on an element makes it a containing block for absolutely-positioned children — this matters for the header logo (see `layout/AGENTS.md`).
|
||||||
|
|
||||||
|
### Common Patterns
|
||||||
|
- Polymorphic `as` prop (via `astro/types` `Polymorphic`) for heading-level control (e.g. `PostPreview`, `Project`).
|
||||||
|
- `data-pagefind-body` marks content for the search index.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### External
|
||||||
|
- `astro-icon` (`mdi` icon set) for SVG icons.
|
||||||
|
|
||||||
|
<!-- MANUAL: -->
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useContext } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
|
|
||||||
import { AppContext } from '@/app/providers'
|
|
||||||
import { Container } from '@/components/Container'
|
|
||||||
import { Prose } from '@/components/Prose'
|
|
||||||
import { type ArticleWithSlug } from '@/lib/articles'
|
|
||||||
import { formatDate } from '@/lib/formatDate'
|
|
||||||
|
|
||||||
function ArrowLeftIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
|
||||||
<path
|
|
||||||
d="M7.25 11.25 3.75 8m0 0 3.5-3.25M3.75 8h8.5"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ArticleLayout({
|
|
||||||
article,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
article: ArticleWithSlug
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
let router = useRouter()
|
|
||||||
let { previousPathname } = useContext(AppContext)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container className="mt-16 lg:mt-32">
|
|
||||||
<div className="xl:relative">
|
|
||||||
<div className="mx-auto max-w-2xl">
|
|
||||||
{previousPathname && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
aria-label="Go back to articles"
|
|
||||||
className="group mb-8 flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-md shadow-zinc-800/5 ring-1 ring-zinc-900/5 transition dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0 dark:ring-white/10 dark:hover:border-zinc-700 dark:hover:ring-white/20 lg:absolute lg:-left-5 lg:-mt-2 lg:mb-0 xl:-top-1.5 xl:left-0 xl:mt-0"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="h-4 w-4 stroke-zinc-500 transition group-hover:stroke-zinc-700 dark:stroke-zinc-500 dark:group-hover:stroke-zinc-400" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<article>
|
|
||||||
<header className="flex flex-col">
|
|
||||||
<h1 className="mt-6 text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
|
||||||
{article.title}
|
|
||||||
</h1>
|
|
||||||
<time
|
|
||||||
dateTime={article.date}
|
|
||||||
className="order-first flex items-center text-base text-zinc-400 dark:text-zinc-500"
|
|
||||||
>
|
|
||||||
<span className="h-4 w-0.5 rounded-full bg-zinc-200 dark:bg-zinc-500" />
|
|
||||||
<span className="ml-3">{formatDate(article.date)}</span>
|
|
||||||
</time>
|
|
||||||
</header>
|
|
||||||
<Prose className="mt-8" data-mdx-content>
|
|
||||||
{children}
|
|
||||||
</Prose>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
import { WEBMENTION_PINGBACK, WEBMENTION_URL } from "astro:env/client";
|
||||||
|
import { siteConfig, socialLinks } from "@/site.config";
|
||||||
|
import type { SiteMeta } from "@/types";
|
||||||
|
import "@/styles/global.css";
|
||||||
|
|
||||||
|
type Props = SiteMeta;
|
||||||
|
|
||||||
|
const { articleDate, description, keywords, ogImage, schema, title } = Astro.props;
|
||||||
|
|
||||||
|
const titleSeparator = "•";
|
||||||
|
const siteTitle = `${title} ${titleSeparator} ${siteConfig.title}`;
|
||||||
|
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||||
|
const socialImageURL = new URL(ogImage ? ogImage : "/social-card.png", Astro.url).href;
|
||||||
|
|
||||||
|
const xLink = socialLinks.find((s) => s.friendlyName === "X")?.link;
|
||||||
|
const twitterHandle = xLink ? `@${xLink.split("/").pop()}` : undefined;
|
||||||
|
const schemas = schema ? (Array.isArray(schema) ? schema : [schema]) : [];
|
||||||
|
---
|
||||||
|
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||||
|
<title>{siteTitle}</title>
|
||||||
|
|
||||||
|
{/* Icons */}
|
||||||
|
<link href="/icon.svg" rel="icon" type="image/svg+xml" />
|
||||||
|
{
|
||||||
|
import.meta.env.PROD && (
|
||||||
|
<>
|
||||||
|
{/* Favicon & Apple Icon, Generated at build */}
|
||||||
|
<link rel="icon" href="/favicon-32x32.png" type="image/png" />
|
||||||
|
<link href="/icons/apple-touch-icon.png" rel="apple-touch-icon" />
|
||||||
|
{/* Manifest */}
|
||||||
|
<link href="/manifest.webmanifest" rel="manifest" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Canonical URL */}
|
||||||
|
<link href={canonicalURL} rel="canonical" />
|
||||||
|
|
||||||
|
{/* Primary Meta Tags */}
|
||||||
|
<meta content={siteTitle} name="title" />
|
||||||
|
<meta content={description} name="description" />
|
||||||
|
{keywords && keywords.length > 0 && <meta content={keywords.join(", ")} name="keywords" />}
|
||||||
|
<meta content={siteConfig.author} name="author" />
|
||||||
|
|
||||||
|
{/* Tell crawlers to index and allow full image/snippet/video previews */}
|
||||||
|
<meta
|
||||||
|
content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"
|
||||||
|
name="robots"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Open Graph / Facebook */}
|
||||||
|
<meta content={articleDate ? "article" : "website"} property="og:type" />
|
||||||
|
<meta content={title} property="og:title" />
|
||||||
|
<meta content={description} property="og:description" />
|
||||||
|
<meta content={canonicalURL} property="og:url" />
|
||||||
|
<meta content={siteConfig.title} property="og:site_name" />
|
||||||
|
<meta content={siteConfig.ogLocale} property="og:locale" />
|
||||||
|
<meta content={socialImageURL} property="og:image" />
|
||||||
|
<meta content={`${title} — ${siteConfig.title}`} property="og:image:alt" />
|
||||||
|
<meta content="1200" property="og:image:width" />
|
||||||
|
<meta content="630" property="og:image:height" />
|
||||||
|
{
|
||||||
|
articleDate && (
|
||||||
|
<>
|
||||||
|
<meta content={siteConfig.author} property="article:author" />
|
||||||
|
<meta content={articleDate} property="article:published_time" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Twitter */}
|
||||||
|
<meta content="summary_large_image" property="twitter:card" />
|
||||||
|
<meta content={canonicalURL} property="twitter:url" />
|
||||||
|
<meta content={title} property="twitter:title" />
|
||||||
|
<meta content={description} property="twitter:description" />
|
||||||
|
<meta content={socialImageURL} property="twitter:image" />
|
||||||
|
{twitterHandle && <meta content={twitterHandle} property="twitter:creator" />}
|
||||||
|
{twitterHandle && <meta content={twitterHandle} property="twitter:site" />}
|
||||||
|
|
||||||
|
{/* Structured data (schema.org JSON-LD) */}
|
||||||
|
{schemas.map((s) => <script type="application/ld+json" is:inline set:html={JSON.stringify(s)} />)}
|
||||||
|
|
||||||
|
{/* Sitemap */}
|
||||||
|
<link href="/sitemap-index.xml" rel="sitemap" />
|
||||||
|
|
||||||
|
{/* RSS auto-discovery */}
|
||||||
|
<link href="/rss.xml" title="Writing" rel="alternate" type="application/rss+xml" />
|
||||||
|
<link href="/projects/rss.xml" title="Projects" rel="alternate" type="application/rss+xml" />
|
||||||
|
|
||||||
|
{/* Webmentions */}
|
||||||
|
{
|
||||||
|
WEBMENTION_URL && (
|
||||||
|
<>
|
||||||
|
<link href={WEBMENTION_URL} rel="webmention" />
|
||||||
|
{WEBMENTION_PINGBACK && <link href={WEBMENTION_PINGBACK} rel="pingback" />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<meta content={Astro.generator} name="generator" />
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import Link from 'next/link'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
const variantStyles = {
|
|
||||||
primary:
|
|
||||||
'bg-zinc-800 font-semibold text-zinc-100 hover:bg-zinc-700 active:bg-zinc-800 active:text-zinc-100/70 dark:bg-zinc-700 dark:hover:bg-zinc-600 dark:active:bg-zinc-700 dark:active:text-zinc-100/70',
|
|
||||||
secondary:
|
|
||||||
'bg-zinc-50 font-medium text-zinc-900 hover:bg-zinc-100 active:bg-zinc-100 active:text-zinc-900/60 dark:bg-zinc-800/50 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:active:bg-zinc-800/50 dark:active:text-zinc-50/70',
|
|
||||||
}
|
|
||||||
|
|
||||||
type ButtonProps = {
|
|
||||||
variant?: keyof typeof variantStyles
|
|
||||||
} & (
|
|
||||||
| (React.ComponentPropsWithoutRef<'button'> & { href?: undefined })
|
|
||||||
| React.ComponentPropsWithoutRef<typeof Link>
|
|
||||||
)
|
|
||||||
|
|
||||||
export function Button({
|
|
||||||
variant = 'primary',
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ButtonProps) {
|
|
||||||
className = clsx(
|
|
||||||
'inline-flex items-center gap-2 justify-center rounded-md py-2 px-3 text-sm outline-offset-2 transition active:transition-none',
|
|
||||||
variantStyles[variant],
|
|
||||||
className,
|
|
||||||
)
|
|
||||||
|
|
||||||
return typeof props.href === 'undefined' ? (
|
|
||||||
<button className={className} {...props} />
|
|
||||||
) : (
|
|
||||||
<Link className={className} {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import Link from 'next/link'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
function ChevronRightIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
|
||||||
<path
|
|
||||||
d="M6.75 5.75 9.25 8l-2.5 2.25"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Card<T extends React.ElementType = 'div'>({
|
|
||||||
as,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
}: Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'className'> & {
|
|
||||||
as?: T
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
let Component = as ?? 'div'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Component
|
|
||||||
className={clsx(className, 'group relative flex flex-col items-start')}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Component>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Card.Link = function CardLink({
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentPropsWithoutRef<typeof Link>) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="absolute -inset-x-4 -inset-y-6 z-0 scale-95 bg-zinc-50 opacity-0 transition group-hover:scale-100 group-hover:opacity-100 dark:bg-zinc-800/50 sm:-inset-x-6 sm:rounded-2xl" />
|
|
||||||
<Link {...props}>
|
|
||||||
<span className="absolute -inset-x-4 -inset-y-6 z-20 sm:-inset-x-6 sm:rounded-2xl" />
|
|
||||||
<span className="relative z-10">{children}</span>
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Card.Title = function CardTitle<T extends React.ElementType = 'h2'>({
|
|
||||||
as,
|
|
||||||
href,
|
|
||||||
children,
|
|
||||||
}: Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'href'> & {
|
|
||||||
as?: T
|
|
||||||
href?: string
|
|
||||||
}) {
|
|
||||||
let Component = as ?? 'h2'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Component className="text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
|
|
||||||
{href ? <Card.Link href={href}>{children}</Card.Link> : children}
|
|
||||||
</Component>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Card.Description = function CardDescription({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<p className="relative z-10 mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Card.Cta = function CardCta({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="relative z-10 mt-4 flex items-center text-sm font-medium text-teal-500"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRightIcon className="ml-1 h-4 w-4 stroke-current" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Card.Eyebrow = function CardEyebrow<T extends React.ElementType = 'p'>({
|
|
||||||
as,
|
|
||||||
decorate = false,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'decorate'> & {
|
|
||||||
as?: T
|
|
||||||
decorate?: boolean
|
|
||||||
}) {
|
|
||||||
let Component = as ?? 'p'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Component
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'relative z-10 order-first mb-3 flex items-center text-sm text-zinc-400 dark:text-zinc-500',
|
|
||||||
decorate && 'pl-3.5',
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{decorate && (
|
|
||||||
<span
|
|
||||||
className="absolute inset-y-0 left-0 flex items-center"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<span className="h-4 w-0.5 rounded-full bg-zinc-200 dark:bg-zinc-500" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</Component>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { forwardRef } from 'react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
export const ContainerOuter = forwardRef<
|
|
||||||
React.ElementRef<'div'>,
|
|
||||||
React.ComponentPropsWithoutRef<'div'>
|
|
||||||
>(function OuterContainer({ className, children, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<div ref={ref} className={clsx('sm:px-8', className)} {...props}>
|
|
||||||
<div className="mx-auto w-full max-w-7xl lg:px-8">{children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ContainerInner = forwardRef<
|
|
||||||
React.ElementRef<'div'>,
|
|
||||||
React.ComponentPropsWithoutRef<'div'>
|
|
||||||
>(function InnerContainer({ className, children, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={clsx('relative px-4 sm:px-8 lg:px-12', className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="mx-auto max-w-2xl lg:max-w-5xl">{children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Container = forwardRef<
|
|
||||||
React.ElementRef<typeof ContainerOuter>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContainerOuter>
|
|
||||||
>(function Container({ children, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<ContainerOuter ref={ref} {...props}>
|
|
||||||
<ContainerInner>{children}</ContainerInner>
|
|
||||||
</ContainerOuter>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
import { getEntries } from "astro:content";
|
||||||
|
import type { CollectionEntry } from "astro:content";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
experiences: CollectionEntry<"projects" | "writing" | "speaking">["data"]["experiences"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { experiences } = Astro.props;
|
||||||
|
const entries = experiences.length ? await getEntries(experiences) : [];
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
entries.length > 0 && (
|
||||||
|
<ul class="flex flex-wrap gap-2">
|
||||||
|
{entries.map((exp) => (
|
||||||
|
<li>
|
||||||
|
<a class="cactus-link inline-block" href={`/experience/${exp.id}/`}>
|
||||||
|
{exp.data.title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
import { ContainerInner, ContainerOuter } from '@/components/Container'
|
|
||||||
|
|
||||||
function NavLink({
|
|
||||||
href,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
href: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className="transition hover:text-teal-500 dark:hover:text-teal-400"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
return (
|
|
||||||
<footer className="mt-32 flex-none">
|
|
||||||
<ContainerOuter>
|
|
||||||
<div className="border-t border-zinc-100 pb-16 pt-10 dark:border-zinc-700/40">
|
|
||||||
<ContainerInner>
|
|
||||||
<div className="flex flex-col items-center justify-between gap-6 sm:flex-row">
|
|
||||||
<div className="flex flex-wrap justify-center gap-x-6 gap-y-1 text-sm font-medium text-zinc-800 dark:text-zinc-200">
|
|
||||||
<NavLink href="/about">About</NavLink>
|
|
||||||
<NavLink href="/projects">Projects</NavLink>
|
|
||||||
<NavLink href="/speaking">Speaking</NavLink>
|
|
||||||
<NavLink href="/uses">Uses</NavLink>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-zinc-400 dark:text-zinc-500">
|
|
||||||
© {new Date().getFullYear()} Prad Nukala. All rights
|
|
||||||
reserved.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</ContainerInner>
|
|
||||||
</div>
|
|
||||||
</ContainerOuter>
|
|
||||||
</footer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
import type { HTMLAttributes } from "astro/types";
|
||||||
|
import { getFormattedDate } from "@/utils/date";
|
||||||
|
|
||||||
|
type Props = HTMLAttributes<"time"> & {
|
||||||
|
date: Date;
|
||||||
|
dateTimeOptions?: Intl.DateTimeFormatOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { date, dateTimeOptions, ...attrs } = Astro.props;
|
||||||
|
|
||||||
|
const postDate = getFormattedDate(date, dateTimeOptions);
|
||||||
|
const ISO = date.toISOString();
|
||||||
|
---
|
||||||
|
|
||||||
|
<time datetime={ISO} title={ISO} {...attrs}>{postDate}</time>
|
||||||
@@ -1,461 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Fragment, useEffect, useRef, useState } from 'react'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { usePathname } from 'next/navigation'
|
|
||||||
import { useTheme } from 'next-themes'
|
|
||||||
import { Popover, Transition } from '@headlessui/react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
import { Container } from '@/components/Container'
|
|
||||||
import avatarImage from '@/images/avatar.jpg'
|
|
||||||
|
|
||||||
function CloseIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
|
||||||
<path
|
|
||||||
d="m17.25 6.75-10.5 10.5M6.75 6.75l10.5 10.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChevronDownIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 8 6" aria-hidden="true" {...props}>
|
|
||||||
<path
|
|
||||||
d="M1.75 1.75 4 4.25l2.25-2.5"
|
|
||||||
fill="none"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SunIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path d="M8 12.25A4.25 4.25 0 0 1 12.25 8v0a4.25 4.25 0 0 1 4.25 4.25v0a4.25 4.25 0 0 1-4.25 4.25v0A4.25 4.25 0 0 1 8 12.25v0Z" />
|
|
||||||
<path
|
|
||||||
d="M12.25 3v1.5M21.5 12.25H20M18.791 18.791l-1.06-1.06M18.791 5.709l-1.06 1.06M12.25 20v1.5M4.5 12.25H3M6.77 6.77 5.709 5.709M6.77 17.73l-1.061 1.061"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MoonIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
|
||||||
<path
|
|
||||||
d="M17.25 16.22a6.937 6.937 0 0 1-9.47-9.47 7.451 7.451 0 1 0 9.47 9.47ZM12.75 7C17 7 17 2.75 17 2.75S17 7 21.25 7C17 7 17 11.25 17 11.25S17 7 12.75 7Z"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileNavItem({
|
|
||||||
href,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
href: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<li>
|
|
||||||
<Popover.Button as={Link} href={href} className="block py-2">
|
|
||||||
{children}
|
|
||||||
</Popover.Button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileNavigation(
|
|
||||||
props: React.ComponentPropsWithoutRef<typeof Popover>,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<Popover {...props}>
|
|
||||||
<Popover.Button className="group flex items-center rounded-full bg-white/90 px-4 py-2 text-sm font-medium text-zinc-800 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur dark:bg-zinc-800/90 dark:text-zinc-200 dark:ring-white/10 dark:hover:ring-white/20">
|
|
||||||
Menu
|
|
||||||
<ChevronDownIcon className="ml-3 h-auto w-2 stroke-zinc-500 group-hover:stroke-zinc-700 dark:group-hover:stroke-zinc-400" />
|
|
||||||
</Popover.Button>
|
|
||||||
<Transition.Root>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="duration-150 ease-out"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="duration-150 ease-in"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<Popover.Overlay className="fixed inset-0 z-50 bg-zinc-800/40 backdrop-blur-sm dark:bg-black/80" />
|
|
||||||
</Transition.Child>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="duration-150 ease-out"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="duration-150 ease-in"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<Popover.Panel
|
|
||||||
focus
|
|
||||||
className="fixed inset-x-4 top-8 z-50 origin-top rounded-3xl bg-white p-8 ring-1 ring-zinc-900/5 dark:bg-zinc-900 dark:ring-zinc-800"
|
|
||||||
>
|
|
||||||
<div className="flex flex-row-reverse items-center justify-between">
|
|
||||||
<Popover.Button aria-label="Close menu" className="-m-1 p-1">
|
|
||||||
<CloseIcon className="h-6 w-6 text-zinc-500 dark:text-zinc-400" />
|
|
||||||
</Popover.Button>
|
|
||||||
<h2 className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
|
|
||||||
Navigation
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<nav className="mt-6">
|
|
||||||
<ul className="-my-2 divide-y divide-zinc-100 text-base text-zinc-800 dark:divide-zinc-100/5 dark:text-zinc-300">
|
|
||||||
<MobileNavItem href="/about">About</MobileNavItem>
|
|
||||||
<MobileNavItem href="/articles">Writing</MobileNavItem>
|
|
||||||
<MobileNavItem href="/projects">Projects</MobileNavItem>
|
|
||||||
<MobileNavItem href="/speaking">Speaking</MobileNavItem>
|
|
||||||
<MobileNavItem href="/uses">Uses</MobileNavItem>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</Popover.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</Transition.Root>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavItem({
|
|
||||||
href,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
href: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
let isActive = usePathname() === href
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className={clsx(
|
|
||||||
'relative block px-3 py-2 transition',
|
|
||||||
isActive
|
|
||||||
? 'text-teal-500 dark:text-teal-400'
|
|
||||||
: 'hover:text-teal-500 dark:hover:text-teal-400',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{isActive && (
|
|
||||||
<span className="absolute inset-x-1 -bottom-px h-px bg-gradient-to-r from-teal-500/0 via-teal-500/40 to-teal-500/0 dark:from-teal-400/0 dark:via-teal-400/40 dark:to-teal-400/0" />
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DesktopNavigation(props: React.ComponentPropsWithoutRef<'nav'>) {
|
|
||||||
return (
|
|
||||||
<nav {...props}>
|
|
||||||
<ul className="flex rounded-full bg-white/90 px-3 text-sm font-medium text-zinc-800 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur dark:bg-zinc-800/90 dark:text-zinc-200 dark:ring-white/10">
|
|
||||||
<NavItem href="/about">About</NavItem>
|
|
||||||
<NavItem href="/articles">Writing</NavItem>
|
|
||||||
<NavItem href="/projects">Projects</NavItem>
|
|
||||||
<NavItem href="/speaking">Speaking</NavItem>
|
|
||||||
<NavItem href="/uses">Uses</NavItem>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThemeToggle() {
|
|
||||||
let { resolvedTheme, setTheme } = useTheme()
|
|
||||||
let otherTheme = resolvedTheme === 'dark' ? 'light' : 'dark'
|
|
||||||
let [mounted, setMounted] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={mounted ? `Switch to ${otherTheme} theme` : 'Toggle theme'}
|
|
||||||
className="group rounded-full bg-white/90 px-3 py-2 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur transition dark:bg-zinc-800/90 dark:ring-white/10 dark:hover:ring-white/20"
|
|
||||||
onClick={() => setTheme(otherTheme)}
|
|
||||||
>
|
|
||||||
<SunIcon className="h-6 w-6 fill-zinc-100 stroke-zinc-500 transition group-hover:fill-zinc-200 group-hover:stroke-zinc-700 dark:hidden [@media(prefers-color-scheme:dark)]:fill-teal-50 [@media(prefers-color-scheme:dark)]:stroke-teal-500 [@media(prefers-color-scheme:dark)]:group-hover:fill-teal-50 [@media(prefers-color-scheme:dark)]:group-hover:stroke-teal-600" />
|
|
||||||
<MoonIcon className="hidden h-6 w-6 fill-zinc-700 stroke-zinc-500 transition dark:block [@media(prefers-color-scheme:dark)]:group-hover:stroke-zinc-400 [@media_not_(prefers-color-scheme:dark)]:fill-teal-400/10 [@media_not_(prefers-color-scheme:dark)]:stroke-teal-500" />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(number: number, a: number, b: number) {
|
|
||||||
let min = Math.min(a, b)
|
|
||||||
let max = Math.max(a, b)
|
|
||||||
return Math.min(Math.max(number, min), max)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AvatarContainer({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentPropsWithoutRef<'div'>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'h-10 w-10 rounded-full bg-white/90 p-0.5 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur dark:bg-zinc-800/90 dark:ring-white/10',
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Avatar({
|
|
||||||
large = false,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: Omit<React.ComponentPropsWithoutRef<typeof Link>, 'href'> & {
|
|
||||||
large?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
aria-label="Home"
|
|
||||||
className={clsx(className, 'pointer-events-auto')}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={avatarImage}
|
|
||||||
alt=""
|
|
||||||
sizes={large ? '4rem' : '2.25rem'}
|
|
||||||
className={clsx(
|
|
||||||
'rounded-full bg-zinc-100 object-cover dark:bg-zinc-800',
|
|
||||||
large ? 'h-16 w-16' : 'h-9 w-9',
|
|
||||||
)}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Header() {
|
|
||||||
let isHomePage = usePathname() === '/'
|
|
||||||
|
|
||||||
let headerRef = useRef<React.ElementRef<'div'>>(null)
|
|
||||||
let avatarRef = useRef<React.ElementRef<'div'>>(null)
|
|
||||||
let isInitial = useRef(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let downDelay = avatarRef.current?.offsetTop ?? 0
|
|
||||||
let upDelay = 64
|
|
||||||
|
|
||||||
function setProperty(property: string, value: string) {
|
|
||||||
document.documentElement.style.setProperty(property, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeProperty(property: string) {
|
|
||||||
document.documentElement.style.removeProperty(property)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHeaderStyles() {
|
|
||||||
if (!headerRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let { top, height } = headerRef.current.getBoundingClientRect()
|
|
||||||
let scrollY = clamp(
|
|
||||||
window.scrollY,
|
|
||||||
0,
|
|
||||||
document.body.scrollHeight - window.innerHeight,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isInitial.current) {
|
|
||||||
setProperty('--header-position', 'sticky')
|
|
||||||
}
|
|
||||||
|
|
||||||
setProperty('--content-offset', `${downDelay}px`)
|
|
||||||
|
|
||||||
if (isInitial.current || scrollY < downDelay) {
|
|
||||||
setProperty('--header-height', `${downDelay + height}px`)
|
|
||||||
setProperty('--header-mb', `${-downDelay}px`)
|
|
||||||
} else if (top + height < -upDelay) {
|
|
||||||
let offset = Math.max(height, scrollY - upDelay)
|
|
||||||
setProperty('--header-height', `${offset}px`)
|
|
||||||
setProperty('--header-mb', `${height - offset}px`)
|
|
||||||
} else if (top === 0) {
|
|
||||||
setProperty('--header-height', `${scrollY + height}px`)
|
|
||||||
setProperty('--header-mb', `${-scrollY}px`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (top === 0 && scrollY > 0 && scrollY >= downDelay) {
|
|
||||||
setProperty('--header-inner-position', 'fixed')
|
|
||||||
removeProperty('--header-top')
|
|
||||||
removeProperty('--avatar-top')
|
|
||||||
} else {
|
|
||||||
removeProperty('--header-inner-position')
|
|
||||||
setProperty('--header-top', '0px')
|
|
||||||
setProperty('--avatar-top', '0px')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAvatarStyles() {
|
|
||||||
if (!isHomePage) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let fromScale = 1
|
|
||||||
let toScale = 36 / 64
|
|
||||||
let fromX = 0
|
|
||||||
let toX = 2 / 16
|
|
||||||
|
|
||||||
let scrollY = downDelay - window.scrollY
|
|
||||||
|
|
||||||
let scale = (scrollY * (fromScale - toScale)) / downDelay + toScale
|
|
||||||
scale = clamp(scale, fromScale, toScale)
|
|
||||||
|
|
||||||
let x = (scrollY * (fromX - toX)) / downDelay + toX
|
|
||||||
x = clamp(x, fromX, toX)
|
|
||||||
|
|
||||||
setProperty(
|
|
||||||
'--avatar-image-transform',
|
|
||||||
`translate3d(${x}rem, 0, 0) scale(${scale})`,
|
|
||||||
)
|
|
||||||
|
|
||||||
let borderScale = 1 / (toScale / scale)
|
|
||||||
let borderX = (-toX + x) * borderScale
|
|
||||||
let borderTransform = `translate3d(${borderX}rem, 0, 0) scale(${borderScale})`
|
|
||||||
|
|
||||||
setProperty('--avatar-border-transform', borderTransform)
|
|
||||||
setProperty('--avatar-border-opacity', scale === toScale ? '1' : '0')
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStyles() {
|
|
||||||
updateHeaderStyles()
|
|
||||||
updateAvatarStyles()
|
|
||||||
isInitial.current = false
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStyles()
|
|
||||||
window.addEventListener('scroll', updateStyles, { passive: true })
|
|
||||||
window.addEventListener('resize', updateStyles)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('scroll', updateStyles)
|
|
||||||
window.removeEventListener('resize', updateStyles)
|
|
||||||
}
|
|
||||||
}, [isHomePage])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<header
|
|
||||||
className="pointer-events-none relative z-50 flex flex-none flex-col"
|
|
||||||
style={{
|
|
||||||
height: 'var(--header-height)',
|
|
||||||
marginBottom: 'var(--header-mb)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isHomePage && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
ref={avatarRef}
|
|
||||||
className="order-last mt-[calc(theme(spacing.16)-theme(spacing.3))]"
|
|
||||||
/>
|
|
||||||
<Container
|
|
||||||
className="top-0 order-last -mb-3 pt-3"
|
|
||||||
style={{
|
|
||||||
position:
|
|
||||||
'var(--header-position)' as React.CSSProperties['position'],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="top-[var(--avatar-top,theme(spacing.3))] w-full"
|
|
||||||
style={{
|
|
||||||
position:
|
|
||||||
'var(--header-inner-position)' as React.CSSProperties['position'],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<AvatarContainer
|
|
||||||
className="absolute left-0 top-3 origin-left transition-opacity"
|
|
||||||
style={{
|
|
||||||
opacity: 'var(--avatar-border-opacity, 0)',
|
|
||||||
transform: 'var(--avatar-border-transform)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Avatar
|
|
||||||
large
|
|
||||||
className="block h-16 w-16 origin-left"
|
|
||||||
style={{ transform: 'var(--avatar-image-transform)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
ref={headerRef}
|
|
||||||
className="top-0 z-10 h-16 pt-6"
|
|
||||||
style={{
|
|
||||||
position:
|
|
||||||
'var(--header-position)' as React.CSSProperties['position'],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Container
|
|
||||||
className="top-[var(--header-top,theme(spacing.6))] w-full"
|
|
||||||
style={{
|
|
||||||
position:
|
|
||||||
'var(--header-inner-position)' as React.CSSProperties['position'],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="relative flex gap-4">
|
|
||||||
<div className="flex flex-1">
|
|
||||||
{!isHomePage && (
|
|
||||||
<AvatarContainer>
|
|
||||||
<Avatar />
|
|
||||||
</AvatarContainer>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 justify-end md:justify-center">
|
|
||||||
<MobileNavigation className="pointer-events-auto md:hidden" />
|
|
||||||
<DesktopNavigation className="pointer-events-auto hidden md:block" />
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end md:flex-1">
|
|
||||||
<div className="pointer-events-auto">
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{isHomePage && (
|
|
||||||
<div
|
|
||||||
className="flex-none"
|
|
||||||
style={{ height: 'var(--content-offset)' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Footer } from '@/components/Footer'
|
|
||||||
import { Header } from '@/components/Header'
|
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="fixed inset-0 flex justify-center sm:px-8">
|
|
||||||
<div className="flex w-full max-w-7xl lg:px-8">
|
|
||||||
<div className="w-full bg-white ring-1 ring-zinc-100 dark:bg-zinc-900 dark:ring-zinc-300/20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative flex w-full flex-col">
|
|
||||||
<Header />
|
|
||||||
<main className="flex-auto">{children}</main>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
import type { PaginationLink } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nextUrl?: PaginationLink;
|
||||||
|
prevUrl?: PaginationLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { nextUrl, prevUrl } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
(prevUrl || nextUrl) && (
|
||||||
|
<nav class="mt-8 flex items-center gap-x-4">
|
||||||
|
{prevUrl && (
|
||||||
|
<a class="hover:text-accent me-auto py-2" href={prevUrl.url}>
|
||||||
|
{prevUrl.srLabel && <span class="sr-only">{prevUrl.srLabel}</span>}
|
||||||
|
{prevUrl.text ? prevUrl.text : "Previous"}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{nextUrl && (
|
||||||
|
<a class="hover:text-accent ms-auto py-2" href={nextUrl.url}>
|
||||||
|
{nextUrl.srLabel && <span class="sr-only">{nextUrl.srLabel}</span>}
|
||||||
|
{nextUrl.text ? nextUrl.text : "Next"}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
export function Prose({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentPropsWithoutRef<'div'>) {
|
|
||||||
return (
|
|
||||||
<div className={clsx(className, 'prose dark:prose-invert')} {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
import "@/styles/blocks/search.css";
|
||||||
|
---
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
if (import.meta.env.DEV) return;
|
||||||
|
const onIdle = window.requestIdleCallback || ((cb) => setTimeout(cb, 1));
|
||||||
|
onIdle(async () => {
|
||||||
|
await import("@pagefind/component-ui");
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<pagefind-config
|
||||||
|
baseUrl={`${import.meta.env.BASE_URL}`}
|
||||||
|
bundle-path={`${import.meta.env.BASE_URL.replace(/\/$/, "")}/pagefind/`}></pagefind-config>
|
||||||
|
<pagefind-modal-trigger id="search" class="ms-auto" hide-shortcut compact></pagefind-modal-trigger>
|
||||||
|
<pagefind-modal></pagefind-modal>
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useId } from 'react'
|
|
||||||
|
|
||||||
export function Section({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
let id = useId()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
aria-labelledby={id}
|
|
||||||
className="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40"
|
|
||||||
>
|
|
||||||
<div className="grid max-w-3xl grid-cols-1 items-baseline gap-y-8 md:grid-cols-4">
|
|
||||||
<h2
|
|
||||||
id={id}
|
|
||||||
className="text-sm font-semibold text-zinc-800 dark:text-zinc-100"
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<div className="md:col-span-3">{children}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Container } from '@/components/Container'
|
|
||||||
|
|
||||||
export function SimpleLayout({
|
|
||||||
title,
|
|
||||||
intro,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
intro: string
|
|
||||||
children?: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Container className="mt-16 sm:mt-32">
|
|
||||||
<header className="max-w-2xl">
|
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-6 text-base text-zinc-600 dark:text-zinc-400">
|
|
||||||
{intro}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
{children && <div className="mt-16 sm:mt-20">{children}</div>}
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<a class="sr-only focus:not-sr-only focus:fixed focus:start-1 focus:top-1.5" href="#main"
|
||||||
|
>skip to content
|
||||||
|
</a>
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
export function TwitterIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
|
||||||
<path d="M20.055 7.983c.011.174.011.347.011.523 0 5.338-3.92 11.494-11.09 11.494v-.003A10.755 10.755 0 0 1 3 18.186c.308.038.618.057.928.058a7.655 7.655 0 0 0 4.841-1.733c-1.668-.032-3.13-1.16-3.642-2.805a3.753 3.753 0 0 0 1.76-.07C5.07 13.256 3.76 11.6 3.76 9.676v-.05a3.77 3.77 0 0 0 1.77.505C3.816 8.945 3.288 6.583 4.322 4.737c1.98 2.524 4.9 4.058 8.034 4.22a4.137 4.137 0 0 1 1.128-3.86A3.807 3.807 0 0 1 19 5.274a7.657 7.657 0 0 0 2.475-.98c-.29.934-.9 1.729-1.713 2.233A7.54 7.54 0 0 0 22 5.89a8.084 8.084 0 0 1-1.945 2.093Z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MediumIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 64 64" aria-hidden="true" {...props}>
|
|
||||||
<path d="M 18.998047 15 A 17.002 17.002 0 0 0 18.998047 49.003906 A 17.002 17.002 0 0 0 18.998047 15 z M 45.498047 16 A 8.502 16.002 0 0 0 45.498047 48.003906 A 8.502 16.002 0 0 0 45.498047 16 z M 58.5 17 A 3.5 15.002 0 1 0 58.5 47.003906 A 3.5 15.002 0 1 0 58.5 17 z"></path>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InstagramIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
|
||||||
<path d="M12 3c-2.444 0-2.75.01-3.71.054-.959.044-1.613.196-2.185.418A4.412 4.412 0 0 0 4.51 4.511c-.5.5-.809 1.002-1.039 1.594-.222.572-.374 1.226-.418 2.184C3.01 9.25 3 9.556 3 12s.01 2.75.054 3.71c.044.959.196 1.613.418 2.185.23.592.538 1.094 1.039 1.595.5.5 1.002.808 1.594 1.038.572.222 1.226.374 2.184.418C9.25 20.99 9.556 21 12 21s2.75-.01 3.71-.054c.959-.044 1.613-.196 2.185-.419a4.412 4.412 0 0 0 1.595-1.038c.5-.5.808-1.002 1.038-1.594.222-.572.374-1.226.418-2.184.044-.96.054-1.267.054-3.711s-.01-2.75-.054-3.71c-.044-.959-.196-1.613-.419-2.185A4.412 4.412 0 0 0 19.49 4.51c-.5-.5-1.002-.809-1.594-1.039-.572-.222-1.226-.374-2.184-.418C14.75 3.01 14.444 3 12 3Zm0 1.622c2.403 0 2.688.009 3.637.052.877.04 1.354.187 1.67.31.421.163.72.358 1.036.673.315.315.51.615.673 1.035.123.317.27.794.31 1.671.043.95.052 1.234.052 3.637s-.009 2.688-.052 3.637c-.04.877-.187 1.354-.31 1.67-.163.421-.358.72-.673 1.036a2.79 2.79 0 0 1-1.035.673c-.317.123-.794.27-1.671.31-.95.043-1.234.052-3.637.052s-2.688-.009-3.637-.052c-.877-.04-1.354-.187-1.67-.31a2.789 2.789 0 0 1-1.036-.673 2.79 2.79 0 0 1-.673-1.035c-.123-.317-.27-.794-.31-1.671-.043-.95-.052-1.234-.052-3.637s.009-2.688.052-3.637c.04-.877.187-1.354.31-1.67.163-.421.358-.72.673-1.036.315-.315.615-.51 1.035-.673.317-.123.794-.27 1.671-.31.95-.043 1.234-.052 3.637-.052Z" />
|
|
||||||
<path d="M12 15a3 3 0 1 1 0-6 3 3 0 0 1 0 6Zm0-7.622a4.622 4.622 0 1 0 0 9.244 4.622 4.622 0 0 0 0-9.244Zm5.884-.182a1.08 1.08 0 1 1-2.16 0 1.08 1.08 0 0 1 2.16 0Z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GitHubIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
clipRule="evenodd"
|
|
||||||
d="M12 2C6.475 2 2 6.588 2 12.253c0 4.537 2.862 8.369 6.838 9.727.5.09.687-.218.687-.487 0-.243-.013-1.05-.013-1.91C7 20.059 6.35 18.957 6.15 18.38c-.113-.295-.6-1.205-1.025-1.448-.35-.192-.85-.667-.013-.68.788-.012 1.35.744 1.538 1.051.9 1.551 2.338 1.116 2.912.846.088-.666.35-1.115.638-1.371-2.225-.256-4.55-1.14-4.55-5.062 0-1.115.387-2.038 1.025-2.756-.1-.256-.45-1.307.1-2.717 0 0 .837-.269 2.75 1.051.8-.23 1.65-.346 2.5-.346.85 0 1.7.115 2.5.346 1.912-1.333 2.75-1.05 2.75-1.05.55 1.409.2 2.46.1 2.716.637.718 1.025 1.628 1.025 2.756 0 3.934-2.337 4.806-4.562 5.062.362.32.675.936.675 1.897 0 1.371-.013 2.473-.013 2.82 0 .268.188.589.688.486a10.039 10.039 0 0 0 4.932-3.74A10.447 10.447 0 0 0 22 12.253C22 6.588 17.525 2 12 2Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LinkedInIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
|
||||||
<path d="M18.335 18.339H15.67v-4.177c0-.996-.02-2.278-1.39-2.278-1.389 0-1.601 1.084-1.601 2.205v4.25h-2.666V9.75h2.56v1.17h.035c.358-.674 1.228-1.387 2.528-1.387 2.7 0 3.2 1.778 3.2 4.091v4.715zM7.003 8.575a1.546 1.546 0 01-1.548-1.549 1.548 1.548 0 111.547 1.549zm1.336 9.764H5.666V9.75H8.34v8.589zM19.67 3H4.329C3.593 3 3 3.58 3 4.297v15.406C3 20.42 3.594 21 4.328 21h15.338C20.4 21 21 20.42 21 19.703V4.297C21 3.58 20.4 3 19.666 3h.003z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
import { slugifyTag } from "@/data/tags";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tags: string[];
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tags, class: className } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
tags.length > 0 && (
|
||||||
|
<ul class:list={["flex flex-wrap gap-x-3 gap-y-1", className]}>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="text-muted hover:text-link text-xs before:content-['#']"
|
||||||
|
href={`/skills/${slugifyTag(tag)}/`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{/* Inlined to avoid FOUC. This is a parser blocking script. */}
|
||||||
|
<script is:inline>
|
||||||
|
const lightModePref = window.matchMedia("(prefers-color-scheme: light)");
|
||||||
|
|
||||||
|
function getUserPref() {
|
||||||
|
const storedTheme = typeof localStorage !== "undefined" && localStorage.getItem("theme");
|
||||||
|
return storedTheme || (lightModePref.matches ? "light" : "dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(newTheme) {
|
||||||
|
if (newTheme !== "light" && newTheme !== "dark") {
|
||||||
|
return console.warn(
|
||||||
|
`Invalid theme value '${newTheme}' received. Expected 'light' or 'dark'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
// root already set to newTheme, exit early
|
||||||
|
if (newTheme === root.getAttribute("data-theme")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.setAttribute("data-theme", newTheme);
|
||||||
|
|
||||||
|
if (typeof localStorage !== "undefined") {
|
||||||
|
localStorage.setItem("theme", newTheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial setup
|
||||||
|
setTheme(getUserPref());
|
||||||
|
|
||||||
|
// listen for pageshow event, browser restored page from bfcache
|
||||||
|
window.addEventListener("pageshow", (e) => {
|
||||||
|
if (e.persisted) {
|
||||||
|
setTheme(getUserPref());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// listen for theme-change custom event, fired in src/components/ThemeToggle.astro
|
||||||
|
document.addEventListener("theme-change", (e) => {
|
||||||
|
setTheme(e.detail.theme);
|
||||||
|
});
|
||||||
|
|
||||||
|
// listen for prefers-color-scheme change.
|
||||||
|
lightModePref.addEventListener("change", (e) => setTheme(e.matches ? "light" : "dark"));
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<theme-toggle class="ms-2 sm:ms-4">
|
||||||
|
<button class="hover:text-accent relative h-9 w-9 cursor-pointer p-2" type="button">
|
||||||
|
<span class="sr-only">Dark Theme</span>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="absolute inset-s-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-100 opacity-100 transition-all dark:scale-0 dark:opacity-0"
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
id="sun-svg"
|
||||||
|
stroke-width="1.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 18C15.3137 18 18 15.3137 18 12C18 8.68629 15.3137 6 12 6C8.68629 6 6 8.68629 6 12C6 15.3137 8.68629 18 12 18Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"></path>
|
||||||
|
<path d="M22 12L23 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
></path>
|
||||||
|
<path d="M12 2V1" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
<path d="M12 23V22" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
></path>
|
||||||
|
<path d="M20 20L19 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
></path>
|
||||||
|
<path d="M20 4L19 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
></path>
|
||||||
|
<path d="M4 20L5 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
></path>
|
||||||
|
<path d="M4 4L5 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
></path>
|
||||||
|
<path d="M1 12L2 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="absolute inset-s-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all dark:scale-100 dark:opacity-100"
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
id="moon-svg"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path d="M0 0h24v24H0z" fill="none" stroke="none"></path>
|
||||||
|
<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z"
|
||||||
|
></path>
|
||||||
|
<path d="M17 4a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2"></path>
|
||||||
|
<path d="M19 11h2m-1 -1v2"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</theme-toggle>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Note that if you fire the theme-change event outside of this component, it will not be reflected in the button's aria-checked attribute. You will need to add an event listener if you want that.
|
||||||
|
import { rootInDarkMode } from "@/utils/domElement";
|
||||||
|
|
||||||
|
class ThemeToggle extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const button = this.querySelector<HTMLButtonElement>("button");
|
||||||
|
|
||||||
|
if (button) {
|
||||||
|
// set aria role value
|
||||||
|
button.setAttribute("role", "switch");
|
||||||
|
button.setAttribute("aria-checked", String(rootInDarkMode()));
|
||||||
|
|
||||||
|
// button event
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
// invert theme
|
||||||
|
let themeChangeEvent = new CustomEvent("theme-change", {
|
||||||
|
detail: {
|
||||||
|
theme: rootInDarkMode() ? "light" : "dark",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// dispatch event -> ThemeProvider.astro
|
||||||
|
document.dispatchEvent(themeChangeEvent);
|
||||||
|
|
||||||
|
// set the aria-checked attribute
|
||||||
|
button.setAttribute("aria-checked", String(rootInDarkMode()));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("Theme Toggle: No button found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("theme-toggle", ThemeToggle);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, title = "YouTube video player" } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="aspect-video overflow-hidden rounded-md">
|
||||||
|
<iframe
|
||||||
|
class="h-full w-full border-0"
|
||||||
|
src={`https://www.youtube-nocookie.com/embed/${id}`}
|
||||||
|
title={title}
|
||||||
|
loading="lazy"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
referrerpolicy="strict-origin-when-cross-origin"
|
||||||
|
allowfullscreen></iframe>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!-- Parent: ../AGENTS.md -->
|
||||||
|
<!-- Generated: 2026-06-30 | Updated: 2026-06-30 -->
|
||||||
|
|
||||||
|
# blog
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Components for rendering Writing (the `writing` collection) — post mastheads, list previews, table of contents, and webmentions. (Folder is named `blog` for historical reasons; it serves the `/writing` section.)
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `Masthead.astro` | Post header: cover image, title, date, reading time, updated badge, and `ExperienceTags` (linked experience chips — replaces the old tag links). |
|
||||||
|
| `PostPreview.astro` | One-line writing entry (date + linked title) used in lists; links to `/writing/<id>`. |
|
||||||
|
| `TOC.astro` / `TOCHeading.astro` | Table of contents built from rendered headings. |
|
||||||
|
|
||||||
|
## Subdirectories
|
||||||
|
| Directory | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `webmentions/` | Webmention display components (see `webmentions/AGENTS.md`). |
|
||||||
|
|
||||||
|
## For AI Agents
|
||||||
|
|
||||||
|
### Working In This Directory
|
||||||
|
- These consume `CollectionEntry<"writing">`.
|
||||||
|
- `Masthead` renders `data.experiences` via `ExperienceTags`; it no longer links freeform `tags` to a `/tags` route (that route was removed).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- `@/components/ExperienceTags.astro`, `@/components/FormattedDate.astro`, `@/utils/generateToc`.
|
||||||
|
|
||||||
|
<!-- MANUAL: -->
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
import { Image } from "astro:assets";
|
||||||
|
import type { CollectionEntry } from "astro:content";
|
||||||
|
import ExperienceTags from "@/components/ExperienceTags.astro";
|
||||||
|
import FormattedDate from "@/components/FormattedDate.astro";
|
||||||
|
import TagList from "@/components/TagList.astro";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: CollectionEntry<"writing">;
|
||||||
|
readingTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
content: { data },
|
||||||
|
readingTime,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const dateTimeOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
month: "long",
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
data.coverImage && (
|
||||||
|
<div class="mb-6 aspect-video">
|
||||||
|
<Image
|
||||||
|
alt={data.coverImage.alt}
|
||||||
|
layout="constrained"
|
||||||
|
width={748}
|
||||||
|
height={420}
|
||||||
|
priority
|
||||||
|
src={data.coverImage.src}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{data.draft ? <span class="text-base text-red-500">(Draft)</span> : null}
|
||||||
|
<h1 class="title">
|
||||||
|
{data.title}
|
||||||
|
</h1>
|
||||||
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
|
<p class="font-semibold">
|
||||||
|
<FormattedDate date={data.publishDate} dateTimeOptions={dateTimeOptions} /> /{" "}
|
||||||
|
{readingTime}
|
||||||
|
</p>
|
||||||
|
{
|
||||||
|
data.updatedDate && (
|
||||||
|
<span class="bg-quote/5 text-quote rounded-lg px-2 py-1">
|
||||||
|
Updated:
|
||||||
|
<FormattedDate class="ms-1" date={data.updatedDate} dateTimeOptions={dateTimeOptions} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
!!data.experiences?.length && (
|
||||||
|
<div class="mt-3">
|
||||||
|
<ExperienceTags experiences={data.experiences} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<TagList tags={data.tags} class="mt-3" />
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
import type { CollectionEntry } from "astro:content";
|
||||||
|
import type { HTMLTag, Polymorphic } from "astro/types";
|
||||||
|
import FormattedDate from "@/components/FormattedDate.astro";
|
||||||
|
|
||||||
|
type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & {
|
||||||
|
post: CollectionEntry<"writing">;
|
||||||
|
withDesc?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { as: Tag = "div", post, withDesc = false } = Astro.props;
|
||||||
|
const { externalUrl, publisher } = post.data;
|
||||||
|
const href = externalUrl ?? `/writing/${post.id}/`;
|
||||||
|
---
|
||||||
|
|
||||||
|
<FormattedDate class="text-muted min-w-30 font-semibold" date={post.data.publishDate} />
|
||||||
|
<Tag>
|
||||||
|
{post.data.draft && <span class="text-red-500">(Draft) </span>}
|
||||||
|
<a
|
||||||
|
class="cactus-link"
|
||||||
|
href={href}
|
||||||
|
{...externalUrl ? { rel: "noopener noreferrer", target: "_blank" } : {}}
|
||||||
|
>
|
||||||
|
{post.data.title}
|
||||||
|
</a>
|
||||||
|
{
|
||||||
|
externalUrl && (
|
||||||
|
<span class="text-muted text-xs whitespace-nowrap">
|
||||||
|
{" ↗ "}
|
||||||
|
{publisher ?? "External"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Tag>
|
||||||
|
{withDesc && <q class="line-clamp-3 italic">{post.data.description}</q>}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
import type { MarkdownHeading } from "astro";
|
||||||
|
import { generateToc } from "@/utils/generateToc";
|
||||||
|
import TOCHeading from "./TOCHeading.astro";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
headings: MarkdownHeading[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { headings } = Astro.props;
|
||||||
|
|
||||||
|
const toc = generateToc(headings);
|
||||||
|
---
|
||||||
|
|
||||||
|
<details open class="lg:sticky lg:top-12 lg:order-2 lg:-me-32 lg:basis-64">
|
||||||
|
<summary class="title hover:marker:text-accent cursor-pointer text-lg">Table of Contents</summary>
|
||||||
|
<nav class="ms-4 lg:w-full">
|
||||||
|
<ol class="mt-4">
|
||||||
|
{toc.map((heading) => <TOCHeading heading={heading} />)}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</details>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
import type { TocItem } from "@/utils/generateToc";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
heading: TocItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
heading: { children, depth, slug, text },
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<li class={`${depth > 2 ? "ms-2" : ""}`}>
|
||||||
|
<a
|
||||||
|
class={`line-clamp-2 hover:text-accent ${depth <= 2 ? "mt-3" : "mt-2 text-xs"}`}
|
||||||
|
href={`#${slug}`}><span aria-hidden="true" class="me-0.5">#</span>{text}</a
|
||||||
|
>
|
||||||
|
{
|
||||||
|
!!children.length && (
|
||||||
|
<ol>
|
||||||
|
{children.map((subheading) => (
|
||||||
|
<Astro.self heading={subheading} />
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</li>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!-- Parent: ../AGENTS.md -->
|
||||||
|
<!-- Generated: 2026-06-30 | Updated: 2026-06-30 -->
|
||||||
|
|
||||||
|
# webmentions
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Display [webmentions](https://webmention.io/) (likes and comments) fetched for a post.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `index.astro` | Entry component; gates rendering on the `WEBMENTION_URL` env var and fetches mentions. |
|
||||||
|
| `Likes.astro` | Renders "like" webmentions. |
|
||||||
|
| `Comments.astro` | Renders comment/reply webmentions. |
|
||||||
|
|
||||||
|
## For AI Agents
|
||||||
|
|
||||||
|
### Working In This Directory
|
||||||
|
- Webmentions are optional and only active when the `WEBMENTION_*` client env vars are set (see `astro:env/client` usage in `BaseHead.astro`). Fetch/caching logic lives in `@/utils/webmentions.ts`.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- `@/utils/webmentions.ts`, `@/types` (webmention types).
|
||||||
|
|
||||||
|
<!-- MANUAL: -->
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
import { Image } from "astro:assets";
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
import type { WebmentionsChildren } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mentions: WebmentionsChildren[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mentions } = Astro.props;
|
||||||
|
|
||||||
|
const validComments = ["mention-of", "in-reply-to"];
|
||||||
|
|
||||||
|
const comments = mentions.filter(
|
||||||
|
(mention) => validComments.includes(mention["wm-property"]) && mention.content?.text,
|
||||||
|
);
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
!!comments.length && (
|
||||||
|
<div>
|
||||||
|
<p class="text-accent-2 mb-0">
|
||||||
|
<strong>{comments.length}</strong> Mention{comments.length > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
<ul class="divide-global-text/20 mt-0 divide-y ps-0" role="list">
|
||||||
|
{comments.map((mention) => (
|
||||||
|
<li class="p-comment h-cite my-0 flex items-start gap-x-5 py-5">
|
||||||
|
{mention.author?.photo && mention.author.photo !== "" ? (
|
||||||
|
mention.author.url && mention.author.url !== "" ? (
|
||||||
|
<a
|
||||||
|
class="u-author not-prose ring-global-text hover:ring-link focus-visible:ring-link shrink-0 overflow-hidden rounded-full ring-2 hover:ring-4 focus-visible:ring-4"
|
||||||
|
href={mention.author.url}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
title={mention.author.name}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
alt={mention.author?.name}
|
||||||
|
class="u-photo my-0 h-12 w-12"
|
||||||
|
height={48}
|
||||||
|
src={mention.author?.photo}
|
||||||
|
width={48}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
alt={mention.author?.name}
|
||||||
|
class="u-photo my-0 h-12 w-12 rounded-full"
|
||||||
|
height={48}
|
||||||
|
src={mention.author?.photo}
|
||||||
|
width={48}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
<div class="flex-auto">
|
||||||
|
<div class="p-author h-card flex items-center justify-between gap-x-2">
|
||||||
|
<p class="p-name text-accent-2 my-0 line-clamp-1 font-semibold">
|
||||||
|
{mention.author?.name}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
aria-labelledby="cmt-source"
|
||||||
|
class="u-url not-prose hover:text-link"
|
||||||
|
href={mention.url}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span class="hidden" id="cmt-source">
|
||||||
|
Visit the source of this webmention
|
||||||
|
</span>
|
||||||
|
<Icon
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5"
|
||||||
|
focusable="false"
|
||||||
|
name="mdi:open-in-new"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="comment-content mt-1 mb-0 [word-break:break-word] whitespace-pre-wrap">
|
||||||
|
{mention.content?.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
import { Image } from "astro:assets";
|
||||||
|
import type { WebmentionsChildren } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mentions: WebmentionsChildren[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mentions } = Astro.props;
|
||||||
|
const MAX_LIKES = 10;
|
||||||
|
|
||||||
|
const likes = mentions.filter((mention) => mention["wm-property"] === "like-of");
|
||||||
|
const likesToShow = likes
|
||||||
|
.filter((like) => like.author?.photo && like.author.photo !== "")
|
||||||
|
.slice(0, MAX_LIKES);
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
!!likes.length && (
|
||||||
|
<div>
|
||||||
|
<p class="text-accent-2 mb-0">
|
||||||
|
<strong>{likes.length}</strong>
|
||||||
|
{likes.length > 1 ? " People" : " Person"} liked this
|
||||||
|
</p>
|
||||||
|
{!!likesToShow.length && (
|
||||||
|
<ul class="flex list-none flex-wrap overflow-hidden ps-2" role="list">
|
||||||
|
{likesToShow.map((like) => (
|
||||||
|
<li class="p-like h-cite -ms-2">
|
||||||
|
<a
|
||||||
|
class="u-url not-prose ring-global-text hover:ring-link focus-visible:ring-link relative inline-block overflow-hidden rounded-full ring-2 hover:z-10 hover:ring-4 focus-visible:z-10 focus-visible:ring-4"
|
||||||
|
href={like.author?.url}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
title={like.author?.name}
|
||||||
|
>
|
||||||
|
<span class="p-author h-card">
|
||||||
|
<Image
|
||||||
|
alt={like.author!.name}
|
||||||
|
class="u-photo my-0 inline-block h-12 w-12"
|
||||||
|
height={48}
|
||||||
|
src={like.author!.photo}
|
||||||
|
width={48}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
import { getWebmentionsForUrl } from "@/utils/webmentions";
|
||||||
|
import Comments from "./Comments.astro";
|
||||||
|
import Likes from "./Likes.astro";
|
||||||
|
|
||||||
|
const url = new URL(Astro.url.pathname, Astro.site);
|
||||||
|
|
||||||
|
const webMentions = await getWebmentionsForUrl(`${url}`);
|
||||||
|
|
||||||
|
// Return if no webmentions
|
||||||
|
if (!webMentions.length) return;
|
||||||
|
---
|
||||||
|
|
||||||
|
<hr class="border-solid" />
|
||||||
|
<h2 class="mb-8 before:hidden">Webmentions for this post</h2>
|
||||||
|
<div class="space-y-10">
|
||||||
|
<Likes mentions={webMentions} />
|
||||||
|
<Comments mentions={webMentions} />
|
||||||
|
</div>
|
||||||
|
<p class="mt-8">
|
||||||
|
Responses powered by{" "}
|
||||||
|
<a href="https://webmention.io" rel="noreferrer" target="_blank">Webmentions</a>
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!-- Parent: ../AGENTS.md -->
|
||||||
|
<!-- Generated: 2026-06-30 | Updated: 2026-06-30 -->
|
||||||
|
|
||||||
|
# layout
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Site-wide chrome rendered on every page by `src/layouts/Base.astro`.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `Header.astro` | Top nav: avatar logo, site title, `menuLinks` nav, search, theme toggle, mobile menu button. |
|
||||||
|
| `Footer.astro` | Three centered rows — nav links, social icon row (from `socialLinks`), copyright — with a top border. |
|
||||||
|
|
||||||
|
## For AI Agents
|
||||||
|
|
||||||
|
### Working In This Directory
|
||||||
|
- **Header logo positioning:** the avatar is `position: absolute` and overflows left into the page margin. It is centered on the **header** block (not just the title) — this only works because the logo's containing block is the `<header>` (which is `relative`). Do **not** put a `filter`/`transform`/`position: relative` on the wrapping `<a>`, or it becomes the containing block and the logo mis-centers. The grayscale/hover effect therefore lives on the `<img>`, not the `<a>`.
|
||||||
|
- Logo geometry was tuned against the header center; if you change the logo size (`sm:size-36`) or header padding (`sm:ps-28`), re-verify vertical centering and the left overflow (`sm:-start-[48px]`).
|
||||||
|
- `menuLinks` and `socialLinks` come from `src/site.config.ts` — edit there, not here.
|
||||||
|
|
||||||
|
### Testing Requirements
|
||||||
|
- Verify visually at desktop and mobile widths (the logo is inline on mobile, absolute/overflowing on `sm+`). Chrome DevTools MCP works for measuring element rects.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- `@/site.config` (`menuLinks`, `socialLinks`, `siteConfig`), `astro-icon` for social/RSS icons.
|
||||||
|
|
||||||
|
<!-- MANUAL: -->
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
import { menuLinks, siteConfig, socialLinks } from "@/site.config";
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
---
|
||||||
|
|
||||||
|
<footer
|
||||||
|
class="text-muted border-global-text/15 mt-auto flex w-full flex-col items-center gap-y-6 border-t pt-8 pb-6 text-center text-sm font-semibold"
|
||||||
|
>
|
||||||
|
{/* Row 1: site nav */}
|
||||||
|
<nav aria-labelledby="footer_links" class="flex flex-wrap justify-center gap-x-1 gap-y-1">
|
||||||
|
<p id="footer_links" class="sr-only">More on this site</p>
|
||||||
|
<a class="hover:text-global-text px-3 py-1 hover:underline" href="/skills/">Skills</a>
|
||||||
|
{
|
||||||
|
menuLinks.map((link) => (
|
||||||
|
<a class="hover:text-global-text px-3 py-1 hover:underline" href={link.path}>
|
||||||
|
{link.title}
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Row 2: social icons */}
|
||||||
|
<ul class="flex items-center justify-center gap-x-4">
|
||||||
|
{
|
||||||
|
socialLinks.map(({ friendlyName, isWebmention, link, name }) => (
|
||||||
|
<li class="flex">
|
||||||
|
<a
|
||||||
|
class="hover:text-link inline-block"
|
||||||
|
href={link}
|
||||||
|
rel={`me noreferrer ${isWebmention ? "authn" : ""}`}
|
||||||
|
target={link.startsWith("mailto:") ? undefined : "_blank"}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" class="h-6 w-6" focusable="false" name={name} />
|
||||||
|
<span class="sr-only">{friendlyName}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Row 3: copyright */}
|
||||||
|
<p class="text-xs font-normal">© {year} {siteConfig.author}</p>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
import Search from "@/components/Search.astro";
|
||||||
|
import ThemeToggle from "@/components/ThemeToggle.astro";
|
||||||
|
import { menuLinks } from "@/site.config";
|
||||||
|
import { siteConfig } from "../../site.config";
|
||||||
|
---
|
||||||
|
|
||||||
|
<header class="group relative mb-28 flex items-center sm:ps-28" id="main-header">
|
||||||
|
<div class="flex sm:flex-col">
|
||||||
|
<a
|
||||||
|
aria-current={Astro.url.pathname === "/" ? "page" : false}
|
||||||
|
class="inline-flex items-center sm:inline-block"
|
||||||
|
href="/"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt={`${siteConfig.title} logo`}
|
||||||
|
class="me-3 size-14 rounded-full sm:absolute sm:-start-[48px] sm:top-1/2 sm:me-0 sm:size-36 sm:-translate-y-1/2 sm:grayscale sm:hover:filter-none"
|
||||||
|
height="144"
|
||||||
|
width="144"
|
||||||
|
src="/avatar.png"
|
||||||
|
/>
|
||||||
|
<span class="text-xl font-bold sm:text-2xl">{siteConfig.title}</span>
|
||||||
|
</a>
|
||||||
|
<nav
|
||||||
|
aria-label="Main menu"
|
||||||
|
class="bg-global-bg absolute -inset-x-4 top-12 hidden flex-col divide-y px-2 py-4 group-[.menu-open]:z-50 group-[.menu-open]:flex sm:static sm:z-auto sm:-ms-4 sm:mt-1 sm:flex sm:flex-row sm:divide-x sm:divide-y-0 sm:bg-transparent sm:p-0"
|
||||||
|
id="navigation-menu"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
menuLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
aria-current={Astro.url.pathname === link.path ? "page" : false}
|
||||||
|
class="text-accent px-2 py-4 font-semibold sm:px-4 sm:py-0 sm:underline-offset-2 sm:hover:underline"
|
||||||
|
href={link.path}
|
||||||
|
>
|
||||||
|
{link.title}
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<Search />
|
||||||
|
<ThemeToggle />
|
||||||
|
<mobile-button>
|
||||||
|
<button
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
class="group relative ms-4 h-7 w-7 sm:invisible sm:hidden"
|
||||||
|
id="toggle-navigation-menu"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Open main menu</span>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="absolute start-1/2 top-1/2 h-full w-full -translate-x-1/2 -translate-y-1/2 transition-all group-aria-expanded:scale-0 group-aria-expanded:opacity-0"
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
id="line-svg"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path d="M3.75 9h16.5m-16.5 6.75h16.5" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="text-accent absolute start-1/2 top-1/2 h-full w-full -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all group-aria-expanded:scale-100 group-aria-expanded:opacity-100"
|
||||||
|
class="text-accent"
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
id="cross-svg"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path d="M6 18L18 6M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</mobile-button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { toggleClass } from "@/utils/domElement";
|
||||||
|
|
||||||
|
class MobileNavBtn extends HTMLElement {
|
||||||
|
#menuOpen: boolean = false;
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
const headerEl = document.getElementById("main-header")!;
|
||||||
|
const mobileButtonEl = this.querySelector<HTMLButtonElement>("button");
|
||||||
|
|
||||||
|
mobileButtonEl?.addEventListener("click", () => {
|
||||||
|
if (headerEl) toggleClass(headerEl, "menu-open");
|
||||||
|
this.#menuOpen = !this.#menuOpen;
|
||||||
|
mobileButtonEl.setAttribute("aria-expanded", this.#menuOpen.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("mobile-button", MobileNavBtn);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!-- Parent: ../AGENTS.md -->
|
||||||
|
<!-- Generated: 2026-06-30 | Updated: 2026-06-30 -->
|
||||||
|
|
||||||
|
# project
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Rendering for the `projects` collection.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `Project.astro` | Polymorphic component for a project. In `isPreview` mode it renders a compact card linking to `/projects/<id>`; in full mode it shows the title, date, `link`/`repo` buttons, rendered body, and `ExperienceTags`. |
|
||||||
|
|
||||||
|
## For AI Agents
|
||||||
|
|
||||||
|
### Working In This Directory
|
||||||
|
- Prop is `project: CollectionEntry<"projects">` (note: the source dir is `src/content/projects`).
|
||||||
|
- Used by `src/pages/projects/[...page].astro` (preview list), `[...slug].astro` (full), and the homepage.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- `@/components/ExperienceTags.astro`, `@/components/FormattedDate.astro`.
|
||||||
|
|
||||||
|
<!-- MANUAL: -->
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
import { type CollectionEntry, getEntries, render } from "astro:content";
|
||||||
|
import type { HTMLTag, Polymorphic } from "astro/types";
|
||||||
|
import TagList from "@/components/TagList.astro";
|
||||||
|
|
||||||
|
type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & {
|
||||||
|
project: CollectionEntry<"projects">;
|
||||||
|
isPreview?: boolean | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { as: Tag = "div", project, isPreview = false } = Astro.props;
|
||||||
|
const { Content } = await render(project);
|
||||||
|
const { title, startDate, endDate, link, repo, tags, experiences } = project.data;
|
||||||
|
const range = `${startDate.getFullYear()} – ${endDate ? endDate.getFullYear() : "Present"}`;
|
||||||
|
const expEntries = experiences.length ? await getEntries(experiences) : [];
|
||||||
|
---
|
||||||
|
|
||||||
|
<article
|
||||||
|
class:list={[isPreview && "bg-global-text/5 inline-grid rounded-md px-4 py-3"]}
|
||||||
|
data-pagefind-body={isPreview ? false : true}
|
||||||
|
>
|
||||||
|
<Tag class="title" class:list={{ "text-base": isPreview }}>
|
||||||
|
{
|
||||||
|
isPreview ? (
|
||||||
|
<a class="cactus-link" href={`/projects/${project.id}/`}>
|
||||||
|
{title}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<>{title}</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Tag>
|
||||||
|
<p class="text-muted text-sm">
|
||||||
|
<span>{range}</span>{expEntries.length > 0 && (
|
||||||
|
<span> ({expEntries.map((exp, i) => (
|
||||||
|
<Fragment>{i > 0 && ", "}<a class="cactus-link" href={`/experience/${exp.id}/`}>{exp.data.title}</a></Fragment>
|
||||||
|
))})</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{
|
||||||
|
!isPreview && (link || repo) && (
|
||||||
|
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{link && (
|
||||||
|
<a class="cactus-link" href={link} rel="noopener noreferrer" target="_blank">
|
||||||
|
Visit →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{repo && (
|
||||||
|
<a class="cactus-link" href={repo} rel="noopener noreferrer" target="_blank">
|
||||||
|
Source →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<div
|
||||||
|
class="prose prose-sm prose-cactus mt-4 max-w-none [&>p:last-of-type]:mb-0"
|
||||||
|
class:list={{ "line-clamp-6": isPreview }}
|
||||||
|
>
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
!isPreview && tags.length > 0 && (
|
||||||
|
<TagList tags={tags} class="border-global-text/10 mt-4 border-t pt-3" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</article>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { defineCollection, reference } from "astro:content";
|
||||||
|
import { glob } from "astro/loaders";
|
||||||
|
import { z } from "astro/zod";
|
||||||
|
|
||||||
|
function removeDupsAndLowerCase(array: string[]) {
|
||||||
|
return [...new Set(array.map((str) => str.toLowerCase()))];
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleSchema = z.string().max(60);
|
||||||
|
|
||||||
|
const baseSchema = z.object({
|
||||||
|
title: titleSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Experiences double as a taxonomy: projects, writing & speaking reference them
|
||||||
|
// by id so each experience page can render everything filed under it.
|
||||||
|
const experienceRefs = z.array(reference("experience")).default([]);
|
||||||
|
|
||||||
|
// /writing section.
|
||||||
|
const writing = defineCollection({
|
||||||
|
loader: glob({ base: "./src/content/writing", pattern: "**/*.{md,mdx}" }),
|
||||||
|
schema: ({ image }) =>
|
||||||
|
baseSchema.extend({
|
||||||
|
description: z.string(),
|
||||||
|
coverImage: z
|
||||||
|
.object({
|
||||||
|
alt: z.string(),
|
||||||
|
src: image(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
ogImage: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).default([]).transform(removeDupsAndLowerCase),
|
||||||
|
experiences: experienceRefs,
|
||||||
|
// External articles (PESOS): link out to the publisher instead of a local page.
|
||||||
|
externalUrl: z.url().optional(),
|
||||||
|
publisher: z.string().optional(), // e.g. "Fast Company", "Medium"
|
||||||
|
publishDate: z
|
||||||
|
.string()
|
||||||
|
.or(z.date())
|
||||||
|
.transform((val) => new Date(val)),
|
||||||
|
updatedDate: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((str) => (str ? new Date(str) : undefined)),
|
||||||
|
pinned: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// /projects section.
|
||||||
|
const projects = defineCollection({
|
||||||
|
loader: glob({ base: "./src/content/projects", pattern: "**/*.{md,mdx}" }),
|
||||||
|
schema: ({ image }) =>
|
||||||
|
baseSchema.extend({
|
||||||
|
description: z.string().optional(),
|
||||||
|
coverImage: z
|
||||||
|
.object({
|
||||||
|
alt: z.string(),
|
||||||
|
src: image(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
link: z.url().optional(),
|
||||||
|
repo: z.url().optional(),
|
||||||
|
// Skills double as tags for cross-project categorization.
|
||||||
|
tags: z.array(z.string()).default([]).transform(removeDupsAndLowerCase),
|
||||||
|
experiences: experienceRefs,
|
||||||
|
startDate: z.coerce.date(),
|
||||||
|
endDate: z.coerce.date().optional(), // omit for ongoing projects
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Experience = resume entries that also act as the taxonomy for the site.
|
||||||
|
const experience = defineCollection({
|
||||||
|
loader: glob({ base: "./src/content/experience", pattern: "**/*.{md,mdx}" }),
|
||||||
|
schema: baseSchema.extend({
|
||||||
|
organization: z.string(),
|
||||||
|
role: z.string().optional(),
|
||||||
|
location: z.string().optional(),
|
||||||
|
url: z.url().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
// Skills double as site-wide tags (see /tags).
|
||||||
|
tags: z.array(z.string()).default([]).transform(removeDupsAndLowerCase),
|
||||||
|
startDate: z.coerce.date(),
|
||||||
|
endDate: z.coerce.date().optional(), // omit for current/ongoing
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Speaking = talks/presentations rendered with an embedded YouTube player.
|
||||||
|
const speaking = defineCollection({
|
||||||
|
loader: glob({ base: "./src/content/speaking", pattern: "**/*.{md,mdx}" }),
|
||||||
|
schema: baseSchema.extend({
|
||||||
|
description: z.string(),
|
||||||
|
youtubeId: z.string(), // YouTube video ID
|
||||||
|
event: z.string().optional(),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
tags: z.array(z.string()).default([]).transform(removeDupsAndLowerCase),
|
||||||
|
experiences: experienceRefs,
|
||||||
|
publishDate: z
|
||||||
|
.string()
|
||||||
|
.or(z.date())
|
||||||
|
.transform((val) => new Date(val)),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = { writing, projects, experience, speaking };
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!-- Parent: ../AGENTS.md -->
|
||||||
|
<!-- Generated: 2026-06-30 | Updated: 2026-06-30 -->
|
||||||
|
|
||||||
|
# content
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Markdown/MDX content for the four collections. Each subdirectory is a collection; the filename is the entry id/slug. Schemas live in `src/content.config.ts`.
|
||||||
|
|
||||||
|
## Subdirectories
|
||||||
|
| Directory | Collection key | Route | Notes |
|
||||||
|
|-----------|----------------|-------|-------|
|
||||||
|
| `projects/` | `projects` | `/projects` | Things built. Fields: `title`, `startDate`, `endDate?` (omit = ongoing), `description?`, `link?`, `repo?`, `tags?` (skills; browsable site-wide at `/skills/<skill>`), `experiences?`. Sorted by `endDate` desc, ongoing first. |
|
||||||
|
| `writing/` | `writing` | `/writing` | Posts. Fields incl. `description`, `coverImage?`, `ogImage?`, `tags?`, `experiences?`, `draft`, `pinned`, `externalUrl?`/`publisher?` (PESOS — entry links out to the publisher, no local detail page). |
|
||||||
|
| `experience/` | `experience` | `/experience` | Resume entries + taxonomy. Fields: `title`, `organization`, `startDate`, `endDate?`, `role?`, `location?`, `url?`, `description?`. |
|
||||||
|
| `speaking/` | `speaking` | `/speaking` | Talks. Fields: `title`, `description`, `publishDate`, `youtubeId` (YouTube video ID), `event?`, `tags?`, `experiences?`, `draft`. |
|
||||||
|
|
||||||
|
## For AI Agents
|
||||||
|
|
||||||
|
### Working In This Directory
|
||||||
|
- **Experience taxonomy:** entries in `projects`, `writing`, and `speaking` reference experiences via an `experiences: [<id>]` array (validated by `reference("experience")`). The id is the experience filename without extension (e.g. `experiences: ["sonr"]` → `experience/sonr.md`). Adding a ref to a non-existent experience fails the build.
|
||||||
|
- `draft: true` (writing/speaking) hides an entry from production builds.
|
||||||
|
- After editing frontmatter, `pnpm build` validates it against the schema.
|
||||||
|
|
||||||
|
### Common Patterns
|
||||||
|
- Some `writing/` samples (`markdown-elements`, `testing/`) are leftover theme demos showcasing markdown rendering — safe to delete once real content exists.
|
||||||
|
|
||||||
|
<!-- MANUAL: -->
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
title: "Britetwist"
|
||||||
|
organization: "Britetwist"
|
||||||
|
role: "iOS Developer"
|
||||||
|
location: "Ashburn, VA, USA"
|
||||||
|
tags: ["Executive Leadership", "iOS Development", "Game Development", "C#", "Unity", "Product Management", "User Interface Design"]
|
||||||
|
startDate: "2016-07-01"
|
||||||
|
endDate: "2019-05-01"
|
||||||
|
description: "Founded an independent mobile app studio, driving end-to-end product development—from ideation and UI/UX design to full-stack programming—for three successful applications."
|
||||||
|
---
|
||||||
|
|
||||||
|
- Founded an independent mobile app studio, driving end-to-end product development—from ideation and UI/UX design to full-stack programming—for three successful applications.
|
||||||
|
- Scaled the company's portfolio to over 1 million total downloads, encompassing a peer-to-peer volunteer network (VolunTree) and highly engaging mobile games (Space Bolt, Yuē).
|
||||||
|
- Achieved peak user engagement metrics, including 15,000 Daily Active Users (DAU) for Space Bolt and 1,250 Monthly Active Users (MAU) for VolunTree.
|
||||||
|
- Fostered strong developer community engagement by authoring technical architecture and game development articles that accumulated over 40,000 reads.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
title: "Hobsons"
|
||||||
|
organization: "Hobsons"
|
||||||
|
role: "Software Engineering Intern"
|
||||||
|
location: "Arlington, VA, USA"
|
||||||
|
tags: ["Java", "AngularJS", "Spring Boot", "REST API", "Backend Development", "Algorithms"]
|
||||||
|
startDate: "2018-05-01"
|
||||||
|
endDate: "2018-08-01"
|
||||||
|
description: "Architected a robust backend monitoring system using Java and the Spring Boot framework to track data synchronization between CommonApp documents and the Naviance platform."
|
||||||
|
---
|
||||||
|
|
||||||
|
- Architected a robust backend monitoring system using Java and the Spring Boot framework to track data synchronization between CommonApp documents and the Naviance platform.
|
||||||
|
- Built an internal frontend dashboard using JavaScript and AngularJS to seamlessly consume, display, and filter REST API JSON responses.
|
||||||
|
- Empowered the sales team to independently troubleshoot and answer customer QA inquiries by creating self-serve tools, drastically reducing response times from several days to just a few minutes.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
title: "Leidos"
|
||||||
|
organization: "Leidos"
|
||||||
|
role: "Mobile App Developer"
|
||||||
|
location: "Herndon, VA, USA"
|
||||||
|
tags: ["Flutter", "Dart", "Cross-Platform Development", "Mobile App Development", "Agile Methodologies", "Mobile Security"]
|
||||||
|
startDate: "2020-08-01"
|
||||||
|
endDate: "2021-05-01"
|
||||||
|
description: "Engineered and deployed features for the official Customs and Border Protection (CBP) Mobile Passport application, a high-visibility tool utilized by millions of travelers to expedite entry into the United States."
|
||||||
|
---
|
||||||
|
|
||||||
|
- Engineered and deployed features for the official Customs and Border Protection (CBP) Mobile Passport application, a high-visibility tool utilized by millions of travelers to expedite entry into the United States.
|
||||||
|
- Leveraged the Flutter framework and Dart to build and maintain a seamless, high-performance cross-platform experience for both iOS and Android users.
|
||||||
|
- Collaborated within a highly regulated, hybrid agile environment to ensure the application met strict federal security, privacy, and accessibility standards.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
title: "Sonr"
|
||||||
|
organization: "Sonr"
|
||||||
|
role: "Co-Founder / CEO"
|
||||||
|
location: "New York, USA"
|
||||||
|
url: "https://sonr.io"
|
||||||
|
tags: ["Executive Leadership", "Startup Fundraising", "Cosmos SDK", "React Native", "Kubernetes", "Docker", "Google Cloud", "Terraform", "Ansible", "Protocol Buffers", "Decentralized Identifiers (DIDs)", "WebAuthn", "WebAssembly (WASM)", "IPFS", "Matrix Protocol", "Technology Strategy", "DevOps", "Linux Administration"]
|
||||||
|
startDate: "2021-03-01"
|
||||||
|
description: "Founded the startup and successfully led executive fundraising efforts, securing $4.7M in capital to build a peer-to-peer identity and asset management system leveraging decentralized identifiers (DIDs), WebAuthn, and IPFS."
|
||||||
|
---
|
||||||
|
|
||||||
|
> Founder building Decentralized Identity | W3C Working Group Member for DIDs, WebAuthn, and WASM | 6000+ Github Contributions 2021-2023
|
||||||
|
|
||||||
|
- Founded the startup and successfully led executive fundraising efforts, securing $4.7M in capital to build a peer-to-peer identity and asset management system leveraging decentralized identifiers (DIDs), WebAuthn, and IPFS.
|
||||||
|
- Directed technical strategy and led a cross-functional engineering team in the co-development of a React Native mobile chat application powered by the decentralized Matrix protocol.
|
||||||
|
- Architected a custom Layer 1 blockchain using the Cosmos SDK, optimizing data serialization and network communication with Protobufs.
|
||||||
|
- Orchestrated advanced DevOps and server deployments across Google Cloud (GCP), managing complex network infrastructure using Kubernetes (K8s), Docker, Terraform, Ansible, and comprehensive Linux administration.
|
||||||
|
- Drove massive open-source community growth, achieving the 4th highest global GitHub contributions (Oct 2021 – Oct 2022) for the platform's MVP, and successfully onboarding over 120 first-time blockchain developers to the Motor SDKs during DevNet.
|
||||||
|
- Actively shaped global web standards as a W3C Working Group Member for DIDs, WebAuthn, and WebAssembly (WASM).
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
title: "Unissant"
|
||||||
|
organization: "Unissant"
|
||||||
|
role: "Data Science Intern"
|
||||||
|
location: "Herndon, VA, USA"
|
||||||
|
tags: ["Machine Learning", "Data Science", "AWS Lambda", "React.js", "AWS SageMaker", "Python", "Serverless Computing"]
|
||||||
|
startDate: "2019-05-01"
|
||||||
|
endDate: "2019-08-01"
|
||||||
|
description: "Engineered and deployed a machine learning fraud detection model utilizing AWS SageMaker to identify irregular patterns in healthcare data."
|
||||||
|
---
|
||||||
|
|
||||||
|
- Engineered and deployed a machine learning fraud detection model utilizing AWS SageMaker to identify irregular patterns in healthcare data.
|
||||||
|
|
||||||
|
- Developed a custom, client-facing web application using React, integrated with serverless AWS Lambda functions to interact with the predictive model in real-time.
|
||||||
|
|
||||||
|
- Delivered a successful proof-of-concept (POC) to a consulting client, demonstrating the firm's technical capabilities in advanced data management and automated fraud prevention.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
title: "Layer-1 Blockchain & Decentralized Identity Ecosystem"
|
||||||
|
startDate: "2022-01-01"
|
||||||
|
description: "Engineered a revolutionary \"Gasless Onboarding\" pipeline by writing custom Cosmos AnteHandlers to intercept and validate WebAuthn (Passkeys/Biometrics) credentials directly at the mempool level, completely abstracting away seed phrases and transaction fees for new users."
|
||||||
|
repo: "https://github.com/sonr-io/sonr"
|
||||||
|
tags: ["Go", "Kubernetes", "WebAuthn", "Decentralized Identifiers (DIDs)", "IPFS", "Distributed Networking", "Blockchain Architecture"]
|
||||||
|
experiences: ["sonr"]
|
||||||
|
---
|
||||||
|
|
||||||
|
- Engineered a revolutionary "Gasless Onboarding" pipeline by writing custom Cosmos AnteHandlers to intercept and validate WebAuthn (Passkeys/Biometrics) credentials directly at the mempool level, completely abstracting away seed phrases and transaction fees for new users.
|
||||||
|
- Designed a capability-based authorization system integrating User-Controlled Authorization Networks (UCAN) and Decentralized Web Nodes (DWN) to provide granular, time-bound, and delegatable access to IPFS-backed encrypted data vaults.
|
||||||
|
- Bridged Web3 ecosystems by implementing full EVM compatibility alongside CosmWasm smart contracts, supporting multi-chain token mappings and cross-chain operations via IBC protocols.
|
||||||
|
- Built enterprise-grade DevOps infrastructure leveraging Kubernetes (Starship), Docker, and Devbox to orchestrate multi-node testnets and local development environments, utilizing GoReleaser for automated, multi-architecture CI/CD pipelines.
|
||||||
|
- Integrated automated legal compliance tooling directly into the blockchain CLI, generating cryptographic proofs and automated filings for Wyoming DAO LC amendments.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
title: "Beam — E2EE Decentralized Messaging"
|
||||||
|
startDate: "2022-08-01"
|
||||||
|
endDate: "2025-08-01"
|
||||||
|
description: "Led the architecture and hands-on development of Beam, a cross-platform end-to-end encrypted (E2EE) mobile messaging application built in React Native, directing a 6-engineer cross-functional team across mobile, infrastructure, and product."
|
||||||
|
link: "https://www.youtube.com/watch?v=plaYwlGl4NM"
|
||||||
|
tags: ["React Native", "Matrix Protocol", "WebAuthn", "Decentralized Identifiers (DIDs)", "Cryptography"]
|
||||||
|
experiences: ["sonr"]
|
||||||
|
---
|
||||||
|
|
||||||
|
- Led the architecture and hands-on development of Beam, a cross-platform end-to-end encrypted (E2EE) mobile messaging application built in React Native, directing a 6-engineer cross-functional team across mobile, infrastructure, and product.
|
||||||
|
- Architected and managed a self-hosted Matrix homeserver, customizing the decentralized Matrix protocol to deliver end-to-end encrypted (E2EE), censorship-resistant messaging with self-custodied message history.
|
||||||
|
- Engineered a passkey-based authentication layer as a drop-in replacement for the Matrix authorization server, swapping centralized email/phone login for WebAuthn passkey wallets and Sonr Decentralized Identifiers (DIDs) to enable passwordless, self-sovereign onboarding and cryptographic key exchange.
|
||||||
|
- Abstracted decentralized routing and cryptographic key management behind a Web2-grade chat UX, guiding frontend and product teams to deliver a frictionless messaging experience over fully decentralized identity and transport infrastructure.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
title: "Client SDK & Motr Cryptographic Enclave"
|
||||||
|
startDate: "2022-06-01"
|
||||||
|
endDate: "2023-08-01"
|
||||||
|
description: "Architected \"Motr,\" a WebAssembly (WASM) cryptographic enclave compiled from Go, designed to run entirely in client-side Web Workers. This sandboxed environment securely handles Multi-Party Computation (MPC), data signing, and UCAN token generation without ever exposing private keys to the main application thread."
|
||||||
|
link: "https://pub.dev/packages/motor_flutter"
|
||||||
|
tags: ["API Design", "React.js", "Flutter", "WebAuthn", "Cryptography", "WebAssembly (WASM)", "TypeScript", "Developer Relations"]
|
||||||
|
experiences: ["sonr"]
|
||||||
|
---
|
||||||
|
|
||||||
|
- Architected "Motr," a WebAssembly (WASM) cryptographic enclave compiled from Go, designed to run entirely in client-side Web Workers. This sandboxed environment securely handles Multi-Party Computation (MPC), data signing, and UCAN token generation without ever exposing private keys to the main application thread.
|
||||||
|
- Built an edge-native orchestration layer using Cloudflare Workers, utilizing Durable Objects, KV namespaces, and Service Bindings to manage persistent cross-platform sessions, smart routing, and state synchronization across the decentralized network.
|
||||||
|
- Pioneered a "Web2-to-Web3" authentication bridge by integrating WebAuthn (Passkeys/Biometrics) and standard OAuth 2.0 flows directly into the SDK, allowing developers to offer passwordless, seed-phrase-free onboarding to their users.
|
||||||
|
- Implemented account-isolated client persistence leveraging Dexie.js (IndexedDB) to create a robust offline caching layer for cryptographic states, delegated capabilities, and decentralized web node (DWN) records.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
title: "Core Cryptography Library (ZKP & MPC Infrastructure)"
|
||||||
|
startDate: "2022-01-01"
|
||||||
|
endDate: "2026-02-01"
|
||||||
|
description: "Implemented advanced Zero-Knowledge Proof (ZKP) systems, including Bulletproofs (Range Proofs and Inner Product Proofs) and cryptographic accumulators, leveraging the Fiat-Shamir heuristic for efficient, non-interactive verification."
|
||||||
|
repo: "https://github.com/sonr-io/crypto"
|
||||||
|
tags: ["Go", "Security Testing", "Quantum Computing", "Cryptography"]
|
||||||
|
experiences: ["sonr"]
|
||||||
|
---
|
||||||
|
|
||||||
|
- Implemented advanced Zero-Knowledge Proof (ZKP) systems, including Bulletproofs (Range Proofs and Inner Product Proofs) and cryptographic accumulators, leveraging the Fiat-Shamir heuristic for efficient, non-interactive verification.
|
||||||
|
- Designed Multi-Party Computation (MPC) and Distributed Key Generation (DKG) protocols, enabling decentralized infrastructure through Threshold ECDSA/Ed25519, FROST, and Gennaro signature schemes without single points of failure.
|
||||||
|
- Engineered highly secure, constant-time elliptic curve arithmetic across multiple standards (secp256k1, Ed25519, BLS12-381, NIST P-Curves), strictly enforcing safeguards against side-channel timing attacks, signature malleability, and nonce reuse.
|
||||||
|
- Hardened system security by integrating AES-GCM authenticated encryption and Argon2id memory-hard key derivation, backed by an exhaustive suite of automated security tests, race-condition checks, and performance benchmarks.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
title: "Decentralized File-Sharing Platform"
|
||||||
|
startDate: "2020-10-01"
|
||||||
|
endDate: "2021-12-01"
|
||||||
|
description: "Engineered deep native OS integrations by writing custom iOS Share Extensions (Swift) and Android Intent filters, allowing users to seamlessly share photos, videos, and files directly from the OS-level share sheet into the decentralized libp2p network."
|
||||||
|
link: "https://www.youtube.com/watch?v=HeBcg4P3Ipw"
|
||||||
|
tags: ["Flutter", "Dart", "Swift", "Kotlin", "Java", "FFI (Foreign Function Interface)", "Decentralized Identifiers (DIDs)", "SQLite", "Continuous Integration and Deployment (CI/CD)", "Libp2p"]
|
||||||
|
experiences: ["sonr"]
|
||||||
|
---
|
||||||
|
|
||||||
|
- Engineered deep native OS integrations by writing custom iOS Share Extensions (Swift) and Android Intent filters, allowing users to seamlessly share photos, videos, and files directly from the OS-level share sheet into the decentralized libp2p network.
|
||||||
|
- Integrated a custom Go-based networking core using Gomobile, creating strict type-conversion bindings to translate low-level libp2p data streams, cryptographic keypairs, and Namebase decentralized routing into Dart.
|
||||||
|
- Designed a reactive, high-performance UI/UX utilizing the GetX framework for state management, combined with complex Lottie and Rive animations to provide intuitive, real-time feedback during peer discovery and file transfers.
|
||||||
|
- Built a robust local caching layer using Moor (SQLite) to securely store decentralized identity profiles, transfer history, and media metadata offline.
|
||||||
|
- Automated the entire CI/CD pipeline by configuring custom Makefiles, Fastlane lanes, and GitHub Actions to fetch core framework submodules, manage code-generation (build_runner), and seamlessly deploy App Bundles and iOS Archives to the Play Store and App Store TestFlight.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
title: "Web3 Developer Ecosystem & DevNet Launch"
|
||||||
|
startDate: "2022-01-01"
|
||||||
|
endDate: "2022-06-01"
|
||||||
|
description: "Designed and hosted hands-on technical workshops aimed at abstracting away the friction of Web3 development, successfully onboarding dozens of first-time blockchain developers to build decentralized applications (dApps) without requiring deep cryptographic expertise."
|
||||||
|
link: "https://www.youtube.com/watch?v=zy6Xkh9Mvcs"
|
||||||
|
tags: ["Developer Relations", "Technical Workshops", "Web3 Ecosystem", "Public Speaking", "API Design"]
|
||||||
|
experiences: ["sonr"]
|
||||||
|
---
|
||||||
|
|
||||||
|
Spearheaded the launch of the Sonr DevNet at premier collegiate hackathons, including PennApps XXII and UC Berkeley’s CalHacks, to drive grassroots adoption of decentralized identity protocols.
|
||||||
|
|
||||||
|
- Designed and hosted hands-on technical workshops aimed at abstracting away the friction of Web3 development, successfully onboarding dozens of first-time blockchain developers to build decentralized applications (dApps) without requiring deep cryptographic expertise.
|
||||||
|
- Provided direct architectural mentorship and SDK support (utilizing the Sonr Motor API and DID modules), enabling developers to seamlessly integrate decentralized identity, secure messaging, and token marketplaces within 48-hour constraints.
|
||||||
|
- Fostered a thriving early-stage ecosystem that produced highly innovative dApps, including Ptolemy (an on-chain/off-chain validator reputation scoring system), Faiza (a decentralized, token-incentivized health forum), and WalkWithMe (a secure, DID-authenticated peer-to-peer transit network).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
title: "Floadt"
|
||||||
|
startDate: "2013-05-01"
|
||||||
|
endDate: "2015-10-01"
|
||||||
|
description: "Developed a unified social media client designed to aggregate and display Instagram, Facebook, and Twitter feeds within a single, Pinterest-style user interface."
|
||||||
|
repo: "https://github.com/prdlk/floadt"
|
||||||
|
tags: ["REST APIs", "User Interface Design", "Social Media Integration", "Network Architecture", "Frontend Development"]
|
||||||
|
experiences: ["britetwist"]
|
||||||
|
---
|
||||||
|
|
||||||
|
- Developed a unified social media client designed to aggregate and display Instagram, Facebook, and Twitter feeds within a single, Pinterest-style user interface.
|
||||||
|
- Engineered advanced social management features, including cross-platform scheduled publishing, feed filtering to hide specific users, and mutual-follower verification.
|
||||||
|
- Gained deep, hands-on experience in network architecture and complex REST API integration prior to major platform policy changes that restricted third-party developer access.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
title: "Like.ly"
|
||||||
|
startDate: "2017-05-01"
|
||||||
|
endDate: "2017-08-01"
|
||||||
|
description: "Engineered a predictive analytics tool leveraging TensorFlow and supervised machine learning algorithms to forecast Instagram post engagement and likes."
|
||||||
|
repo: "https://github.com/prdlk/like.ly"
|
||||||
|
tags: ["React Native", "Machine Learning", "TensorFlow", "Computer Vision", "Web Scraping"]
|
||||||
|
---
|
||||||
|
|
||||||
|
- Engineered a predictive analytics tool leveraging TensorFlow and supervised machine learning algorithms to forecast Instagram post engagement and likes.
|
||||||
|
- Developed an intuitive user interface by building a cross-platform mobile application and a cohesive messenger bot using React Native.
|
||||||
|
- Customized a web scraper to systematically aggregate public user data to train the model and optimize prediction accuracy.
|
||||||
|
- Integrated a Computer Vision API to analyze visual post content and extract key features for the machine learning pipeline.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
title: "Pool"
|
||||||
|
startDate: "2019-08-01"
|
||||||
|
endDate: "2019-08-01"
|
||||||
|
description: "Designed and engineered the frontend architecture for an event-sharing mobile application utilizing the Flutter framework and Dart."
|
||||||
|
repo: "https://github.com/prdlk/pool-tutorial-part-1"
|
||||||
|
tags: ["Flutter", "Dart", "Cross-Platform Development", "User Interface Design", "Technical Writing"]
|
||||||
|
---
|
||||||
|
|
||||||
|
- Designed and engineered the frontend architecture for an event-sharing mobile application utilizing the Flutter framework and Dart.
|
||||||
|
- Translated complex Dribbble UI/UX designs into fully functional programmatic widget hierarchies, implementing custom TableView cells, dynamic navigation, and animated backgrounds using the simple-animations library.
|
||||||
|
- Authored and published a comprehensive technical article ("A Deep Dive into Flutter") detailing the application's structural development to educate developers on cross-platform best practices.
|
||||||
|
- Architected a scalable, modular codebase designed to seamlessly integrate with future Node.js and MongoDB backend implementations.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
title: "Space Bolt"
|
||||||
|
startDate: "2016-08-01"
|
||||||
|
endDate: "2018-07-01"
|
||||||
|
description: "Designed and developed an endless-runner space game, successfully scaling the player base to 15,000 daily active users and 850,000 total downloads by 2018."
|
||||||
|
repo: "https://github.com/prdlk/spacebolt"
|
||||||
|
tags: ["Game Development", "C#", "Social Integration", "API Design", "Microtransactions"]
|
||||||
|
experiences: ["britetwist"]
|
||||||
|
---
|
||||||
|
|
||||||
|
- Designed and developed an endless-runner space game, successfully scaling the player base to 15,000 daily active users and 850,000 total downloads by 2018.
|
||||||
|
- Engineered a custom API and server-side caching system to generate, retrieve, and distribute shareable gameplay GIFs.
|
||||||
|
- Integrated Facebook leaderboards to drive user engagement, retention, and global competition among players.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
title: "Superball"
|
||||||
|
startDate: "2011-12-01"
|
||||||
|
endDate: "2012-04-01"
|
||||||
|
description: "Developed and launched a puzzle-based casual game for iOS using the Cocos2D framework and Objective-C at age 12."
|
||||||
|
link: "https://vimeo.com/172015296"
|
||||||
|
tags: ["Game Development", "Objective-C", "Cocos2d"]
|
||||||
|
experiences: ["britetwist"]
|
||||||
|
---
|
||||||
|
|
||||||
|
- Developed and launched a puzzle-based casual game for iOS using the Cocos2D framework and Objective-C at age 12.
|
||||||
|
- Navigated the constraints of early iOS development by successfully managing memory allocation manually, prior to the introduction of built-in automated memory management.
|
||||||
|
- Designed the complete game loop, mechanics, and user interface tailored specifically for the mobile casual gaming market.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user