feat: add audio playback and enhanced note UI with sidebar navigation
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Add AudioSection component with media player and waveform visualization - Add NoteContent component with inline link support and syntax highlighting - Add NoteSidebar with collapsible sections for navigation - Add SidebarContext for managing sidebar state - Update note page layout to use new component architecture - Add assets utility for GCS audio/video URL generation - Update content library with audio/skill/prompt type support - Add vision.md with editorial guidelines - Update .gitignore with additional security patterns - Add upload-asset.sh script for GCS asset management Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a65c3f7243
commit
ec0b89206f
18
.gitignore
vendored
18
.gitignore
vendored
@ -5,10 +5,24 @@ node_modules/
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
*.exe
|
||||
*.dmg
|
||||
vendor/
|
||||
__pycache__/
|
||||
|
||||
# Environment
|
||||
.env*
|
||||
!.env.example
|
||||
.envault/
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
credentials.json
|
||||
service-account*.json
|
||||
*_secret*
|
||||
*_token*
|
||||
*_password*
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
@ -20,3 +34,7 @@ dist/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Large assets (stored in GCS)
|
||||
blog/public/audio/
|
||||
blog/public/video/
|
||||
|
||||
17
CLAUDE.md
17
CLAUDE.md
@ -16,8 +16,25 @@ cd blog && npm run dev
|
||||
# Open http://localhost:19197
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
"Deploy" = sync assets to GCS + commit + push. Cloud Build auto-deploys on push.
|
||||
|
||||
```bash
|
||||
# Sync any new audio/video to GCS
|
||||
gcloud storage cp blog/public/audio/notes/{slug}/* gs://orchard9-assets/research-notes/audio/notes/{slug}/
|
||||
# Then commit and push
|
||||
```
|
||||
|
||||
## AI Routing
|
||||
|
||||
### Editorial voice
|
||||
→ [`blog/vision.md`](blog/vision.md) — raw notes, direct tone, no fluff
|
||||
|
||||
Writing feedback → understand it → update CLAUDE.md
|
||||
|
||||
**Style:** flat over nested, show don't hide, inline over accordion, direct lowercase casual
|
||||
|
||||
### For blog development
|
||||
→ `blog/CLAUDE.md`
|
||||
|
||||
|
||||
@ -2,6 +2,10 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Asset URL for GCS storage
|
||||
ARG NEXT_PUBLIC_ASSET_BASE_URL=https://storage.googleapis.com/orchard9-assets/research-notes
|
||||
ENV NEXT_PUBLIC_ASSET_BASE_URL=$NEXT_PUBLIC_ASSET_BASE_URL
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
|
||||
@ -71,6 +71,16 @@ content/
|
||||
| `src/components/white-paper/OutlineSection.tsx` | Outline sections |
|
||||
| `src/components/copyable.tsx` | CopyButton, CopyableBlock, ExpandableFile |
|
||||
|
||||
## Inline Links
|
||||
|
||||
Reference sidebar items in content.md:
|
||||
- `[[prompt:id]]` — link to a prompt by its id
|
||||
- `[[file:name.md]]` — link to a file by its name
|
||||
- `[[skill:name]]` — link to a skill by its name
|
||||
- `[[audio:file.m4a]]` — link to an audio file by its name
|
||||
|
||||
Click scrolls to item + highlights for 1.5s.
|
||||
|
||||
## Note meta.yaml Schema
|
||||
|
||||
```yaml
|
||||
@ -90,6 +100,12 @@ filesCreated:
|
||||
- name: filename.md
|
||||
description: What this file is
|
||||
|
||||
audioFiles: # optional
|
||||
- name: filename.m4a
|
||||
title: "Display title"
|
||||
description: "What this covers"
|
||||
source: NotebookLM # optional badge
|
||||
|
||||
navigation:
|
||||
prev: null # or { slug, id, title }
|
||||
next:
|
||||
|
||||
@ -339,6 +339,12 @@ skillsUsed:
|
||||
- ANNOUNCE wave plan before executing
|
||||
- MAX 3 fix cycles per task, then escalate
|
||||
|
||||
audioFiles:
|
||||
- name: Maxwell_Verifies_AI_With_Thermodynamic_Fingerprints.m4a
|
||||
title: "Research Overview"
|
||||
description: "NotebookLM synthesis of 10 research directives"
|
||||
source: NotebookLM
|
||||
|
||||
filesCreated:
|
||||
- name: firecracker-latency-benchmarks.md
|
||||
description: Benchmarking pause/resume latency for thermal emergencies
|
||||
|
||||
168
blog/package-lock.json
generated
168
blog/package-lock.json
generated
@ -17,6 +17,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
@ -3045,6 +3046,18 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.24.1",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
|
||||
@ -4114,6 +4127,64 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-from-parse5": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
|
||||
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/unist": "^3.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"hastscript": "^9.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"vfile": "^6.0.0",
|
||||
"vfile-location": "^5.0.0",
|
||||
"web-namespaces": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-parse-selector": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
||||
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-raw": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
|
||||
"integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/unist": "^3.0.0",
|
||||
"@ungap/structured-clone": "^1.0.0",
|
||||
"hast-util-from-parse5": "^8.0.0",
|
||||
"hast-util-to-parse5": "^8.0.0",
|
||||
"html-void-elements": "^3.0.0",
|
||||
"mdast-util-to-hast": "^13.0.0",
|
||||
"parse5": "^7.0.0",
|
||||
"unist-util-position": "^5.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vfile": "^6.0.0",
|
||||
"web-namespaces": "^2.0.0",
|
||||
"zwitch": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-jsx-runtime": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
||||
@ -4141,6 +4212,25 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"comma-separated-tokens": "^2.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"space-separated-tokens": "^2.0.0",
|
||||
"web-namespaces": "^2.0.0",
|
||||
"zwitch": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-whitespace": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
|
||||
@ -4154,6 +4244,23 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hastscript": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
|
||||
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"comma-separated-tokens": "^2.0.0",
|
||||
"hast-util-parse-selector": "^4.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"space-separated-tokens": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hermes-estree": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||
@ -4181,6 +4288,16 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/html-void-elements": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
|
||||
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@ -6490,6 +6607,18 @@
|
||||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@ -6737,6 +6866,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-raw": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
|
||||
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"hast-util-raw": "^9.0.0",
|
||||
"vfile": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-gfm": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||
@ -7912,6 +8056,20 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile-location": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
|
||||
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"vfile": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile-message": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
|
||||
@ -7926,6 +8084,16 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/web-namespaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
|
||||
@ -4,165 +4,187 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-serif: Georgia, Cambria, "Times New Roman", serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-serif: Georgia, Cambria, "Times New Roman", serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
/* Research Paper Theme - Light tan/offwhite with dark charcoal */
|
||||
:root {
|
||||
--radius: 0.375rem;
|
||||
/* Warm offwhite/cream background */
|
||||
--background: oklch(0.965 0.015 85);
|
||||
/* Dark charcoal text */
|
||||
--foreground: oklch(0.25 0 0);
|
||||
/* Slightly lighter cream for cards */
|
||||
--card: oklch(0.98 0.01 85);
|
||||
--card-foreground: oklch(0.25 0 0);
|
||||
--popover: oklch(0.98 0.01 85);
|
||||
--popover-foreground: oklch(0.25 0 0);
|
||||
/* Near-black for emphasis */
|
||||
--primary: oklch(0.3 0 0);
|
||||
--primary-foreground: oklch(0.965 0.015 85);
|
||||
/* Subtle tan for secondary */
|
||||
--secondary: oklch(0.92 0.012 85);
|
||||
--secondary-foreground: oklch(0.3 0 0);
|
||||
/* Muted tan for borders and backgrounds */
|
||||
--muted: oklch(0.92 0.012 85);
|
||||
--muted-foreground: oklch(0.45 0 0);
|
||||
/* Warm accent for hovers */
|
||||
--accent: oklch(0.9 0.025 85);
|
||||
--accent-foreground: oklch(0.25 0 0);
|
||||
/* Destructive - muted red */
|
||||
--destructive: oklch(0.577 0.2 25);
|
||||
/* Subtle warm borders */
|
||||
--border: oklch(0.88 0.015 85);
|
||||
--input: oklch(0.88 0.015 85);
|
||||
--ring: oklch(0.3 0 0);
|
||||
/* Chart colors - warm academic tones */
|
||||
--chart-1: oklch(0.5 0.12 45);
|
||||
--chart-2: oklch(0.55 0.1 180);
|
||||
--chart-3: oklch(0.45 0.08 220);
|
||||
--chart-4: oklch(0.65 0.15 85);
|
||||
--chart-5: oklch(0.6 0.14 70);
|
||||
/* Sidebar */
|
||||
--sidebar: oklch(0.96 0.012 85);
|
||||
--sidebar-foreground: oklch(0.25 0 0);
|
||||
--sidebar-primary: oklch(0.3 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.965 0.015 85);
|
||||
--sidebar-accent: oklch(0.92 0.012 85);
|
||||
--sidebar-accent-foreground: oklch(0.3 0 0);
|
||||
--sidebar-border: oklch(0.88 0.015 85);
|
||||
--sidebar-ring: oklch(0.5 0 0);
|
||||
--radius: 0.375rem;
|
||||
/* Warm offwhite/cream background */
|
||||
--background: oklch(0.965 0.015 85);
|
||||
/* Dark charcoal text */
|
||||
--foreground: oklch(0.25 0 0);
|
||||
/* Slightly lighter cream for cards */
|
||||
--card: oklch(0.98 0.01 85);
|
||||
--card-foreground: oklch(0.25 0 0);
|
||||
--popover: oklch(0.98 0.01 85);
|
||||
--popover-foreground: oklch(0.25 0 0);
|
||||
/* Near-black for emphasis */
|
||||
--primary: oklch(0.3 0 0);
|
||||
--primary-foreground: oklch(0.965 0.015 85);
|
||||
/* Subtle tan for secondary */
|
||||
--secondary: oklch(0.92 0.012 85);
|
||||
--secondary-foreground: oklch(0.3 0 0);
|
||||
/* Muted tan for borders and backgrounds */
|
||||
--muted: oklch(0.92 0.012 85);
|
||||
--muted-foreground: oklch(0.45 0 0);
|
||||
/* Warm accent for hovers */
|
||||
--accent: oklch(0.9 0.025 85);
|
||||
--accent-foreground: oklch(0.25 0 0);
|
||||
/* Destructive - muted red */
|
||||
--destructive: oklch(0.577 0.2 25);
|
||||
/* Subtle warm borders */
|
||||
--border: oklch(0.88 0.015 85);
|
||||
--input: oklch(0.88 0.015 85);
|
||||
--ring: oklch(0.3 0 0);
|
||||
/* Chart colors - warm academic tones */
|
||||
--chart-1: oklch(0.5 0.12 45);
|
||||
--chart-2: oklch(0.55 0.1 180);
|
||||
--chart-3: oklch(0.45 0.08 220);
|
||||
--chart-4: oklch(0.65 0.15 85);
|
||||
--chart-5: oklch(0.6 0.14 70);
|
||||
/* Sidebar */
|
||||
--sidebar: oklch(0.96 0.012 85);
|
||||
--sidebar-foreground: oklch(0.25 0 0);
|
||||
--sidebar-primary: oklch(0.3 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.965 0.015 85);
|
||||
--sidebar-accent: oklch(0.92 0.012 85);
|
||||
--sidebar-accent-foreground: oklch(0.3 0 0);
|
||||
--sidebar-border: oklch(0.88 0.015 85);
|
||||
--sidebar-ring: oklch(0.5 0 0);
|
||||
}
|
||||
|
||||
/* Dark mode - inverted but still warm */
|
||||
.dark {
|
||||
--background: oklch(0.18 0.01 85);
|
||||
--foreground: oklch(0.92 0.01 85);
|
||||
--card: oklch(0.22 0.01 85);
|
||||
--card-foreground: oklch(0.92 0.01 85);
|
||||
--popover: oklch(0.22 0.01 85);
|
||||
--popover-foreground: oklch(0.92 0.01 85);
|
||||
--primary: oklch(0.92 0.01 85);
|
||||
--primary-foreground: oklch(0.22 0.01 85);
|
||||
--secondary: oklch(0.28 0.01 85);
|
||||
--secondary-foreground: oklch(0.92 0.01 85);
|
||||
--muted: oklch(0.28 0.01 85);
|
||||
--muted-foreground: oklch(0.65 0.01 85);
|
||||
--accent: oklch(0.28 0.01 85);
|
||||
--accent-foreground: oklch(0.92 0.01 85);
|
||||
--destructive: oklch(0.65 0.18 22);
|
||||
--border: oklch(0.92 0.01 85 / 12%);
|
||||
--input: oklch(0.92 0.01 85 / 15%);
|
||||
--ring: oklch(0.6 0 0);
|
||||
--chart-1: oklch(0.55 0.2 260);
|
||||
--chart-2: oklch(0.65 0.15 160);
|
||||
--chart-3: oklch(0.7 0.16 70);
|
||||
--chart-4: oklch(0.6 0.22 300);
|
||||
--chart-5: oklch(0.6 0.2 20);
|
||||
--sidebar: oklch(0.22 0.01 85);
|
||||
--sidebar-foreground: oklch(0.92 0.01 85);
|
||||
--sidebar-primary: oklch(0.55 0.2 260);
|
||||
--sidebar-primary-foreground: oklch(0.92 0.01 85);
|
||||
--sidebar-accent: oklch(0.28 0.01 85);
|
||||
--sidebar-accent-foreground: oklch(0.92 0.01 85);
|
||||
--sidebar-border: oklch(0.92 0.01 85 / 12%);
|
||||
--sidebar-ring: oklch(0.6 0 0);
|
||||
--background: oklch(0.18 0.01 85);
|
||||
--foreground: oklch(0.92 0.01 85);
|
||||
--card: oklch(0.22 0.01 85);
|
||||
--card-foreground: oklch(0.92 0.01 85);
|
||||
--popover: oklch(0.22 0.01 85);
|
||||
--popover-foreground: oklch(0.92 0.01 85);
|
||||
--primary: oklch(0.92 0.01 85);
|
||||
--primary-foreground: oklch(0.22 0.01 85);
|
||||
--secondary: oklch(0.28 0.01 85);
|
||||
--secondary-foreground: oklch(0.92 0.01 85);
|
||||
--muted: oklch(0.28 0.01 85);
|
||||
--muted-foreground: oklch(0.65 0.01 85);
|
||||
--accent: oklch(0.28 0.01 85);
|
||||
--accent-foreground: oklch(0.92 0.01 85);
|
||||
--destructive: oklch(0.65 0.18 22);
|
||||
--border: oklch(0.92 0.01 85 / 12%);
|
||||
--input: oklch(0.92 0.01 85 / 15%);
|
||||
--ring: oklch(0.6 0 0);
|
||||
--chart-1: oklch(0.55 0.2 260);
|
||||
--chart-2: oklch(0.65 0.15 160);
|
||||
--chart-3: oklch(0.7 0.16 70);
|
||||
--chart-4: oklch(0.6 0.22 300);
|
||||
--chart-5: oklch(0.6 0.2 20);
|
||||
--sidebar: oklch(0.22 0.01 85);
|
||||
--sidebar-foreground: oklch(0.92 0.01 85);
|
||||
--sidebar-primary: oklch(0.55 0.2 260);
|
||||
--sidebar-primary-foreground: oklch(0.92 0.01 85);
|
||||
--sidebar-accent: oklch(0.28 0.01 85);
|
||||
--sidebar-accent-foreground: oklch(0.92 0.01 85);
|
||||
--sidebar-border: oklch(0.92 0.01 85 / 12%);
|
||||
--sidebar-ring: oklch(0.6 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-feature-settings: "kern" 1, "liga" 1;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-feature-settings:
|
||||
"kern" 1,
|
||||
"liga" 1;
|
||||
}
|
||||
|
||||
/* Research paper typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-serif);
|
||||
@apply tracking-tight;
|
||||
}
|
||||
/* Research paper typography */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-serif);
|
||||
@apply tracking-tight;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply leading-relaxed;
|
||||
}
|
||||
p {
|
||||
@apply leading-relaxed;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ol {
|
||||
@apply list-decimal list-inside my-4 space-y-2 pl-4;
|
||||
}
|
||||
/* Lists */
|
||||
ol {
|
||||
@apply list-decimal list-outside my-4 space-y-2 pl-6;
|
||||
}
|
||||
|
||||
ul {
|
||||
@apply list-disc list-inside my-4 space-y-2 pl-4;
|
||||
}
|
||||
ul {
|
||||
@apply list-disc list-outside my-4 space-y-2 pl-6;
|
||||
}
|
||||
|
||||
li {
|
||||
@apply leading-relaxed;
|
||||
}
|
||||
li {
|
||||
@apply leading-relaxed;
|
||||
}
|
||||
|
||||
/* Slightly larger base font for readability */
|
||||
html {
|
||||
font-size: 17px;
|
||||
}
|
||||
/* Slightly larger base font for readability */
|
||||
html {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar highlight animation */
|
||||
@keyframes sidebar-highlight {
|
||||
0%,
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
25% {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-highlight {
|
||||
animation: sidebar-highlight 1.5s ease-out;
|
||||
}
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { getAllNoteSlugs, getNoteBySlug } from "@/lib/content";
|
||||
import { PageLayout } from "@/components/layout/PageLayout";
|
||||
import { BackNav } from "@/components/layout/BackNav";
|
||||
import { NoteHeader } from "@/components/notes/NoteHeader";
|
||||
import { PromptsSection } from "@/components/notes/PromptsSection";
|
||||
import { SkillsSection } from "@/components/notes/SkillsSection";
|
||||
import { FilesSection } from "@/components/notes/FilesSection";
|
||||
import { AudioSection } from "@/components/notes/AudioSection";
|
||||
import { NoteFooter } from "@/components/notes/NoteFooter";
|
||||
import { NoteSidebarLayout } from "@/components/notes/NoteSidebar";
|
||||
import { NoteContent } from "@/components/notes/NoteContent";
|
||||
|
||||
interface NotePageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
@ -27,55 +27,36 @@ export default async function NotePage({ params }: NotePageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Count resources for the mobile button
|
||||
const resourceCount =
|
||||
note.prompts.length +
|
||||
note.files.length +
|
||||
(note.skillsUsed?.length || 0) +
|
||||
(note.audioFiles?.length || 0);
|
||||
|
||||
// Sidebar content
|
||||
const sidebarContent = (
|
||||
<>
|
||||
<PromptsSection prompts={note.prompts} />
|
||||
<SkillsSection skills={note.skillsUsed || []} />
|
||||
<FilesSection files={note.files} />
|
||||
<AudioSection noteSlug={note.slug} audioFiles={note.audioFiles || []} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<NoteSidebarLayout sidebar={sidebarContent} resourceCount={resourceCount}>
|
||||
<BackNav href="/maxwell" label="Back to Maxwell" />
|
||||
|
||||
<NoteHeader id={note.id} date={note.date} title={note.title} />
|
||||
|
||||
<PromptsSection prompts={note.prompts} />
|
||||
|
||||
<SkillsSection skills={note.skillsUsed || []} />
|
||||
|
||||
<FilesSection files={note.files} />
|
||||
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-semibold mb-4 mt-12 first:mt-0">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="font-medium mt-6 mb-2">{children}</h3>
|
||||
),
|
||||
p: ({ children }) => <p className="mb-4">{children}</p>,
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-4 text-muted-foreground">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-4 text-muted-foreground">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="mb-1">{children}</li>,
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-foreground">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => <em>{children}</em>,
|
||||
}}
|
||||
>
|
||||
{note.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<NoteContent content={note.content} />
|
||||
|
||||
<NoteFooter
|
||||
prev={note.navigation.prev}
|
||||
next={note.navigation.next}
|
||||
allNotesHref="/maxwell"
|
||||
/>
|
||||
</PageLayout>
|
||||
</NoteSidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ interface PageLayoutProps {
|
||||
export function PageLayout({ children }: PageLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<article className="mx-auto max-w-[800px] px-6 py-16 text-foreground">
|
||||
<article className="mx-auto max-w-[800px] px-6 py-8 text-foreground">
|
||||
{children}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
69
blog/src/components/notes/AudioSection.tsx
Normal file
69
blog/src/components/notes/AudioSection.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import type { AudioRef } from "@/lib/content";
|
||||
import { getAudioUrl } from "@/lib/assets";
|
||||
import { SidebarItem } from "./SidebarItem";
|
||||
|
||||
interface AudioSectionProps {
|
||||
noteSlug: string;
|
||||
audioFiles: AudioRef[];
|
||||
}
|
||||
|
||||
export function AudioSection({ noteSlug, audioFiles }: AudioSectionProps) {
|
||||
if (audioFiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<h2 className="text-sm font-medium text-muted-foreground mb-4">Audio</h2>
|
||||
<div className="space-y-6">
|
||||
{audioFiles.map((audio) => {
|
||||
const audioPath = getAudioUrl(noteSlug, audio.name);
|
||||
return (
|
||||
<SidebarItem key={audio.name} id={`audio-${audio.name}`}>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium">{audio.title}</span>
|
||||
{audio.source && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-muted rounded text-muted-foreground">
|
||||
{audio.source}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={audioPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
open
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{audio.description}
|
||||
</p>
|
||||
<audio controls className="w-full" preload="metadata">
|
||||
<source src={audioPath} type="audio/mp4" />
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
</SidebarItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
Made with{" "}
|
||||
<a
|
||||
href="https://notebooklm.google.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
NotebookLM
|
||||
</a>
|
||||
: upload research files → Audio Overview → download .m4a →{" "}
|
||||
<code className="text-[10px]">scripts/upload-asset.sh</code>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { ExpandableFile } from "@/components/copyable";
|
||||
import { SidebarItem } from "./SidebarItem";
|
||||
import type { NoteFile } from "@/lib/content";
|
||||
|
||||
interface FilesSectionProps {
|
||||
@ -12,23 +13,24 @@ export function FilesSection({ files }: FilesSectionProps) {
|
||||
const [expandedFile, setExpandedFile] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<section className="mb-12 p-4 border border-border rounded">
|
||||
<section className="mb-8">
|
||||
<h2 className="text-sm font-medium text-muted-foreground mb-4">
|
||||
Files Created
|
||||
</h2>
|
||||
{files.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{files.map((file) => (
|
||||
<ExpandableFile
|
||||
key={file.name}
|
||||
name={file.name}
|
||||
description={file.description}
|
||||
content={file.content}
|
||||
expanded={expandedFile === file.name}
|
||||
onToggle={() =>
|
||||
setExpandedFile(expandedFile === file.name ? null : file.name)
|
||||
}
|
||||
/>
|
||||
<SidebarItem key={file.name} id={`file-${file.name}`}>
|
||||
<ExpandableFile
|
||||
name={file.name}
|
||||
description={file.description}
|
||||
content={file.content}
|
||||
expanded={expandedFile === file.name}
|
||||
onToggle={() =>
|
||||
setExpandedFile(expandedFile === file.name ? null : file.name)
|
||||
}
|
||||
/>
|
||||
</SidebarItem>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
27
blog/src/components/notes/InlineLink.tsx
Normal file
27
blog/src/components/notes/InlineLink.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useSidebar } from "./SidebarContext";
|
||||
|
||||
interface InlineLinkProps {
|
||||
type: "prompt" | "file" | "skill" | "audio";
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function InlineLink({ type, id, children }: InlineLinkProps) {
|
||||
const { highlightItem } = useSidebar();
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
highlightItem(`${type}-${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-foreground underline underline-offset-2 decoration-muted-foreground/50 hover:decoration-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
147
blog/src/components/notes/NoteContent.tsx
Normal file
147
blog/src/components/notes/NoteContent.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { InlineLink } from "./InlineLink";
|
||||
|
||||
interface NoteContentProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Match [[type:id]] patterns
|
||||
const INLINE_LINK_REGEX = /\[\[(prompt|file|skill|audio):([^\]]+)\]\]/g;
|
||||
|
||||
interface ContentPart {
|
||||
type: "text" | "link";
|
||||
content: string;
|
||||
linkType?: "prompt" | "file" | "skill" | "audio";
|
||||
linkId?: string;
|
||||
}
|
||||
|
||||
function parseContentWithLinks(text: string): ContentPart[] {
|
||||
const parts: ContentPart[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
// Reset regex
|
||||
INLINE_LINK_REGEX.lastIndex = 0;
|
||||
|
||||
while ((match = INLINE_LINK_REGEX.exec(text)) !== null) {
|
||||
// Add text before the match
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({
|
||||
type: "text",
|
||||
content: text.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
|
||||
// Add the link
|
||||
parts.push({
|
||||
type: "link",
|
||||
content: match[2],
|
||||
linkType: match[1] as "prompt" | "file" | "skill" | "audio",
|
||||
linkId: match[2],
|
||||
});
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({
|
||||
type: "text",
|
||||
content: text.slice(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function renderTextWithLinks(text: string): React.ReactNode {
|
||||
const parts = parseContentWithLinks(text);
|
||||
|
||||
if (parts.length === 1 && parts[0].type === "text") {
|
||||
return text;
|
||||
}
|
||||
|
||||
return parts.map((part, index) => {
|
||||
if (part.type === "link") {
|
||||
return (
|
||||
<InlineLink
|
||||
key={index}
|
||||
type={part.linkType!}
|
||||
id={part.linkId!}
|
||||
>
|
||||
{part.content}
|
||||
</InlineLink>
|
||||
);
|
||||
}
|
||||
return part.content;
|
||||
});
|
||||
}
|
||||
|
||||
export function NoteContent({ content }: NoteContentProps) {
|
||||
return (
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-semibold mb-4 mt-12 first:mt-0">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="font-medium mt-6 mb-2">{children}</h3>
|
||||
),
|
||||
p: ({ children }) => {
|
||||
// Process text children for inline links
|
||||
const processedChildren = processChildren(children);
|
||||
return <p className="mb-4">{processedChildren}</p>;
|
||||
},
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-4 text-muted-foreground">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-4 text-muted-foreground">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => {
|
||||
const processedChildren = processChildren(children);
|
||||
return <li className="mb-1">{processedChildren}</li>;
|
||||
},
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-foreground">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => <em>{children}</em>,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function processChildren(children: React.ReactNode): React.ReactNode {
|
||||
if (typeof children === "string") {
|
||||
return renderTextWithLinks(children);
|
||||
}
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
return children.map((child, index) => {
|
||||
if (typeof child === "string") {
|
||||
const result = renderTextWithLinks(child);
|
||||
// If it's still just a string, return as-is
|
||||
if (typeof result === "string") {
|
||||
return result;
|
||||
}
|
||||
// If it's an array of parts, wrap in fragment with key
|
||||
return <span key={index}>{result}</span>;
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
107
blog/src/components/notes/NoteSidebar.tsx
Normal file
107
blog/src/components/notes/NoteSidebar.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { useSidebar, SidebarProvider } from "./SidebarContext";
|
||||
|
||||
interface NoteSidebarLayoutProps {
|
||||
children: ReactNode;
|
||||
sidebar: ReactNode;
|
||||
resourceCount: number;
|
||||
}
|
||||
|
||||
function MobileDrawerButton({ count }: { count: number }) {
|
||||
const { openDrawer } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={openDrawer}
|
||||
className="lg:hidden fixed top-4 right-4 z-40 flex items-center gap-1.5 px-3 py-1.5 bg-card border border-border rounded-full text-sm font-medium shadow-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<span>resources</span>
|
||||
<span className="text-xs bg-muted px-1.5 py-0.5 rounded-full">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileDrawer({ children }: { children: ReactNode }) {
|
||||
const { isDrawerOpen, closeDrawer } = useSidebar();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`lg:hidden fixed inset-0 z-40 bg-black/50 transition-opacity ${isDrawerOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
onClick={closeDrawer}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
className={`lg:hidden fixed top-0 right-0 z-50 h-full w-[320px] max-w-[85vw] bg-background border-l border-border shadow-xl transform transition-transform ${isDrawerOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Resources
|
||||
</span>
|
||||
<button
|
||||
onClick={closeDrawer}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto h-[calc(100%-57px)]">{children}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutInner({
|
||||
children,
|
||||
sidebar,
|
||||
resourceCount,
|
||||
}: NoteSidebarLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<MobileDrawerButton count={resourceCount} />
|
||||
<MobileDrawer>{sidebar}</MobileDrawer>
|
||||
|
||||
<div className="relative mx-auto flex gap-8 px-6">
|
||||
{/* Main content */}
|
||||
<article className="flex-1 py-8 text-foreground">
|
||||
{children}
|
||||
</article>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden lg:block w-[320px] xl:w-[380px] pt-16 pr-6">
|
||||
<div className="sticky top-4 max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||
{sidebar}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoteSidebarLayout(props: NoteSidebarLayoutProps) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<LayoutInner {...props} />
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CopyableBlock } from "@/components/copyable";
|
||||
import { SidebarItem } from "./SidebarItem";
|
||||
import type { Prompt } from "@/lib/content";
|
||||
|
||||
interface PromptsSectionProps {
|
||||
@ -7,17 +10,18 @@ interface PromptsSectionProps {
|
||||
|
||||
export function PromptsSection({ prompts }: PromptsSectionProps) {
|
||||
return (
|
||||
<section className="mb-8 p-4 border border-border rounded">
|
||||
<section className="mb-8">
|
||||
<h2 className="text-sm font-medium text-muted-foreground mb-4">
|
||||
Prompts Used
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{prompts.map((prompt) => (
|
||||
<CopyableBlock
|
||||
key={prompt.id}
|
||||
label={prompt.label}
|
||||
content={prompt.content.trim()}
|
||||
/>
|
||||
<SidebarItem key={prompt.id} id={`prompt-${prompt.id}`}>
|
||||
<CopyableBlock
|
||||
label={prompt.label}
|
||||
content={prompt.content.trim()}
|
||||
/>
|
||||
</SidebarItem>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
66
blog/src/components/notes/SidebarContext.tsx
Normal file
66
blog/src/components/notes/SidebarContext.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
|
||||
interface SidebarContextValue {
|
||||
isDrawerOpen: boolean;
|
||||
openDrawer: () => void;
|
||||
closeDrawer: () => void;
|
||||
highlightedItem: string | null;
|
||||
highlightItem: (id: string) => void;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextValue | null>(null);
|
||||
|
||||
export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [highlightedItem, setHighlightedItem] = useState<string | null>(null);
|
||||
|
||||
const openDrawer = useCallback(() => setIsDrawerOpen(true), []);
|
||||
const closeDrawer = useCallback(() => setIsDrawerOpen(false), []);
|
||||
|
||||
const highlightItem = useCallback((id: string) => {
|
||||
// Scroll to the element
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
// Open drawer on mobile
|
||||
setIsDrawerOpen(true);
|
||||
|
||||
// Set highlight (will trigger animation)
|
||||
setHighlightedItem(id);
|
||||
|
||||
// Clear highlight after animation completes
|
||||
setTimeout(() => setHighlightedItem(null), 1500);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider
|
||||
value={{
|
||||
isDrawerOpen,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
highlightedItem,
|
||||
highlightItem,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebar() {
|
||||
const context = useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
23
blog/src/components/notes/SidebarItem.tsx
Normal file
23
blog/src/components/notes/SidebarItem.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { useSidebar } from "./SidebarContext";
|
||||
|
||||
interface SidebarItemProps {
|
||||
id: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SidebarItem({ id, children }: SidebarItemProps) {
|
||||
const { highlightedItem } = useSidebar();
|
||||
const isHighlighted = highlightedItem === id;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={isHighlighted ? "sidebar-highlight rounded" : ""}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { CopyableBlock } from "@/components/copyable";
|
||||
import { SidebarItem } from "./SidebarItem";
|
||||
|
||||
export interface SkillUsed {
|
||||
name: string;
|
||||
@ -20,37 +21,39 @@ export function SkillsSection({ skills }: SkillsSectionProps) {
|
||||
if (skills.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="mb-8 p-4 border border-border rounded">
|
||||
<section className="mb-8">
|
||||
<h2 className="text-sm font-medium text-muted-foreground mb-4">
|
||||
Skills Used
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{skills.map((skill) => (
|
||||
<div key={skill.name} className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedSkill(
|
||||
expandedSkill === skill.name ? null : skill.name
|
||||
)
|
||||
}
|
||||
className="flex items-baseline gap-3 text-sm hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{skill.command}
|
||||
</code>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{skill.description}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{expandedSkill === skill.name ? "−" : "+"}
|
||||
</span>
|
||||
</button>
|
||||
<SidebarItem key={skill.name} id={`skill-${skill.name}`}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedSkill(
|
||||
expandedSkill === skill.name ? null : skill.name
|
||||
)
|
||||
}
|
||||
className="flex items-baseline gap-3 text-sm hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{skill.command}
|
||||
</code>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{skill.description}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{expandedSkill === skill.name ? "−" : "+"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{expandedSkill === skill.name && skill.usage && (
|
||||
<CopyableBlock content={skill.usage} />
|
||||
)}
|
||||
</div>
|
||||
{expandedSkill === skill.name && skill.usage && (
|
||||
<CopyableBlock content={skill.usage} />
|
||||
)}
|
||||
</div>
|
||||
</SidebarItem>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
10
blog/src/lib/assets.ts
Normal file
10
blog/src/lib/assets.ts
Normal file
@ -0,0 +1,10 @@
|
||||
const ASSET_BASE = process.env.NEXT_PUBLIC_ASSET_BASE_URL
|
||||
|| "https://storage.googleapis.com/orchard9-assets/research-notes";
|
||||
|
||||
export function getAssetUrl(path: string): string {
|
||||
return `${ASSET_BASE}/${path}`;
|
||||
}
|
||||
|
||||
export function getAudioUrl(noteSlug: string, filename: string): string {
|
||||
return getAssetUrl(`audio/notes/${noteSlug}/${filename}`);
|
||||
}
|
||||
@ -30,6 +30,13 @@ export interface FileRef {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AudioRef {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface SkillUsed {
|
||||
name: string;
|
||||
command: string;
|
||||
@ -52,6 +59,7 @@ export interface NoteMeta {
|
||||
prompts: Prompt[];
|
||||
skillsUsed?: SkillUsed[];
|
||||
filesCreated: FileRef[];
|
||||
audioFiles?: AudioRef[];
|
||||
navigation: {
|
||||
prev: NoteNavLink | null;
|
||||
next: NoteNavLink | null;
|
||||
@ -179,3 +187,32 @@ export function getWhitePaperOutline(): WhitePaperOutline {
|
||||
const fileContents = fs.readFileSync(filePath, "utf8");
|
||||
return yaml.load(fileContents) as WhitePaperOutline;
|
||||
}
|
||||
|
||||
// Inline link parsing for [[type:id]] syntax
|
||||
const INLINE_LINK_REGEX = /\[\[(prompt|file|skill|audio):([^\]]+)\]\]/g;
|
||||
|
||||
export interface InlineLinkMatch {
|
||||
type: "prompt" | "file" | "skill" | "audio";
|
||||
id: string;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export function parseInlineLinks(content: string): string {
|
||||
return content.replace(INLINE_LINK_REGEX, (_match, type, id) => {
|
||||
// Create a custom element that React can render
|
||||
return `<inline-ref type="${type}" id="${id}">${id}</inline-ref>`;
|
||||
});
|
||||
}
|
||||
|
||||
export function extractInlineLinks(content: string): InlineLinkMatch[] {
|
||||
const matches: InlineLinkMatch[] = [];
|
||||
let match;
|
||||
while ((match = INLINE_LINK_REGEX.exec(content)) !== null) {
|
||||
matches.push({
|
||||
type: match[1] as InlineLinkMatch["type"],
|
||||
id: match[2],
|
||||
displayText: match[2],
|
||||
});
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
41
blog/vision.md
Normal file
41
blog/vision.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Vision
|
||||
|
||||
Notes from building things.
|
||||
|
||||
## What This Is
|
||||
|
||||
A project log. I write down what I'm doing as I do it. Prompts, decisions, dead ends, things that worked.
|
||||
|
||||
Sometimes there's audio or video when it's easier to explain that way.
|
||||
|
||||
## Why
|
||||
|
||||
Two reasons:
|
||||
|
||||
1. **For me** — I forget what I did and why. Notes help me pick up where I left off.
|
||||
2. **For others** — If you're building something similar, maybe this saves you time.
|
||||
|
||||
## Tone
|
||||
|
||||
- Casual
|
||||
- Concise
|
||||
- Direct
|
||||
- No fluff
|
||||
- No hyperbole
|
||||
|
||||
If something sucked, I'll say it sucked. If something worked, I'll say what worked.
|
||||
|
||||
## Format
|
||||
|
||||
Each project gets a series of notes. Each note covers one session or one phase. Notes include:
|
||||
|
||||
- What I was trying to do
|
||||
- The prompts I used
|
||||
- What happened
|
||||
- What I learned
|
||||
|
||||
Code snippets, screenshots, audio/video when useful.
|
||||
|
||||
## Not a Tutorial
|
||||
|
||||
This isn't polished educational content. It's raw notes. Some things will be wrong. Some things will be incomplete. That's fine.
|
||||
16
scripts/upload-asset.sh
Executable file
16
scripts/upload-asset.sh
Executable file
@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
BUCKET="orchard9-assets"
|
||||
PREFIX="research-notes"
|
||||
|
||||
LOCAL_FILE="$1"
|
||||
REMOTE_PATH="$2"
|
||||
|
||||
[[ -z "$LOCAL_FILE" || -z "$REMOTE_PATH" ]] && {
|
||||
echo "Usage: $0 <local-file> <remote-path>"
|
||||
echo "Example: $0 ./audio.m4a audio/notes/003-research-planning/audio.m4a"
|
||||
exit 1
|
||||
}
|
||||
|
||||
gcloud storage cp "$LOCAL_FILE" "gs://${BUCKET}/${PREFIX}/${REMOTE_PATH}"
|
||||
echo "https://storage.googleapis.com/${BUCKET}/${PREFIX}/${REMOTE_PATH}"
|
||||
Loading…
Reference in New Issue
Block a user