72 Commits

Author SHA1 Message Date
281c3d02f2 scope codemirror-helix
All checks were successful
Publish codemirror-helix / publish (push) Successful in 2m19s
2026-06-19 17:15:51 +02:00
781e03c50c publish codemirrot helix package flow
Some checks failed
Publish codemirror-helix / publish (push) Failing after 2m16s
2026-06-19 17:07:18 +02:00
81c60feed9 mdx layout components 2026-06-19 14:48:38 +02:00
0fe62fcce5 music desc order 2026-06-18 05:55:37 +02:00
b5f40a4007 remove tacky red circles 2026-06-18 05:50:15 +02:00
1100e35091 Merge branch 'additional-ai-tools' 2026-06-18 05:37:16 +02:00
246d7339fb homepage, cv page layout 2026-06-18 05:37:11 +02:00
85af4aec77 make meeting creation user level 2026-06-18 04:55:25 +02:00
fb379b912a Merge branch 'additional-ai-tools' 2026-06-18 03:38:06 +02:00
05740e122e Add AI assistant tools for site search, project details, experience matching, and calendar availability 2026-06-18 03:37:22 +02:00
ffa475e876 Merge branch 'blog-scroll' 2026-06-18 02:57:44 +02:00
d85cc205de blog slug scroll 2026-06-18 02:57:36 +02:00
95666e20e9 Merge branch 'chat-scroll-behaiviour' 2026-06-18 02:53:20 +02:00
993137068e chat scroll to bottom by default 2026-06-18 02:53:06 +02:00
5755bd3184 fix chat mobile cutoff 2026-06-18 02:49:05 +02:00
ca29bd5003 Merge branch 'project-music-srr' 2026-06-18 02:47:15 +02:00
62f808b0cf animated components hidden default 2026-06-18 02:47:03 +02:00
cb3ece4f99 ssr 2026-06-18 02:39:45 +02:00
c5faf8fa57 same mde editor in all admin forms 2026-06-18 02:12:37 +02:00
a7354ad774 Merge branch 'fix-cv-animations' 2026-06-18 01:59:00 +02:00
c742b8e457 fix height problems 2026-06-18 01:58:53 +02:00
4ce93a0466 Merge branch 'fix-cv-animations' 2026-06-18 01:43:35 +02:00
0d79adb104 fix cv animations 2026-06-18 01:41:25 +02:00
3e5be46503 Merge branch 'mdx-refactor' 2026-06-18 01:33:00 +02:00
73ba2b573d cleanup markdown 2026-06-18 01:32:05 +02:00
c1fe73dbd0 remove stupid spacer 2026-06-16 19:55:38 +02:00
af4ff18917 Merge branch 'fix-cv-animations' 2026-06-16 19:16:37 +02:00
b59fb2b3af fetch on render instead of fetch as you render 2026-06-16 19:16:31 +02:00
58dc4ce53f Merge branch 'background-music-playback' 2026-06-16 19:13:44 +02:00
53bc70ab05 use audio session api 2026-06-16 19:13:32 +02:00
1b3f30cc90 Merge branch 'fix-cv-animations' 2026-06-16 19:10:33 +02:00
fd5063d1c4 cv entry uses animated components 2026-06-16 19:10:28 +02:00
303ac83fe2 Merge branch 'backround-music-playback' 2026-06-16 19:01:21 +02:00
91315730ac background playback 2026-06-16 19:00:59 +02:00
13649cd6dc Merge branch 'fix-cv-animations' 2026-06-16 18:56:05 +02:00
1e0f033d07 refactor to use existing animated components 2026-06-16 18:56:02 +02:00
39563b6740 Merge branch 'fix-cv-animations' 2026-06-16 18:42:19 +02:00
dcc4f47ccf fix cv animations 2026-06-16 18:42:06 +02:00
c8a9ab5984 Merge branch 'ai-model-select' 2026-06-16 18:40:14 +02:00
54f108ac8d model selector 2026-06-16 18:40:08 +02:00
865ef0b316 refactor animations once again 2026-06-16 15:41:03 +02:00
63b0405a7a display waveform 2026-06-16 13:07:41 +02:00
7aa1746f97 convert flacs to aac for streaming 2026-06-16 12:49:30 +02:00
f5e8b87846 package updates, minor bug fixes, stack items, background animation fixes 2026-06-06 12:15:01 +02:00
65b9184a22 refactor animated background, fix type 2026-04-24 14:23:52 +02:00
ea7ddb8e51 fix admin redirect loop 2026-04-24 12:41:27 +02:00
bcefe397ca speedinsights next 2026-04-24 12:26:44 +02:00
8ce95f2b5c add speed insides 2026-04-24 12:23:31 +02:00
da43b31aa3 chat interface, validate user id 2026-04-24 12:11:29 +02:00
538d896b0e Merge branch 'blog' 2026-04-24 11:58:34 +02:00
be6df0c8ad blog editor 2026-04-24 11:58:19 +02:00
daab745c13 slop 2026-04-23 11:10:35 +02:00
c527391259 Merge branch 'projectpage' 2026-04-23 11:06:24 +02:00
404062904f finish up project page 2026-04-23 11:04:59 +02:00
4e8538552e Merge branch 'aiassistant' 2026-04-22 21:19:48 +02:00
64bd5c429e logged out chat modal, call to action 2026-04-22 21:05:53 +02:00
52e0a65113 hide google sign in 2026-04-22 19:31:09 +02:00
30e3dbb42b chat interface 2026-04-21 14:19:50 +02:00
caa9604704 early return on no sessionid 2026-03-31 15:47:41 +02:00
c62ee37538 prooompt engineering 2026-03-31 15:30:42 +02:00
ead9548744 server auth stuff, prooompt engineering 2026-03-31 15:29:47 +02:00
c5b3ee3875 get session by userid 2026-03-31 14:44:08 +02:00
2b5c105abb current time tool 2026-03-31 14:39:56 +02:00
e25fc39bac current time tool 2026-03-31 14:23:04 +02:00
e481fa66cd reapply layout effect fix for timeline cleanup 2026-03-31 14:16:26 +02:00
009d2b8d60 drop multitimeline support from gsap provider but keep scroller priority feature 2026-03-31 14:12:29 +02:00
d567fa3e02 fix chat 2026-03-31 14:03:41 +02:00
399d78e508 reset timeline cleanup needs to be returned from layout effect 2026-03-30 19:04:20 +02:00
bfc2bb1501 reset gsap provider to music page version 2026-03-30 18:54:19 +02:00
d7a9e53d9a revert some gsap provider changes 2026-03-30 18:50:05 +02:00
18abc4b3f7 chat fab 2026-03-30 18:10:09 +02:00
34dc53a8e9 ai modal 2026-03-30 18:09:55 +02:00
114 changed files with 8949 additions and 1596 deletions

0
.codex Normal file
View File

View File

@@ -0,0 +1,48 @@
name: Publish codemirror-helix
# Releases the packages/codemirror-helix workspace package to npm.
# Trigger by pushing a tag like `codemirror-helix-v0.1.0` whose version
# matches packages/codemirror-helix/package.json.
on:
push:
tags:
- "codemirror-helix-v*"
jobs:
publish:
runs-on: x86
defaults:
run:
working-directory: packages/codemirror-helix
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: "https://registry.npmjs.org"
# Workspace install must happen at the monorepo root.
- name: Install dependencies
working-directory: .
run: bun install --frozen-lockfile
- name: Check tag matches package version
run: |
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
TAG_NAME="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}"
test "codemirror-helix-v${PACKAGE_VERSION}" = "${TAG_NAME}"
- name: Build
run: bun run build
- name: Publish
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

3
.gitignore vendored
View File

@@ -48,3 +48,6 @@ yarn-error.log*
/.clerk/ /.clerk/
.worktrees .worktrees
.claudesession .claudesession
# built workspace packages (rebuilt by helix:build / package CI)
/packages/*/dist

View File

@@ -1,2 +1,4 @@
node_modules/** node_modules/**
.next/** .next/**
.worktrees
.clerk

1624
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,10 @@ import "./src/env.js";
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
transpilePackages: ["next-mdx-remote"],
typescript: { typescript: {
ignoreBuildErrors: true ignoreBuildErrors: true,
} },
}; };
export default config; export default config;

View File

@@ -3,8 +3,11 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"workspaces": ["packages/*"],
"scripts": { "scripts": {
"build": "next build", "build": "bun run helix:build && next build",
"helix:build": "bun run --cwd packages/codemirror-helix build",
"build:ffmpeg-worker": "bun build node_modules/@ffmpeg/ffmpeg/dist/esm/worker.js --target browser --format esm --minify --outfile public/ffmpeg/worker.js",
"check": "biome check .", "check": "biome check .",
"check:unsafe": "biome check --write --unsafe .", "check:unsafe": "biome check --write --unsafe .",
"check:write": "biome check --write .", "check:write": "biome check --write .",
@@ -12,21 +15,36 @@
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"dev": "next dev --turbo", "dev": "bun run helix:build && next dev --turbo",
"preview": "next build && next start", "preview": "next build && next start",
"start": "next start", "start": "next start",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest --typecheck" "test": "vitest --typecheck"
}, },
"dependencies": { "dependencies": {
"@clerk/nextjs": "^7.0.2", "@ai-sdk/openai": "^3.0.67",
"@electric-sql/pglite": "^0.3.16", "@ai-sdk/react": "^3.0.195",
"@clerk/nextjs": "^7.4.2",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.5.6",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.1",
"@electric-sql/pglite": "^0.4.6",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.2.0", "@fortawesome/react-fontawesome": "^3.3.1",
"@gsap/react": "^2.1.2", "@gsap/react": "^2.1.2",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.4.0",
"@neondatabase/serverless": "^1.0.2", "@mdx-js/loader": "^3.1.1",
"@mdx-js/mdx": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@neondatabase/serverless": "^1.1.0",
"@next/mdx": "^16.2.9",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-aspect-ratio": "^1.1.8",
@@ -53,79 +71,89 @@
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@t3-oss/env-nextjs": "^0.13.10", "@t3-oss/env-nextjs": "^0.13.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.100.14",
"@tanstack/react-query-next-experimental": "^5.91.0", "@tanstack/react-query-next-experimental": "^5.100.14",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@trpc/client": "^11.12.0", "@trpc/client": "^11.17.0",
"@trpc/next": "^11.12.0", "@trpc/next": "^11.17.0",
"@trpc/react-query": "^11.12.0", "@trpc/react-query": "^11.17.0",
"@trpc/server": "^11.12.0", "@trpc/server": "^11.17.0",
"@uiw/react-md-editor": "^4.0.11", "@types/mdx": "^2.0.14",
"@uiw/react-codemirror": "^4.25.10",
"@uiw/react-md-editor": "^4.1.1",
"@gregorlohaus/codemirror-helix": "workspace:*",
"@uploadthing/react": "^7.3.3", "@uploadthing/react": "^7.3.3",
"@vercel/speed-insights": "^2.0.0",
"ai": "^6.0.193",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.4.0",
"date-format": "^4.0.14", "date-format": "^4.0.14",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.2",
"drizzle-zod": "^0.8.3", "drizzle-zod": "^0.8.3",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"glazejs": "^2.0.1", "glazejs": "^2.0.1",
"gsap": "^3.14.2", "googleapis": "^173.0.0",
"gray-matter": "^4.0.3",
"gsap": "^3.15.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.577.0", "lucide-react": "^1.17.0",
"next": "16.1.6", "next": "16.2.6",
"next-mdx-remote": "^6.0.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"postgres": "^3.4.8", "postgres": "^3.4.9",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.4", "react": "^19.2.6",
"react-day-picker": "^9.14.0", "react-day-picker": "^10.0.1",
"react-dom": "^19.2.4", "react-dom": "^19.2.6",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.77.0",
"react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.2",
"react-resizable-panels": "^4.7.2", "recharts": "3.8.1",
"recharts": "2.15.4",
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"shadcn": "^4.0.2", "shadcn": "^4.10.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "superjson": "^2.2.6",
"tailwind-merge": "^3.6.0",
"tailwindcss-motion": "^1.1.1", "tailwindcss-motion": "^1.1.1",
"type-fest": "^5.4.4", "type-fest": "^5.7.0",
"uploadthing": "^7.7.4", "uploadthing": "^7.7.4",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.3.6" "wavesurfer.js": "^7.12.8",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.6", "@biomejs/biome": "2.4.16",
"@swc/jest": "^0.2.39", "@swc/jest": "^0.2.39",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.3.0",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^25.4.0", "@types/node": "^25.9.1",
"@types/react": "^19.2.14", "@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.1.8",
"dotenv": "^17.3.1", "dotenv": "^17.4.2",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.10",
"jest": "^30.3.0", "jest": "^30.4.2",
"jest-environment-jsdom": "^30.3.0", "jest-environment-jsdom": "^30.4.1",
"jsdom": "^28.1.0", "jsdom": "^29.1.1",
"next-router-mock": "^1.0.5", "next-router-mock": "^1.0.5",
"pg-mem": "^3.0.14", "pg-mem": "^3.0.14",
"postcss": "^8.5.8", "postcss": "^8.5.15",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.3.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^6.0.3",
"vite-tsconfig-paths": "^6.1.1", "vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.18" "vitest": "^4.1.8"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.39.3" "initVersion": "7.39.3"

10
packages/codemirror-helix/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# build output (rebuilt by `bun run build` / helix:build / publish CI)
dist/
# dependencies
node_modules/
# logs / caches
*.log
.DS_Store
*.tsbuildinfo

View File

@@ -0,0 +1,69 @@
# @gregorlohaus/codemirror-helix
[Helix](https://helix-editor.com/)-style modal editing for [CodeMirror 6](https://codemirror.net/).
Selection-first editing with multiple selections, Normal/Insert/Select modes,
goto & match modes, textobjects, surround, registers, counts, and search.
> A **core motions subset** — broad coverage of everyday Helix keys, not full
> parity. No tree-sitter textobjects, LSP gotos, macros, or jumplist.
## Install
```sh
bun add @gregorlohaus/codemirror-helix
# peers: @codemirror/{state,view,commands,language,search}
```
## Usage
```ts
import { EditorView, basicSetup } from "codemirror";
import { helix } from "@gregorlohaus/codemirror-helix";
new EditorView({
doc: "hello world",
extensions: [basicSetup, helix()],
parent: document.body,
});
```
`helix()` needs a selection drawer for multi-cursor rendering — `basicSetup`
(or `drawSelection()`) covers it.
### Options
```ts
helix({
startInInsert: false, // start in Normal mode (default)
statusBar: true, // show the bottom mode line
// Let an open autocomplete popup eat the first Escape instead of leaving Insert:
escapeGuard: (state) => completionStatus(state) === "active",
});
```
## Keys
Starts in **Normal** mode. The status line shows the mode, pending count,
register, and selection count.
| Group | Keys |
| --- | --- |
| Modes | `i`/`a` insert before/after, `I`/`A` line start/end, `o`/`O` open line, `v` select (extend), `Esc` normal |
| Motion | `h j k l`, `w W b B e E`, `f t F T {char}`, `Alt-.` repeat find, `Home`/`End`, counts (`3w`) |
| Goto `g` | `gg`/`Ng` line, `ge` end, `gh`/`gl` line ends, `gs` first non-blank, `gt`/`gc`/`gb` view top/center/bottom |
| Select | `x` line (repeat extends), `X` line bounds, `%` all, `;` collapse, `Alt-;` flip, `Alt-:` forward, `,` keep primary, `Alt-,` remove primary, `(`/`)` rotate, `_` trim |
| Multi | `s` select regex, `S` split, `K`/`Alt-K` keep/remove, `C` copy selection below, `Alt-C` above |
| Match `m` | `mm` matching bracket, `mi{o}`/`ma{o}` inside/around (`w W p ( [ { < " ' \` m`), `ms{c}` surround, `md{c}` delete, `mr{c}{c}` replace |
| Edit | `d` delete, `c` change, `y` yank, `p`/`P` paste, `R` replace w/ register, `r{c}` replace char, `~`/`` ` ``/`Alt-`` ` `` case, `J` join, `>`/`<` indent, `u`/`U` undo/redo |
| Registers | `"{c}` select register for the next yank/delete/paste |
| Search | `/` `?` search, `n`/`N` next/prev, `*` search selection |
| View | `zz`/`zt`/`zb` center/top/bottom, `Ctrl-d`/`Ctrl-u` half page, `Ctrl-f`/`Ctrl-b` page |
| Clipboard | `space y` copy, `space p`/`space P` paste (system clipboard) |
`s`/`S`/`K`/`Alt-K` open a prompt and preview live as you type the regex;
`Enter` commits, `Esc` restores the original selection.
## License
MIT

View File

@@ -0,0 +1,51 @@
{
"name": "@gregorlohaus/codemirror-helix",
"version": "0.1.0",
"description": "Helix-editor-style modal editing for CodeMirror 6 (multiple selections, select mode, goto/match modes, textobjects, surround, registers, search).",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist", "README.md"],
"sideEffects": false,
"scripts": {
"build": "tsc -p tsconfig.json",
"prepublishOnly": "tsc -p tsconfig.json"
},
"keywords": [
"codemirror",
"codemirror6",
"helix",
"modal",
"vim",
"editor",
"keybindings"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/gregorlohaus.com",
"directory": "packages/codemirror-helix"
},
"peerDependencies": {
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
},
"devDependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.5.6",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.1",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,593 @@
import {
EditorSelection,
type ChangeSpec,
type EditorState,
type SelectionRange,
} from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { indentLess, indentMore, redo, undo } from "@codemirror/commands";
import { findNext, findPrevious, SearchQuery, setSearchQuery } from "@codemirror/search";
import {
clearPending,
getHelix,
helixEffect,
readRegister,
setRegister,
type HelixMode,
} from "./state";
import * as M from "./motions";
const settle = { ...clearPending() };
function clamp(n: number, state: EditorState): number {
return Math.max(0, Math.min(n, state.doc.length));
}
function rangesText(state: EditorState, ranges: readonly SelectionRange[]): string {
return ranges.map((r) => state.doc.sliceString(r.from, r.to)).join("\n");
}
function maybeClipboard(register: string | null, text: string) {
if ((register === "+" || register === "*") && typeof navigator !== "undefined" && navigator.clipboard) {
void navigator.clipboard.writeText(text).catch(() => {});
}
}
/** After leaving an edit, choose the resting mode. */
function restMode(current: HelixMode, target?: HelixMode): HelixMode {
if (target) return target;
return current === "select" ? "normal" : current;
}
// --- motion application ------------------------------------------------------
export type PointFn = (state: EditorState, pos: number) => number;
export interface MotionOpts {
extend: boolean;
span: boolean; // word-like: selects the traversed span in normal mode
count: number;
}
export function moveByPoint(view: EditorView, fn: PointFn, opts: MotionOpts) {
const { state } = view;
const selection = EditorSelection.create(
state.selection.ranges.map((r) => {
let head = r.head;
for (let i = 0; i < Math.max(1, opts.count); i++) head = fn(state, head);
head = clamp(head, state);
const anchor = opts.extend ? r.anchor : opts.span ? r.head : head;
return EditorSelection.range(clamp(anchor, state), head);
}),
state.selection.mainIndex,
);
view.dispatch({ selection, scrollIntoView: true });
}
export function moveVertical(view: EditorView, forward: boolean, opts: { extend: boolean; count: number }) {
const selection = EditorSelection.create(
view.state.selection.ranges.map((r) => {
let cur: SelectionRange = EditorSelection.cursor(r.head);
for (let i = 0; i < Math.max(1, opts.count); i++) cur = view.moveVertically(cur, forward);
return opts.extend
? EditorSelection.range(r.anchor, cur.head)
: EditorSelection.cursor(cur.head);
}),
view.state.selection.mainIndex,
);
view.dispatch({ selection, scrollIntoView: true });
}
// --- insertion entry points --------------------------------------------------
export function enterInsert(view: EditorView, posOf: (state: EditorState, r: SelectionRange) => number) {
const { state } = view;
const selection = EditorSelection.create(
state.selection.ranges.map((r) => EditorSelection.cursor(clamp(posOf(state, r), state))),
state.selection.mainIndex,
);
view.dispatch({ selection, effects: helixEffect.of({ mode: "insert", ...settle }) });
}
export function openLine(view: EditorView, above: boolean) {
const { state } = view;
const seen = new Set<number>();
const changes: ChangeSpec[] = [];
for (const r of state.selection.ranges) {
const line = state.doc.lineAt(r.head);
if (seen.has(line.number)) continue;
seen.add(line.number);
const indent = line.text.match(/^\s*/)?.[0] ?? "";
changes.push(above ? { from: line.from, insert: `${indent}\n` } : { from: line.to, insert: `\n${indent}` });
}
const changeSet = state.changes(changes);
// Place a cursor on each newly opened line.
const selection = EditorSelection.create(
[...seen].map((n) => {
const line = state.doc.line(n);
const pos = above ? line.from : line.to;
const mapped = changeSet.mapPos(pos, 1);
return EditorSelection.cursor(mapped);
}),
);
view.dispatch({
changes: changeSet,
selection,
effects: helixEffect.of({ mode: "insert", ...settle }),
scrollIntoView: true,
});
}
// --- delete / change / yank / paste -----------------------------------------
interface DeleteOpts {
insert?: boolean; // change (c)
}
export function deleteSelection(view: EditorView, opts: DeleteOpts = {}) {
const { state } = view;
const helix = getHelix(state);
const specs = state.selection.ranges.map((r) => ({
from: r.from,
to: r.empty ? Math.min(r.to + 1, state.doc.length) : r.to,
}));
const yanked = specs.map((s) => state.doc.sliceString(s.from, s.to)).join("\n");
const changeSet = state.changes(specs.map((s) => ({ from: s.from, to: s.to, insert: "" })));
const selection = EditorSelection.create(
specs.map((s) => EditorSelection.cursor(changeSet.mapPos(s.from, -1))),
state.selection.mainIndex,
);
view.dispatch({
changes: changeSet,
selection,
effects: [
setRegister.of({ name: helix.register ?? '"', text: yanked }),
helixEffect.of({ mode: opts.insert ? "insert" : restMode(helix.mode), ...settle }),
],
scrollIntoView: true,
});
maybeClipboard(helix.register, yanked);
}
export function yankSelection(view: EditorView) {
const { state } = view;
const helix = getHelix(state);
const text = rangesText(state, state.selection.ranges);
view.dispatch({
effects: [
setRegister.of({ name: helix.register ?? '"', text }),
helixEffect.of({ mode: restMode(helix.mode), ...settle }),
],
});
maybeClipboard(helix.register, text);
}
export function paste(view: EditorView, before: boolean) {
const { state } = view;
const helix = getHelix(state);
const text = readRegister(state, helix.register);
if (!text) return;
const linewise = text.endsWith("\n");
const changes: { from: number; to: number; insert: string }[] = [];
for (const r of state.selection.ranges) {
if (linewise) {
const line = state.doc.lineAt(before ? r.from : r.to);
if (before) changes.push({ from: line.from, to: line.from, insert: text });
else changes.push({ from: line.to, to: line.to, insert: `\n${text.replace(/\n$/, "")}` });
} else {
const at = before ? r.from : r.to;
changes.push({ from: at, to: at, insert: text });
}
}
const changeSet = state.changes(changes);
view.dispatch({
changes: changeSet,
selection: state.selection.map(changeSet),
effects: helixEffect.of(settle),
scrollIntoView: true,
});
}
/** R: replace selections with the register contents. */
export function replaceWithYank(view: EditorView) {
const { state } = view;
const helix = getHelix(state);
const text = readRegister(state, helix.register);
if (!text) return;
const changes = state.selection.ranges.map((r) => ({ from: r.from, to: r.to, insert: text }));
const changeSet = state.changes(changes);
view.dispatch({
changes: changeSet,
selection: state.selection.map(changeSet),
effects: helixEffect.of(settle),
scrollIntoView: true,
});
}
/** r{char}: replace every selected character with `char`. */
export function replaceChar(view: EditorView, char: string) {
if (char.length !== 1) return;
const { state } = view;
const changes = state.selection.ranges
.map((r) => {
const from = r.from;
const to = r.empty ? Math.min(r.to + 1, state.doc.length) : r.to;
const text = state.doc.sliceString(from, to).replace(/[^\n]/g, char);
return { from, to, insert: text };
})
.filter((c) => c.to > c.from);
if (!changes.length) return;
view.dispatch({ changes, effects: helixEffect.of(settle) });
}
type CaseMode = "toggle" | "lower" | "upper";
export function changeCase(view: EditorView, mode: CaseMode) {
const { state } = view;
const changes = state.selection.ranges
.map((r) => {
const from = r.from;
const to = r.empty ? Math.min(r.to + 1, state.doc.length) : r.to;
const text = state.doc.sliceString(from, to);
const next =
mode === "lower"
? text.toLowerCase()
: mode === "upper"
? text.toUpperCase()
: text.replace(/[a-zA-Z]/g, (c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()));
return { from, to, insert: next };
})
.filter((c) => c.to > c.from);
if (!changes.length) return;
view.dispatch({ changes, effects: helixEffect.of(settle) });
}
/** J: join the lines spanned by each selection into one. */
export function joinLines(view: EditorView) {
const { state } = view;
const changes: ChangeSpec[] = [];
for (const r of state.selection.ranges) {
const startLine = state.doc.lineAt(r.from).number;
const endLine = state.doc.lineAt(r.to).number;
const last = Math.max(endLine, startLine + 1);
for (let n = startLine; n < last && n < state.doc.lines; n++) {
const line = state.doc.line(n);
const nextLine = state.doc.line(n + 1);
const trimmed = nextLine.text.match(/^\s*/)?.[0].length ?? 0;
changes.push({ from: line.to, to: nextLine.from + trimmed, insert: " " });
}
}
if (!changes.length) return;
view.dispatch({ changes, effects: helixEffect.of(settle), scrollIntoView: true });
}
export function indent(view: EditorView, less: boolean) {
(less ? indentLess : indentMore)(view);
view.dispatch({ effects: helixEffect.of(settle) });
}
export function undoCmd(view: EditorView) {
undo(view);
}
export function redoCmd(view: EditorView) {
redo(view);
}
// --- selection manipulation --------------------------------------------------
function setSelection(view: EditorView, ranges: SelectionRange[], mainIndex?: number) {
if (!ranges.length) return;
view.dispatch({
selection: EditorSelection.create(ranges, mainIndex ?? ranges.length - 1),
scrollIntoView: true,
});
}
export function collapseToCursor(view: EditorView) {
setSelection(
view,
view.state.selection.ranges.map((r) => EditorSelection.cursor(r.head)),
view.state.selection.mainIndex,
);
}
export function flipSelections(view: EditorView) {
setSelection(
view,
view.state.selection.ranges.map((r) => EditorSelection.range(r.head, r.anchor)),
view.state.selection.mainIndex,
);
}
export function ensureForward(view: EditorView) {
setSelection(
view,
view.state.selection.ranges.map((r) => EditorSelection.range(r.from, r.to)),
view.state.selection.mainIndex,
);
}
export function keepPrimary(view: EditorView) {
const main = view.state.selection.main;
setSelection(view, [main], 0);
}
export function removePrimary(view: EditorView) {
const { ranges, mainIndex } = view.state.selection;
if (ranges.length < 2) return;
const remaining = ranges.filter((_, i) => i !== mainIndex);
setSelection(view, remaining, Math.min(mainIndex, remaining.length - 1));
}
export function rotatePrimary(view: EditorView, forward: boolean) {
const { ranges, mainIndex } = view.state.selection;
if (ranges.length < 2) return;
const next = (mainIndex + (forward ? 1 : -1) + ranges.length) % ranges.length;
setSelection(view, [...ranges], next);
}
export function selectAll(view: EditorView) {
setSelection(view, [EditorSelection.range(0, view.state.doc.length)], 0);
}
export function trimSelections(view: EditorView) {
const { state } = view;
const ranges: SelectionRange[] = [];
for (const r of state.selection.ranges) {
const text = state.doc.sliceString(r.from, r.to);
const lead = text.match(/^\s*/)?.[0].length ?? 0;
const trail = text.match(/\s*$/)?.[0].length ?? 0;
const from = r.from + lead;
const to = Math.max(from, r.to - trail);
ranges.push(EditorSelection.range(from, to));
}
setSelection(view, ranges, state.selection.mainIndex);
}
/** x: extend each selection to whole line(s); repeat grows downward. */
export function selectLine(view: EditorView, count: number) {
const { state } = view;
const ranges = state.selection.ranges.map((r) => {
const startLine = state.doc.lineAt(r.from);
let endLine = state.doc.lineAt(r.to);
const from = startLine.from;
let to = Math.min(endLine.to + 1, state.doc.length);
const whole = r.from === from && r.to === to;
const steps = whole ? Math.max(1, count) : Math.max(0, count - 1);
for (let i = 0; i < steps && endLine.number < state.doc.lines; i++) {
endLine = state.doc.line(endLine.number + 1);
to = Math.min(endLine.to + 1, state.doc.length);
}
return EditorSelection.range(from, to);
});
setSelection(view, ranges, state.selection.mainIndex);
}
/** X: extend selections to cover full lines without crossing into the next. */
export function extendToLineBounds(view: EditorView) {
const { state } = view;
const ranges = state.selection.ranges.map((r) =>
EditorSelection.range(state.doc.lineAt(r.from).from, state.doc.lineAt(r.to).to),
);
setSelection(view, ranges, state.selection.mainIndex);
}
/** C / Alt-C: copy each selection onto the next/previous line at the same columns. */
export function copySelectionToLine(view: EditorView, below: boolean) {
const { state } = view;
const additions: SelectionRange[] = [];
for (const r of state.selection.ranges) {
const anchorLine = state.doc.lineAt(r.anchor);
const headLine = state.doc.lineAt(r.head);
const anchorCol = r.anchor - anchorLine.from;
const headCol = r.head - headLine.from;
const targetAnchorNo = anchorLine.number + (below ? 1 : -1);
const targetHeadNo = headLine.number + (below ? 1 : -1);
if (targetAnchorNo < 1 || targetAnchorNo > state.doc.lines) continue;
if (targetHeadNo < 1 || targetHeadNo > state.doc.lines) continue;
const ta = state.doc.line(targetAnchorNo);
const th = state.doc.line(targetHeadNo);
additions.push(
EditorSelection.range(
Math.min(ta.from + anchorCol, ta.to),
Math.min(th.from + headCol, th.to),
),
);
}
if (!additions.length) return;
const all = [...state.selection.ranges, ...additions];
setSelection(view, all, all.length - 1);
}
function restoreBase(view: EditorView, base: EditorSelection) {
view.dispatch({ selection: base, scrollIntoView: true });
}
/**
* s: keep only the regex matches found within each selection of `base`.
* `base` is the selection captured when the prompt opened, so this can be
* called live on every keystroke without compounding.
*/
export function selectRegexInSelections(
view: EditorView,
pattern: string,
base: EditorSelection = view.state.selection,
) {
if (!pattern) return restoreBase(view, base);
let re: RegExp;
try {
re = new RegExp(pattern, "g");
} catch {
return; // incomplete/invalid regex: leave the last good preview in place
}
const { state } = view;
const ranges: SelectionRange[] = [];
for (const r of base.ranges) {
const text = state.doc.sliceString(r.from, r.to);
for (const m of text.matchAll(re)) {
const from = r.from + (m.index ?? 0);
const to = from + m[0].length;
ranges.push(EditorSelection.range(from, Math.max(from, to)));
if (m[0].length === 0) re.lastIndex++;
}
}
if (ranges.length) setSelection(view, ranges, ranges.length - 1);
else restoreBase(view, base);
}
/** S: split each selection of `base` on the regex, keeping the pieces between. */
export function splitOnRegex(
view: EditorView,
pattern: string,
base: EditorSelection = view.state.selection,
) {
if (!pattern) return restoreBase(view, base);
let re: RegExp;
try {
re = new RegExp(pattern, "g");
} catch {
return;
}
const { state } = view;
const ranges: SelectionRange[] = [];
for (const r of base.ranges) {
const text = state.doc.sliceString(r.from, r.to);
let last = 0;
for (const m of text.matchAll(re)) {
const idx = m.index ?? 0;
ranges.push(EditorSelection.range(r.from + last, r.from + idx));
last = idx + m[0].length;
if (m[0].length === 0) re.lastIndex++;
}
ranges.push(EditorSelection.range(r.from + last, r.to));
}
const filtered = ranges.filter((r) => r.to >= r.from);
if (filtered.length) setSelection(view, filtered, filtered.length - 1);
else restoreBase(view, base);
}
/** Keep (or remove) selections of `base` whose text matches the regex. */
export function filterSelections(
view: EditorView,
pattern: string,
keep: boolean,
base: EditorSelection = view.state.selection,
) {
if (!pattern) return restoreBase(view, base);
let re: RegExp;
try {
re = new RegExp(pattern);
} catch {
return;
}
const { state } = view;
const ranges = base.ranges.filter((r) => {
const matches = re.test(state.doc.sliceString(r.from, r.to));
return keep ? matches : !matches;
});
if (ranges.length) setSelection(view, ranges, ranges.length - 1);
else restoreBase(view, base);
}
// --- surround ----------------------------------------------------------------
const SURROUND_PAIRS: Record<string, [string, string]> = {
"(": ["(", ")"],
")": ["(", ")"],
"[": ["[", "]"],
"]": ["[", "]"],
"{": ["{", "}"],
"}": ["{", "}"],
"<": ["<", ">"],
">": ["<", ">"],
};
function surroundPair(ch: string): [string, string] {
return SURROUND_PAIRS[ch] ?? [ch, ch];
}
/** ms{char}: wrap each selection in the chosen pair. */
export function surroundAdd(view: EditorView, ch: string) {
const [open, close] = surroundPair(ch);
const { state } = view;
const changes: ChangeSpec[] = [];
for (const r of state.selection.ranges) {
changes.push({ from: r.from, insert: open });
changes.push({ from: r.to, insert: close });
}
const changeSet = state.changes(changes);
view.dispatch({
changes: changeSet,
selection: state.selection.map(changeSet),
effects: helixEffect.of(settle),
});
}
/** md{char}: remove the surrounding pair around each selection. */
export function surroundDelete(view: EditorView, ch: string) {
const [open, close] = surroundPair(ch);
const { state } = view;
const changes: { from: number; to: number; insert: string }[] = [];
for (const r of state.selection.ranges) {
const openPos = M.findOpen(state, r.head, open, close);
const closePos = openPos === null ? null : M.findClose(state, openPos, open, close);
if (openPos === null || closePos === null) continue;
changes.push({ from: openPos, to: openPos + 1, insert: "" });
changes.push({ from: closePos, to: closePos + 1, insert: "" });
}
if (!changes.length) return;
view.dispatch({ changes, effects: helixEffect.of(settle) });
}
/** mr{from}{to}: replace the surrounding pair. */
export function surroundReplace(view: EditorView, fromCh: string, toCh: string) {
const [open, close] = surroundPair(fromCh);
const [newOpen, newClose] = surroundPair(toCh);
const { state } = view;
const changes: { from: number; to: number; insert: string }[] = [];
for (const r of state.selection.ranges) {
const openPos = M.findOpen(state, r.head, open, close);
const closePos = openPos === null ? null : M.findClose(state, openPos, open, close);
if (openPos === null || closePos === null) continue;
changes.push({ from: openPos, to: openPos + 1, insert: newOpen });
changes.push({ from: closePos, to: closePos + 1, insert: newClose });
}
if (!changes.length) return;
view.dispatch({ changes, effects: helixEffect.of(settle) });
}
// --- search ------------------------------------------------------------------
export function runSearch(view: EditorView, query: string, reverse: boolean) {
if (!query) return;
view.dispatch({ effects: setSearchQuery.of(new SearchQuery({ search: query, regexp: true })) });
(reverse ? findPrevious : findNext)(view);
}
export function searchNext(view: EditorView, reverse: boolean) {
(reverse ? findPrevious : findNext)(view);
}
export function searchSelection(view: EditorView, reverse: boolean) {
const text = view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to);
if (!text) return;
view.dispatch({
effects: setSearchQuery.of(new SearchQuery({ search: text, regexp: false })),
});
(reverse ? findPrevious : findNext)(view);
}
// --- view scrolling ----------------------------------------------------------
export function scrollTo(view: EditorView, y: "center" | "start" | "end") {
const pos = view.state.selection.main.head;
view.dispatch({ effects: EditorView.scrollIntoView(pos, { y }) });
}
export function halfPage(view: EditorView, forward: boolean, extend: boolean) {
const lineHeight = view.defaultLineHeight || 16;
const lines = Math.max(1, Math.floor(view.dom.clientHeight / lineHeight / 2));
moveVertical(view, forward, { extend, count: lines });
}
export { restMode };

View File

@@ -0,0 +1,51 @@
import { EditorState, type Extension } from "@codemirror/state";
import { search } from "@codemirror/search";
import { helixState, registersField } from "./state";
import { blockCursor, helixStatusPanel, helixTheme, modeEditorClass } from "./view";
import { helixPrompt } from "./prompt";
import { helixKeymap, type HelixOptions } from "./keymap";
export interface HelixConfig extends HelixOptions {
/** Render the bottom status line. Default: true. */
statusBar?: boolean;
/** Start the editor in Insert mode instead of Normal. Default: false. */
startInInsert?: boolean;
}
/**
* Helix-style modal editing for CodeMirror 6.
*
* Add to a CodeMirror instance's `extensions`. Requires a selection drawer
* (CodeMirror's `basicSetup` or `drawSelection()`) for multi-cursor rendering.
*
* ```ts
* import { basicSetup } from "codemirror";
* import { helix } from "codemirror-helix";
*
* new EditorView({ extensions: [basicSetup, helix()], parent });
* ```
*/
export function helix(config: HelixConfig = {}): Extension {
return [
EditorState.allowMultipleSelections.of(true),
helixState.init(() => ({
mode: config.startInInsert ? "insert" : "normal",
pending: null,
count: 0,
register: null,
lastFind: null,
})),
registersField,
search(),
helixKeymap(config),
blockCursor,
modeEditorClass,
helixTheme,
...(config.statusBar === false ? [] : [helixStatusPanel]),
helixPrompt,
];
}
export { getMode, getHelix, helixState } from "./state";
export type { HelixMode, HelixStateValue } from "./state";
export type { HelixOptions } from "./keymap";

View File

@@ -0,0 +1,562 @@
import { EditorSelection, Prec, type EditorState, type Extension } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import {
clearPending,
getHelix,
helixEffect,
type HelixStateValue,
type Pending,
} from "./state";
import * as C from "./commands";
import * as M from "./motions";
import { textObject } from "./textobjects";
import { openPrompt } from "./prompt";
export interface HelixOptions {
/**
* Return true to let a pressed Escape fall through to other handlers instead
* of leaving Insert mode — e.g. when an autocompletion popup is open.
*/
escapeGuard?: (state: EditorState) => boolean;
}
function reset(view: EditorView, extra: Partial<HelixStateValue> = {}) {
view.dispatch({ effects: helixEffect.of({ ...clearPending(), ...extra }) });
}
function setPending(view: EditorView, pending: Pending) {
view.dispatch({ effects: helixEffect.of({ pending }) });
}
// --- find char ---------------------------------------------------------------
function doFind(view: EditorView, kind: "f" | "t" | "F" | "T", char: string, helix: HelixStateValue) {
const fn: C.PointFn = (state, pos) => {
const found = M.findCharOnLine(state, pos, char, kind);
return found === null ? pos : found;
};
C.moveByPoint(view, fn, { extend: helix.mode === "select", span: false, count: helix.count || 1 });
}
// --- goto mode ---------------------------------------------------------------
function gotoAbsolute(view: EditorView, pos: number, select: boolean) {
const anchor = select ? view.state.selection.main.anchor : pos;
view.dispatch({ selection: EditorSelection.range(anchor, pos), scrollIntoView: true });
}
function doGoto(view: EditorView, key: string, helix: HelixStateValue) {
const select = helix.mode === "select";
const { state } = view;
switch (key) {
case "h":
C.moveByPoint(view, (s, p) => s.doc.lineAt(p).from, { extend: select, span: false, count: 1 });
break;
case "l":
C.moveByPoint(view, (s, p) => s.doc.lineAt(p).to, { extend: select, span: false, count: 1 });
break;
case "s":
C.moveByPoint(view, (s, p) => M.firstNonBlank(s, s.doc.lineAt(p).number), {
extend: select,
span: false,
count: 1,
});
break;
case "g":
gotoAbsolute(view, helix.count > 0 ? state.doc.line(Math.min(helix.count, state.doc.lines)).from : 0, select);
break;
case "e":
gotoAbsolute(view, state.doc.length, select);
break;
case "t":
gotoAbsolute(view, state.doc.lineAt(view.viewport.from).from, select);
break;
case "b":
gotoAbsolute(view, state.doc.lineAt(view.viewport.to).from, select);
break;
case "c":
gotoAbsolute(view, state.doc.lineAt(Math.floor((view.viewport.from + view.viewport.to) / 2)).from, select);
break;
default:
break;
}
}
// --- match mode --------------------------------------------------------------
function doMatch(view: EditorView, key: string, helix: HelixStateValue) {
switch (key) {
case "m":
C.moveByPoint(
view,
(s, p) => {
const m = M.matchingBracket(s, p);
return m === null ? p : m;
},
{ extend: helix.mode === "select", span: false, count: 1 },
);
reset(view);
return;
case "i":
setPending(view, { type: "textobject", around: false });
return;
case "a":
setPending(view, { type: "textobject", around: true });
return;
case "s":
setPending(view, { type: "surround-add" });
return;
case "d":
setPending(view, { type: "surround-delete" });
return;
case "r":
setPending(view, { type: "surround-replace" });
return;
default:
reset(view);
}
}
function doTextObject(view: EditorView, key: string, around: boolean) {
const { state } = view;
const ranges = state.selection.ranges.map((r) => {
const span = textObject(state, r, key, around);
return span ? EditorSelection.range(span.from, span.to) : r;
});
view.dispatch({
selection: EditorSelection.create(ranges, state.selection.mainIndex),
scrollIntoView: true,
});
}
// --- view mode (z) -----------------------------------------------------------
function doView(view: EditorView, key: string) {
if (key === "z") C.scrollTo(view, "center");
else if (key === "t") C.scrollTo(view, "start");
else if (key === "b") C.scrollTo(view, "end");
}
// --- space menu (clipboard subset) ------------------------------------------
function doSpace(view: EditorView, key: string) {
const text = view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to);
if (key === "y") {
if (typeof navigator !== "undefined" && navigator.clipboard && text) {
void navigator.clipboard.writeText(text).catch(() => {});
}
} else if (key === "p" || key === "P") {
if (typeof navigator !== "undefined" && navigator.clipboard) {
void navigator.clipboard.readText().then((clip) => {
if (!clip) return;
const changes = view.state.selection.ranges.map((r) => {
const at = key === "P" ? r.from : r.to;
return { from: at, to: at, insert: clip };
});
const set = view.state.changes(changes);
view.dispatch({ changes: set, selection: view.state.selection.map(set) });
});
}
}
}
// --- pending dispatch --------------------------------------------------------
function handlePending(view: EditorView, pending: Pending, key: string, helix: HelixStateValue) {
switch (pending.type) {
case "register":
if (key.length === 1) view.dispatch({ effects: helixEffect.of({ register: key, pending: null }) });
else reset(view);
return;
case "find":
if (key.length === 1) {
doFind(view, pending.kind, key, helix);
reset(view, { lastFind: { kind: pending.kind, char: key } });
} else reset(view);
return;
case "replace":
if (key.length === 1) C.replaceChar(view, key);
reset(view);
return;
case "g":
doGoto(view, key, helix);
reset(view);
return;
case "z":
doView(view, key);
reset(view);
return;
case "space":
doSpace(view, key);
reset(view);
return;
case "m":
doMatch(view, key, helix);
return;
case "textobject":
doTextObject(view, key, pending.around);
reset(view);
return;
case "surround-add":
if (key.length === 1) C.surroundAdd(view, key);
reset(view);
return;
case "surround-delete":
if (key.length === 1) C.surroundDelete(view, key);
reset(view);
return;
case "surround-replace":
if (pending.from === undefined) {
view.dispatch({ effects: helixEffect.of({ pending: { type: "surround-replace", from: key } }) });
} else {
C.surroundReplace(view, pending.from, key);
reset(view);
}
return;
}
}
// --- terminal commands -------------------------------------------------------
function handleAlt(view: EditorView, key: string, helix: HelixStateValue): boolean {
switch (key) {
case ";":
C.flipSelections(view);
return true;
case ":":
C.ensureForward(view);
return true;
case ",":
C.removePrimary(view);
return true;
case "c":
case "C":
C.copySelectionToLine(view, false);
return true;
case "`":
C.changeCase(view, "upper");
return true;
case ".":
if (helix.lastFind) doFind(view, helix.lastFind.kind, helix.lastFind.char, helix);
return true;
case "k":
case "K": {
const base = view.state.selection;
openPrompt(view, {
label: "remove:",
onChange: (p) => C.filterSelections(view, p, false, base),
onSubmit: (p) => C.filterSelections(view, p, false, base),
onCancel: () => view.dispatch({ selection: base }),
});
return true;
}
default:
return false;
}
}
function handleCommand(view: EditorView, key: string, event: KeyboardEvent, helix: HelixStateValue): boolean {
const extend = helix.mode === "select";
const count = helix.count || 1;
if (event.altKey) return handleAlt(view, key, helix);
switch (key) {
// movement
case "h":
case "ArrowLeft":
C.moveByPoint(view, (_s, p) => Math.max(0, p - 1), { extend, span: false, count });
return true;
case "l":
case "ArrowRight":
C.moveByPoint(view, (s, p) => Math.min(s.doc.length, p + 1), { extend, span: false, count });
return true;
case "j":
case "ArrowDown":
C.moveVertical(view, true, { extend, count });
return true;
case "k":
case "ArrowUp":
C.moveVertical(view, false, { extend, count });
return true;
case "w":
C.moveByPoint(view, (s, p) => M.nextWordStart(s, p, false), { extend, span: true, count });
return true;
case "W":
C.moveByPoint(view, (s, p) => M.nextWordStart(s, p, true), { extend, span: true, count });
return true;
case "b":
C.moveByPoint(view, (s, p) => M.prevWordStart(s, p, false), { extend, span: true, count });
return true;
case "B":
C.moveByPoint(view, (s, p) => M.prevWordStart(s, p, true), { extend, span: true, count });
return true;
case "e":
C.moveByPoint(view, (s, p) => M.nextWordEnd(s, p, false), { extend, span: true, count });
return true;
case "E":
C.moveByPoint(view, (s, p) => M.nextWordEnd(s, p, true), { extend, span: true, count });
return true;
case "Home":
C.moveByPoint(view, (s, p) => s.doc.lineAt(p).from, { extend, span: false, count: 1 });
return true;
case "End":
C.moveByPoint(view, (s, p) => s.doc.lineAt(p).to, { extend, span: false, count: 1 });
return true;
// selection manipulation
case "x":
C.selectLine(view, count);
return true;
case "X":
C.extendToLineBounds(view);
return true;
case "%":
C.selectAll(view);
return true;
case ";":
C.collapseToCursor(view);
return true;
case ",":
C.keepPrimary(view);
return true;
case "(":
C.rotatePrimary(view, false);
return true;
case ")":
C.rotatePrimary(view, true);
return true;
case "_":
C.trimSelections(view);
return true;
case "C":
C.copySelectionToLine(view, true);
return true;
// mode switches
case "v":
view.dispatch({ effects: helixEffect.of({ mode: extend ? "normal" : "select" }) });
return true;
case "i":
C.enterInsert(view, (_s, r) => r.from);
return true;
case "a":
C.enterInsert(view, (_s, r) => r.to);
return true;
case "I":
C.enterInsert(view, (s, r) => M.firstNonBlank(s, s.doc.lineAt(r.head).number));
return true;
case "A":
C.enterInsert(view, (s, r) => s.doc.lineAt(r.head).to);
return true;
case "o":
C.openLine(view, false);
return true;
case "O":
C.openLine(view, true);
return true;
// edits
case "d":
case "Delete":
C.deleteSelection(view);
return true;
case "c":
C.deleteSelection(view, { insert: true });
return true;
case "y":
C.yankSelection(view);
return true;
case "p":
C.paste(view, false);
return true;
case "P":
C.paste(view, true);
return true;
case "R":
C.replaceWithYank(view);
return true;
case "~":
C.changeCase(view, "toggle");
return true;
case "`":
C.changeCase(view, "lower");
return true;
case "J":
C.joinLines(view);
return true;
case ">":
C.indent(view, false);
return true;
case "<":
C.indent(view, true);
return true;
case "u":
C.undoCmd(view);
return true;
case "U":
C.redoCmd(view);
return true;
// search
case "/":
openPrompt(view, { label: "/", onSubmit: (q) => C.runSearch(view, q, false) });
return true;
case "?":
openPrompt(view, { label: "?", onSubmit: (q) => C.runSearch(view, q, true) });
return true;
case "n":
C.searchNext(view, false);
return true;
case "N":
C.searchNext(view, true);
return true;
case "*":
C.searchSelection(view, false);
return true;
// regex selection (live preview against the selection captured here)
case "s": {
const base = view.state.selection;
openPrompt(view, {
label: "select:",
onChange: (p) => C.selectRegexInSelections(view, p, base),
onSubmit: (p) => C.selectRegexInSelections(view, p, base),
onCancel: () => view.dispatch({ selection: base }),
});
return true;
}
case "S": {
const base = view.state.selection;
openPrompt(view, {
label: "split:",
onChange: (p) => C.splitOnRegex(view, p, base),
onSubmit: (p) => C.splitOnRegex(view, p, base),
onCancel: () => view.dispatch({ selection: base }),
});
return true;
}
case "K": {
const base = view.state.selection;
openPrompt(view, {
label: "keep:",
onChange: (p) => C.filterSelections(view, p, true, base),
onSubmit: (p) => C.filterSelections(view, p, true, base),
onCancel: () => view.dispatch({ selection: base }),
});
return true;
}
case "Escape":
C.collapseToCursor(view);
view.dispatch({ effects: helixEffect.of({ mode: "normal", ...clearPending() }) });
return true;
default:
return false;
}
}
function handleCtrl(view: EditorView, key: string, helix: HelixStateValue): boolean {
const extend = helix.mode === "select";
switch (key) {
case "d":
C.halfPage(view, true, extend);
return true;
case "u":
C.halfPage(view, false, extend);
return true;
case "f":
C.moveVertical(view, true, { extend, count: 20 });
return true;
case "b":
C.moveVertical(view, false, { extend, count: 20 });
return true;
default:
return false;
}
}
// --- top-level keydown -------------------------------------------------------
export function helixKeymap(options: HelixOptions = {}): Extension {
return Prec.highest(
EditorView.domEventHandlers({
keydown(event, view) {
if (event.isComposing) return false;
const helix = getHelix(view.state);
if (helix.mode === "insert") {
if (event.key === "Escape") {
if (options.escapeGuard?.(view.state)) return false;
view.dispatch({ effects: helixEffect.of({ mode: "normal", ...clearPending() }) });
event.preventDefault();
return true;
}
return false;
}
if (event.metaKey) return false;
const key = event.key;
if (helix.pending) {
event.preventDefault();
handlePending(view, helix.pending, key, helix);
return true;
}
if (event.ctrlKey) {
if (handleCtrl(view, key, helix)) {
event.preventDefault();
reset(view);
return true;
}
return false;
}
// count digits (0 only continues an existing count)
if (!event.altKey && /^[0-9]$/.test(key) && !(key === "0" && helix.count === 0)) {
event.preventDefault();
view.dispatch({ effects: helixEffect.of({ count: helix.count * 10 + Number(key) }) });
return true;
}
// prefixes awaiting another key
if (!event.altKey) {
const prefix: Record<string, Pending> = {
g: { type: "g" },
m: { type: "m" },
z: { type: "z" },
" ": { type: "space" },
'"': { type: "register" },
f: { type: "find", kind: "f" },
t: { type: "find", kind: "t" },
F: { type: "find", kind: "F" },
T: { type: "find", kind: "T" },
r: { type: "replace" },
};
const pending = prefix[key];
if (pending) {
setPending(view, pending);
event.preventDefault();
return true;
}
}
if (handleCommand(view, key, event, helix)) {
event.preventDefault();
reset(view);
return true;
}
// Swallow stray printable keys so Normal/Select mode never types.
if (key.length === 1 || ["Enter", "Backspace", "Tab", "Delete"].includes(key)) {
event.preventDefault();
return true;
}
return false;
},
}),
);
}

View File

@@ -0,0 +1,180 @@
import type { EditorState } from "@codemirror/state";
export type CharClass = "space" | "word" | "punct";
export function charAt(state: EditorState, i: number): string {
return i >= 0 && i < state.doc.length ? state.doc.sliceString(i, i + 1) : "";
}
export function classOf(ch: string): CharClass {
if (ch === "" || /\s/.test(ch)) return "space";
if (/[\p{L}\p{N}_]/u.test(ch)) return "word";
return "punct";
}
// For WORD (big) motions only whitespace separates tokens.
function bigClassOf(ch: string): CharClass {
return ch === "" || /\s/.test(ch) ? "space" : "word";
}
function classAt(state: EditorState, i: number, big: boolean): CharClass {
const ch = charAt(state, i);
return big ? bigClassOf(ch) : classOf(ch);
}
/** Helix `w`/`W`: move to the start of the next word. */
export function nextWordStart(state: EditorState, pos: number, big: boolean): number {
const len = state.doc.length;
let i = pos;
if (i >= len) return len;
if (classAt(state, i, big) === "space") {
while (i < len && classAt(state, i, big) === "space") i++;
} else {
const cls = classAt(state, i, big);
while (i < len && classAt(state, i, big) === cls) i++;
while (i < len && classAt(state, i, big) === "space") i++;
}
return i;
}
/** Helix `b`/`B`: move to the start of the previous word. */
export function prevWordStart(state: EditorState, pos: number, big: boolean): number {
let i = pos;
if (i <= 0) return 0;
i--;
while (i > 0 && classAt(state, i, big) === "space") i--;
const cls = classAt(state, i, big);
while (i > 0 && classAt(state, i - 1, big) === cls) i--;
return i;
}
/** Helix `e`/`E`: move to the end of the next word. */
export function nextWordEnd(state: EditorState, pos: number, big: boolean): number {
const len = state.doc.length;
let i = pos + 1;
while (i < len && classAt(state, i, big) === "space") i++;
const cls = classAt(state, i, big);
while (i < len && classAt(state, i, big) === cls) i++;
return Math.min(i, len);
}
export function firstNonBlank(state: EditorState, line: number): number {
const l = state.doc.line(line);
return l.from + (l.text.match(/^\s*/)?.[0].length ?? 0);
}
/** f/t/F/T: find `target` from `pos`. Returns null if not found on the line. */
export function findCharOnLine(
state: EditorState,
pos: number,
target: string,
kind: "f" | "t" | "F" | "T",
): number | null {
const line = state.doc.lineAt(pos);
const forward = kind === "f" || kind === "t";
const till = kind === "t" || kind === "T";
if (forward) {
let i = pos + 1;
// `t` should skip an adjacent target so repeated presses advance.
if (till && charAt(state, i) === target) i++;
for (; i <= line.to; i++) {
if (charAt(state, i) === target) return till ? i - 1 : i;
}
} else {
let i = pos - 1;
if (till && charAt(state, i) === target) i--;
for (; i >= line.from; i--) {
if (charAt(state, i) === target) return till ? i + 1 : i;
}
}
return null;
}
const PAIRS: Record<string, { open: string; close: string }> = {
"(": { open: "(", close: ")" },
")": { open: "(", close: ")" },
"[": { open: "[", close: "]" },
"]": { open: "[", close: "]" },
"{": { open: "{", close: "}" },
"}": { open: "{", close: "}" },
"<": { open: "<", close: ">" },
">": { open: "<", close: ">" },
};
export function pairFor(ch: string): { open: string; close: string } | null {
return PAIRS[ch] ?? null;
}
/** Scan forward for the close that matches an open at/after `pos` (nesting-aware). */
export function findClose(state: EditorState, from: number, open: string, close: string): number | null {
const len = state.doc.length;
let depth = 0;
for (let i = from; i < len; i++) {
const ch = charAt(state, i);
if (ch === open) depth++;
else if (ch === close) {
depth--;
if (depth === 0) return i;
}
}
return null;
}
/** Scan backward for the open that matches a close at/before `pos` (nesting-aware). */
export function findOpen(state: EditorState, from: number, open: string, close: string): number | null {
let depth = 0;
for (let i = from; i >= 0; i--) {
const ch = charAt(state, i);
if (ch === close) depth++;
else if (ch === open) {
depth--;
if (depth === 0) return i;
}
}
return null;
}
/** `mm`: position of the bracket matching the one at/under `pos`, or null. */
export function matchingBracket(state: EditorState, pos: number): number | null {
for (const probe of [pos, pos - 1]) {
const ch = charAt(state, probe);
const pair = pairFor(ch);
if (!pair) continue;
if (ch === pair.open) return findClose2(state, probe, pair.open, pair.close);
return findOpen2(state, probe, pair.open, pair.close);
}
return null;
}
function findClose2(state: EditorState, openPos: number, open: string, close: string): number | null {
const len = state.doc.length;
let depth = 0;
for (let i = openPos; i < len; i++) {
const ch = charAt(state, i);
if (ch === open) depth++;
else if (ch === close && --depth === 0) return i;
}
return null;
}
function findOpen2(state: EditorState, closePos: number, open: string, close: string): number | null {
let depth = 0;
for (let i = closePos; i >= 0; i--) {
const ch = charAt(state, i);
if (ch === close) depth++;
else if (ch === open && --depth === 0) return i;
}
return null;
}
/** Paragraph bounds (blank-line delimited) containing `pos`. */
export function paragraphAt(state: EditorState, pos: number): { from: number; to: number } {
const { doc } = state;
let startLine = doc.lineAt(pos).number;
let endLine = startLine;
const blank = (n: number) => doc.line(n).text.trim() === "";
while (startLine > 1 && !blank(startLine - 1)) startLine--;
while (endLine < doc.lines && !blank(endLine + 1)) endLine++;
return { from: doc.line(startLine).from, to: doc.line(endLine).to };
}

View File

@@ -0,0 +1,83 @@
import { StateEffect, StateField } from "@codemirror/state";
import { EditorView, showPanel, type Panel } from "@codemirror/view";
export interface PromptConfig {
label: string;
initial?: string;
onSubmit: (value: string, view: EditorView) => void;
onChange?: (value: string, view: EditorView) => void;
onCancel?: (view: EditorView) => void;
}
const openPromptEffect = StateEffect.define<PromptConfig>();
const closePromptEffect = StateEffect.define<null>();
const promptState = StateField.define<PromptConfig | null>({
create: () => null,
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(openPromptEffect)) return effect.value;
if (effect.is(closePromptEffect)) return null;
}
return value;
},
});
export function openPrompt(view: EditorView, config: PromptConfig) {
view.dispatch({ effects: openPromptEffect.of(config) });
// Focus after the panel mounts.
requestAnimationFrame(() => {
const input = view.dom.querySelector<HTMLInputElement>(".cm-helix-prompt input");
input?.focus();
input?.select();
});
}
function closePrompt(view: EditorView) {
view.dispatch({ effects: closePromptEffect.of(null) });
view.focus();
}
function promptPanel(view: EditorView): Panel {
const config = view.state.field(promptState);
const dom = document.createElement("form");
dom.className = "cm-helix-prompt";
const label = document.createElement("label");
label.textContent = config?.label ?? "";
const input = document.createElement("input");
input.type = "text";
input.value = config?.initial ?? "";
input.spellcheck = false;
input.autocomplete = "off";
dom.append(label, input);
input.addEventListener("input", () => {
view.state.field(promptState)?.onChange?.(input.value, view);
});
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
const current = view.state.field(promptState);
closePrompt(view);
current?.onSubmit(input.value, view);
} else if (event.key === "Escape") {
event.preventDefault();
const current = view.state.field(promptState);
closePrompt(view);
current?.onCancel?.(view);
}
});
return { dom, top: false };
}
export const helixPrompt = [
promptState,
showPanel.from(promptState, (config) => (config ? promptPanel : null)),
];
export function isPromptOpen(view: EditorView): boolean {
return view.state.field(promptState) !== null;
}

View File

@@ -0,0 +1,87 @@
import { StateEffect, StateField, type EditorState } from "@codemirror/state";
export type HelixMode = "normal" | "insert" | "select";
/** A multi-key prefix or a command waiting for one more keystroke. */
export type Pending =
| { type: "g" } // goto mode
| { type: "m" } // match mode
| { type: "z" } // view mode
| { type: "space" } // space menu (minimal)
| { type: "find"; kind: "f" | "t" | "F" | "T" } // awaiting target char
| { type: "replace" } // r{char}
| { type: "register" } // "{char}
| { type: "textobject"; around: boolean } // mi{o} / ma{o}
| { type: "surround-add" } // ms{char}
| { type: "surround-delete" } // md{char}
| { type: "surround-replace"; from?: string }; // mr{from}{to}
export interface LastFind {
kind: "f" | "t" | "F" | "T";
char: string;
}
export interface HelixStateValue {
mode: HelixMode;
pending: Pending | null;
count: number; // 0 = no explicit count
register: string | null; // selected register for next yank/paste/delete
lastFind: LastFind | null;
}
const initial: HelixStateValue = {
mode: "normal",
pending: null,
count: 0,
register: null,
lastFind: null,
};
/** Patch the transient Helix state. */
export const helixEffect = StateEffect.define<Partial<HelixStateValue>>();
export const helixState = StateField.define<HelixStateValue>({
create: () => initial,
update(value, tr) {
let next = value;
for (const effect of tr.effects) {
if (effect.is(helixEffect)) next = { ...next, ...effect.value };
}
return next;
},
});
export function getHelix(state: EditorState): HelixStateValue {
return state.field(helixState);
}
export function getMode(state: EditorState): HelixMode {
return state.field(helixState).mode;
}
// --- registers ---------------------------------------------------------------
export const setRegister = StateEffect.define<{ name: string; text: string }>();
export const registersField = StateField.define<Map<string, string>>({
create: () => new Map(),
update(value, tr) {
let next = value;
for (const effect of tr.effects) {
if (effect.is(setRegister)) {
next = new Map(next);
next.set(effect.value.name, effect.value.text);
}
}
return next;
},
});
export function readRegister(state: EditorState, name: string | null): string {
return state.field(registersField).get(name ?? '"') ?? "";
}
/** Clear any pending prefix / count / register selection. */
export function clearPending(): Partial<HelixStateValue> {
return { pending: null, count: 0, register: null };
}

View File

@@ -0,0 +1,127 @@
import type { EditorState, SelectionRange } from "@codemirror/state";
import { charAt, classOf, findClose, findOpen, pairFor, paragraphAt } from "./motions";
export interface Span {
from: number;
to: number;
}
function wordSpan(state: EditorState, pos: number, around: boolean): Span | null {
const cls = classOf(charAt(state, pos));
if (cls === "space") return null;
let from = pos;
let to = pos;
while (from > 0 && classOf(charAt(state, from - 1)) === cls) from--;
while (to < state.doc.length && classOf(charAt(state, to)) === cls) to++;
if (around) {
const start = to;
while (to < state.doc.length && classOf(charAt(state, to)) === "space") to++;
if (to === start) while (from > 0 && classOf(charAt(state, from - 1)) === "space") from--;
}
return { from, to };
}
function pairSpan(
state: EditorState,
pos: number,
open: string,
close: string,
around: boolean,
): Span | null {
// If we sit on a bracket, anchor the search to it.
const here = charAt(state, pos);
const openPos =
here === open ? pos : findOpen(state, here === close ? pos - 1 : pos, open, close);
if (openPos === null) return null;
const closePos = findClose(state, openPos, open, close);
if (closePos === null) return null;
return around ? { from: openPos, to: closePos + 1 } : { from: openPos + 1, to: closePos };
}
function quoteSpan(state: EditorState, pos: number, quote: string, around: boolean): Span | null {
const line = state.doc.lineAt(pos);
const positions: number[] = [];
for (let i = line.from; i <= line.to; i++) if (charAt(state, i) === quote) positions.push(i);
for (let i = 0; i + 1 < positions.length; i += 2) {
const a = positions[i]!;
const b = positions[i + 1]!;
if (pos >= a && pos <= b + 1) {
return around ? { from: a, to: b + 1 } : { from: a + 1, to: b };
}
}
return null;
}
function paragraphSpan(state: EditorState, pos: number, around: boolean): Span {
const span = paragraphAt(state, pos);
if (!around) return span;
let to = span.to;
while (to < state.doc.length && state.doc.lineAt(to + 1).text.trim() === "") {
to = state.doc.lineAt(to + 1).to;
}
return { from: span.from, to };
}
const QUOTES = new Set(['"', "'", "`"]);
/** Innermost enclosing bracket/quote pair (Helix `mi m` / `ma m`). */
function nearestPair(state: EditorState, pos: number, around: boolean): Span | null {
const candidates: Span[] = [];
for (const ch of ["(", "[", "{", "<"]) {
const span = pairSpan(state, pos, ch, pairFor(ch)!.close, around);
if (span) candidates.push(span);
}
for (const q of QUOTES) {
const span = quoteSpan(state, pos, q, around);
if (span) candidates.push(span);
}
if (!candidates.length) return null;
// Smallest enclosing span wins.
return candidates.sort((a, b) => b.from - a.from || a.to - b.to)[0]!;
}
/**
* Resolve a Helix textobject for `mi{obj}` / `ma{obj}`.
* Returns null when nothing sensible encloses the cursor.
*/
export function textObject(
state: EditorState,
range: SelectionRange,
obj: string,
around: boolean,
): Span | null {
const pos = range.head > range.from ? range.head - 1 : range.head;
switch (obj) {
case "w":
case "W":
return wordSpan(state, range.head, around);
case "p":
return paragraphSpan(state, range.head, around);
case "(":
case ")":
case "b":
return pairSpan(state, pos, "(", ")", around);
case "{":
case "}":
case "B":
return pairSpan(state, pos, "{", "}", around);
case "[":
case "]":
return pairSpan(state, pos, "[", "]", around);
case "<":
case ">":
return pairSpan(state, pos, "<", ">", around);
case '"':
return quoteSpan(state, range.head, '"', around);
case "'":
return quoteSpan(state, range.head, "'", around);
case "`":
return quoteSpan(state, range.head, "`", around);
case "m":
return nearestPair(state, pos, around);
default:
return null;
}
}
export { pairFor } from "./motions";

View File

@@ -0,0 +1,123 @@
import {
Decoration,
EditorView,
ViewPlugin,
WidgetType,
showPanel,
type DecorationSet,
type Panel,
type ViewUpdate,
} from "@codemirror/view";
import { getMode, helixState } from "./state";
class EolCursorWidget extends WidgetType {
toDOM() {
const span = document.createElement("span");
span.className = "cm-helix-block cm-helix-block-eol";
span.textContent = " ";
return span;
}
ignoreEvent() {
return true;
}
}
const blockMark = Decoration.mark({ class: "cm-helix-block" });
const selectionMark = Decoration.mark({ class: "cm-helix-selection" });
const eolCursor = Decoration.widget({ widget: new EolCursorWidget(), side: 1 });
// Draws the selection ranges + a block cursor at each head while in
// normal/select mode. We render the selection ourselves (rather than relying
// on drawSelection, which the host theme can leave invisible) so multi-cursor
// selections are always clearly highlighted.
export const blockCursor = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.build(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.selectionSet ||
update.viewportChanged ||
update.startState.field(helixState).mode !== update.state.field(helixState).mode
) {
this.decorations = this.build(update.view);
}
}
build(view: EditorView): DecorationSet {
if (getMode(view.state) === "insert") return Decoration.none;
const deco: ReturnType<typeof blockMark.range>[] = [];
for (const range of view.state.selection.ranges) {
if (!range.empty) deco.push(selectionMark.range(range.from, range.to));
const pos = range.head;
const line = view.state.doc.lineAt(pos);
deco.push(pos < line.to ? blockMark.range(pos, pos + 1) : eolCursor.range(pos));
}
return Decoration.set(deco, true);
}
},
{ decorations: (plugin) => plugin.decorations },
);
export const modeEditorClass = EditorView.editorAttributes.compute([helixState], (state) => ({
class: `cm-helix cm-helix-${getMode(state)}`,
}));
function statusPanel(view: EditorView): Panel {
const dom = document.createElement("div");
dom.className = "cm-helix-status";
const render = () => {
const s = view.state.field(helixState);
const label = s.mode === "insert" ? "INS" : s.mode === "select" ? "SEL" : "NOR";
const count = s.count ? ` ${s.count}` : "";
const reg = s.register ? ` "${s.register}` : "";
const pend = s.pending ? ` ${s.pending.type}` : "";
const sels = view.state.selection.ranges.length;
const multi = sels > 1 ? ` ${sels} sels` : "";
dom.textContent = `${label}${count}${reg}${pend}${multi}`;
dom.dataset.mode = s.mode;
};
render();
return {
dom,
update: (u) => {
if (u.docChanged || u.selectionSet || u.transactions.length) render();
},
};
}
export const helixStatusPanel = showPanel.of(statusPanel);
export const helixTheme = EditorView.baseTheme({
"&.cm-helix-normal .cm-cursor, &.cm-helix-normal .cm-cursorLayer": { display: "none" },
"&.cm-helix-select .cm-cursor, &.cm-helix-select .cm-cursorLayer": { display: "none" },
".cm-helix-selection": { backgroundColor: "rgba(120,160,255,0.32)" },
".cm-helix-block": { backgroundColor: "rgba(125,165,255,0.7)", borderRadius: "1px" },
".cm-helix-block-eol": { display: "inline-block", width: "0.55em" },
".cm-helix-status": {
padding: "1px 10px",
font: "11px ui-monospace, SFMono-Regular, Menlo, monospace",
letterSpacing: "0.05em",
},
".cm-helix-status[data-mode=insert]": { color: "#16a34a" },
".cm-helix-status[data-mode=select]": { color: "#d97706" },
".cm-helix-status[data-mode=normal]": { color: "#2563eb" },
".cm-helix-prompt": {
display: "flex",
gap: "0.5em",
alignItems: "center",
padding: "2px 10px",
font: "12px ui-monospace, SFMono-Regular, Menlo, monospace",
},
".cm-helix-prompt label": { opacity: 0.7 },
".cm-helix-prompt input": {
flex: "1",
border: "none",
outline: "none",
background: "transparent",
color: "inherit",
font: "inherit",
},
});

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

1
public/ffmpeg/worker.js Normal file
View File

@@ -0,0 +1 @@
var C="https://unpkg.com/@ffmpeg/core@0.12.9/dist/umd/ffmpeg-core.js",N;(function(E){E.LOAD="LOAD",E.EXEC="EXEC",E.FFPROBE="FFPROBE",E.WRITE_FILE="WRITE_FILE",E.READ_FILE="READ_FILE",E.DELETE_FILE="DELETE_FILE",E.RENAME="RENAME",E.CREATE_DIR="CREATE_DIR",E.LIST_DIR="LIST_DIR",E.DELETE_DIR="DELETE_DIR",E.ERROR="ERROR",E.DOWNLOAD="DOWNLOAD",E.PROGRESS="PROGRESS",E.LOG="LOG",E.MOUNT="MOUNT",E.UNMOUNT="UNMOUNT"})(N||(N={}));var G=Error("unknown message type"),V=Error("ffmpeg is not loaded, call `await ffmpeg.load()` first"),b=Error("called FFmpeg.terminate()"),v=Error("failed to import ffmpeg-core.js");var I,X=async({coreURL:E,wasmURL:D,workerURL:O})=>{let S=!I;try{if(!E)E=C;importScripts(E)}catch{if(!E||E===C)E=C.replace("/umd/","/esm/");if(self.createFFmpegCore=(await import(E)).default,!self.createFFmpegCore)throw v}let A=E,x=D?D:E.replace(/.js$/g,".wasm"),B=O?O:E.replace(/.js$/g,".worker.js");return I=await self.createFFmpegCore({mainScriptUrlOrBlob:`${A}#${btoa(JSON.stringify({wasmURL:x,workerURL:B}))}`}),I.setLogger((W)=>self.postMessage({type:N.LOG,data:W})),I.setProgress((W)=>self.postMessage({type:N.PROGRESS,data:W})),S},Y=({args:E,timeout:D=-1})=>{I.setTimeout(D),I.exec(...E);let O=I.ret;return I.reset(),O},j=({args:E,timeout:D=-1})=>{I.setTimeout(D),I.ffprobe(...E);let O=I.ret;return I.reset(),O},J=({path:E,data:D})=>{return I.FS.writeFile(E,D),!0},$=({path:E,encoding:D})=>I.FS.readFile(E,{encoding:D}),q=({path:E})=>{return I.FS.unlink(E),!0},z=({oldPath:E,newPath:D})=>{return I.FS.rename(E,D),!0},H=({path:E})=>{return I.FS.mkdir(E),!0},K=({path:E})=>{let D=I.FS.readdir(E),O=[];for(let S of D){let A=I.FS.stat(`${E}/${S}`),x=I.FS.isDir(A.mode);O.push({name:S,isDir:x})}return O},Q=({path:E})=>{return I.FS.rmdir(E),!0},R=({fsType:E,options:D,mountPoint:O})=>{let S=E,A=I.FS.filesystems[S];if(!A)return!1;return I.FS.mount(A,D,O),!0},Z=({mountPoint:E})=>{return I.FS.unmount(E),!0};self.onmessage=async({data:{id:E,type:D,data:O}})=>{let S=[],A;try{if(D!==N.LOAD&&!I)throw V;switch(D){case N.LOAD:A=await X(O);break;case N.EXEC:A=Y(O);break;case N.FFPROBE:A=j(O);break;case N.WRITE_FILE:A=J(O);break;case N.READ_FILE:A=$(O);break;case N.DELETE_FILE:A=q(O);break;case N.RENAME:A=z(O);break;case N.CREATE_DIR:A=H(O);break;case N.LIST_DIR:A=K(O);break;case N.DELETE_DIR:A=Q(O);break;case N.MOUNT:A=R(O);break;case N.UNMOUNT:A=Z(O);break;default:throw G}}catch(x){self.postMessage({id:E,type:N.ERROR,data:x.toString()});return}if(A instanceof Uint8Array)S.push(A.buffer);self.postMessage({id:E,type:D,data:A},S)};

View File

@@ -0,0 +1,31 @@
'use client'
import { useRouter } from 'next/navigation'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog'
import ChatInterface from '~/app/chat/_components/ChatInterface'
import { useMessages } from '~/app/_providers/MessagesProvider';
import { Spinner } from '~/components/ui/spinner';
export default function ChatModal() {
const router = useRouter()
const {messages,session,isLoading,error} = useMessages()
return (
<Dialog modal={true} open onOpenChange={() => router.back()}>
<DialogContent className="w-full max-w-full rounded-none sm:max-w-full h-[100svh] lg:max-w-3xl lg:rounded-xl lg:h-[80vh] flex flex-col p-0 gap-0">
<DialogHeader className="p-4 border-b shrink-0">
<DialogTitle>Talk To My AI-Assistant</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden min-h-0">
{!isLoading &&
<ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
}
{isLoading &&
<><Spinner/> Loading Messages...</>
}
{error &&
<div> {error} </div>
}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,8 @@
'use client'
import ChatModal from './_components/ChatModal'
export default function AssistantModalPage() {
return (
<ChatModal/>
)
}

View File

@@ -1,38 +1,48 @@
import { useGSAP } from "@gsap/react"; "use client"
import { useRef, type HTMLAttributes, type ReactNode } from "react"; import { useRef, type HTMLAttributes, type ReactNode } from "react";
import { useGsapContext } from "~/app/_providers/GsapProvicer";
import { SplitText } from "gsap/SplitText"; import { SplitText } from "gsap/SplitText";
import gsap from 'gsap' import gsap from 'gsap'
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { useReveal } from "./useReveal";
const AnimateTextIn = ({ const AnimateTextIn = ({
children, children,
animation = "type", animation = "type",
position, position = 0,
speed = 1,
scrollOnly = false,
once = false,
debugId,
className className
}: { }: {
children: ReactNode, children: ReactNode,
animation?: "type" | "slide", animation?: "type" | "slide",
position: gsap.Position, position?: gsap.Position,
className?:HTMLAttributes<HTMLDivElement>['className'] scrollOnly?: boolean,
once?: boolean,
debugId?: string,
speed?: number,
className?: HTMLAttributes<HTMLDivElement>['className']
}) => { }) => {
const el = useRef<HTMLDivElement>(null) const el = useRef<HTMLDivElement>(null)
const gsapContext = useGsapContext(); useReveal(el, {
useGSAP(() => { position,
const rect = el.current?.getBoundingClientRect() scrollOnly,
const isInView = rect && rect.top < window.innerHeight once,
const chars = new SplitText(el.current, { type: 'chars' }) debugId: debugId ?? `text-${position}`,
gsapContext?.addAnimation(gsap.to(el.current, { opacity: 100, duration: 0 }), 0) makeReveal: (node) => {
// The wrapper starts at opacity 0 (so there's no flash of unsplit text);
// reveal the wrapper and let the per-character tween do the animation.
gsap.set(node, { opacity: 1 })
const split = new SplitText(node, { type: 'chars' })
const fromVars = animation === "slide" const fromVars = animation === "slide"
? { opacity: 0, x: -10, duration: 0.2, stagger: { each: 0.08 }, ease: 'bounce.inOut', onComplete: () => chars.revert() } ? { opacity: 0, x: -10, duration: 0.2 * speed, stagger: { each: 0.08 * speed }, ease: 'bounce.inOut' }
: { opacity: 0, duration: 0.01, stagger: { each: 0.04 }, ease: 'bounce.inOut', onComplete: () => chars.revert() } : { opacity: 0, duration: 0.01 * speed, stagger: { each: 0.04 * speed }, ease: 'bounce.inOut' }
if (isInView) { return gsap.from(split.chars, { ...fromVars, paused: true })
gsapContext?.addAnimation(gsap.from(chars.chars, fromVars), position) },
} else { })
gsap.from(chars.chars, { ...fromVars, scrollTrigger: { trigger: el.current, start: 'top 85%', scroller: gsapContext?.getScroller() } })
}
}, { dependencies: [] })
return ( return (
<div ref={el} className={cn(className,"opacity-0")}> <div ref={el} className={cn(className, "opacity-0")}>
{children} {children}
</div> </div>
) )

View File

@@ -6,16 +6,22 @@ const AnimatePopUp = ({
position, position,
className, className,
duration=1, duration=1,
ease='elastic' ease='elastic',
scrollOnly=false,
once=false,
debugId,
}:{ }:{
children:ReactNode children:ReactNode
position:gsap.Position, position:gsap.Position,
className?:HTMLAttributes<HTMLDivElement>['className'] className?:HTMLAttributes<HTMLDivElement>['className']
duration?:number, duration?:number,
ease?:gsap.EaseString|gsap.EaseFunction ease?:gsap.EaseString|gsap.EaseFunction,
scrollOnly?:boolean,
once?:boolean,
debugId?:string,
}) => { }) => {
return ( return (
<AnimatedDiv children={children} position={position} className={cn(className,'h-0 translate-y-[50] overflow-hidden')} height='auto' y={0} overflow='' ease={ease} duration={duration} /> <AnimatedDiv children={children} position={position} scrollOnly={scrollOnly} once={once} debugId={debugId} className={cn(className,'h-0 translate-y-[50] overflow-hidden')} height='auto' y={0} overflow='' ease={ease} duration={duration} />
) )
} }

View File

@@ -1,16 +1,11 @@
"use client"; "use client";
import React, { useRef, useEffect, useCallback, useState } from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import type React from "react";
import { useCallback, useEffect, useRef } from "react";
/* ─────────────────────────────────────────────
* Config — grayscale palettes
* ───────────────────────────────────────────── */
const PALETTES = { const PALETTES = {
dark: { dark: {
base: "#0a0a0a",
particles: [ particles: [
"rgba(255,255,255,0.70)", "rgba(255,255,255,0.70)",
"rgba(255,255,255,0.45)", "rgba(255,255,255,0.45)",
@@ -18,9 +13,9 @@ const PALETTES = {
"rgba(200,200,200,0.35)", "rgba(200,200,200,0.35)",
"rgba(255,255,255,0.22)", "rgba(255,255,255,0.22)",
], ],
grainOpacity: 0.05,
}, },
light: { light: {
base: "#f5f5f5",
particles: [ particles: [
"rgba(0,0,0,0.55)", "rgba(0,0,0,0.55)",
"rgba(0,0,0,0.35)", "rgba(0,0,0,0.35)",
@@ -28,22 +23,10 @@ const PALETTES = {
"rgba(80,80,80,0.25)", "rgba(80,80,80,0.25)",
"rgba(0,0,0,0.18)", "rgba(0,0,0,0.18)",
], ],
grainOpacity: 0.03,
}, },
} as const; } as const;
/* ─────────────────────────────────────────────
* Helpers
* ───────────────────────────────────────────── */
const isMobileDevice = (): boolean => {
if (typeof window === "undefined") return false;
return window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 768;
};
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
/* ─────────────────────────────────────────────
* Particle
* ───────────────────────────────────────────── */
interface Particle { interface Particle {
angle: number; angle: number;
radius: number; radius: number;
@@ -55,33 +38,52 @@ interface Particle {
wobblePhase: number; wobblePhase: number;
} }
const spawnParticle = (): Particle => ({ interface CanvasSize {
angle: rand(0, Math.PI * 2), dpr: number;
radius: rand(30, 240), height: number;
speed: rand(0.003, 0.002) * (Math.random() > 0.5 ? 1 : -1), width: number;
size: rand(1.2, 4), }
colorIndex: Math.floor(rand(0, 5)),
wobbleAmp: rand(6, 30),
wobbleSpeed: rand(0.008, 0.035),
wobblePhase: rand(0, Math.PI * 2),
});
/* ─────────────────────────────────────────────
* Component
* ───────────────────────────────────────────── */
interface AnimatedBackgroundContainerProps { interface AnimatedBackgroundContainerProps {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
/** Number of orbiting particles. Default 60 */
particleCount?: number; particleCount?: number;
/** Max orbit radius in px — controls how far particles spread from the cursor. Default 240 */
orbitRadius?: number; orbitRadius?: number;
/** How quickly particles catch up to cursor (01). Default 0.06 */
followSpeed?: number; followSpeed?: number;
/** Speed multiplier for mobile random anchor drift. Default 1 */
mobileSpeed?: number; mobileSpeed?: number;
} }
const DEFAULT_PARTICLE_COLORS: readonly string[] = PALETTES.dark.particles;
const PARTICLE_COLOR_COUNT = DEFAULT_PARTICLE_COLORS.length;
const EDGE_FADE_DISTANCE = 80;
const MAX_DEVICE_PIXEL_RATIO = 2;
const MOBILE_TARGET_DISTANCE = 30;
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
const isMobileDevice = () => {
if (typeof window === "undefined") {
return false;
}
return window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 768;
};
const createParticle = (orbitRadius: number): Particle => {
const minRadius = Math.max(10, orbitRadius * 0.12);
return {
angle: rand(0, Math.PI * 2),
radius: rand(minRadius, orbitRadius),
speed: rand(0.002, 0.003) * (Math.random() > 0.5 ? 1 : -1),
size: rand(1.2, 4),
colorIndex: Math.floor(rand(0, PARTICLE_COLOR_COUNT)),
wobbleAmp: rand(orbitRadius * 0.025, orbitRadius * 0.12),
wobbleSpeed: rand(0.008, 0.035),
wobblePhase: rand(0, Math.PI * 2),
};
};
export default function AnimatedBackgroundContainer({ export default function AnimatedBackgroundContainer({
children, children,
className = "", className = "",
@@ -92,172 +94,304 @@ export default function AnimatedBackgroundContainer({
}: AnimatedBackgroundContainerProps) { }: AnimatedBackgroundContainerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mousePos = useRef({ x: 0, y: 0 }); const frameRef = useRef(0);
const smoothMouse = useRef({ x: 0, y: 0 }); const animationFrameRef = useRef<number | null>(null);
const mobileAnchor = useRef({ x: 0, y: 0 }); const isMobileRef = useRef(false);
const mobileTarget = useRef({ x: 0, y: 0 }); const isVisibleRef = useRef(true);
const isMobile = useRef(false); const prefersReducedMotionRef = useRef(false);
const particles = useRef<Particle[]>([]); const particlesRef = useRef<Particle[]>([]);
const frame = useRef(0); const mousePosRef = useRef({ x: 0, y: 0 });
const [mounted, setMounted] = useState(false); const smoothMouseRef = useRef({ x: 0, y: 0 });
const mobileAnchorRef = useRef({ x: 0, y: 0 });
const mobileTargetRef = useRef({ x: 0, y: 0 });
const canvasSizeRef = useRef<CanvasSize>({ dpr: 1, height: 0, width: 0 });
const containerRectRef = useRef<DOMRect | null>(null);
const followSpeedRef = useRef(followSpeed);
const mobileSpeedRef = useRef(mobileSpeed);
const particleColorsRef = useRef<readonly string[]>(DEFAULT_PARTICLE_COLORS);
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
let isDark = resolvedTheme === "dark"; const isDark = resolvedTheme === undefined || resolvedTheme === "dark";
if (resolvedTheme == undefined) {
isDark = true;
}
const palette = isDark ? PALETTES.dark : PALETTES.light; const palette = isDark ? PALETTES.dark : PALETTES.light;
/* Spawn particles */
useEffect(() => { useEffect(() => {
const minR = Math.max(10, orbitRadius * 0.12); particleColorsRef.current = palette.particles;
particles.current = Array.from({ length: particleCount }, () => ({ }, [palette]);
...spawnParticle(),
radius: rand(minR, orbitRadius), useEffect(() => {
wobbleAmp: rand(orbitRadius * 0.025, orbitRadius * 0.12), followSpeedRef.current = followSpeed;
})); }, [followSpeed]);
useEffect(() => {
mobileSpeedRef.current = mobileSpeed;
}, [mobileSpeed]);
useEffect(() => {
particlesRef.current = Array.from({ length: particleCount }, () =>
createParticle(orbitRadius),
);
}, [particleCount, orbitRadius]); }, [particleCount, orbitRadius]);
/* Detect mobile & seed positions */ const seedPositions = useCallback(() => {
useEffect(() => { const { height, width } = canvasSizeRef.current;
setMounted(true); if (width === 0 || height === 0) {
isMobile.current = isMobileDevice(); return;
if (containerRef.current) { }
const cx = containerRef.current.clientWidth / 2;
const cy = containerRef.current.clientHeight / 2; const centerX = width / 2;
mousePos.current = { x: cx, y: cy }; const centerY = height / 2;
smoothMouse.current = { x: cx, y: cy };
mobileAnchor.current = { x: cx, y: cy }; mousePosRef.current = { x: centerX, y: centerY };
mobileTarget.current = { smoothMouseRef.current = { x: centerX, y: centerY };
x: rand(cx * 0.4, cx * 1.6), mobileAnchorRef.current = { x: centerX, y: centerY };
y: rand(cy * 0.4, cy * 1.6), mobileTargetRef.current = {
x: rand(centerX * 0.4, centerX * 1.6),
y: rand(centerY * 0.4, centerY * 1.6),
}; };
}, []);
const updateContainerRect = useCallback(() => {
const container = containerRef.current;
if (container) {
containerRectRef.current = container.getBoundingClientRect();
} }
}, []); }, []);
/* Resize canvas to match container */ const resizeCanvas = useCallback(() => {
useEffect(() => {
const resize = () => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
const container = containerRef.current; const container = containerRef.current;
if (!canvas || !container) return; if (!canvas || !container) {
const dpr = window.devicePixelRatio || 1; return;
const w = container.clientWidth; }
const h = container.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext("2d");
if (ctx) ctx.scale(dpr, dpr);
};
resize();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
/* Mouse tracking (desktop only) */ const width = container.clientWidth;
const handleMouseMove = useCallback((e: MouseEvent) => { const height = container.clientHeight;
if (!containerRef.current || isMobile.current) return; const dpr = Math.min(window.devicePixelRatio || 1, MAX_DEVICE_PIXEL_RATIO);
const rect = containerRef.current.getBoundingClientRect(); const nextWidth = Math.round(width * dpr);
mousePos.current = { const nextHeight = Math.round(height * dpr);
x: e.clientX - rect.left,
y: e.clientY - rect.top, canvasSizeRef.current = { dpr, height, width };
updateContainerRect();
if (canvas.width !== nextWidth) {
canvas.width = nextWidth;
}
if (canvas.height !== nextHeight) {
canvas.height = nextHeight;
}
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
seedPositions();
}, [seedPositions, updateContainerRect]);
useEffect(() => {
isMobileRef.current = isMobileDevice();
resizeCanvas();
const handleResize = () => {
isMobileRef.current = isMobileDevice();
resizeCanvas();
};
const resizeObserver =
"ResizeObserver" in window
? new ResizeObserver(() => {
handleResize();
})
: null;
if (containerRef.current && resizeObserver) {
resizeObserver.observe(containerRef.current);
}
window.addEventListener("resize", handleResize);
window.addEventListener("scroll", updateContainerRect, {
capture: true,
passive: true,
});
return () => {
resizeObserver?.disconnect();
window.removeEventListener("resize", handleResize);
window.removeEventListener("scroll", updateContainerRect, { capture: true });
};
}, [resizeCanvas, updateContainerRect]);
const handleMouseMove = useCallback((event: MouseEvent) => {
const rect = containerRectRef.current;
if (!rect || isMobileRef.current) {
return;
}
mousePosRef.current = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
}; };
}, []); }, []);
useEffect(() => { useEffect(() => {
const el = containerRef.current; const container = containerRef.current;
if (!el) return; if (!container) {
el.addEventListener("mousemove", handleMouseMove, { passive: true }); return;
return () => el.removeEventListener("mousemove", handleMouseMove); }
container.addEventListener("mousemove", handleMouseMove, { passive: true });
return () => container.removeEventListener("mousemove", handleMouseMove);
}, [handleMouseMove]); }, [handleMouseMove]);
/* ── GSAP ticker — draw loop ── */ useEffect(() => {
useGSAP(
() => {
if (!mounted) return;
const canvas = canvasRef.current; const canvas = canvasRef.current;
const container = containerRef.current; if (!canvas) {
if (!canvas || !container) return; return;
}
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) {
return;
}
const tick = () => { const stopAnimation = () => {
const w = container.clientWidth; if (animationFrameRef.current !== null) {
const h = container.clientHeight; window.cancelAnimationFrame(animationFrameRef.current);
frame.current++; animationFrameRef.current = null;
}
};
/* Anchor: smooth-follow cursor or drift on mobile */ const draw = () => {
if (isMobile.current) { if (!isVisibleRef.current || prefersReducedMotionRef.current) {
mobileAnchor.current.x += animationFrameRef.current = null;
(mobileTarget.current.x - mobileAnchor.current.x) * 0.008 * mobileSpeed; return;
mobileAnchor.current.y += }
(mobileTarget.current.y - mobileAnchor.current.y) * 0.008 * mobileSpeed;
const dx = mobileTarget.current.x - mobileAnchor.current.x; const { height, width } = canvasSizeRef.current;
const dy = mobileTarget.current.y - mobileAnchor.current.y; if (width === 0 || height === 0) {
if (Math.sqrt(dx * dx + dy * dy) < 30) { animationFrameRef.current = window.requestAnimationFrame(draw);
mobileTarget.current = { return;
x: rand(w * 0.15, w * 0.85), }
y: rand(h * 0.15, h * 0.85),
frameRef.current += 1;
if (isMobileRef.current) {
const mobileLerp = 0.008 * mobileSpeedRef.current;
mobileAnchorRef.current.x +=
(mobileTargetRef.current.x - mobileAnchorRef.current.x) * mobileLerp;
mobileAnchorRef.current.y +=
(mobileTargetRef.current.y - mobileAnchorRef.current.y) * mobileLerp;
const dx = mobileTargetRef.current.x - mobileAnchorRef.current.x;
const dy = mobileTargetRef.current.y - mobileAnchorRef.current.y;
if (Math.hypot(dx, dy) < MOBILE_TARGET_DISTANCE) {
mobileTargetRef.current = {
x: rand(width * 0.15, width * 0.85),
y: rand(height * 0.15, height * 0.85),
}; };
} }
smoothMouse.current.x = mobileAnchor.current.x;
smoothMouse.current.y = mobileAnchor.current.y; smoothMouseRef.current.x = mobileAnchorRef.current.x;
smoothMouseRef.current.y = mobileAnchorRef.current.y;
} else { } else {
smoothMouse.current.x += const desktopLerp = followSpeedRef.current;
(mousePos.current.x - smoothMouse.current.x) * followSpeed; smoothMouseRef.current.x +=
smoothMouse.current.y += (mousePosRef.current.x - smoothMouseRef.current.x) * desktopLerp;
(mousePos.current.y - smoothMouse.current.y) * followSpeed; smoothMouseRef.current.y +=
(mousePosRef.current.y - smoothMouseRef.current.y) * desktopLerp;
} }
const cx = smoothMouse.current.x; const centerX = smoothMouseRef.current.x;
const cy = smoothMouse.current.y; const centerY = smoothMouseRef.current.y;
const colors = particleColorsRef.current;
/* Clear frame */ ctx.clearRect(0, 0, width, height);
ctx.clearRect(0, 0, w, h);
/* Draw each particle */ for (const particle of particlesRef.current) {
particles.current.forEach((p) => { particle.angle += particle.speed;
p.angle += p.speed;
const wobble = const wobble =
Math.sin(frame.current * p.wobbleSpeed + p.wobblePhase) * p.wobbleAmp; Math.sin(frameRef.current * particle.wobbleSpeed + particle.wobblePhase) *
const r = p.radius + wobble; particle.wobbleAmp;
const radius = particle.radius + wobble;
const x = centerX + Math.cos(particle.angle) * radius;
const y = centerY + Math.sin(particle.angle) * radius;
const x = cx + Math.cos(p.angle) * r;
const y = cy + Math.sin(p.angle) * r;
/* Soft fade near viewport edges */
const edgeFade = Math.max( const edgeFade = Math.max(
0, 0,
Math.min(x / 80, (w - x) / 80, y / 80, (h - y) / 80, 1), Math.min(
x / EDGE_FADE_DISTANCE,
(width - x) / EDGE_FADE_DISTANCE,
y / EDGE_FADE_DISTANCE,
(height - y) / EDGE_FADE_DISTANCE,
1,
),
); );
if (edgeFade <= 0) return;
if (edgeFade <= 0) {
continue;
}
ctx.globalAlpha = edgeFade; ctx.globalAlpha = edgeFade;
ctx.fillStyle = palette.particles[p.colorIndex]; ctx.fillStyle = colors[particle.colorIndex] ?? colors[0] ?? "#ffffff";
ctx.beginPath(); ctx.beginPath();
ctx.arc(x, y, p.size, 0, Math.PI * 2); ctx.arc(x, y, particle.size, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
}); }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
animationFrameRef.current = window.requestAnimationFrame(draw);
}; };
gsap.ticker.add(tick); const startAnimation = () => {
if (
animationFrameRef.current === null &&
isVisibleRef.current &&
!prefersReducedMotionRef.current
) {
animationFrameRef.current = window.requestAnimationFrame(draw);
}
};
const handleVisibilityChange = () => {
isVisibleRef.current = document.visibilityState === "visible";
if (isVisibleRef.current) {
startAnimation();
} else {
stopAnimation();
}
};
const motionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");
const handleMotionChange = () => {
prefersReducedMotionRef.current = motionMedia.matches;
if (prefersReducedMotionRef.current) {
stopAnimation();
ctx.clearRect(0, 0, canvasSizeRef.current.width, canvasSizeRef.current.height);
} else {
startAnimation();
}
};
isVisibleRef.current = document.visibilityState === "visible";
prefersReducedMotionRef.current = motionMedia.matches;
startAnimation();
document.addEventListener("visibilitychange", handleVisibilityChange);
motionMedia.addEventListener("change", handleMotionChange);
return () => { return () => {
gsap.ticker.remove(tick); document.removeEventListener("visibilitychange", handleVisibilityChange);
motionMedia.removeEventListener("change", handleMotionChange);
stopAnimation();
}; };
}, }, []);
{
scope: containerRef,
dependencies: [mounted, isDark, followSpeed, mobileSpeed, orbitRadius, palette],
},
);
/* ── Render ── */
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@@ -281,14 +415,13 @@ export default function AnimatedBackgroundContainer({
}} }}
/> />
{/* Grain texture */}
<div <div
aria-hidden aria-hidden
style={{ style={{
position: "absolute", position: "absolute",
inset: 0, inset: 0,
zIndex: 1, zIndex: 1,
opacity: isDark ? 0.05 : 0.03, opacity: palette.grainOpacity,
pointerEvents: "none", pointerEvents: "none",
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`, backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
backgroundRepeat: "repeat", backgroundRepeat: "repeat",

View File

@@ -1,36 +1,40 @@
"use client"
import gsap from "gsap"; import gsap from "gsap";
import { type HTMLAttributes, import { type HTMLAttributes, type ReactNode, useRef } from "react";
type ReactNode, useLayoutEffect, useRef } from "react"; import { useReveal } from "./useReveal";
import { useGsapContext } from "~/app/_providers/GsapProvicer";
const AnimatedDiv = ( const AnimatedDiv = (
{ {
children, children,
position, position,
className, className,
animationMode='to', animationMode = 'to',
scrollOnly = false,
once = false,
debugId,
...tweenVars ...tweenVars
}: }:
gsap.TweenVars & { gsap.TweenVars & {
children:ReactNode, children: ReactNode,
position:gsap.Position, position: gsap.Position,
animationMode?: 'from'|'to', animationMode?: 'from' | 'to',
className?:HTMLAttributes<HTMLDivElement>['className'] scrollOnly?: boolean,
once?: boolean,
debugId?: string,
className?: HTMLAttributes<HTMLDivElement>['className']
} }
) => { ) => {
const div = useRef<HTMLDivElement>(null); const div = useRef<HTMLDivElement>(null)
const gsapContext = useGsapContext() useReveal(div, {
useLayoutEffect(() => { position,
let tween:gsap.core.Tween; scrollOnly,
switch(animationMode) { once,
case 'from': debugId,
tween = gsap.from(div.current,tweenVars); makeReveal: (el) =>
break; animationMode === 'from'
case 'to': ? gsap.from(el, { ...tweenVars, paused: true })
tween = gsap.to(div.current,tweenVars); : gsap.to(el, { ...tweenVars, paused: true }),
break; })
}
gsapContext?.addAnimation(tween,position)
},[])
return ( return (
<div ref={div} className={className}> <div ref={div} className={className}>
{children} {children}

View File

@@ -0,0 +1,134 @@
'use client'
import { useGSAP } from "@gsap/react"
import { ScrollTrigger } from "gsap/all"
import type { RefObject } from "react"
import { GSAP_DEBUG, nearestScroller, useGsapContext } from "~/app/_providers/GsapProvicer"
export type UseRevealOptions = {
position: gsap.Position
/** Skip the orchestrated entrance and let ScrollTrigger drive from the start. */
scrollOnly?: boolean
/**
* Reveal once and keep it: after the element animates in (entrance or first
* scroll-in) it never reverses on leave. Default false = animate out at the
* top and back in on scroll-up.
*/
once?: boolean
debugId?: string
/**
* Build the hidden -> shown animation for `el`. It must be a single,
* *independent* animation (not added to any timeline): `play()` reveals,
* `reverse()` hides. The hook pauses it, schedules its entrance through the
* shared timeline, and lets a ScrollTrigger drive the very same animation on
* scroll — so the two modes never fight over the element.
*/
makeReveal: (el: HTMLElement) => gsap.core.Tween | gsap.core.Timeline
}
/**
* Shared reveal behavior for cards, text and pop-ups: an element in view at
* load plays an orchestrated timeline entrance, then hands the *same* tween to
* a ScrollTrigger that animates it out at the top and back in on scroll-up. An
* element off-screen at load is ScrollTrigger-driven from the start.
*/
export function useReveal(
ref: RefObject<HTMLElement | null>,
{ position, scrollOnly = false, once = false, debugId, makeReveal }: UseRevealOptions,
) {
const ctx = useGsapContext()
useGSAP(() => {
const el = ref.current
if (!el || !ctx) {
if (GSAP_DEBUG) console.log("[cv-debug][useReveal:skip]", { debugId, hasEl: !!el, hasCtx: !!ctx })
return
}
const scroller = nearestScroller(el)
const scrollerEl = scroller instanceof Element ? scroller : undefined
const rect = el.getBoundingClientRect()
let top = 0
let bottom = window.innerHeight
if (scrollerEl) {
const r = scrollerEl.getBoundingClientRect()
top = r.top
bottom = r.top + r.height
}
const isInView = rect.bottom > top && rect.top < bottom
if (GSAP_DEBUG) {
const scrollerRect = scrollerEl?.getBoundingClientRect()
console.log("[cv-debug][useReveal:register]", {
debugId,
position,
scrollOnly,
once,
isInView,
rect: { top: rect.top, bottom: rect.bottom, height: rect.height },
viewport: { top, bottom },
scroller:
scroller === window
? "window"
: {
slot: scrollerEl?.getAttribute("data-slot"),
className: scrollerEl?.className,
clientHeight: scrollerEl?.clientHeight,
scrollHeight: scrollerEl?.scrollHeight,
rect: scrollerRect ? { top: scrollerRect.top, bottom: scrollerRect.bottom, height: scrollerRect.height } : undefined,
},
})
}
const reveal = makeReveal(el)
// A reveal that animates height (pop-ups) shifts every trigger below it.
// Re-measure as it animates so positions track the real layout instead of
// only correcting at the very end. requestRefresh is throttled + deferred to
// the next frame, so this won't re-enter a ScrollTrigger callback.
reveal.eventCallback("onUpdate", () => ctx.requestRefresh())
reveal.pause()
const baseTrigger = {
trigger: el,
start: "top bottom",
end: "bottom top",
scroller: scrollerEl,
markers: GSAP_DEBUG,
id: GSAP_DEBUG ? debugId : undefined,
}
// Full behavior: in at the bottom, out at the top, and back on scroll-up.
const addReplayTrigger = () =>
ScrollTrigger.create({
...baseTrigger,
onEnter: () => reveal.play(),
onEnterBack: () => reveal.play(),
onLeave: () => reveal.reverse(),
onLeaveBack: () => reveal.reverse(),
})
if (isInView && !scrollOnly) {
// The shared timeline only decides *when* the entrance starts; the reveal
// plays independently so the ScrollTrigger can take it over afterwards.
if (GSAP_DEBUG) console.log("[cv-debug][useReveal:schedule]", { debugId, position })
ctx.schedule(() => reveal.play(), position)
// `once` elements keep their revealed state — no scroll trigger at all.
if (!once) {
if (GSAP_DEBUG) console.log("[cv-debug][useReveal:onReady]", { debugId })
ctx.onReady(addReplayTrigger)
}
} else if (isInView) {
// scrollOnly + already on screen: no enter crossing will fire, so reveal
// now. Keep a trigger for scroll-out unless this is a `once` element.
if (GSAP_DEBUG) console.log("[cv-debug][useReveal:play-now]", { debugId, position })
reveal.play()
if (!once) addReplayTrigger()
} else if (once) {
// Off-screen: reveal when first reached, then self-destruct so it never
// reverses.
if (GSAP_DEBUG) console.log("[cv-debug][useReveal:scroll-once]", { debugId, position })
ScrollTrigger.create({ ...baseTrigger, once: true, onEnter: () => reveal.play() })
} else {
if (GSAP_DEBUG) console.log("[cv-debug][useReveal:scroll-trigger-only]", { debugId, position })
addReplayTrigger()
}
}, { dependencies: [] })
}

View File

@@ -0,0 +1,22 @@
'use client'
import Link from 'next/link'
import { MessageCircle } from 'lucide-react'
import { Button } from '~/components/ui/button'
import { usePathname } from 'next/navigation'
export default function ChatFAB() {
const pathName = usePathname()
const isChat = pathName.indexOf('\/chat') > -1
return (
<>
{!isChat &&
<div className="fixed bottom-6 right-6 z-50">
<Button asChild size="icon" className="h-14 w-14 rounded-full shadow-lg">
<Link href="/assistant">
<MessageCircle className="h-6 w-6" />
</Link>
</Button>
</div>
}
</>
)
}

View File

@@ -15,7 +15,7 @@ export default function FormScaffold<T extends FieldValues,>(params: {
}) { }) {
const { form, onSubmit, title, id, className, children } = params const { form, onSubmit, title, id, className, children } = params
return ( return (
<Card.Card className={className ? className : "w-5/6 lg:w-1/2"}> <Card.Card className={className ? className : "w-full"}>
<Card.CardHeader> <Card.CardHeader>
<Card.CardTitle> <Card.CardTitle>
<DependentText bool={id ? true : false} true={`Update ${title}`} false={`Create ${title}`} /> <DependentText bool={id ? true : false} true={`Update ${title}`} false={`Create ${title}`} />

View File

@@ -1,4 +1,3 @@
import type { UseTRPCMutationResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs";
import { createContext, useContext, type ReactNode } from "react"; import { createContext, useContext, type ReactNode } from "react";
interface ToString { interface ToString {
@@ -8,7 +7,7 @@ interface ToString {
export interface MutationInterface { export interface MutationInterface {
mutate: (params:{id:string}) => void mutate: (params: any) => void
error: ToString | null error: ToString | null
status: "error" | "idle" | "pending" | "success" status: "error" | "idle" | "pending" | "success"
} }

View File

@@ -0,0 +1,53 @@
import type { ComponentProps } from "react";
import type { Control, FieldValues, Path } from "react-hook-form";
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
type IntInputFormFieldProps<T extends FieldValues> = Omit<
ComponentProps<typeof Input>,
"defaultValue" | "name" | "onChange" | "type" | "value"
> & {
control: Control<T>;
emptyValue?: null | undefined;
label: string;
name: Path<T>;
};
export default function IntInputFormField<T extends FieldValues>({
control,
emptyValue,
label,
name,
...inputProps
}: IntInputFormFieldProps<T>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>{label}</FormLabel>
<FormControl>
<Input
{...inputProps}
inputMode="numeric"
onBlur={field.onBlur}
onChange={(event) => {
const value = event.currentTarget.value;
field.onChange(
value === "" ? emptyValue : Number.parseInt(value, 10),
);
}}
placeholder={inputProps.placeholder ?? name}
ref={field.ref}
step={inputProps.step ?? 1}
type="number"
value={field.value ?? ""}
/>
</FormControl>
</FormItem>
)}
/>
);
}

View File

@@ -0,0 +1,224 @@
'use client'
import { forwardRef, useMemo, useRef, useState, type KeyboardEvent, type TextareaHTMLAttributes } from 'react'
export type InternalLinkSuggestion = {
label: string
href: string
group: string
}
export type MdeAutocompleteSuggestion = {
label: string
value: string
detail: string
group: string
trigger: string
}
export const AUTOCOMPLETE_CURSOR_MARKER = '{{cursor}}'
export type AutocompleteTriggerConfig = {
trigger: string
label: string
isQueryValid?: (query: string) => boolean
}
type ActiveToken = {
start: number
end: number
query: string
trigger: MdeAutocompleteSuggestion['trigger']
}
const defaultTriggerConfigs: AutocompleteTriggerConfig[] = [
{
trigger: '[[',
label: 'Internal links',
isQueryValid: (query) => !query.includes(']'),
},
{
trigger: '<',
label: 'MDX components',
isQueryValid: (query) => !/[\s>]/.test(query),
},
]
function findActiveToken(
value: string,
cursor: number,
triggerConfigs: AutocompleteTriggerConfig[],
): ActiveToken | null {
const beforeCursor = value.slice(0, cursor)
const activeTrigger = triggerConfigs
.map((config) => ({ config, start: beforeCursor.lastIndexOf(config.trigger) }))
.filter((candidate) => candidate.start !== -1)
.sort((a, b) => b.start - a.start)[0]
if (!activeTrigger) return null
const query = beforeCursor.slice(activeTrigger.start + activeTrigger.config.trigger.length)
if (query.includes('\n')) return null
if (activeTrigger.config.isQueryValid && !activeTrigger.config.isQueryValid(query)) return null
return {
start: activeTrigger.start,
end: cursor,
query,
trigger: activeTrigger.config.trigger,
}
}
export function linkSuggestionsToAutocomplete(suggestions: InternalLinkSuggestion[]): MdeAutocompleteSuggestion[] {
return suggestions.map((suggestion) => ({
label: suggestion.label,
value: `[${suggestion.label}](${suggestion.href})`,
detail: suggestion.href,
group: suggestion.group,
trigger: '[[',
}))
}
export const InternalLinkTextarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement> & {
suggestions: MdeAutocompleteSuggestion[]
triggerConfigs?: AutocompleteTriggerConfig[]
}>(({ suggestions, triggerConfigs, value, onChange, onKeyDown, ...props }, ref) => {
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const [token, setToken] = useState<ActiveToken | null>(null)
const [selectedIndex, setSelectedIndex] = useState(0)
function setRefs(element: HTMLTextAreaElement | null) {
textareaRef.current = element
if (typeof ref === 'function') ref(element)
else if (ref) ref.current = element
}
const resolvedTriggerConfigs = useMemo(() => {
const configured = triggerConfigs?.length ? triggerConfigs : defaultTriggerConfigs
const merged = new Map(configured.map((config) => [config.trigger, config]))
for (const suggestion of suggestions) {
if (!merged.has(suggestion.trigger)) {
merged.set(suggestion.trigger, {
trigger: suggestion.trigger,
label: suggestion.trigger,
})
}
}
return Array.from(merged.values()).sort((a, b) => b.trigger.length - a.trigger.length)
}, [suggestions, triggerConfigs])
const triggerLabels = useMemo(
() => new Map(resolvedTriggerConfigs.map((config) => [config.trigger, config.label])),
[resolvedTriggerConfigs],
)
const matches = useMemo(() => {
if (!token) return []
const query = token.query.toLowerCase()
return suggestions
.filter((suggestion) => suggestion.trigger === token.trigger)
.filter((suggestion) => {
const haystack = `${suggestion.group} ${suggestion.label} ${suggestion.detail}`.toLowerCase()
return haystack.includes(query)
})
.slice(0, 8)
}, [suggestions, token])
function updateToken(textarea: HTMLTextAreaElement) {
const nextToken = findActiveToken(textarea.value, textarea.selectionStart, resolvedTriggerConfigs)
setToken(nextToken)
setSelectedIndex(0)
}
function insertSuggestion(textarea: HTMLTextAreaElement, suggestion: MdeAutocompleteSuggestion) {
if (!token) return
const markerIndex = suggestion.value.indexOf(AUTOCOMPLETE_CURSOR_MARKER)
const insertedValue = markerIndex === -1
? suggestion.value
: suggestion.value.replace(AUTOCOMPLETE_CURSOR_MARKER, '')
const cursor = token.start + (markerIndex === -1 ? insertedValue.length : markerIndex)
const nextValue = `${textarea.value.slice(0, token.start)}${insertedValue}${textarea.value.slice(token.end)}`
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
nativeInputValueSetter?.call(textarea, nextValue)
textarea.dispatchEvent(new Event('input', { bubbles: true }))
textarea.setSelectionRange(cursor, cursor)
setToken(null)
setSelectedIndex(0)
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (token && matches.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedIndex((index) => (index + 1) % matches.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSelectedIndex((index) => (index - 1 + matches.length) % matches.length)
return
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
const suggestion = matches[selectedIndex]
if (suggestion) insertSuggestion(event.currentTarget, suggestion)
return
}
if (event.key === 'Escape') {
event.preventDefault()
setToken(null)
return
}
}
onKeyDown?.(event)
}
return (
<>
<textarea
{...props}
ref={setRefs}
value={value}
onChange={(event) => {
onChange?.(event)
updateToken(event.currentTarget)
}}
onClick={(event) => updateToken(event.currentTarget)}
onKeyUp={(event) => {
if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(event.key)) return
updateToken(event.currentTarget)
}}
onKeyDown={handleKeyDown}
/>
{token && matches.length > 0 && (
<div className='bg-popover text-popover-foreground absolute left-3 top-12 z-50 w-80 overflow-hidden rounded-md border shadow-md'>
<div className='border-b px-3 py-2 text-xs text-muted-foreground'>
{triggerLabels.get(token.trigger) ?? token.trigger} for {token.trigger}{token.query}
</div>
<div className='max-h-64 overflow-y-auto py-1'>
{matches.map((suggestion, index) => (
<button
key={`${suggestion.trigger}:${suggestion.group}:${suggestion.label}`}
type='button'
className={`flex w-full flex-col px-3 py-2 text-left text-sm ${index === selectedIndex ? 'bg-muted' : ''}`}
onMouseDown={(event) => {
event.preventDefault()
if (textareaRef.current) insertSuggestion(textareaRef.current, suggestion)
}}
>
<span className='font-medium'>{suggestion.label}</span>
<span className='text-xs text-muted-foreground'>{suggestion.group} - {suggestion.detail}</span>
</button>
))}
</div>
</div>
)}
</>
)
})
InternalLinkTextarea.displayName = 'InternalLinkTextarea'

View File

@@ -1,25 +1,125 @@
import MDEditor from "@uiw/react-md-editor"; "use client";
import CodeMirror from "@uiw/react-codemirror";
import { markdown } from "@codemirror/lang-markdown";
import { autocompletion, completionStatus } from "@codemirror/autocomplete";
import { EditorView } from "@codemirror/view";
import { helix } from "@gregorlohaus/codemirror-helix";
import { Maximize2, Minimize2 } from "lucide-react";
import { useEffect, useMemo, useState, type ReactElement } from "react";
import { createPortal } from "react-dom";
import type { Control, FieldValues, Path } from "react-hook-form"; import type { Control, FieldValues, Path } from "react-hook-form";
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form"; import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
export default function MdeFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, dataColorMode: "dark"|"light" }) { import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import {
type AutocompleteTriggerConfig,
type MdeAutocompleteSuggestion,
} from "./InternalLinkTextarea";
import { mdxCompletionSource } from "./codemirror/mdxAutocomplete";
export default function MdeFormField<T extends FieldValues>(params: {
control: Control<T>;
name: Path<T>;
label: string;
dataColorMode: "dark" | "light";
autocompleteSuggestions?: MdeAutocompleteSuggestion[];
triggerConfigs?: AutocompleteTriggerConfig[];
renderPreview?: (source: string) => ReactElement;
}) {
const [fullscreen, setFullscreen] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!fullscreen) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = originalOverflow;
};
}, [fullscreen]);
const extensions = useMemo(
() => [
markdown(),
EditorView.lineWrapping,
helix({ escapeGuard: (state) => completionStatus(state) === "active" }),
autocompletion({
override: [mdxCompletionSource(params.autocompleteSuggestions ?? [], params.triggerConfigs)],
activateOnTyping: true,
defaultKeymap: true,
}),
],
[params.autocompleteSuggestions, params.triggerConfigs],
);
return ( return (
<FormField <FormField
control={params.control} control={params.control}
name={params.name} name={params.name}
render={({ field }) => ( render={({ field }) => {
<FormItem> const source: string = field.value ?? "";
<FormLabel> const editor = (
Description <FormItem className={cn(fullscreen && "mde-form-field-fullscreen")}>
</FormLabel> <div className="flex shrink-0 items-center justify-between gap-2">
<FormControl> <FormLabel>{params.label}</FormLabel>
<MDEditor <Button
value={field.value ? field.value : ""} type="button"
onChange={field.onChange} variant="outline"
data-color-mode={params.dataColorMode} size="icon-sm"
aria-label={fullscreen ? "Exit fullscreen editor" : "Open fullscreen editor"}
onClick={() => setFullscreen((value) => !value)}
>
{fullscreen ? <Minimize2 /> : <Maximize2 />}
</Button>
</div>
<FormControl className={cn(fullscreen && "min-h-0 flex-1")}>
<div
className={cn(
"flex flex-wrap gap-4",
fullscreen ? "min-h-0 flex-1 items-stretch" : "items-start",
)}
>
<div className="min-w-[18rem] flex-1 overflow-hidden rounded-md border">
<CodeMirror
value={source}
onChange={(value) => field.onChange(value)}
extensions={extensions}
theme={params.dataColorMode === "dark" ? "dark" : "light"}
height={fullscreen ? "calc(100vh - 96px)" : "360px"}
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightActiveLineGutter: false,
}}
/> />
</div>
{params.renderPreview && (
<div
className={cn(
"prose dark:prose-invert min-w-[18rem] max-w-none flex-1 overflow-auto rounded-md border p-4",
fullscreen ? "min-h-0" : "max-h-[360px]",
)}
>
{params.renderPreview(source)}
</div>
)}
</div>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} );
if (fullscreen && mounted) {
return createPortal(editor, document.body);
}
return editor;
}}
/> />
) );
} }

View File

@@ -1,6 +1,7 @@
import type { CheckedState } from "@radix-ui/react-checkbox"; import type { CheckedState } from "@radix-ui/react-checkbox";
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
import { createContext,useContext, useState } from "react"; import { ScrollArea } from "~/components/ui/scroll-area";
import { createContext,useContext, useState, type KeyboardEventHandler } from "react";
import { useFormContext, type Control, type ControllerRenderProps, type FieldValues, type Path } from "react-hook-form"; import { useFormContext, type Control, type ControllerRenderProps, type FieldValues, type Path } from "react-hook-form";
import type { Entries } from "type-fest"; import type { Entries } from "type-fest";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -16,6 +17,7 @@ const MultiBooleanFieldContext = createContext<MultiBooleanFieldContextProps|und
function InnerMultiBooleanFormField(params: { options: string[], onChange: (arg0: string[]) => void }) { function InnerMultiBooleanFormField(params: { options: string[], onChange: (arg0: string[]) => void }) {
const context = useContext(MultiBooleanFieldContext) const context = useContext(MultiBooleanFieldContext)
const [searchBuffer, setSearchBuffer] = useState<string>("")
if (context === undefined) { if (context === undefined) {
return (<></>) return (<></>)
} }
@@ -43,19 +45,40 @@ function InnerMultiBooleanFormField(params: { options: string[], onChange: (arg0
} }
return context.checkedValues[key] return context.checkedValues[key]
} }
const handleKeyDown:KeyboardEventHandler = (e) => {
if (e.ctrlKey && e.key == "c") {
setSearchBuffer("")
} else if (e.code == "Backspace") {
setSearchBuffer((prev) => {
const newVal = prev.substring(0,prev.length - 2)
console.log(newVal)
return newVal
})
}
else if (e.key.length === 1) {
setSearchBuffer((prev) => {
const newVal = prev + e.key;
console.log(newVal)
return newVal;
})
}
}
return ( return (
<> <ScrollArea onKeyDown={handleKeyDown} className="flex h-60">
{ {
params.options.map((opt) => ( params.options.map((opt) => (
<FormItem key={opt}> opt.startsWith(searchBuffer) && <FormItem key={opt}>
<div className="flex flex-row justify-between py-2 border-b-1"> <div className="flex flex-row justify-between py-2 border-b">
<FormLabel>{opt}</FormLabel> <FormLabel>{opt}</FormLabel>
<Checkbox data-testid="multiboolean-checkbox" checked={checked(opt)} onCheckedChange={onCheckedItemChange(opt)} /> <Checkbox data-testid="multiboolean-checkbox" checked={checked(opt)} onCheckedChange={onCheckedItemChange(opt)} />
</div> </div>
</FormItem> </FormItem>
)) ))
} }
</> </ScrollArea>
) )
} }
export default function MultiBooleanFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, options: string[], defaultValues?: string[] }) { export default function MultiBooleanFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, options: string[], defaultValues?: string[] }) {

View File

@@ -0,0 +1,85 @@
import { EditorSelection } from "@codemirror/state";
import type { Completion, CompletionSource } from "@codemirror/autocomplete";
import {
AUTOCOMPLETE_CURSOR_MARKER,
type AutocompleteTriggerConfig,
type MdeAutocompleteSuggestion,
} from "../InternalLinkTextarea";
// Re-implements the textarea autocomplete (trigger tokens like `<`, `[[`, `!`)
// as a CodeMirror completion source, driven by the exact same suggestion data
// produced by `useMdxEditorFieldProps`.
type ResolvedTrigger = { trigger: string };
function resolveTriggers(
suggestions: MdeAutocompleteSuggestion[],
triggerConfigs: AutocompleteTriggerConfig[] | undefined,
): (AutocompleteTriggerConfig & ResolvedTrigger)[] {
const map = new Map<string, AutocompleteTriggerConfig>();
for (const config of triggerConfigs ?? []) map.set(config.trigger, config);
for (const suggestion of suggestions) {
if (!map.has(suggestion.trigger)) {
map.set(suggestion.trigger, { trigger: suggestion.trigger, label: suggestion.trigger });
}
}
// Longer triggers first so `[[` wins over a hypothetical `[`.
return Array.from(map.values()).sort((a, b) => b.trigger.length - a.trigger.length);
}
function toCompletion(suggestion: MdeAutocompleteSuggestion, triggerStart: number): Completion {
const group = suggestion.group.toLowerCase();
const type = group === "component" ? "class" : group === "markdown" ? "keyword" : "variable";
return {
label: suggestion.label,
detail: suggestion.detail,
type,
apply: (view, _completion, _from, to) => {
const markerIndex = suggestion.value.indexOf(AUTOCOMPLETE_CURSOR_MARKER);
const inserted =
markerIndex === -1 ? suggestion.value : suggestion.value.replace(AUTOCOMPLETE_CURSOR_MARKER, "");
const cursor = triggerStart + (markerIndex === -1 ? inserted.length : markerIndex);
view.dispatch({
changes: { from: triggerStart, to, insert: inserted },
selection: EditorSelection.cursor(cursor),
});
},
};
}
export function mdxCompletionSource(
suggestions: MdeAutocompleteSuggestion[],
triggerConfigs?: AutocompleteTriggerConfig[],
): CompletionSource {
const triggers = resolveTriggers(suggestions, triggerConfigs);
return (context) => {
const before = context.state.sliceDoc(0, context.pos);
const active = triggers
.map((config) => ({ config, start: before.lastIndexOf(config.trigger) }))
.filter((candidate) => candidate.start !== -1)
.sort((a, b) => b.start - a.start)[0];
if (!active) return null;
const queryStart = active.start + active.config.trigger.length;
const query = before.slice(queryStart);
if (query.includes("\n")) return null;
if (active.config.isQueryValid && !active.config.isQueryValid(query)) return null;
const options = suggestions
.filter((suggestion) => suggestion.trigger === active.config.trigger)
.map((suggestion) => toCompletion(suggestion, active.start));
if (!options.length) return null;
return {
from: queryStart,
to: context.pos,
options,
// Keep the popup open while the query stays a single token.
validFor: /^[^\s>\]\)]*$/,
};
};
}

View File

@@ -1,5 +1,6 @@
export { default as BooleanFormField } from './BooleanFormField' export { default as BooleanFormField } from './BooleanFormField'
export { default as TextInputFormField } from './TextInputFormField' export { default as TextInputFormField } from './TextInputFormField'
export { default as IntInputFormField } from './IntInputFormField'
export { default as MultiBooleanFormField } from './MultiBooleanFormField' export { default as MultiBooleanFormField } from './MultiBooleanFormField'
export { default as SelectFormField } from './SelectFormField' export { default as SelectFormField } from './SelectFormField'
export { default as MdeFormField } from './MdeFormField' export { default as MdeFormField } from './MdeFormField'

View File

@@ -0,0 +1,122 @@
"use client";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import Link from "next/link";
import { useRef } from "react";
// The centerpiece is the site's own "G" mark (public/GLIcon.svg). GSAP draws the
// outline on with a stroke-dash trick, fades the fill in, then keeps everything
// gently alive (the logo floats). A curved arrow in the lower-right corner, with
// its label above the tail, points at the chat FAB to nudge people toward the
// assistant.
export default function HomeHero() {
const root = useRef<HTMLDivElement>(null);
const gPath = useRef<SVGPathElement>(null);
useGSAP(
() => {
const logo = gPath.current;
if (logo) {
const len = logo.getTotalLength();
gsap.set(logo, { strokeDasharray: len, strokeDashoffset: len });
}
gsap.set(".hero-fill", { fillOpacity: 0 });
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
if (logo) tl.to(logo, { strokeDashoffset: 0, duration: 1.3 }, 0.25);
tl.to(".hero-fill", { fillOpacity: 1, duration: 0.6 }, "-=0.35");
tl.from(".hero-line", { yPercent: 120, opacity: 0, duration: 0.7, stagger: 0.12 }, "-=0.25");
tl.from(".hero-arrow-label", { yPercent: 120, opacity: 0, duration: 0.6 }, "-=0.1");
tl.from(
".hero-arrow-svg",
{ opacity: 0, scale: 0.7, transformOrigin: "top left", duration: 0.6 },
"<+=0.1",
);
// Idle life — runs forever once the entrance has settled.
gsap.to(".hero-logo", { y: -12, duration: 3.2, ease: "sine.inOut", yoyo: true, repeat: -1 });
gsap.to(".hero-arrow-wrap", { y: 10, duration: 1.5, ease: "sine.inOut", yoyo: true, repeat: -1 });
},
{ scope: root },
);
return (
<div
ref={root}
className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden px-6 text-center"
>
{/* Logo */}
<div className="hero-logo relative h-56 w-56 sm:h-64 sm:w-64">
<svg
className="absolute inset-0 h-full w-full p-10 text-foreground"
viewBox="0 0 74.193405 74.232162"
aria-label="Gregor Lohaus logo"
>
<g transform="translate(-24.550957,-64.437925)">
<path
ref={gPath}
className="hero-fill"
d="m 61.66652,64.437927 c -20.498425,1.81e-4 -37.115669,16.617653 -37.115564,37.116083 -1.05e-4,20.49842 16.617139,37.1159 37.115564,37.11608 16.081184,-0.0265 30.316081,-10.4061 35.258313,-25.70903 1.144195,-3.51294 1.757471,-7.1771 1.819527,-10.87117 H 87.864404 67.217603 v 10.87117 h 17.977714 c -4.361366,9.03731 -13.494221,14.79672 -23.528797,14.83786 -14.494622,0 -26.244916,-11.75029 -26.244909,-26.24491 -7e-6,-14.494627 11.750287,-26.244918 26.244909,-26.244912 z"
fill="currentColor"
stroke="currentColor"
strokeWidth="0.9"
/>
<rect
className="hero-fill"
width="31.802109"
height="11.397169"
x="-96.2453"
y="67.460899"
transform="rotate(-90)"
fill="currentColor"
/>
</g>
</svg>
</div>
{/* Headline */}
<div className="mt-10 max-w-xl">
<h1 className="overflow-hidden pb-2">
<span className="hero-line block text-4xl font-semibold leading-tight tracking-tight sm:text-6xl">
Gregor Lohaus
</span>
</h1>
<div className="mt-4 overflow-hidden">
<p className="hero-line text-lg text-muted-foreground sm:text-xl">
Full Stack Developer
</p>
</div>
</div>
{/* Lower-right arrow pointing at the chat FAB */}
<Link
href="/assistant"
aria-label="Chat with my AI assistant"
className="hero-arrow-wrap absolute bottom-10 right-8 z-40 flex flex-col items-start text-foreground transition-opacity hover:opacity-80 sm:right-16"
>
<span className="mb-1 max-w-[11rem] -translate-x-20 -translate-y-2 overflow-hidden sm:-translate-x-24">
<span className="hero-arrow-label block font-semibold leading-snug text-foreground drop-shadow-[0_2px_10px_rgba(0,0,0,0.7)] sm:text-lg">
Chat with my AI&nbsp;assistant
</span>
</span>
<svg
className="hero-arrow-svg h-28 w-32 drop-shadow-[0_2px_10px_rgba(0,0,0,0.4)] sm:h-32 sm:w-36"
viewBox="0 0 776.09175 693.66538"
fill="currentColor"
aria-hidden="true"
>
<g transform="matrix(2.7190747,0,0,3.1037754,-326.9763,-1172.9045)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="m 130.838,381.118 c 1.125,28.749 5.277,54.82 12.695,78.018 7.205,22.53 18.847,40.222 36.812,53.747 52.018,39.16 153.369,16.572 153.369,16.572 l -4.632,-32.843 72.918,42.778 -58.597,58.775 -3.85,-27.303 c 0,0 -100.347,18.529 -163.905,-34.881 -37.659,-31.646 -53.293,-84.021 -51.593,-153.962 0.266,-0.247 4.728,-0.908 6.783,-0.901 z"
/>
</g>
</svg>
</Link>
</div>
);
}

View File

@@ -1,25 +0,0 @@
import MDEditor from "@uiw/react-md-editor";
import type { Control, FieldValues, Path } from "react-hook-form";
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
export default function MdeFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, dataColorMode: "dark"|"light" }) {
return (
<FormField
control={params.control}
name={params.name}
render={({ field }) => (
<FormItem>
<FormLabel>
{params.label}
</FormLabel>
<FormControl>
<MDEditor
value={field.value ? field.value : ""}
onChange={field.onChange}
data-color-mode={params.dataColorMode}
/>
</FormControl>
</FormItem>
)}
/>
)
}

View File

@@ -22,6 +22,11 @@ export default function TopNav() {
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline"> <Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
<Link href={"/music"}> Music </Link> <Link href={"/music"}> Music </Link>
</Button> </Button>
<Show when="signed-in">
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
<Link href="/chat"> Chat </Link>
</Button>
</Show>
</div> </div>
<div className="flex flex-col-reverse flex-wrap lg:h-full w-20 lg:w-fit lg:flex-row lg:ml-auto"> <div className="flex flex-col-reverse flex-wrap lg:h-full w-20 lg:w-fit lg:flex-row lg:ml-auto">
<AdminWrap> <AdminWrap>
@@ -47,7 +52,13 @@ export default function TopNav() {
<Show when="signed-in"> <Show when="signed-in">
<Button asChild className="flex h-10 lg:h-full cursor-pointer lg:w-20 content-center" variant={"outline"}> <Button asChild className="flex h-10 lg:h-full cursor-pointer lg:w-20 content-center" variant={"outline"}>
<div> <div>
<UserButton /> <UserButton
userProfileProps={{
additionalOAuthScopes: {
google: ['https://www.googleapis.com/auth/calendar'],
},
}}
/>
</div> </div>
</Button> </Button>
</Show> </Show>

View File

@@ -3,17 +3,57 @@ import { useGSAP } from '@gsap/react'
import gsap from 'gsap' import gsap from 'gsap'
import { SplitText } from 'gsap/SplitText' import { SplitText } from 'gsap/SplitText'
import { ScrollTrigger, GSDevTools } from 'gsap/all' import { ScrollTrigger, GSDevTools } from 'gsap/all'
import { createContext, useCallback, useContext, useEffect, useRef, type ReactNode } from 'react' import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'
gsap.registerPlugin(useGSAP) gsap.registerPlugin(useGSAP)
gsap.registerPlugin(ScrollTrigger) gsap.registerPlugin(ScrollTrigger)
gsap.registerPlugin(SplitText) gsap.registerPlugin(SplitText)
gsap.registerPlugin(GSDevTools) gsap.registerPlugin(GSDevTools)
// iOS Safari shows/hides its address bar at the scroll extremes, which resizes
// the viewport and makes ScrollTrigger refresh + fire spurious onLeave/onEnter
// toggles (text animating out at the bottom and not coming back). Ignoring those
// mobile-toolbar resizes keeps the real enter/leave reverse behavior intact.
ScrollTrigger.config({ ignoreMobileResize: true })
// Flip to true to draw ScrollTrigger start/end markers on every animated
// element and mount the GSDevTools timeline scrubber. Handy for seeing exactly
// where each card's enter/exit lines sit relative to the viewport.
export const GSAP_DEBUG = false
export function nearestScroller(el: Element): Element | Window {
let node: Element | null = el.parentElement
while (node) {
if (node.getAttribute('data-slot') === 'scroll-area-viewport') {
const viewport = node as HTMLElement
const rect = viewport.getBoundingClientRect()
const hasUsableBox = rect.width > 0 && rect.height > 0
const canScroll =
viewport.scrollHeight > viewport.clientHeight ||
viewport.scrollWidth > viewport.clientWidth
if (hasUsableBox && canScroll) return viewport
}
node = node.parentElement
}
return window
}
const GsapContext = createContext<{ const GsapContext = createContext<{
// Add a real animation (with its own duration) to the entrance timeline.
addAnimation: ( addAnimation: (
animation: gsap.core.TimelineChild, animation: gsap.core.TimelineChild,
position: gsap.Position position: gsap.Position
) => void, ) => void,
// Schedule a zero-duration callback at `position` — used to *start* an
// independent reveal tween so the timeline only orchestrates timing.
schedule: (fn: () => void, position: gsap.Position) => void,
// Run `cb` once the entrance is done (timeline complete or first user scroll).
onReady: (cb: () => void) => void,
// Re-measure all ScrollTriggers (throttled to once per frame). Call it
// whenever an animation changes content height so trigger positions stay
// aligned with the real layout.
requestRefresh: () => void,
resetTimeline: () => void, resetTimeline: () => void,
resumeTimeline: () => void, resumeTimeline: () => void,
getScroller: () => Element | Window | null getScroller: () => Element | Window | null
@@ -26,19 +66,29 @@ export function useGsapContext() {
export const useTimeLine = (dep:any,all?:boolean) => { export const useTimeLine = (dep:any,all?:boolean) => {
const gsapContext = useGsapContext() const gsapContext = useGsapContext()
useEffect(() => { useEffect(() => {
if (GSAP_DEBUG) {
console.log("[cv-debug][useTimeLine:effect]", {
hasDep: !!dep,
isArray: dep instanceof Array,
length: dep instanceof Array ? dep.length : undefined,
all,
})
}
if (dep instanceof Array && all) { if (dep instanceof Array && all) {
let acc = true; let acc = true;
let allDepsSatisfied = dep.reduce((p,c) => c !== undefined && p ,acc ) let allDepsSatisfied = dep.reduce((p,c) => c !== undefined && p ,acc )
if (allDepsSatisfied) { if (allDepsSatisfied) {
if (GSAP_DEBUG) console.log("[cv-debug][useTimeLine:resume-all]")
gsapContext?.resumeTimeline() gsapContext?.resumeTimeline()
} }
} else { } else {
if (dep) { if (dep) {
if (GSAP_DEBUG) console.log("[cv-debug][useTimeLine:resume]")
gsapContext?.resumeTimeline() gsapContext?.resumeTimeline()
} }
} }
},[dep]) },[dep])
useEffect(() => { useLayoutEffect(() => {
return () => { return () => {
gsapContext?.resetTimeline() gsapContext?.resetTimeline()
} }
@@ -49,38 +99,195 @@ export default function GsapProvider({ children }: { children: ReactNode }) {
const tl = useRef<gsap.core.Timeline | null>(null) const tl = useRef<gsap.core.Timeline | null>(null)
const scrollerRef = useRef<Element | Window | null>(null) const scrollerRef = useRef<Element | Window | null>(null)
const getScroller = useCallback(() => { const getScroller = useCallback(() => {
const cached = scrollerRef.current // const cached = scrollerRef.current
if (!cached || (cached instanceof Element && !document.contains(cached))) { // if (!cached || (cached instanceof Element && !document.contains(cached))) {
scrollerRef.current = document.querySelector('[data-slot="scroll-area-viewport"]') ?? window let scrollers = document.querySelectorAll('[data-slot="scroll-area-viewport"]')
if (scrollers.length < 1) {
scrollerRef.current = window
} else {
let scrollerArray = Array.from(scrollers.values()).sort((a,b) => {
const s1 = a as HTMLDivElement;
const s2 = b as HTMLDivElement;
// using bitwise not (~~) to coerce NaN values to 0
const aPriority = ~~Number(s1.dataset?.scrollerPriority)
const bPriority = ~~Number(s2.dataset?.scrollerPriority)
return aPriority - bPriority;
})
let prioScroller = scrollerArray.pop();
scrollerRef.current = prioScroller || window;
} }
// }
return scrollerRef.current return scrollerRef.current
}, []) }, [])
const devToolsCreated = useRef(false)
useGSAP(() => { useGSAP(() => {
if (!tl.current) { if (!tl.current) {
tl.current = gsap.timeline({ paused: true, id:'mainTimeline', onComplete: ()=>{ tl.current = gsap.timeline({ paused: true })
console.log('timeline revert')
gsap.getById('mainTimeline')?.revert()
} })
} }
return () => { console.log("gsap cleanup") } if (GSAP_DEBUG && tl.current && !devToolsCreated.current) {
devToolsCreated.current = true
GSDevTools.create({ animation: tl.current })
}
return () => { if (GSAP_DEBUG) console.log("gsap cleanup") }
}) })
const addAnimation = useCallback((animation: gsap.core.TimelineChild, position: gsap.Position) => { // Handoff: fire registered callbacks once, when the entrance finishes.
console.log("add animation to:", position, tl.current !== undefined) const readyFired = useRef(false)
tl.current?.add(animation, position); const readyCbs = useRef<Array<() => void>>([])
const fireReady = useCallback(() => {
if (readyFired.current) return
if (GSAP_DEBUG) {
console.log("[cv-debug][gsap:ready]", {
callbacks: readyCbs.current.length,
duration: tl.current?.duration(),
progress: tl.current?.progress(),
})
}
readyFired.current = true
readyCbs.current.forEach((cb) => cb())
readyCbs.current = []
},[]) },[])
const onReady = useCallback((cb: () => void) => {
if (GSAP_DEBUG) console.log("[cv-debug][gsap:onReady]", { readyFired: readyFired.current })
if (readyFired.current) cb()
else readyCbs.current.push(cb)
},[])
const addAnimation = useCallback((animation: gsap.core.TimelineChild, position: gsap.Position) => {
// Content can mount in waves (e.g. nested queries resolving after the
// entrance already played). Parking a tween in a finished, paused timeline
// would freeze it at its from-state, so once the entrance is done let the
// (live) tween play on its own instead.
if (GSAP_DEBUG) {
console.log("[cv-debug][gsap:addAnimation]", {
position,
readyFired: readyFired.current,
durationBefore: tl.current?.duration(),
})
}
if (readyFired.current) return
tl.current?.add(animation, position);
if (GSAP_DEBUG) {
console.log("[cv-debug][gsap:addAnimation:done]", {
position,
durationAfter: tl.current?.duration(),
children: tl.current?.getChildren(false, true, true).length,
})
}
},[])
const schedule = useCallback((fn: () => void, position: gsap.Position) => {
// Same late-arrival case: a callback added past the playhead never fires, so
// run the reveal immediately once the entrance has finished.
if (GSAP_DEBUG) {
console.log("[cv-debug][gsap:schedule]", {
position,
readyFired: readyFired.current,
durationBefore: tl.current?.duration(),
childrenBefore: tl.current?.getChildren(false, true, true).length,
})
}
if (readyFired.current) { fn(); return }
tl.current?.add(fn, position)
if (GSAP_DEBUG) {
console.log("[cv-debug][gsap:schedule:done]", {
position,
durationAfter: tl.current?.duration(),
childrenAfter: tl.current?.getChildren(false, true, true).length,
})
}
},[])
// Throttle ScrollTrigger.refresh() to once per frame so the ResizeObserver
// can fire freely while content height animates.
const refreshQueued = useRef(false)
const scheduleRefresh = useCallback(() => {
if (refreshQueued.current) return
refreshQueued.current = true
requestAnimationFrame(() => {
refreshQueued.current = false
ScrollTrigger.refresh()
})
},[])
const scrollCleanup = useRef<(() => void) | null>(null)
const resizeObserver = useRef<ResizeObserver | null>(null)
const resetTimeline = useCallback(() => { const resetTimeline = useCallback(() => {
if (GSAP_DEBUG) {
console.log("[cv-debug][gsap:reset]", {
duration: tl.current?.duration(),
progress: tl.current?.progress(),
})
}
tl.current?.kill() tl.current?.kill()
tl.current?.revert() tl.current?.revert()
ScrollTrigger.getAll().forEach(st => st.kill()) ScrollTrigger.getAll().forEach(st => st.kill())
resizeObserver.current?.disconnect()
scrollCleanup.current?.()
scrollCleanup.current = null
readyFired.current = false
readyCbs.current = []
tl.current = gsap.timeline({paused:true}) tl.current = gsap.timeline({paused:true})
},[]) },[])
const resumeTimeline = useCallback(() => { const resumeTimeline = useCallback(() => {
console.log("resuming timeline:",tl.current) const t = tl.current
tl.current?.resume() if (!t) {
},[]) if (GSAP_DEBUG) console.log("[cv-debug][gsap:resume:skip-no-timeline]")
return
}
if (GSAP_DEBUG) {
console.log("[cv-debug][gsap:resume:start]", {
duration: t.duration(),
progress: t.progress(),
paused: t.paused(),
readyFired: readyFired.current,
children: t.getChildren(false, true, true).length,
})
}
// When the orchestrated entrance finishes, hand off to scroll control and
// realign triggers against the now-settled layout.
t.eventCallback("onComplete", () => { fireReady(); ScrollTrigger.refresh() })
const scroller = getScroller()
// If the user scrolls before the entrance finishes, snap it to the end and
// switch to scroll control so the timeline and ScrollTriggers never fight
// over the same elements.
scrollCleanup.current?.()
const onFirstScroll = () => { t.progress(1); fireReady() }
scroller?.addEventListener("scroll", onFirstScroll, { once: true, passive: true })
scrollCleanup.current = () => scroller?.removeEventListener("scroll", onFirstScroll)
// Continuously realign triggers while content height changes — entrance
// growth, scroll-driven collapses, late-loading media.
if (scroller instanceof Element) {
const target = scroller.firstElementChild ?? scroller
resizeObserver.current?.disconnect()
resizeObserver.current = new ResizeObserver(scheduleRefresh)
resizeObserver.current.observe(target)
}
t.resume()
if (GSAP_DEBUG) {
console.log("[cv-debug][gsap:resume:after]", {
duration: t.duration(),
progress: t.progress(),
paused: t.paused(),
})
}
},[getScroller, fireReady, scheduleRefresh])
// Fonts/markdown/images loading also change content height after the triggers
// were created; refresh so start/end stay aligned with the real card sizes.
useEffect(() => {
const refresh = () => ScrollTrigger.refresh()
window.addEventListener("load", refresh)
document.fonts?.ready.then(refresh).catch(() => {})
return () => window.removeEventListener("load", refresh)
}, [])
return ( return (
<GsapContext.Provider value={{ addAnimation, resetTimeline, resumeTimeline, getScroller }}> <GsapContext.Provider value={{ addAnimation, schedule, onReady, requestRefresh: scheduleRefresh, resetTimeline, resumeTimeline, getScroller }}>
{children} {children}
</GsapContext.Provider> </GsapContext.Provider>
) )

View File

@@ -0,0 +1,95 @@
'use client'
import type { inferRouterOutputs } from '@trpc/server';
import { useUser } from '@clerk/nextjs'
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { trpc } from '~/app/_trpc/Client'
import type { ChatRouter } from '~/server/routers/chat'
const MessageContext = createContext<{
session?: inferRouterOutputs<ChatRouter>['getSession']
messages?: inferRouterOutputs<ChatRouter>['getMessages']
refetchMessages: () => void
clearChat: (callback?: () => void) => void
error: string|null
isLoading: boolean
clearingChat: boolean
clearedChat: boolean
}>({
session: undefined,
messages: undefined,
refetchMessages: () => undefined,
clearChat: () => undefined,
error: null,
isLoading: true,
clearingChat: false,
clearedChat: false
})
export const useMessages = () => useContext(MessageContext)
export const MessagesProvider = ({children}:{children:ReactNode}) => {
const [error,setError] = useState<string|null>(null)
const [isLoading,setIsLoading] = useState<boolean>(true)
const { isLoaded, isSignedIn } = useUser()
const { data: session,error:sessionError,isLoading:sessionLoading} = trpc.chat.getSession.useQuery(undefined, {
enabled: isSignedIn === true,
})
const { data: messages, refetch, error:messageError, isLoading:messagesLoading } = trpc.chat.getMessages.useQuery(session?.id ? session.id : "", {
enabled: isSignedIn === true && session?.id != undefined,
})
const { mutate ,isPending:clearingChat,isSuccess:clearedChat } = trpc.chat.clearChat.useMutation()
const utils = trpc.useUtils()
const refetchMessages = () => {
if (!isSignedIn) {
return;
}
utils.chat.getMessages.invalidate()
refetch()
}
const clearChat = (callback?: () => void) => {
if (!isSignedIn) {
if (callback) {
callback()
}
return;
}
mutate(undefined,{onSuccess: () => {
if (callback) {
callback()
}
utils.chat.getMessages.invalidate()
}})
}
useEffect(() => {
if (isSignedIn !== true) {
setError(null)
return;
}
messageError && setError(messageError.message)
sessionError && setError(sessionError.message)
},[messageError,sessionError,isSignedIn])
useEffect(() => {
if (!isLoaded) {
setIsLoading(true)
return;
}
if (isSignedIn !== true) {
setIsLoading(false)
return;
}
setIsLoading(sessionLoading || messagesLoading)
},[isLoaded,isSignedIn,sessionLoading,messagesLoading])
return (
<MessageContext.Provider value={
{
session: isSignedIn === true ? session : undefined,
messages: isSignedIn === true ? messages : undefined,
refetchMessages,
clearChat,
error,
isLoading,
clearingChat,
clearedChat
}
}>
{children}
</MessageContext.Provider>
)
}

View File

@@ -1,22 +1,3 @@
import { httpBatchLink } from "@trpc/client";
import { trpcRouter } from "~/server/routers/_app"; import { trpcRouter } from "~/server/routers/_app";
function getBaseUrl() { export const servTrpc = trpcRouter.createCaller({});
if (typeof window !== 'undefined')
// browser should use relative path
return '';
if (process.env.VERCEL_URL)
// reference for vercel.com
return `https://${process.env.VERCEL_URL}`;
if (process.env.RENDER_INTERNAL_HOSTNAME)
// reference for render.com
return `http://${process.env.RENDER_INTERNAL_HOSTNAME}:${process.env.PORT}`;
// assume localhost
return `http://localhost:${process.env.PORT ?? 3000}`;
};
export const servTrpc = trpcRouter.createCaller({
links: [
httpBatchLink({url: `${getBaseUrl()}/api/trpc`}),
],
});

View File

@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental' import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'
import { httpBatchLink } from "@trpc/client"; import { httpBatchLink } from "@trpc/client";
import React, { useState } from "react" import React, { useState } from "react"
import superjson from "superjson";
import { trpc } from "./Client"; import { trpc } from "./Client";
import getBaseUrl from "~/app/_trpc/GetBaseUrl"; import getBaseUrl from "~/app/_trpc/GetBaseUrl";
let clientQueryClient: QueryClient | undefined = undefined; let clientQueryClient: QueryClient | undefined = undefined;
@@ -33,6 +34,7 @@ export default function TrpcProvider({ children }: { children: React.ReactNode }
links: [ links: [
httpBatchLink({ httpBatchLink({
url: `${baseUrl}/api/trpc`, url: `${baseUrl}/api/trpc`,
transformer: superjson,
}), }),
], ],
}) })

View File

@@ -0,0 +1,25 @@
'use server'
import { getGoogleCalendarClient, getGoogleCalendarId } from '~/server/googleCalendar'
export async function cancelMeeting({ eventId }: { eventId: string }) {
try {
const calendar = getGoogleCalendarClient()
await calendar.events.delete({
calendarId: getGoogleCalendarId(),
eventId,
})
return {
success: true,
eventId,
message: 'Meeting removed from Gregor availability calendar.',
}
} catch (error) {
console.error('Failed to cancel meeting:', error)
return {
success: false,
error: 'Failed to remove the meeting from Gregor availability calendar.',
}
}
}

View File

@@ -0,0 +1,8 @@
export default function currentTime() {
let now = Date.now();
console.log(now);
return {
success: true,
time: now
}
}

View File

@@ -0,0 +1,87 @@
'use server'
import { env } from '~/env'
import { getGoogleCalendarClient, getGoogleCalendarId } from '~/server/googleCalendar'
function googleCalendarDate(date: Date) {
return date.toISOString().replace(/[-:]|\.\d{3}/g, '')
}
function createGoogleCalendarTemplateLink({
title,
description,
startTime,
endTime,
gregorEmail,
}: {
title: string
description: string
startTime: Date
endTime: Date
gregorEmail: string
}) {
const params = new URLSearchParams({
action: 'TEMPLATE',
text: title,
dates: `${googleCalendarDate(startTime)}/${googleCalendarDate(endTime)}`,
details: description,
add: gregorEmail,
})
return `https://calendar.google.com/calendar/render?${params.toString()}`
}
export async function scheduleMeeting({
title,
description,
dateTime,
durationMinutes,
attendeeEmail,
attendeeName,
}: {
title: string
description: string
dateTime: string
durationMinutes: number
attendeeEmail?: string
attendeeName?: string
}) {
try {
const calendar = getGoogleCalendarClient()
const startTime = new Date(dateTime)
const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000)
const attendeeNote = attendeeEmail
? `\n\nVisitor: ${attendeeName ?? 'Unknown'} <${attendeeEmail}>`
: ''
const eventDescription = `${description}${attendeeNote}`
const eventRequest = {
summary: title,
description: eventDescription,
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' },
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
}
const event = await calendar.events.insert({
calendarId: getGoogleCalendarId(),
requestBody: eventRequest,
})
const addToCalendarLink = createGoogleCalendarTemplateLink({
title,
description,
startTime,
endTime,
gregorEmail: env.GREGOR_MEETING_EMAIL,
})
return {
success: true,
eventId: event.data.id,
addToCalendarLink,
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}.${attendeeEmail ? ` Visitor email noted: ${attendeeEmail}.` : ''} The add-to-calendar link invites ${env.GREGOR_MEETING_EMAIL}.`,
}
} catch (error) {
console.error('Failed to schedule meeting:', error)
return { success: false, error: 'Failed to schedule meeting. Please try again.' }
}
}

View File

@@ -1,14 +1,15 @@
import Link from "next/link"; import Link from "next/link";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger } from "~/components/ui/sidebar"; import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger } from "~/components/ui/sidebar";
import SimpleSidebarGroup from "~/components/ui/simple-sidebar-group"; import SimpleSidebarGroup from "~/components/ui/simple-sidebar-group";
export default function AdminSideBar() { export default function AdminSideBar() {
return ( return (
<> <>
<SidebarProvider> <Sidebar variant="floating" className="h-full lg:h-[96%] lg:mt-10 z-51">
<Sidebar className="z-[51]"> <SidebarTrigger className="absolute z-52 left-65 top-100" />
<SidebarTrigger className="absolute z-[52] left-65 top-100" />
<SidebarContent> <SidebarContent>
<ScrollArea>
<SimpleSidebarGroup lable="CV"> <SimpleSidebarGroup lable="CV">
<Link href={"/admin/cv/category/create"}> Create Category </Link> <Link href={"/admin/cv/category/create"}> Create Category </Link>
<Link href={"/admin/cv/entry/create"}> Create Entry </Link> <Link href={"/admin/cv/entry/create"}> Create Entry </Link>
@@ -24,11 +25,15 @@ export default function AdminSideBar() {
<Link href={"/admin/music"}> Manage Music </Link> <Link href={"/admin/music"}> Manage Music </Link>
</SimpleSidebarGroup> </SimpleSidebarGroup>
<SimpleSidebarGroup lable="Blog"> <SimpleSidebarGroup lable="Blog">
<Link href={"/"}> Some Blog Action </Link> <Link href={"/admin/blog/create"}> Create Post </Link>
<Link href={"/admin/blog/list"}> Post List </Link>
</SimpleSidebarGroup> </SimpleSidebarGroup>
<SimpleSidebarGroup lable="Chat">
<Link href={"/admin/chat"}> System Prompt </Link>
</SimpleSidebarGroup>
</ScrollArea>
</SidebarContent> </SidebarContent>
</Sidebar> </Sidebar>
</SidebarProvider>
</> </>
) )
} }

View File

@@ -0,0 +1,118 @@
import { ChevronsUpDown } from "lucide-react";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/components/ui/collapsible";
const examples = [
{
name: "Lead",
description: "Intro paragraph with larger muted text.",
code: `<Lead>
Short opening summary.
</Lead>`,
},
{
name: "Callout",
description: "Highlighted note, tip, or warning block.",
code: `<Callout title="Heads up" variant="note">
Important context for readers.
</Callout>
<Callout title="Tip" variant="tip">
A practical recommendation.
</Callout>
<Callout title="Careful" variant="warning">
A caveat or tradeoff.
</Callout>`,
},
{
name: "ButtonLink",
description: "Button-styled internal or external link.",
code: `<ButtonLink href="/projects">
View projects
</ButtonLink>
<ButtonLink href="https://example.com" variant="outline">
External resource
</ButtonLink>`,
},
{
name: "Figure",
description: "Image with optional caption.",
code: `<Figure
src="https://example.com/image.jpg"
alt="Describe the image"
caption="Optional caption"
/>`,
},
{
name: "Layout / Row / Column",
description: "Border-less flex layout. Columns sit side by side and wrap when narrow.",
code: `<Layout>
<Row>
<Column>
- First list item
- Second list item
</Column>
<Column>
- Another list
- Side by side
</Column>
</Row>
</Layout>`,
},
{
name: "PullQuote",
description: "Large emphasized quote or takeaway.",
code: `<PullQuote>
A highlighted quote or strong takeaway.
</PullQuote>`,
},
{
name: "TagList",
description: "Inline list of tag badges.",
code: `<TagList tags={["nextjs", "mdx", "uploadthing"]} />`,
},
{
name: "Badge",
description: "Small inline label.",
code: `<Badge variant="outline">Next.js</Badge>`,
},
];
export default function MdxComponentReference() {
return (
<Collapsible className="rounded-lg border">
<CollapsibleTrigger className="flex w-full items-center justify-between gap-2 p-4 text-left">
<h2 className="text-base font-semibold">MDX Components</h2>
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-4">
<p className="text-muted-foreground text-sm">
Components available inside MDX content. Type <code className="rounded bg-muted px-1">[[</code> for internal links or <code className="rounded bg-muted px-1">&lt;</code> for component snippets.
</p>
<Accordion type="single" collapsible className="mt-3">
{examples.map((example) => (
<AccordionItem key={example.name} value={example.name}>
<AccordionTrigger>
<span>
<span className="block">{example.name}</span>
<span className="text-muted-foreground block text-xs font-normal">{example.description}</span>
</span>
</AccordionTrigger>
<AccordionContent>
<pre className="bg-muted overflow-x-auto rounded-md p-3 text-xs">
<code>{example.code}</code>
</pre>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CollapsibleContent>
</Collapsible>
);
}

View File

@@ -0,0 +1,58 @@
'use client'
import { useEffect, useState } from 'react'
import { MDXRemote } from 'next-mdx-remote'
import { serialize } from 'next-mdx-remote/serialize'
import type { MDXRemoteSerializeResult } from 'next-mdx-remote'
import { mdxComponents } from '~/components/mdx-components'
export default function MdxEditorPreview(params: { source: string }) {
const [compiled, setCompiled] = useState<MDXRemoteSerializeResult | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
const timeout = window.setTimeout(() => {
void serialize(params.source, {
parseFrontmatter: false,
mdxOptions: {
remarkPlugins: [],
rehypePlugins: [],
},
})
.then((result) => {
if (cancelled) return
setCompiled(result)
setError(null)
})
.catch((nextError: unknown) => {
if (cancelled) return
setCompiled(null)
setError(nextError instanceof Error ? nextError.message : 'Failed to compile MDX preview')
})
}, 200)
return () => {
cancelled = true
window.clearTimeout(timeout)
}
}, [params.source])
if (error) {
return (
<div className='rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive'>
{error}
</div>
)
}
if (!compiled) {
return <div className='text-muted-foreground p-4 text-sm'>Rendering preview...</div>
}
return (
<article className='prose dark:prose-invert max-w-none'>
<MDXRemote {...compiled} components={mdxComponents} />
</article>
)
}

View File

@@ -0,0 +1,164 @@
'use client'
import { trpc } from '~/app/_trpc/Client'
import type { RouterOutputs } from '~/server/routers/_app'
import {
AUTOCOMPLETE_CURSOR_MARKER,
linkSuggestionsToAutocomplete,
type AutocompleteTriggerConfig,
type InternalLinkSuggestion,
type MdeAutocompleteSuggestion,
} from '~/app/_components/Form/Fields/InternalLinkTextarea'
import MdxEditorPreview from './MdxEditorPreview'
function internalLinkSuggestions(params: {
posts?: RouterOutputs['blog']['list'],
projects?: RouterOutputs['projectv2']['listWithStack'],
}): InternalLinkSuggestion[] {
const postLinks = params.posts?.map((post) => ({
label: post.title,
href: `/blog/${post.slug}`,
group: 'Blog',
})) ?? []
const projectLinks = params.projects?.map((project) => ({
label: project.title,
href: `/projects#${project.id}`,
group: 'Project',
})) ?? []
return [...postLinks, ...projectLinks]
}
const mdxAutocompleteSuggestions: MdeAutocompleteSuggestion[] = [
{
label: 'Lead',
value: `<Lead>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Lead>`,
detail: 'Intro paragraph with larger muted text.',
group: 'Component',
trigger: '<',
},
{
label: 'Callout note',
value: `<Callout title="Heads up" variant="note">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
detail: 'Highlighted note block.',
group: 'Component',
trigger: '<',
},
{
label: 'Callout tip',
value: `<Callout title="Tip" variant="tip">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
detail: 'Highlighted tip block.',
group: 'Component',
trigger: '<',
},
{
label: 'Callout warning',
value: `<Callout title="Careful" variant="warning">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
detail: 'Highlighted warning block.',
group: 'Component',
trigger: '<',
},
{
label: 'ButtonLink',
value: `<ButtonLink href="${AUTOCOMPLETE_CURSOR_MARKER}">\nView projects\n</ButtonLink>`,
detail: 'Button-styled internal or external link.',
group: 'Component',
trigger: '<',
},
{
label: 'Figure',
value: `<Figure\n src="${AUTOCOMPLETE_CURSOR_MARKER}"\n alt="Describe the image"\n caption="Optional caption"\n/>`,
detail: 'Image with optional caption.',
group: 'Component',
trigger: '<',
},
{
label: 'Layout',
value: `<Layout>\n<Row>\n<Column>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Column>\n<Column>\n\n</Column>\n</Row>\n</Layout>`,
detail: 'Flex layout wrapper with side-by-side, wrapping columns.',
group: 'Component',
trigger: '<',
},
{
label: 'Row',
value: `<Row>\n<Column>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Column>\n<Column>\n\n</Column>\n</Row>`,
detail: 'Side-by-side columns that wrap when narrow.',
group: 'Component',
trigger: '<',
},
{
label: 'Column',
value: `<Column>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Column>`,
detail: 'Vertically stacked column within a Row.',
group: 'Component',
trigger: '<',
},
{
label: 'PullQuote',
value: `<PullQuote>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</PullQuote>`,
detail: 'Large emphasized quote.',
group: 'Component',
trigger: '<',
},
{
label: 'TagList',
value: `<TagList tags={[${AUTOCOMPLETE_CURSOR_MARKER}]} />`,
detail: 'Inline list of tag badges.',
group: 'Component',
trigger: '<',
},
{
label: 'Badge',
value: `<Badge variant="outline">${AUTOCOMPLETE_CURSOR_MARKER}</Badge>`,
detail: 'Small inline label.',
group: 'Component',
trigger: '<',
},
{
label: 'Image',
value: `![Image](${AUTOCOMPLETE_CURSOR_MARKER})`,
detail: 'Markdown image',
group: 'Markdown',
trigger: '!',
},
]
const mdxTriggerConfigs: AutocompleteTriggerConfig[] = [
{
trigger: '[[',
label: 'Internal links',
isQueryValid: (query) => !query.includes(']'),
},
{
trigger: '<',
label: 'MDX components',
isQueryValid: (query) => !/[\s>]/.test(query),
},
{
trigger: '!',
label: 'Markdown',
isQueryValid: (query) => !/[\s\)]/.test(query),
},
]
/**
* Shared props for an MDX-aware `MdeFormField`: internal-link + component
* autocomplete, trigger configs, and a live MDX preview. Used by every admin
* form that edits MDX content (blog, project, cv entry).
*/
export function useMdxEditorFieldProps() {
const posts = trpc.blog.list.useQuery(undefined, { refetchInterval: 5000 })
const projects = trpc.projectv2.listWithStack.useQuery()
const autocompleteSuggestions = [
...linkSuggestionsToAutocomplete(internalLinkSuggestions({ posts: posts.data, projects: projects.data })),
...mdxAutocompleteSuggestions,
]
return {
autocompleteSuggestions,
triggerConfigs: mdxTriggerConfigs,
renderPreview: (source: string) => <MdxEditorPreview source={source} />,
}
}

View File

@@ -0,0 +1,11 @@
'use client'
import { trpc } from '~/app/_trpc/Client'
import { useParams } from 'next/navigation'
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
export default function Page() {
const { slug } = useParams<{ slug: string }>()
const { data } = trpc.blog.bySlug.useQuery(slug)
if (data) return <CreateUpdateBlogForm entity={data} />
return <></>
}

View File

@@ -0,0 +1,113 @@
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { trpc } from '~/app/_trpc/Client'
import { FormScaffold } from '~/app/_components/Form/Components'
import { FormMutationContextProvider } from '~/app/_components/Form/Components/MutationProvider'
import { useState } from 'react'
import { TextInputFormField, MdeFormField } from '~/app/_components/Form/Fields'
import { usePathname, useRouter } from 'next/navigation'
import { useTheme } from 'next-themes'
import type { RouterOutputs } from '~/server/routers/_app'
import MdxComponentReference from '~/app/admin/_components/MdxComponentReference'
import { useMdxEditorFieldProps } from '~/app/admin/_components/useMdxEditorFieldProps'
type BlogPost = RouterOutputs['blog']['bySlug']
const blogPostSchema = z.object({
slug: z.string().min(1),
title: z.string().min(1),
date: z.string().optional(),
description: z.string().optional(),
tags: z.string().optional(),
content: z.string(),
})
function parseTags(value: string | undefined): string[] {
return value?.split(',').map((tag) => tag.trim()).filter(Boolean) ?? []
}
export default function CreateUpdateBlogForm(params: { className?: string, entity?: BlogPost }) {
const [slug, setSlug] = useState<string | undefined>(params.entity?.slug)
const [originalSlug, setOriginalSlug] = useState<string | undefined>(params.entity?.slug)
const { theme } = useTheme()
const form = useForm<z.infer<typeof blogPostSchema>>({
resolver: zodResolver(blogPostSchema),
defaultValues: {
slug: params.entity?.slug ?? '',
title: params.entity?.title ?? '',
date: params.entity?.date ?? '',
description: params.entity?.description ?? '',
tags: params.entity?.tags?.join(', ') ?? '',
content: params.entity?.content ?? '',
},
})
const path = usePathname()
const router = useRouter()
const mdxEditorProps = useMdxEditorFieldProps()
const createMutation = trpc.blog.insert.useMutation({
onSuccess: (data) => {
if (data[0]) {
setSlug(data[0].slug)
setOriginalSlug(data[0].slug)
}
},
})
const updateMutation = trpc.blog.update.useMutation({
onSuccess: (data) => {
if (data[0]) {
setSlug(data[0].slug)
setOriginalSlug(data[0].slug)
}
},
})
const deleteMutation = trpc.blog.delete.useMutation({
onSuccess: () => {
if (path.includes('list')) { router.refresh(); return }
router.back()
},
})
function onSubmit(values: z.infer<typeof blogPostSchema>) {
const input = { ...values, tags: parseTags(values.tags) }
if (slug && originalSlug) {
updateMutation.mutate({ ...input, originalSlug })
} else {
createMutation.mutate(input)
}
}
return (
<FormMutationContextProvider value={{
createMutation: createMutation,
updateMutation: updateMutation,
deleteMutation: deleteMutation,
}}>
<div className='flex flex-col gap-4'>
<MdxComponentReference />
<FormScaffold
form={form}
onSubmit={onSubmit}
title='Blog Post'
id={slug}
className={params.className}
>
<TextInputFormField control={form.control} name='slug' label='Slug' />
<TextInputFormField control={form.control} name='title' label='Title' />
<TextInputFormField control={form.control} name='date' label='Date (YYYY-MM-DD)' />
<TextInputFormField control={form.control} name='description' label='Description' />
<TextInputFormField control={form.control} name='tags' label='Tags (comma separated)' />
<MdeFormField
control={form.control}
name='content'
label='Content'
dataColorMode={(theme as 'dark' | 'light') ?? 'dark'}
{...mdxEditorProps}
/>
</FormScaffold>
</div>
</FormMutationContextProvider>
)
}

View File

@@ -0,0 +1,6 @@
'use client'
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
export default function Page() {
return <CreateUpdateBlogForm />
}

View File

@@ -0,0 +1,61 @@
'use client'
import Link from 'next/link'
import { trpc } from '~/app/_trpc/Client'
import * as Card from '~/components/ui/card'
import { CollapsibleForm } from '~/app/_components/Form/Components'
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
import { Badge } from '~/components/ui/badge'
import { Button } from '~/components/ui/button'
import { RefreshCw } from 'lucide-react'
export default function BlogListPage() {
const posts = trpc.blog.list.useQuery(undefined, { refetchInterval: 5000 })
const syncMutation = trpc.blog.syncFromUploadThing.useMutation({
onSuccess: () => posts.refetch(),
})
return (
<div className='w-5/6 lg:w-1/2 flex flex-col gap-3'>
<div className='flex justify-end'>
<Button
type='button'
variant='outline'
onClick={() => syncMutation.mutate(undefined)}
disabled={syncMutation.status === 'pending'}
>
<RefreshCw />
Sync
</Button>
</div>
{syncMutation.data && (
<p className='text-sm text-muted-foreground'>
Synced {syncMutation.data.created} created, {syncMutation.data.updated} updated, {syncMutation.data.skipped} skipped.
</p>
)}
{posts.data == undefined ?
<div className='gsapan' /> :
<>
{posts.data.map((post) => (
<Card.Card className='gsapan' key={post.slug}>
<Link href={`/admin/blog/${post.slug}`}>
<Card.CardHeader>
<Card.CardTitle>{post.title}</Card.CardTitle>
{post.date && <p className='text-sm text-muted-foreground'>{post.date}</p>}
{post.description && <p className='text-sm text-muted-foreground'>{post.description}</p>}
{post.tags && post.tags.length > 0 && (
<div className='flex flex-wrap gap-1.5'>
{post.tags.map((tag) => (
<Badge key={tag} variant='outline'>{tag}</Badge>
))}
</div>
)}
</Card.CardHeader>
</Link>
</Card.Card>
))}
<CollapsibleForm entityName='Blog Post' form={CreateUpdateBlogForm} />
</>
}
</div>
)
}

View File

@@ -0,0 +1,48 @@
'use client'
import { trpc } from '~/app/_trpc/Client'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '~/components/ui/select'
export default function ModelSelector({ initialValue }: { initialValue: string }) {
const utils = trpc.useUtils()
const { data: models, isLoading, error } = trpc.chat.listModels.useQuery()
const { data: model = initialValue } = trpc.chat.getModel.useQuery(undefined, {
initialData: initialValue,
})
const mutation = trpc.chat.updateModel.useMutation({
onSuccess: () => utils.chat.getModel.invalidate(),
})
// Ensure the currently-saved model is always selectable, even if the
// OpenAI list doesn't include it (e.g. a deprecated model).
const options = Array.from(new Set([model, ...(models ?? [])])).filter(Boolean)
return (
<div className="flex flex-col gap-2">
<Select value={model} onValueChange={(v) => mutation.mutate({ model: v })}>
<SelectTrigger className="w-72">
<SelectValue placeholder={isLoading ? 'Loading models…' : 'Select a model'} />
</SelectTrigger>
<SelectContent>
{options.map((id) => (
<SelectItem key={id} value={id}>
{id}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
{mutation.isPending && <span>Saving</span>}
{mutation.isSuccess && !mutation.isPending && <span>Saved</span>}
{error && <span className="text-destructive">Failed to load models: {error.message}</span>}
{mutation.error && <span className="text-destructive">{mutation.error.message}</span>}
</div>
</div>
)
}

View File

@@ -0,0 +1,39 @@
'use client'
import { useState } from 'react'
import { Textarea } from '~/components/ui/textarea'
import { Button } from '~/components/ui/button'
import { trpc } from '~/app/_trpc/Client'
export default function SystemPromptForm({ initialValue }: { initialValue: string }) {
const [value, setValue] = useState(initialValue)
const [saved, setSaved] = useState(false)
const mutation = trpc.chat.updateSystemPrompt.useMutation({
onSuccess: () => setSaved(true),
})
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaved(false)
mutation.mutate({ prompt: value })
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 w-full">
<Textarea
value={value}
onChange={(e) => { setValue(e.target.value); setSaved(false) }}
rows={16}
className="font-mono text-sm resize-y"
placeholder="Enter the system prompt for the AI recruiter..."
/>
<div className="flex items-center gap-3">
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving…' : 'Save'}
</Button>
{saved && <span className="text-sm text-muted-foreground">Saved</span>}
{mutation.error && <span className="text-sm text-destructive">{mutation.error.message}</span>}
</div>
</form>
)
}

View File

@@ -0,0 +1,31 @@
import { servTrpc } from '~/app/_trpc/ServerClient'
import SystemPromptForm from './_components/SystemPromptForm'
import ModelSelector from './_components/ModelSelector'
export default async function SystemPromptPage() {
const prompt = await servTrpc.chat.getSystemPrompt()
const model = await servTrpc.chat.getModel()
return (
<div className="w-full max-w-2xl p-6 flex flex-col gap-8">
<div className="flex flex-col gap-4">
<div>
<h1 className="text-lg font-semibold">AI Model</h1>
<p className="text-sm text-muted-foreground">
The OpenAI model used to respond to chat requests.
</p>
</div>
<ModelSelector initialValue={model} />
</div>
<div className="flex flex-col gap-4">
<div>
<h1 className="text-lg font-semibold">AI System Prompt</h1>
<p className="text-sm text-muted-foreground">
This prompt is sent to the model on every chat request.
</p>
</div>
<SystemPromptForm initialValue={prompt} />
</div>
</div>
)
}

View File

@@ -11,13 +11,8 @@ import CreateUpdateCvCategoryForm from "../_components/CreateUpdateForm";
export default function CvPage() { export default function CvPage() {
const categories = trpc.category.select.useQuery({}, { refetchInterval: 1000 }); const categories = trpc.category.select.useQuery({}, { refetchInterval: 1000 });
const entires = trpc.entry.select.useSuspenseQuery({}, { refetchInterval: 1000 }) const entires = trpc.entry.select.useSuspenseQuery({}, { refetchInterval: 1000 })
const gsap = useGsapContext()
const container = useRef<HTMLDivElement>(null);
useGSAP(() => {
gsap?.from('.gsapan', { x: -100, opacity: 0, duration: 0.5, stagger: { each: 0.3 } });
}, { scope: container, dependencies: [categories.status], revertOnUpdate: true });
return ( return (
<div ref={container} className="w-5/6 lg:w-1/2 flex flex-col gap-3"> <>
{categories.data == undefined ? {categories.data == undefined ?
<div className="gsapan"></div> <div className="gsapan"></div>
: :
@@ -64,6 +59,6 @@ export default function CvPage() {
<CollapsibleForm entityName="Category" form={CreateUpdateCvCategoryForm} /> <CollapsibleForm entityName="Category" form={CreateUpdateCvCategoryForm} />
</> </>
} }
</div> </>
) )
} }

View File

@@ -14,6 +14,8 @@ import { useState } from 'react';
import { SelectFormField, TextInputFormField, MdeFormField, CalenderFormField, BooleanFormField } from '~/app/_components/Form/Fields' import { SelectFormField, TextInputFormField, MdeFormField, CalenderFormField, BooleanFormField } from '~/app/_components/Form/Fields'
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import {FormMutationContextProvider, type FormCreateMutationInterface} from '~/app/_components/Form/Components/MutationProvider'; import {FormMutationContextProvider, type FormCreateMutationInterface} from '~/app/_components/Form/Components/MutationProvider';
import MdxComponentReference from '~/app/admin/_components/MdxComponentReference';
import { useMdxEditorFieldProps } from '~/app/admin/_components/useMdxEditorFieldProps';
export default function CreateUpdateCvEntryForm(params: { className?: string, entity?: IterableElement<RouterOutputs['entry']['select']>, isUpdate?: boolean }) { export default function CreateUpdateCvEntryForm(params: { className?: string, entity?: IterableElement<RouterOutputs['entry']['select']>, isUpdate?: boolean }) {
const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined) const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
const { theme } = useTheme() const { theme } = useTheme()
@@ -34,6 +36,7 @@ export default function CreateUpdateCvEntryForm(params: { className?: string, en
}) })
let path = usePathname() let path = usePathname()
let router = useRouter() let router = useRouter()
const mdxEditorProps = useMdxEditorFieldProps()
const createMutation = trpc.entry.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) }) const createMutation = trpc.entry.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) })
const updateMutation = trpc.entry.update.useMutation({ onSuccess: makeOnSuccess('update', form) }) const updateMutation = trpc.entry.update.useMutation({ onSuccess: makeOnSuccess('update', form) })
const deleteMutation = trpc.entry.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) }) const deleteMutation = trpc.entry.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
@@ -51,6 +54,8 @@ export default function CreateUpdateCvEntryForm(params: { className?: string, en
updateMutation:updateMutation, updateMutation:updateMutation,
deleteMutation:deleteMutation deleteMutation:deleteMutation
}}> }}>
<div className='flex flex-col gap-4'>
<MdxComponentReference />
<FormScaffold <FormScaffold
form={form} form={form}
onSubmit={onSubmit} onSubmit={onSubmit}
@@ -70,11 +75,12 @@ export default function CreateUpdateCvEntryForm(params: { className?: string, en
} }
</SelectFormField> </SelectFormField>
<TextInputFormField control={form.control} name='title' label='Title' /> <TextInputFormField control={form.control} name='title' label='Title' />
<MdeFormField control={form.control} name='description' label='Description' dataColorMode={(theme as "dark" | "light") ? (theme as "dark" | "light") : "dark"} /> <MdeFormField control={form.control} name='description' label='Description' dataColorMode={(theme as "dark" | "light") ? (theme as "dark" | "light") : "dark"} {...mdxEditorProps} />
<CalenderFormField control={form.control} name='fromTime' label='From Date' /> <CalenderFormField control={form.control} name='fromTime' label='From Date' />
<CalenderFormField control={form.control} name='toTime' label='To Date' /> <CalenderFormField control={form.control} name='toTime' label='To Date' />
<BooleanFormField control={form.control} name='hideDates' label='Hide Dates' /> <BooleanFormField control={form.control} name='hideDates' label='Hide Dates' />
</FormScaffold> </FormScaffold>
</div>
</FormMutationContextProvider> </FormMutationContextProvider>
) )
} }

View File

@@ -8,13 +8,9 @@ import { useGsapContext } from "~/app/_providers/GsapProvicer";
export default function CvPage() { export default function CvPage() {
const entires = trpc.entry.select.useQuery({}); const entires = trpc.entry.select.useQuery({});
const gsap = useGsapContext()
const container = useRef<HTMLDivElement>(null); const container = useRef<HTMLDivElement>(null);
useGSAP(() => {
gsap?.from('.gsapan', { x: -100, opacity: 0, duration: 0.5, stagger: { each: 0.3 } })
}, { scope: container, dependencies: [entires.status], revertOnUpdate: true });
return ( return (
<div ref={container} className="w-5/6 lg:w-1/2 flex flex-col gap-3"> <>
{entires.data == undefined ? {entires.data == undefined ?
<div className="gsapan"></div> <div className="gsapan"></div>
: :
@@ -40,6 +36,6 @@ export default function CvPage() {
})} })}
</> </>
} }
</div> </>
) )
} }

View File

@@ -1,14 +1,22 @@
import { redirect } from "next/navigation";
import { isAdmin } from "~/app/actions";
import { SidebarProvider } from "~/components/ui/sidebar";
import AdminSideBar from "./_components/AdminSideBar"; import AdminSideBar from "./_components/AdminSideBar";
import { ScrollArea } from "~/components/ui/scroll-area";
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export default function Admin({children}: Readonly<{children: React.ReactNode}>) { export default async function Admin({children}: Readonly<{children: React.ReactNode}>) {
if (!(await isAdmin())) redirect("/");
return ( return (
<> <>
<SidebarProvider>
<AdminSideBar/> <AdminSideBar/>
<main className="absolute flex items-center content-center justify-center flex-wrap w-[100vw] left-0 top-15"> <ScrollArea className="px-10 lg:px-0 w-full h-screen pb-10 max-w-4xl mx-auto pt-10">
{children} {children}
</main> </ScrollArea>
</SidebarProvider>
</> </>
) )
} }

View File

@@ -0,0 +1,100 @@
'use client'
import { useState } from "react";
import { AudioLines, Loader2, RefreshCw } from "lucide-react";
import { Button } from "~/components/ui/button";
import { trpc } from "~/app/_trpc/Client";
import { useUploadThing } from "~/lib/uploadthing";
import { transcodeToAac } from "~/lib/ffmpeg/transcode";
import { toast } from "sonner";
import type { RouterOutputs } from "~/server/routers/_app";
import type { IterableElement } from "type-fest";
export default function ConvertToStreamButton(props: {
track: IterableElement<RouterOutputs['music']['list']>;
}) {
const { track } = props;
const utils = trpc.useUtils();
const [busy, setBusy] = useState(false);
const [stage, setStage] = useState("");
const [progress, setProgress] = useState(0);
const setStream = trpc.music.setStream.useMutation({
onSuccess: () => utils.music.list.invalidate(),
});
const { startUpload } = useUploadThing("musicUploader");
async function handleConvert() {
setBusy(true);
setProgress(0);
let currentStage = "Loading ffmpeg";
const goto = (s: string) => {
currentStage = s;
setStage(s);
};
try {
goto("Transcoding");
const file = await transcodeToAac({
sourceUrl: track.fileUrl,
outputName: `${track.title || "track"}.m4a`,
onProgress: setProgress,
});
goto("Uploading");
const uploaded = await startUpload([file]);
const res = uploaded?.[0];
if (!res) throw new Error("Upload returned no file");
goto("Saving");
await setStream.mutateAsync({
id: track.id,
streamUrl: res.serverData.fileUrl,
streamKey: res.serverData.fileKey,
streamName: res.serverData.fileName,
});
toast("Streaming version saved");
} catch (e) {
console.error("[ConvertToStream] failed during", currentStage, e);
const detail =
e instanceof Error
? e.message
: typeof e === "string"
? e
: (() => {
try {
return JSON.stringify(e);
} catch {
return String(e);
}
})();
toast(`Conversion failed (${currentStage}): ${detail || "see console for details"}`);
} finally {
setBusy(false);
setStage("");
setProgress(0);
}
}
return (
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={handleConvert}>
{busy ? (
<Loader2 className="animate-spin" />
) : track.streamUrl ? (
<RefreshCw />
) : (
<AudioLines />
)}
{busy
? `${stage}${stage === "Transcoding" && progress ? ` ${Math.round(progress * 100)}%` : "…"}`
: track.streamUrl
? "Re-generate stream"
: "Generate stream (AAC)"}
</Button>
{track.streamUrl && !busy && (
<span className="text-xs text-muted-foreground">Streaming version ready</span>
)}
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { trpc } from "~/app/_trpc/Client"; import { trpc } from "~/app/_trpc/Client";
import * as Card from "~/components/ui/card"; import * as Card from "~/components/ui/card";
import UploadMusicForm from "./_components/UploadMusicForm"; import UploadMusicForm from "./_components/UploadMusicForm";
import ConvertToStreamButton from "./_components/ConvertToStreamButton";
import { CollapsibleForm } from "~/app/_components/Form/Components"; import { CollapsibleForm } from "~/app/_components/Form/Components";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -14,8 +15,9 @@ export default function AdminMusicPage() {
{tracks && <> {tracks && <>
{tracks.map((t) => ( {tracks.map((t) => (
<Card.Card key={t.id}> <Card.Card key={t.id}>
<Card.CardContent> <Card.CardContent className="flex flex-col gap-4">
<UploadMusicForm entity={t} className="w-full"/> <UploadMusicForm entity={t} className="w-full"/>
<ConvertToStreamButton track={t} />
</Card.CardContent> </Card.CardContent>
</Card.Card> </Card.Card>
))} ))}

View File

@@ -1,15 +1,9 @@
'use server'
import { Show } from "@clerk/nextjs";
export default async function AdminPage() { export default async function AdminPage() {
return ( return (
<Show when="signed-in">
<main className="flex min-h-screen flex-col items-center justify-center"> <main className="flex min-h-screen flex-col items-center justify-center">
<div> <div>
hello admin hello admin
</div> </div>
</main> </main>
</Show>
) )
} }

View File

@@ -8,12 +8,14 @@ import type { IterableElement } from 'type-fest'
import { entitySchemas, makeOnSuccess } from "~/lib/utils"; import { entitySchemas, makeOnSuccess } from "~/lib/utils";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { RouterOutputs } from '~/server/routers/_app'; import type { RouterOutputs } from '~/server/routers/_app';
import { SelectFormField, TextInputFormField, MdeFormField } from '~/app/_components/Form/Fields' import { SelectFormField, TextInputFormField, MdeFormField, IntInputFormField } from '~/app/_components/Form/Fields'
import { FormScaffold } from '~/app/_components/Form/Components'; import { FormScaffold } from '~/app/_components/Form/Components';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { makeUseRelationShipWithNameIndex } from '~/lib/hooks'; import { makeUseRelationShipWithNameIndex } from '~/lib/hooks';
import { FormMutationContextProvider } from '~/app/_components/Form/Components/MutationProvider'; import { FormMutationContextProvider } from '~/app/_components/Form/Components/MutationProvider';
import MdxComponentReference from '~/app/admin/_components/MdxComponentReference';
import { useMdxEditorFieldProps } from '~/app/admin/_components/useMdxEditorFieldProps';
export default function CreateUpdateProjectForm(params: { className?: string, entity?: IterableElement<RouterOutputs['project']['select']> }) { export default function CreateUpdateProjectForm(params: { className?: string, entity?: IterableElement<RouterOutputs['project']['select']> }) {
const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined) const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
const { theme } = useTheme() const { theme } = useTheme()
@@ -29,11 +31,13 @@ export default function CreateUpdateProjectForm(params: { className?: string, en
releaseStatus: params.entity ? params.entity.releaseStatus : "unreleased", releaseStatus: params.entity ? params.entity.releaseStatus : "unreleased",
releaseLink: params.entity ? params.entity.releaseLink : "", releaseLink: params.entity ? params.entity.releaseLink : "",
sourceType: params.entity ? params.entity.sourceType : "open", sourceType: params.entity ? params.entity.sourceType : "open",
sourceLink: params.entity ? params.entity.sourceLink : "" sourceLink: params.entity ? params.entity.sourceLink : "",
orderPos: params.entity ? params.entity.orderPos : 0
} }
}) })
let path = usePathname() let path = usePathname()
let router = useRouter() let router = useRouter()
const mdxEditorProps = useMdxEditorFieldProps()
const createMutation = trpc.project.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) }) const createMutation = trpc.project.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) })
const updateMutation = trpc.project.update.useMutation({ onSuccess: makeOnSuccess('update', form) }) const updateMutation = trpc.project.update.useMutation({ onSuccess: makeOnSuccess('update', form) })
const deleteMutation = trpc.project.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) }) const deleteMutation = trpc.project.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
@@ -49,6 +53,8 @@ export default function CreateUpdateProjectForm(params: { className?: string, en
updateMutation: updateMutation, updateMutation: updateMutation,
deleteMutation: deleteMutation deleteMutation: deleteMutation
}}> }}>
<div className='flex flex-col gap-4'>
<MdxComponentReference />
<FormScaffold <FormScaffold
form={form} form={form}
onSubmit={onSubmit} onSubmit={onSubmit}
@@ -68,7 +74,7 @@ export default function CreateUpdateProjectForm(params: { className?: string, en
} }
</SelectFormField> </SelectFormField>
<TextInputFormField control={form.control} name='title' label='Title' /> <TextInputFormField control={form.control} name='title' label='Title' />
<MdeFormField control={form.control} name='description' label='Description' dataColorMode={(theme as "dark" | "light") ?? "dark"} /> <MdeFormField control={form.control} name='description' label='Description' dataColorMode={(theme as "dark" | "light") ?? "dark"} {...mdxEditorProps} />
<SelectFormField control={form.control} name='sourceType' label='Source Type' defaultValue={'open'} placeholder='open' > <SelectFormField control={form.control} name='sourceType' label='Source Type' defaultValue={'open'} placeholder='open' >
<SelectItem value="open"> open </SelectItem> <SelectItem value="open"> open </SelectItem>
<SelectItem value="closed"> closed </SelectItem> <SelectItem value="closed"> closed </SelectItem>
@@ -79,7 +85,9 @@ export default function CreateUpdateProjectForm(params: { className?: string, en
<SelectItem value="unreleased"> unreleased </SelectItem> <SelectItem value="unreleased"> unreleased </SelectItem>
</SelectFormField> </SelectFormField>
<TextInputFormField control={form.control} label='Release Link' name='releaseLink' /> <TextInputFormField control={form.control} label='Release Link' name='releaseLink' />
<IntInputFormField control={form.control} label='Order Position' name='orderPos'/>
</FormScaffold> </FormScaffold>
</div>
</FormMutationContextProvider> </FormMutationContextProvider>
) )
} }

View File

@@ -10,7 +10,7 @@ export default function ProjectList() {
const projects = trpc.project.select.useQuery({}, { refetchInterval: 1000 }) const projects = trpc.project.select.useQuery({}, { refetchInterval: 1000 })
const techStacks = trpc.techStack.select.useSuspenseQuery({}, { refetchInterval: 1000 }) const techStacks = trpc.techStack.select.useSuspenseQuery({}, { refetchInterval: 1000 })
return ( return (
<div className="w-5/6 lg:w-1/2 flex flex-col gap-3"> <>
{ {
projects.data == undefined ? projects.data == undefined ?
<></> : <></> :
@@ -55,6 +55,6 @@ export default function ProjectList() {
<CollapsibleForm entityName="Project" form={CreateUpdateProjectForm} /> <CollapsibleForm entityName="Project" form={CreateUpdateProjectForm} />
</> </>
} }
</div> </>
) )
} }

View File

@@ -46,7 +46,7 @@ export default function CreateUpdateStackForm(params: { className?: string, enti
id={id} id={id}
className={params.className} className={params.className}
> >
<MultiBooleanFormField control={form.control} name='stackItems' label='Stack Items' options={stackItemEnum.enumValues} defaultValues={params.entity?.stackItems ?? [""]} /> <MultiBooleanFormField control={form.control} name='stackItems' label='Stack Items' options={stackItemEnum.enumValues} defaultValues={params.entity?.stackItems ?? []} />
</FormScaffold> </FormScaffold>
</FormMutationContextProvider> </FormMutationContextProvider>
</> </>

80
src/app/api/chat/route.ts Normal file
View File

@@ -0,0 +1,80 @@
import { auth } from '@clerk/nextjs/server'
import { createOpenAI } from '@ai-sdk/openai'
import { streamText, convertToModelMessages, stepCountIs, type UIMessage } from 'ai'
import { eq, and } from 'drizzle-orm'
import { env } from '~/env'
import { db } from '~/server/db'
import { chatSession, chatMessage } from '~/server/dbschema/schema'
import { servTrpc } from '~/app/_trpc/ServerClient'
import { createChatTools } from '~/server/ai/tools'
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
export async function POST(req: Request) {
const { userId } = await auth()
if (userId == null) return new Response('Unauthorized', { status: 401 })
const { messages, sessionId } = (await req.json()) as {
messages: UIMessage[]
sessionId: string
}
// Verify this session belongs to the authenticated user
const session = await db
.select()
.from(chatSession)
.where(and(eq(chatSession.id, sessionId), eq(chatSession.userId, userId)))
.limit(1)
.then((r) => r[0])
if (!session) return new Response('Session not found', { status: 404 })
const configuredSystemPrompt = await servTrpc.chat.getSystemPrompt() || 'You are an AI recruiter assistant.'
const systemPrompt = `${configuredSystemPrompt}
Runtime context:
- Current server time: ${new Date().toISOString()}.
- Default meeting timezone: Europe/Berlin.
- For availability questions like "next open spot", call getAvailability once. It defaults to checking from now. Use nextAvailableSlot for the next opening, or the first item in availableSlots if needed. Do not call getAvailability again just to get more slots.
- After scheduleMeeting succeeds, include only the returned addToCalendarLink for the visitor. Format it as a Markdown link like [Add this meeting to your Google Calendar](URL); do not paste the raw URL. Explain briefly that this link lets them add the meeting to their own calendar and invite Gregor. Do not mention internal Google Calendar event links.
- You can remove meetings from Gregor's availability calendar with cancelMeeting only when you have the exact eventId from a previous scheduleMeeting result. If a visitor asks to reschedule and you have both the old eventId and a confirmed new slot, call cancelMeeting once for the old event and scheduleMeeting once for the new event. If you do not have the old eventId, ask for clarification instead of guessing.
- When rescheduling, make clear that cancelMeeting only removes the old slot from Gregor's availability calendar. If the visitor already added the old link to their own calendar, they may need to remove that copy themselves.
- Do not calculate or invent calendar availability yourself.`
const model = await servTrpc.chat.getModel()
// Save the latest user message
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === 'user') {
const content = lastMessage.parts
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
.map((p) => p.text)
.join('')
if (content) {
await db.insert(chatMessage).values({ sessionId, role: 'user', content })
}
}
const result = streamText({
model: openai(model),
system: systemPrompt,
messages: await convertToModelMessages(messages),
tools: createChatTools(),
stopWhen: stepCountIs(3),
onFinish: async ({ text, finishReason }) => {
console.log('[ai:chat:onFinish]', {
finishReason,
hasText: Boolean(text),
textLength: text.length,
})
if (text && finishReason === 'stop') {
await db.insert(chatMessage).values({
sessionId,
role: 'assistant',
content: text,
})
}
},
})
return result.toUIMessageStreamResponse()
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function AssistantPage() {
redirect('/chat')
}

View File

@@ -0,0 +1,60 @@
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import { TRPCError } from "@trpc/server";
import matter from "gray-matter";
import { servTrpc } from "~/app/_trpc/ServerClient";
import { Badge } from "~/components/ui/badge";
import { mdxComponents } from "~/components/mdx-components";
type Props = {
params: Promise<{ slug: string }>;
};
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
let post: Awaited<ReturnType<typeof servTrpc.blog.metadataBySlug>>;
try {
post = await servTrpc.blog.metadataBySlug(slug);
} catch (e) {
if (e instanceof TRPCError && e.code === "NOT_FOUND") notFound();
throw e;
}
const response = await fetch(post.fileUrl, { next: { revalidate: 3600 } });
if (!response.ok) notFound();
const parsed = matter(await response.text());
const tags = Array.isArray(parsed.data.tags)
? parsed.data.tags.map((tag) => String(tag).trim()).filter(Boolean)
: post.tags;
const title = typeof parsed.data.title === "string" ? parsed.data.title : post.title;
const date = typeof parsed.data.date === "string" ? parsed.data.date : post.date;
return (
<main className="mx-auto h-full max-w-2xl overflow-y-auto px-4 py-12">
<header className="mb-8">
<h1 className="text-3xl font-bold">{title}</h1>
{date && (
<time className="text-muted-foreground text-sm">
{new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
)}
{tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<Badge key={tag} variant="outline">{tag}</Badge>
))}
</div>
)}
</header>
<article className="prose dark:prose-invert max-w-none">
<MDXRemote source={parsed.content} components={mdxComponents} />
</article>
</main>
);
}

View File

@@ -1,10 +1,3 @@
'use client' export default function BlogLayout({ children }: { children: React.ReactNode }) {
export default function RootLayout({ return <>{children}</>;
children,
}: Readonly<{ children: React.ReactNode}>) {
return (
<>
{children}
</>
)
} }

View File

@@ -1,12 +1,45 @@
'use client' import Link from "next/link";
import { servTrpc } from "~/app/_trpc/ServerClient";
import { Badge } from "~/components/ui/badge";
import { usePathname } from "next/navigation" export default async function BlogPage() {
const posts = await servTrpc.blog.list();
export default function Page() {
const pathName = usePathname()
return ( return (
<div> <main className="mx-auto h-full max-w-2xl overflow-y-auto px-4 py-12">
{pathName} <h1 className="mb-8 text-3xl font-bold">Blog</h1>
{posts.length === 0 ? (
<p className="text-muted-foreground">No posts yet.</p>
) : (
<ul className="space-y-6">
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`} className="group block">
<h2 className="text-xl font-semibold group-hover:underline">{post.title}</h2>
{post.date && (
<time className="text-muted-foreground text-sm">
{new Date(post.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
)}
{post.description && (
<p className="text-muted-foreground mt-1">{post.description}</p>
)}
{post.tags && post.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{post.tags.map((tag) => (
<Badge key={tag} variant="outline">{tag}</Badge>
))}
</div> </div>
) )}
</Link>
</li>
))}
</ul>
)}
</main>
);
} }

View File

@@ -0,0 +1,119 @@
import type { UIMessage } from "ai";
import { ClientMdx } from "~/components/ClientMdx";
function toolLabel(type: string) {
switch (type) {
case "tool-searchSiteContent":
return "Searching site content";
case "tool-getRelevantExperience":
return "Finding relevant experience";
case "tool-getProjectDetails":
return "Loading project details";
case "tool-getAvailability":
return "Checking availability";
case "tool-cancelMeeting":
return "Removing meeting";
case "tool-getCurrentUnixTime":
return "Checking current time";
default:
return "Using tool";
}
}
export const AssistantMessage = (props: { message: UIMessage }) => {
let message = props.message;
return (
<div
key={message.id}
className='flex justify-start'
>
<div
className=
'max-w-[80%] min-w-0 px-4 py-2 text-sm space-y-2 bg-muted break-words [overflow-wrap:anywhere] [&_a]:break-all [&_pre]:max-w-full [&_pre]:overflow-x-auto'
>
{message.parts.map((part, i) => {
if (part.type === 'text') {
return (
<ClientMdx key={i} source={part.text} fallback={part.text} />
)
}
if (part.type === 'tool-scheduleMeeting') {
const toolPart = part as unknown as {
type: 'tool-scheduleMeeting'
state: string
input: unknown
output?: { success: boolean; error?: string }
}
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
return (
<p key={i} className="text-xs opacity-70 italic">
Scheduling meeting
</p>
)
}
if (toolPart.state === 'output-available' && toolPart.output) {
const result = toolPart.output
if (result.success) return null
return (
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
<span> {result.error}</span>
</div>
)
}
}
if (part.type === 'tool-cancelMeeting') {
const toolPart = part as unknown as {
type: 'tool-cancelMeeting'
state: string
output?: { success: boolean; error?: string }
}
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
return (
<p key={i} className="text-xs opacity-70 italic">
Removing meeting
</p>
)
}
if (toolPart.state === 'output-available' && toolPart.output) {
if (toolPart.output.success) return null
return (
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
<span> {toolPart.output.error}</span>
</div>
)
}
}
if (part.type.startsWith('tool-')) {
const toolPart = part as unknown as {
type: string
state: string
output?: { success?: boolean; results?: unknown[]; matches?: unknown[]; availableSlots?: unknown[]; project?: unknown; error?: string }
}
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
return (
<p key={i} className="text-xs opacity-70 italic">
{toolLabel(toolPart.type)}...
</p>
)
}
if (toolPart.state === 'output-available') {
const count = toolPart.output?.results?.length
?? toolPart.output?.matches?.length
?? toolPart.output?.availableSlots?.length
return (
<p key={i} className="text-xs opacity-70 italic">
{toolPart.output?.success === false
? (toolPart.output.error ?? `${toolLabel(toolPart.type)} failed`)
: count != null
? `${toolLabel(toolPart.type)} complete (${count})`
: `${toolLabel(toolPart.type)} complete`}
</p>
)
}
}
return null
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai'
import { Button } from '~/components/ui/button'
import { Textarea } from '~/components/ui/textarea'
import { SignInButton } from '@clerk/nextjs'
import {
useGsapContext,
} from '~/app/_providers/GsapProvicer';
import Messages from './Messages'
import { DeleteIcon } from 'lucide-react';
import { Spinner } from '~/components/ui/spinner';
import { useMessages } from '~/app/_providers/MessagesProvider';
interface DBMessage {
id: string
role: 'user' | 'assistant'
content: string
}
interface ChatInterfaceProps {
sessionId?: string,
dbMessages: DBMessage[],
}
function SignInChatPrompt() {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 text-center">
<div className="space-y-2">
<h2 className="text-xl font-semibold">Sign in to use the chat</h2>
<p className="text-sm text-muted-foreground">
You need to be signed in before you can talk to Gregor's AI assistant.
</p>
</div>
<SignInButton mode="modal">
<Button type="button">Sign in</Button>
</SignInButton>
</div>
)
}
function toUIMessages(dbMessages: DBMessage[]): UIMessage[] {
return dbMessages.map((m) => ({
id: m.id,
role: m.role,
parts: [{ type: 'text' as const, text: m.content }],
}))
}
function addInitMessage(messageArray: UIMessage[]) {
if (messageArray.at(0)?.id != 'init') {
messageArray.unshift({
id: "init",
role: 'assistant',
parts: [{
type: 'text',
text: "Hi, I'm Gregor's AI assistant. Ask me about his experience, projects, blog posts, or availability for a meeting."
}],
})
}
}
function AuthenticatedChatInterface({ dbMessages, sessionId }: ChatInterfaceProps & { sessionId: string }) {
const [input, setInput] = useState('')
const { clearingChat, clearChat, refetchMessages } = useMessages();
const initialMessages = toUIMessages(dbMessages)
addInitMessage(initialMessages)
const { messages, sendMessage, status, error, clearError, setMessages } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat', body: { sessionId },
}),
messages: initialMessages,
})
useEffect(() => {
return () => {
refetchMessages()
}
}, [])
const handleSend = () => {
const text = input.trim()
if (!text || status != 'ready' || clearingChat) return
setInput('')
sendMessage({ text })
}
const gsapContext = useGsapContext()
const didInitialScroll = useRef(false)
useEffect(() => {
const scroller = gsapContext?.getScroller()
if (!scroller || scroller instanceof Window) {
return
}
// Jump instantly on first open so the chat starts pinned to the bottom;
// animate subsequent updates. Defer a frame so the messages have laid out
// (and any streaming content has grown) before we measure scrollHeight.
const behavior: ScrollBehavior = didInitialScroll.current ? 'smooth' : 'auto'
didInitialScroll.current = true
requestAnimationFrame(() => {
scroller.scrollTo({ behavior, top: scroller.scrollHeight })
})
}, [messages, status])
return (
<div className="flex flex-col h-full">
{messages &&
<Messages status={status} messages={messages} />
}
{error && (
<div className="mx-4 mb-2 flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<span className="flex-1">
{error.message.includes('quota') || error.message.includes('429')
? 'OpenAI quota exceeded. Please try again later.'
: `Error: ${error.message}`}
</span>
<Button
type="button"
onClick={clearError}
className="shrink-0 opacity-60 hover:opacity-100"
variant='destructive'
>
<DeleteIcon />
</Button>
</div>
)}
<div className="p-4 border-t flex flex-row gap-2 shrink-0">
<Textarea
name='message'
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask about Gregor's experience or schedule a meeting"
className="resize-none"
rows={2}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
/>
<div className='flex flex-col gap-2'>
<Button
onClick={handleSend}
disabled={status != "ready" || !input.trim()}
>
Send
</Button>
<Button
variant='destructive'
onClick={() => {
clearChat(() => {
let messages: UIMessage[] = [];
addInitMessage(messages);
setMessages(messages)
})
}}
disabled={status != "ready" || clearingChat}
>
{clearingChat ?
<Spinner /> :
"Clear Chat"
}
</Button>
</div>
</div>
</div>
)
}
export default function ChatInterface({ dbMessages, sessionId }: ChatInterfaceProps) {
if (sessionId == undefined) {
return <SignInChatPrompt />
}
return <AuthenticatedChatInterface sessionId={sessionId} dbMessages={dbMessages} />
}

View File

@@ -0,0 +1,36 @@
import { type ChatStatus, type UIMessage } from 'ai'
import * as Card from "~/components/ui/card"
import { UserMessage } from './UserMessage';
import { AssistantMessage } from './AssistantMessage';
import { ScrollArea } from '~/components/ui/scroll-area';
import { memo } from 'react';
const Messages = memo(({messages,status}: { messages: UIMessage[],status:ChatStatus}) => {
return (
<ScrollArea data-scroller-priority='1' className="w-full flex-1 min-h-0 max-w-4xl mx-auto">
{messages.map((message, i) => (
<Card.AnimatedCard scrollOnly={true} key={i}>
<Card.CardContent>
{message.role == 'assistant' && <AssistantMessage message={message} />}
{message.role == 'user' && <UserMessage message={message} />}
</Card.CardContent>
</Card.AnimatedCard>
))}
{status == 'submitted' &&
<Card.AnimatedCard scrollOnly={true}>
<Card.CardContent>
<AssistantMessage message={{
id:"",
role:"assistant",
parts:[{
type:'text',
text:'Thinking ...'
}]
}}/>
</Card.CardContent>
</Card.AnimatedCard>
}
</ScrollArea>)
})
export default Messages;

View File

@@ -0,0 +1,23 @@
import type { UIMessage } from "ai"
export const UserMessage = (props:{message: UIMessage}) => {
let message = props.message.parts.reduce((acc, part) => {
if (part.type == 'text') {
return acc + part.text
}
return acc
},"");
return (
<div
key={props.message.id}
className='flex justify-end'
>
<div
className=
'max-w-[80%] min-w-0 px-4 py-2 text-sm space-y-2 bg-primary break-words whitespace-pre-wrap [overflow-wrap:anywhere]'
>
{message}
</div>
</div>
)
}

29
src/app/chat/page.tsx Normal file
View File

@@ -0,0 +1,29 @@
'use client'
import ChatInterface from './_components/ChatInterface'
import AnimatedPageTitle from '../_components/Animated/AnimatedPageTitle';
import { useTimeLine } from '../_providers/GsapProvicer';
import { useMessages } from '../_providers/MessagesProvider';
import { Spinner } from '~/components/ui/spinner';
export default function ChatPage() {
const {messages,session,isLoading,error} = useMessages()
useTimeLine(messages)
return (
<div className="flex flex-col px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10 pb-4">
<AnimatedPageTitle position={0}>
<span>Talk To My </span> <span> AI-Assistant</span>
</AnimatedPageTitle>
<div className='flex flex-1 min-h-0 w-full'>
{!isLoading &&
<ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
}
{isLoading &&
<><Spinner/> Loading Messages...</>
}
{error &&
<div> {error} </div>
}
</div>
</div>
)
}

View File

@@ -1,35 +1,67 @@
'use client' import type { ReactNode } from "react"
import { trpc } from "~/app/_trpc/Client"
import CvEntry from "./CvEntry" import CvEntry from "./CvEntry"
import type { servTrpc } from "~/app/_trpc/ServerClient"
import type { inferProcedureOutput } from "@trpc/server"
import type { RouterOutputs } from "~/server/routers/_app" import type { RouterOutputs } from "~/server/routers/_app"
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils"
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card" import { AnimatedCard, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
import type { ArrayElement } from "type-fest" import type { ArrayElement } from "type-fest"
import AnimateTextIn from "~/app/_components/Animated/AnimateIn"
import AnimatedDiv from "~/app/_components/Animated/AnimatedDiv"
import { ScrollArea } from "~/components/ui/scroll-area";
export type CvCategoryData = ArrayElement<RouterOutputs['categoryv2']['listAllWithEntries']>
type CvCategoryProps = { type CvCategoryProps = {
initialData: ArrayElement<RouterOutputs['categoryv2']['listByLayoutPosition']>, category: CvCategoryData,
layout: "row"|"col", layout: "row" | "col",
children?: React.ReactElement<Parameters<typeof CvEntry>> position?: number,
descriptions: Record<string, ReactNode>,
} }
export default function CvCategory(props:CvCategoryProps) { export default function CvCategory({ category, layout, position = 0, descriptions }: CvCategoryProps) {
const category = trpc.categoryv2.getById.useQuery(props.initialData? props.initialData.id : ""); const entries = category.cvEntry
const isRowLayout = layout === "row"
const entryStart = position + 1
const entryStagger = 1.1
const entryItems = entries.map((entry, i) => {
const entryPosition = entryStart + i * entryStagger
return ( return (
<Card className={cn(props.layout == "row" ? "w-full" : "","gsapan")}> <AnimatedDiv
className={cn(isRowLayout ? "min-w-[min(100%,18rem)] flex-1" : "w-full", "opacity-0 -translate-x-6")}
position={entryPosition}
debugId={`cv-entry-wrapper:${category.name}:${entry.title}:${entryPosition}`}
opacity={1}
x={0}
duration={0.4}
ease="power2.out"
key={entry.id}
>
<CvEntry position={entryPosition} entry={entry} description={descriptions[entry.id]} row={isRowLayout} className="w-full" />
</AnimatedDiv>
)
})
return (
<AnimatedCard position={position} className={cn(isRowLayout ? "h-fit min-w-[min(100%,18rem)] flex-1" : "lg:h-full lg:min-h-0")}>
<CardHeader> <CardHeader>
<CardTitle> <AnimateTextIn once position={position + 0.35} animation="slide" debugId={`cv-category-title:${category.name}:${position + 0.35}`}>
{category.data?.name} <CardTitle>{category.name}</CardTitle>
</CardTitle> </AnimateTextIn>
</CardHeader> </CardHeader>
{(category.data?.cvEntry.length ? category.data?.cvEntry.length : 0 ) > 0 ? {entries.length > 0 ?
<CardContent className={cn(props.layout == "row" ? "flex flex-row flex-wrap justify-center lg:justify-between" : "flex flex-col","gap-4","overflow-scroll")}> <CardContent className={cn(isRowLayout ? "flex flex-row flex-wrap items-stretch justify-center lg:justify-between" : "flex flex-col flex-1 min-h-0", "gap-4")}>
{category.data?.cvEntry.map((entry) => ( {isRowLayout ? (
<CvEntry className={props.layout == "row" ? "w-full lg:w-fit" : undefined} key={entry.id} initialData={entry}/> entryItems
))} ) : (
<ScrollArea className="min-h-0 w-full flex-1">
<div className="flex flex-col gap-4 pr-2">
{entryItems}
</div>
</ScrollArea>
)}
</CardContent> </CardContent>
: :
<></> <></>
} }
</Card> </AnimatedCard>
) )
} }

View File

@@ -1,68 +1,56 @@
import { trpc } from "~/app/_trpc/Client" import type { ReactNode } from "react"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "~/components/ui/card" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"
import { Skeleton } from "~/components/ui/skeleton" import { cn } from "~/lib/utils"
import { cn, type Defined } from "~/lib/utils"
import Markdown from 'react-markdown'
import { format } from 'date-fns' import { format } from 'date-fns'
import rehypeHighlight from 'rehype-highlight'
import rehypeRaw from 'rehype-raw'
import type { RouterOutputs } from "~/server/routers/_app"
import type { ArrayElement } from "type-fest" import type { ArrayElement } from "type-fest"
export default function CvEntry(params: { import AnimateTextIn from "~/app/_components/Animated/AnimateIn"
initialData: ArrayElement<Defined<RouterOutputs['categoryv2']['getById']>['cvEntry']>, import AnimatedDiv from "~/app/_components/Animated/AnimatedDiv"
className?: string import type { CvCategoryData } from "./CvCategory"
export type CvEntryData = ArrayElement<CvCategoryData['cvEntry']>
export default function CvEntry({ entry, description, className, position = 0, row = false }: {
entry: CvEntryData,
description?: ReactNode,
className?: string,
position?: number,
row?: boolean,
}) { }) {
const query = trpc.entryv2.getById.useQuery(params.initialData.id); const from = format(new Date(entry.fromTime), 'MMMM yyyy')
const { data, isError, error } = query const to = format(new Date(entry.toTime), 'MMMM yyyy')
return ( return (
<> <Card className={cn("w-full ring-0", row && "h-full", className)}>
{ {entry.title ?
data ?
<>
<Card className={params.className ? cn("w-fit", params.className) : "w-fit"}>
{
data.title ?
<CardHeader> <CardHeader>
<CardTitle> {data.title} </CardTitle> <AnimateTextIn once position={position + 0.25} animation="slide" debugId={`cv-entry-title:${entry.title}:${position + 0.25}`}>
<CardTitle> {entry.title} </CardTitle>
</AnimateTextIn>
</CardHeader> : </CardHeader> :
<></> <></>
} }
{ {entry.description ?
data.description ? <CardContent className={cn("text-sm lg:text-base", row && "flex flex-1 items-center justify-center")}>
<CardContent className="text-sm lg:text-base"> {/* Fade the description in place instead of collapsing its height:
<div> the outer entry pop-up (CvCategory) measures height:auto when it
<Markdown rehypePlugins={[rehypeHighlight, rehypeRaw]}>{data.description}</Markdown> plays, so the description must stay laid out at full height or the
</div> entry reveals too short. */}
<AnimatedDiv once position={position + 0.75} className={cn("opacity-0", row && "w-full")} opacity={1} duration={0.5} debugId={`cv-entry-description:${entry.title}:${position + 0.75}`}>
<article className={cn("prose prose-zinc dark:prose-invert max-w-none", row && "text-center")}>
{description ?? entry.description}
</article>
</AnimatedDiv>
</CardContent> : </CardContent> :
<></> <></>
} }
{ {!entry.hideDates ?
!data.hideDates ? <CardFooter className="border-t-0 text-sm">
<CardFooter className="text-sm"> <AnimateTextIn once position={position + 1.15} debugId={`cv-entry-dates:${entry.title}:${position + 1.15}`}>
{`von ${format((new Date()).setTime(Date.parse(data.fromTime)), 'M. yyyy')} bis zum ${format((new Date()).setTime(Date.parse(data.toTime)), 'M. yyyy')}`} {`${from} to ${to}`}
</AnimateTextIn>
</CardFooter> : </CardFooter> :
<></> <></>
} }
</Card> </Card>
</> :
<>
<Card>
<CardHeader>
<div className="flex flex-row">
<CardTitle> <Skeleton className="h-2rem w-5rem" /> </CardTitle>
<span className="ml-auto text-sm"> <Skeleton className="h-1rem w-3rem" /> - <Skeleton className="h-1rem w-3rem" /> </span>
</div>
</CardHeader>
<CardContent>
<div>
<Skeleton className="h-4 w-60" />
<Skeleton className="h-4 w-50" />
<Skeleton className="h-4 w-50" />
</div>
</CardContent>
</Card>
</>
}
</>
) )
} }

View File

@@ -0,0 +1,122 @@
'use client'
import type { ReactNode } from "react";
import { Sidebar, SidebarContent, SidebarProvider, useSidebar } from "~/components/ui/sidebar";
import { ScrollArea } from "~/components/ui/scroll-area";
import type { RouterOutputs } from "~/server/routers/_app"
import SidebarTriggerDisappearsOnMobile from "./SidebarTriggerDisappearsOnMobile";
import CvCategory from "./CvCategory";
import { useTimeLine } from "~/app/_providers/GsapProvicer";
import { cn } from "~/lib/utils";
export default function CvPage(props: {
cv: RouterOutputs['categoryv2']['listAllWithEntries'],
descriptions: Record<string, ReactNode>,
}) {
useTimeLine(props.cv)
const { descriptions } = props
const byPosition = (pos: "sidebar" | "header" | "col1" | "col2") =>
props.cv?.filter((c) => c.layoutPosition === pos) ?? []
const sidebarCategories = byPosition("sidebar")
const headerCategories = byPosition("header")
const col1Categories = byPosition("col1")
const col2Categories = byPosition("col2")
const hasSidebar = sidebarCategories.length > 0
const hasTwoMainColumns = col1Categories.length > 0 && col2Categories.length > 0
const mainColumnWidthClass = hasTwoMainColumns ? "lg:w-1/2 lg:h-full" : ""
const sequencePositions = <T extends { cvEntry: unknown[] }>(categories: T[], start = 0) => {
let cursor = start
const positions = categories.map((category) => {
const position = cursor
cursor += 1.8 + category.cvEntry.length * 1.2
return position
})
return { end: cursor, positions }
}
const headerSequence = sequencePositions(headerCategories)
const sidebarSequence = sequencePositions(sidebarCategories)
const contentStart = Math.max(headerSequence.end, sidebarSequence.end)
const col1Sequence = sequencePositions(col1Categories, contentStart)
const col2Sequence = sequencePositions(col2Categories, contentStart)
return (
<>
<SidebarProvider className="h-full min-h-0 overflow-hidden">
{sidebarCategories.length > 0 &&
<>
<SidebarTriggerDisappearsOnMobile />
<Sidebar>
<SidebarContent className="p-2 lg:pt-[3.2rem]">
{sidebarCategories.map((cat, i) => (
<CvCategory layout="col" position={sidebarSequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))}
</SidebarContent>
</Sidebar>
</>
}
<MainContent
hasSidebar={hasSidebar}
mainColumnWidthClass={mainColumnWidthClass}
headerCategories={headerCategories}
headerSequence={headerSequence}
col1Categories={col1Categories}
col1Sequence={col1Sequence}
col2Categories={col2Categories}
col2Sequence={col2Sequence}
descriptions={descriptions}
/>
</SidebarProvider>
</>
)
}
type Sequence = { positions: number[] }
type Category = NonNullable<RouterOutputs['categoryv2']['listAllWithEntries']>[number]
function MainContent(props: {
hasSidebar: boolean,
mainColumnWidthClass: string,
headerCategories: Category[],
headerSequence: Sequence,
col1Categories: Category[],
col1Sequence: Sequence,
col2Categories: Category[],
col2Sequence: Sequence,
descriptions: Record<string, ReactNode>,
}) {
const {
hasSidebar, mainColumnWidthClass, headerCategories, headerSequence,
col1Categories, col1Sequence, col2Categories, col2Sequence, descriptions,
} = props
const { open } = useSidebar()
return (
<ScrollArea className="h-full min-h-0 w-full flex-1">
<div
id="mainwrap"
className={cn(
"flex w-full flex-col gap-4 p-4 pt-8",
!hasSidebar && "lg:px-[15vw]",
hasSidebar && !open && "lg:px-[8vw]",
)}
>
<div id="header" className="flex w-full flex-row flex-wrap items-stretch gap-4">
{headerCategories.map((cat, i) => (
<CvCategory layout="row" position={headerSequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
<div id="colwrapper" className="flex w-full flex-col gap-4 lg:h-[80vh] lg:flex-row">
<div id="col1" className={cn("flex min-h-0 w-full flex-col gap-4", mainColumnWidthClass)}>
{col1Categories.map((cat, i) => (
<CvCategory layout="col" position={col1Sequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
<div id="col2" className={cn("flex min-h-0 w-full flex-col gap-4", mainColumnWidthClass)}>
{col2Categories.map((cat, i) => (
<CvCategory layout="col" position={col2Sequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
</div>
</div>
</ScrollArea>
)
}

View File

@@ -1,97 +1,42 @@
'use client' import { Suspense, type ReactNode } from "react";
import { useGSAP } from "@gsap/react"; import { MDXRemote } from "next-mdx-remote/rsc";
import { useGsapContext,useTimeLine } from "../_providers/GsapProvicer"; import rehypeHighlight from "rehype-highlight";
import { trpc } from "../_trpc/Client"; import remarkGfm from "remark-gfm";
import { useRef } from "react"; import { servTrpc as trpc } from "../_trpc/ServerClient";
import { SidebarContent, SidebarProvider, Sidebar } from "~/components/ui/sidebar"; import { mdxComponents } from "~/components/mdx-components";
import SidebarTriggerDisappearsOnMobile from "./_components/SidebarTriggerDisappearsOnMobile"; import Page from "./_components/Page";
import CvCategory from "./_components/CvCategory";
import gsap from 'gsap' export default async function CvPage() {
export default function CvPage() { const cv = await trpc.categoryv2.listAllWithEntries();
const sidebarCategories = trpc.categoryv2.listByLayoutPosition.useQuery("sidebar");
const col1Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col1"); // Render the MDX descriptions on the server so they exist at first paint.
const headerCategories = trpc.categoryv2.listByLayoutPosition.useQuery("header"); // The client tree (which runs the GSAP entrance via useTimeLine) only places
const col2Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col2"); // these already-rendered nodes — it never invokes the MDX renderer itself, so
const gsapContext = useGsapContext() // the 'use client' boundary stays intact and the animations no longer play
const container = useRef<HTMLDivElement>(null) // against an un-rendered fallback.
enum Direction { const descriptions: Record<string, ReactNode> = {};
Left = 1, for (const category of cv ?? []) {
Up, for (const entry of category.cvEntry) {
Right, if (!entry.description?.trim()) continue;
Down descriptions[entry.id] = (
} <MDXRemote
const nextGsapConf = (direction: Direction) => { source={entry.description}
switch (direction) { components={mdxComponents}
case Direction.Left: options={{
return { x: -100, opacity: 0, duration: 0.5 } mdxOptions: {
case Direction.Up: format: "mdx",
return { y: -100, opacity: 0, duration: 0.5 } remarkPlugins: [remarkGfm],
case Direction.Right: rehypePlugins: [rehypeHighlight],
return { x: 100, opacity: 0, duration: 0.5 } },
case Direction.Down: }}
return { y: 100, opacity: 0, duration: 0.5 } />
);
} }
} }
useTimeLine(col2Categories)
useGSAP(() => {
const items = gsap?.utils.toArray<GSAPTweenTarget>('.gsapan');
let dir = Direction.Left;
items?.forEach(item => {
gsapContext?.addAnimation(gsap.from(item, nextGsapConf(dir)),0)
if (dir == Direction.Down) {
dir = Direction.Left
} else {
dir = dir + 1
}
})
}, { scope: container, dependencies: [headerCategories.data, sidebarCategories.data], revertOnUpdate: true })
return ( return (
<> <Suspense>
<SidebarProvider ref={container}> <Page cv={cv} descriptions={descriptions} />
{sidebarCategories.data && </Suspense>
<>
<SidebarTriggerDisappearsOnMobile />
<Sidebar className="gsapan ">
<SidebarContent className="p-2 lg:pt-[3.2rem]">
{sidebarCategories.data?.map((cat) => {
if (cat !== undefined) {
return (
<CvCategory layout="col" initialData={cat} key={cat.id} />
)
}
})}
</SidebarContent>
</Sidebar>
</>
}
<div className="h-full w-full flex flex-wrap flex-row p-4 pt-8 ">
<div id="mainwrap" className="flex w-full flex-col gap-4 lg:px-[15vw]">
<div id="header" className="flex w-full h-fit flex-row gap-4 flex-wrap">
{headerCategories.data?.map((cat) => {
return (
<CvCategory layout="row" initialData={cat} key={cat.id} />
)
})}
</div>
<div id="colwrapper" className="flex flex-col lg:flex-row w-full h-3/4 gap-4">
<div id="col1" className={`flex flex-col w-full ${col1Categories.data?.length ? col1Categories.data?.length : 0 > 0 ? "lg:w-1/2" : ""} h-full gap-4`}>
{col1Categories.data?.map((cat) => {
return (
<CvCategory layout="col" initialData={cat} key={cat.id} />
)
})}
</div>
<div id="col2" className={`flex flex-col w-full ${col2Categories.data?.length ? col2Categories.data?.length : 0 > 0 ? "lg:w-1/2" : ""} h-full gap-4`}>
{col2Categories.data?.map((cat) => {
return (
<CvCategory layout="col" initialData={cat} key={cat.id} />
)
})}
</div>
</div>
</div>
</div>
</SidebarProvider>
</>
) )
} }

View File

@@ -5,15 +5,18 @@ import { ClerkProvider } from "@clerk/nextjs";
import { config } from "@fortawesome/fontawesome-svg-core" import { config } from "@fortawesome/fontawesome-svg-core"
import "@fortawesome/fontawesome-svg-core/styles.css" import "@fortawesome/fontawesome-svg-core/styles.css"
import TopNav from "./_components/TopNav"; import TopNav from "./_components/TopNav";
import ChatFAB from "./_components/ChatFAB";
import TrpcProvider from "./_trpc/TrpcProvider"; import TrpcProvider from "./_trpc/TrpcProvider";
// import dynamic from "next/dynamic"; // import dynamic from "next/dynamic";
// const ThemeProvider = dynamic(() => import("./_providers/ThemeProvider"),{ssr:true}) // const ThemeProvider = dynamic(() => import("./_providers/ThemeProvider"),{ssr:true})
import ThemeProvider from './_providers/ThemeProvider' import ThemeProvider from './_providers/ThemeProvider'
import GsapProvider from "./_providers/GsapProvicer"; import GsapProvider from "./_providers/GsapProvicer";
import {MessagesProvider} from "./_providers/MessagesProvider";
import { MusicPlayerProvider } from "./music/_components/MusicPlayerProvider";
import { CodeHighlightStyle } from "./_components/CodeHighlightSyle"; import { CodeHighlightStyle } from "./_components/CodeHighlightSyle";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import AnimatedBackGroundContainer from "./_components/Animated/AnimatedBackGroundContainer"; import AnimatedBackGroundContainer from "./_components/Animated/AnimatedBackGroundContainer";
import {SpeedInsights} from "@vercel/speed-insights/next"
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }); const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
@@ -35,6 +38,8 @@ export default async function RootLayout({
}: Readonly<{ children: React.ReactNode, modal: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode, modal: React.ReactNode }>) {
return ( return (
<>
<SpeedInsights/>
<ClerkProvider> <ClerkProvider>
<TrpcProvider> <TrpcProvider>
<GsapProvider> <GsapProvider>
@@ -44,18 +49,24 @@ export default async function RootLayout({
</head> </head>
<body className="flex flex-col bg-background text-foreground"> <body className="flex flex-col bg-background text-foreground">
<ThemeProvider> <ThemeProvider>
<MessagesProvider>
<MusicPlayerProvider>
<AnimatedBackGroundContainer followSpeed={0.003} particleCount={100} orbitRadius={2000}> <AnimatedBackGroundContainer followSpeed={0.003} particleCount={100} orbitRadius={2000}>
<TopNav /> <TopNav />
<main className="absolute lg:top-10 h-screen w-screen"> <main className="absolute lg:top-10 h-[100dvh] lg:h-[calc(100vh-var(--spacing)*10)] w-screen">
{children} {children}
</main> </main>
{modal} {modal}
</AnimatedBackGroundContainer> </AnimatedBackGroundContainer>
<ChatFAB />
</MusicPlayerProvider>
</MessagesProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
</GsapProvider> </GsapProvider>
</TrpcProvider> </TrpcProvider>
</ClerkProvider> </ClerkProvider>
</>
); );
} }

View File

@@ -0,0 +1,190 @@
'use client'
import { useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes";
import type WaveSurfer from "wavesurfer.js";
import { Download, Loader2, Pause, Play } from "lucide-react";
import { Button } from "~/components/ui/button";
import { toast } from "sonner";
import { useMusicPlayer } from "./MusicPlayerProvider";
function formatTime(seconds: number) {
if (!Number.isFinite(seconds)) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
function cssVar(name: string, fallback: string) {
if (typeof window === "undefined") return fallback;
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
function waveColors() {
return {
waveColor: cssVar("--muted-foreground", "#9ca3af"),
progressColor: cssVar("--primary", "#e2761b"),
cursorColor: cssVar("--foreground", "#111827"),
};
}
/**
* Per-track waveform. Playback itself is owned by the shared MusicPlayer engine
* (so it keeps running across navigation); this wavesurfer instance is only a
* visual + seek surface that mirrors the engine when its track is active.
*/
export default function AudioPlayer(props: {
id: string;
src: string;
downloadUrl: string;
downloadName: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WaveSurfer | null>(null);
const [ready, setReady] = useState(false);
const [duration, setDuration] = useState(0);
const [downloading, setDownloading] = useState(false);
const { resolvedTheme } = useTheme();
const { currentId, isPlaying, currentTime, toggle, seek, subscribeTime } = useMusicPlayer();
const isActive = currentId === props.id;
// Reach live values from the once-created wavesurfer callbacks.
const isActiveRef = useRef(isActive);
isActiveRef.current = isActive;
const currentTimeRef = useRef(currentTime);
currentTimeRef.current = currentTime;
const toggleRef = useRef(toggle);
toggleRef.current = toggle;
const seekRef = useRef(seek);
seekRef.current = seek;
useEffect(() => {
let ws: WaveSurfer | null = null;
let cancelled = false;
setReady(false);
(async () => {
const WaveSurferClass = (await import("wavesurfer.js")).default;
if (cancelled || !containerRef.current) return;
const instance = WaveSurferClass.create({
container: containerRef.current,
url: props.src,
height: 44,
barWidth: 2,
barGap: 1,
barRadius: 2,
normalize: true,
cursorWidth: 1,
...waveColors(),
});
ws = instance;
wsRef.current = instance;
instance.on("ready", () => {
// This media is for drawing only — never let it make sound.
instance.setMuted(true);
setReady(true);
setDuration(instance.getDuration());
if (isActiveRef.current) instance.setTime(currentTimeRef.current);
});
// Clicking the waveform: start this track if it isn't playing, then seek.
instance.on("interaction", (time: number) => {
if (!isActiveRef.current) toggleRef.current(props.id);
seekRef.current(time);
});
})();
return () => {
cancelled = true;
ws?.destroy();
wsRef.current = null;
};
}, [props.src]);
// Mirror the engine's playback position onto the cursor while active.
useEffect(() => {
const ws = wsRef.current;
if (!ws || !ready) return;
if (!isActive) {
ws.setTime(0);
return;
}
ws.setTime(currentTimeRef.current);
return subscribeTime((t) => wsRef.current?.setTime(t));
}, [isActive, ready, subscribeTime]);
// Re-tint the waveform when the user toggles light/dark.
useEffect(() => {
wsRef.current?.setOptions(waveColors());
}, [resolvedTheme]);
async function handleDownload() {
setDownloading(true);
try {
// The download file is cross-origin, so the <a download> attribute is
// ignored — fetch it as a blob to force a real download.
const res = await fetch(props.downloadUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = props.downloadName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
toast(`Download failed: ${e instanceof Error ? e.message : "unknown error"}`);
} finally {
setDownloading(false);
}
}
const playing = isActive && isPlaying;
return (
<div className="flex items-center gap-3 rounded-lg border bg-transparent px-3 py-2">
<Button
type="button"
size="icon"
variant="ghost"
aria-label={playing ? "Pause" : "Play"}
disabled={!ready}
onClick={() => toggle(props.id)}
>
{playing ? <Pause /> : <Play />}
</Button>
<span className="w-10 shrink-0 text-right font-mono text-xs text-muted-foreground tabular-nums">
{formatTime(isActive ? currentTime : 0)}
</span>
<div className="relative flex-1">
<div ref={containerRef} className="w-full" />
{!ready && (
<div className="absolute inset-0 flex items-center">
<div className="h-7 w-full animate-pulse rounded bg-muted-foreground/15" />
</div>
)}
</div>
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
{formatTime(duration)}
</span>
<Button
type="button"
size="icon"
variant="ghost"
aria-label="Download lossless file"
title="Download lossless file"
disabled={downloading}
onClick={handleDownload}
>
{downloading ? <Loader2 className="animate-spin" /> : <Download />}
</Button>
</div>
);
}

View File

@@ -0,0 +1,73 @@
'use client'
import { useState } from "react";
import { Music } from "lucide-react";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "~/components/ui/drawer";
import { Slider } from "~/components/ui/slider";
import { useMusicPlayer } from "./MusicPlayerProvider";
import PlayerControls from "./PlayerControls";
function formatTime(seconds: number) {
if (!Number.isFinite(seconds)) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
/**
* Global, persistent mini-player: a floating button (shown once something is
* loaded) that opens a drawer with the transport controls and a scrubber, so
* playback can be controlled from any page while it keeps running in the
* background.
*/
export default function MusicMiniPlayer() {
const { currentTrack, isPlaying, currentTime, duration, seek } = useMusicPlayer();
const [open, setOpen] = useState(false);
if (!currentTrack) return null;
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<button
type="button"
aria-label="Open player"
className="fixed bottom-4 left-4 z-40 flex max-w-[60vw] items-center gap-2 rounded-full border bg-background/80 px-4 py-2 shadow-lg backdrop-blur-md transition-colors hover:bg-background"
>
<Music className={isPlaying ? "animate-pulse" : ""} />
<span className="truncate text-sm">{currentTrack.title}</span>
</button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{currentTrack.title}</DrawerTitle>
</DrawerHeader>
<div className="mx-auto flex w-full max-w-md flex-col gap-4 px-4 pb-8">
<div className="flex items-center gap-2">
<span className="w-10 text-right font-mono text-xs text-muted-foreground tabular-nums">
{formatTime(currentTime)}
</span>
<Slider
className="flex-1"
min={0}
max={duration || 1}
step={0.1}
value={[currentTime]}
onValueChange={([v]) => seek(v ?? 0)}
/>
<span className="w-10 font-mono text-xs text-muted-foreground tabular-nums">
{formatTime(duration)}
</span>
</div>
<PlayerControls />
</div>
</DrawerContent>
</Drawer>
);
}

View File

@@ -0,0 +1,314 @@
'use client'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { trpc } from "~/app/_trpc/Client";
import MusicMiniPlayer from "./MusicMiniPlayer";
export type PlayerTrack = {
id: string;
title: string;
/** Streaming-friendly source actually played. */
src: string;
/** Original high-quality file for the download button. */
downloadUrl: string;
downloadName: string;
};
type MusicPlayerValue = {
tracks: PlayerTrack[];
currentId: string | null;
currentTrack: PlayerTrack | null;
isPlaying: boolean;
shuffle: boolean;
currentTime: number;
duration: number;
/** Play button on a track: start it, or toggle play/pause if already current. */
toggle: (id: string) => void;
/** Global play/pause — acts on the current track (or starts the first one). */
togglePlayCurrent: () => void;
next: () => void;
previous: () => void;
toggleShuffle: () => void;
seek: (seconds: number) => void;
/** Subscribe to playback time updates (for waveform cursors). */
subscribeTime: (cb: (time: number) => void) => () => void;
};
const MusicPlayerContext = createContext<MusicPlayerValue | null>(null);
export function useMusicPlayer() {
const ctx = useContext(MusicPlayerContext);
if (!ctx) throw new Error("useMusicPlayer must be used within a MusicPlayerProvider");
return ctx;
}
export function MusicPlayerProvider({ children }: { children: ReactNode }) {
// The provider owns the playlist so playback survives navigating away from
// the music page. The query is cached, so the music page shares this request.
const { data } = trpc.music.list.useQuery();
const tracks = useMemo<PlayerTrack[]>(
() =>
(data ?? []).map((t) => ({
id: t.id,
title: t.title,
src: t.streamUrl ?? t.fileUrl,
downloadUrl: t.fileUrl,
downloadName: t.fileName,
})),
[data],
);
const [currentId, setCurrentId] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [shuffle, setShuffle] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const audioRef = useRef<HTMLAudioElement | null>(null);
const tracksRef = useRef(tracks);
tracksRef.current = tracks;
// Lightweight pub/sub so each waveform can follow playback time without every
// player re-rendering on the audio element's frequent timeupdate.
const timeSubs = useRef<Set<(t: number) => void>>(new Set());
const subscribeTime = useCallback((cb: (t: number) => void) => {
timeSubs.current.add(cb);
return () => {
timeSubs.current.delete(cb);
};
}, []);
const emitTime = useCallback((t: number) => {
timeSubs.current.forEach((cb) => cb(t));
}, []);
const currentTrack = useMemo(
() => tracks.find((t) => t.id === currentId) ?? null,
[tracks, currentId],
);
const toggle = useCallback((id: string) => {
setCurrentId((prev) => {
if (prev === id) {
setIsPlaying((p) => !p);
return prev;
}
setIsPlaying(true);
return id;
});
}, []);
const togglePlayCurrent = useCallback(() => {
setCurrentId((prev) => {
if (prev) {
setIsPlaying((p) => !p);
return prev;
}
const first = tracksRef.current[0]?.id ?? null;
if (first) setIsPlaying(true);
return first;
});
}, []);
const step = useCallback((dir: 1 | -1) => {
const ids = tracksRef.current.map((t) => t.id);
if (ids.length === 0) return;
setCurrentId((prev) => {
let nextId: string;
if (shuffle && dir === 1 && ids.length > 1) {
do {
nextId = ids[Math.floor(Math.random() * ids.length)]!;
} while (nextId === prev);
} else {
const idx = prev ? ids.indexOf(prev) : -1;
nextId = ids[(idx + dir + ids.length) % ids.length]!;
}
setIsPlaying(true);
return nextId;
});
}, [shuffle]);
const next = useCallback(() => step(1), [step]);
const previous = useCallback(() => step(-1), [step]);
const toggleShuffle = useCallback(() => setShuffle((s) => !s), []);
const stepRef = useRef(step);
stepRef.current = step;
const seek = useCallback(
(s: number) => {
if (audioRef.current) audioRef.current.currentTime = s;
setCurrentTime(s);
emitTime(s);
},
[emitTime],
);
// Persistent audio element + listeners, created once on the client.
useEffect(() => {
const audio = new Audio();
audio.preload = "metadata";
audioRef.current = audio;
const onTime = () => {
setCurrentTime(audio.currentTime);
emitTime(audio.currentTime);
};
const onMeta = () => setDuration(audio.duration || 0);
const onEnded = () => stepRef.current(1);
audio.addEventListener("timeupdate", onTime);
audio.addEventListener("loadedmetadata", onMeta);
audio.addEventListener("ended", onEnded);
return () => {
audio.pause();
audio.removeEventListener("timeupdate", onTime);
audio.removeEventListener("loadedmetadata", onMeta);
audio.removeEventListener("ended", onEnded);
audioRef.current = null;
};
}, [emitTime]);
// Swap the source when the current track changes.
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
if (!currentTrack) {
audio.removeAttribute("src");
audio.load();
return;
}
if (audio.src !== currentTrack.src) {
audio.src = currentTrack.src;
audio.load();
setCurrentTime(0);
setDuration(0);
}
}, [currentTrack]);
// Reflect the desired play state onto the element.
useEffect(() => {
const audio = audioRef.current;
if (!audio || !currentTrack) return;
if (isPlaying) audio.play().catch(() => {});
else audio.pause();
}, [isPlaying, currentTrack]);
// OS-level media controls (lock screen, notification shade, media keys, etc.)
// via the Media Session API. Wire the transport actions to our state.
useEffect(() => {
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
const ms = navigator.mediaSession;
const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [
["play", () => setIsPlaying(true)],
["pause", () => setIsPlaying(false)],
["previoustrack", () => stepRef.current(-1)],
["nexttrack", () => stepRef.current(1)],
[
"seekto",
(details) => {
if (typeof details.seekTime === "number") seek(details.seekTime);
},
],
[
"seekbackward",
(details) => seek((audioRef.current?.currentTime ?? 0) - (details.seekOffset ?? 10)),
],
[
"seekforward",
(details) => seek((audioRef.current?.currentTime ?? 0) + (details.seekOffset ?? 10)),
],
];
for (const [action, handler] of handlers) {
try {
ms.setActionHandler(action, handler);
} catch {
// Action unsupported by this browser — ignore.
}
}
return () => {
for (const [action] of handlers) {
try {
ms.setActionHandler(action, null);
} catch {
// ignore
}
}
};
}, [seek]);
// Keep the OS-visible metadata in sync with the current track.
useEffect(() => {
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
navigator.mediaSession.metadata = currentTrack
? new MediaMetadata({
title: currentTrack.title,
artist: "Gregor Lohaus",
})
: null;
}, [currentTrack]);
// Reflect play/pause state to the OS so the right button is shown.
useEffect(() => {
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
navigator.mediaSession.playbackState = currentTrack
? isPlaying
? "playing"
: "paused"
: "none";
}, [isPlaying, currentTrack]);
// Keep the scrubber position on the OS controls in sync.
useEffect(() => {
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
if (!("setPositionState" in navigator.mediaSession)) return;
try {
if (duration > 0 && Number.isFinite(duration)) {
navigator.mediaSession.setPositionState({
duration,
position: Math.min(currentTime, duration),
playbackRate: audioRef.current?.playbackRate ?? 1,
});
} else {
navigator.mediaSession.setPositionState();
}
} catch {
// Some browsers throw on invalid state — ignore.
}
}, [currentTime, duration]);
const value = useMemo<MusicPlayerValue>(
() => ({
tracks,
currentId,
currentTrack,
isPlaying,
shuffle,
currentTime,
duration,
toggle,
togglePlayCurrent,
next,
previous,
toggleShuffle,
seek,
subscribeTime,
}),
[
tracks, currentId, currentTrack, isPlaying, shuffle, currentTime, duration,
toggle, togglePlayCurrent, next, previous, toggleShuffle, seek, subscribeTime,
],
);
return (
<MusicPlayerContext.Provider value={value}>
{children}
<MusicMiniPlayer />
</MusicPlayerContext.Provider>
);
}

View File

@@ -0,0 +1,68 @@
'use client'
import * as Card from "~/components/ui/card";
import { useTimeLine } from "../../_providers/GsapProvicer";
import AnimatedPageTitle from "../../_components/Animated/AnimatedPageTitle";
import AnimateTextIn from "../../_components/Animated/AnimateIn";
import { ScrollArea } from "~/components/ui/scroll-area";
import AnimatePopUp from "../../_components/Animated/AnimatePopUp";
import AudioPlayer from "./AudioPlayer";
import type { RouterOutputs } from "~/server/routers/_app";
export default function MusicPage(props: {
tracks: RouterOutputs['music']['list'],
}) {
const { tracks } = props;
useTimeLine(tracks)
return (
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
<AnimatedPageTitle position={0}><span>Just Some </span> <span>Music I Made</span> </AnimatedPageTitle>
<div className="flex flex-wrap h-fit content-center">
<AnimateTextIn once className="flex flex-wrap mr-[1em]" position={0.5}>
<div><p className="break-after-avoid mr-[1em]">All works on this page are licensed under:</p></div>
<div><a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a></div>
</AnimateTextIn>
<AnimatePopUp duration={1} ease='elastic.inOut' position={2} once className="items-center content-center">
<div className="flex flex-row">
<img className="max-w-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" />
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="" />
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/nc.svg" alt="" />
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="" />
</div>
</AnimatePopUp>
</div>
<div className="pt-10" />
{tracks && tracks.map((track, i) => (
<div key={track.id}>
<Card.AnimatedCard position={i + 1}>
<Card.CardHeader>
<AnimateTextIn once position={i + 1.2} animation="slide">
<Card.CardTitle>{track.title}</Card.CardTitle>
</AnimateTextIn>
</Card.CardHeader>
<Card.CardContent className="flex flex-col gap-3">
{track.description && (
<AnimatePopUp once position={i + 1.25} duration={2}>
<p className="text-sm text-muted-foreground">{track.description}</p>
</AnimatePopUp>
)}
<AnimatePopUp duration={2} ease='elastic.inOut' position={i + 1.3} once>
<AudioPlayer
id={track.id}
src={track.streamUrl ?? track.fileUrl}
downloadUrl={track.fileUrl}
downloadName={track.fileName}
/>
</AnimatePopUp>
</Card.CardContent>
</Card.AnimatedCard>
<div className="pt-5" />
</div>
))}
{!tracks?.length &&
<div className="flex justify-center items-center text-muted-foreground">
No music yet.
</div>}
</ScrollArea>
);
}

View File

@@ -0,0 +1,40 @@
'use client'
import { Pause, Play, Shuffle, SkipBack, SkipForward } from "lucide-react";
import { Button } from "~/components/ui/button";
import { useMusicPlayer } from "./MusicPlayerProvider";
export default function PlayerControls() {
const { isPlaying, shuffle, togglePlayCurrent, next, previous, toggleShuffle } =
useMusicPlayer();
return (
<div className="flex items-center justify-center gap-1">
<Button type="button" size="icon" variant="ghost" aria-label="Previous track" onClick={previous}>
<SkipBack />
</Button>
<Button
type="button"
size="icon-lg"
variant="secondary"
aria-label={isPlaying ? "Pause" : "Play"}
onClick={togglePlayCurrent}
>
{isPlaying ? <Pause /> : <Play />}
</Button>
<Button type="button" size="icon" variant="ghost" aria-label="Skip to next track" onClick={next}>
<SkipForward />
</Button>
<Button
type="button"
size="icon"
variant={shuffle ? "secondary" : "ghost"}
aria-label={shuffle ? "Shuffle on" : "Shuffle off"}
aria-pressed={shuffle}
onClick={toggleShuffle}
>
<Shuffle />
</Button>
</div>
);
}

View File

@@ -1,63 +1,13 @@
'use client' import { Suspense } from "react";
import { trpc } from "~/app/_trpc/Client"; import { servTrpc as trpc } from "../_trpc/ServerClient";
import * as Card from "~/components/ui/card"; import Page from "./_components/Page";
import { useTimeLine } from "../_providers/GsapProvicer";
import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle"; export default async function MusicPage() {
import { Spinner } from "~/components/ui/spinner"; const tracks = await trpc.music.list();
import AnimateTextIn from "../_components/Animated/AnimateIn";
import { ScrollArea } from "~/components/ui/scroll-area";
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
export default function MusicPage() {
const { data: tracks, isLoading } = trpc.music.list.useQuery();
useTimeLine(tracks)
return ( return (
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10"> <Suspense>
<AnimatedPageTitle position={0}><span>Just Some </span> <span>Music I Made</span> </AnimatedPageTitle> <Page tracks={tracks} />
<div className="flex flex-wrap h-fit content-center"> </Suspense>
<AnimateTextIn className="flex flex-wrap mr-[1em]" position={0.5}>
<div><p className="break-after-avoid mr-[1em]">All works on this page are licensed under:</p></div>
<div><a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a></div>
</AnimateTextIn>
<AnimatePopUp position={2} className="items-center content-center">
<div className="flex flex-row">
<img className="max-w-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" />
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="" />
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/nc.svg" alt="" />
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="" />
</div>
</AnimatePopUp>
</div>
<div className="pt-10" />
{tracks && tracks.map((track, i) => (
<div key={track.id}>
<Card.AnimatedCard position={i + 1}>
<Card.CardHeader>
<AnimateTextIn position={i + 1.2} animation="slide">
<Card.CardTitle>{track.title}</Card.CardTitle>
</AnimateTextIn>
</Card.CardHeader>
<Card.CardContent className="flex flex-col gap-3">
{track.description && (
<p className="text-sm text-muted-foreground gsapant">{track.description}</p>
)}
<AnimatePopUp position={i + 1.3}>
<audio controls className="w-full player" src={track.fileUrl}>
Your browser does not support the audio element.
</audio>
</AnimatePopUp>
</Card.CardContent>
</Card.AnimatedCard>
<div className="pt-5" />
</div>
))}
{!isLoading && !tracks?.length &&
<div className="flex justify-center items-center text-muted-foreground">
No music yet.
</div>
}
{isLoading && <div className="w-full h-full items-center flex flex-row content-center gap-4 justify-center">
<Spinner /> Loading Tracks
</div>}
</ScrollArea>
); );
} }

View File

@@ -1,9 +1,9 @@
import HomeHero from "./_components/Home/HomeHero";
export default function HomePage() { export default function HomePage() {
return ( return (
<main> <main className="h-full w-full">
<div> <HomeHero />
hello world
</div>
</main> </main>
); );
} }

View File

@@ -0,0 +1,111 @@
'use client'
import type { ReactNode } from "react";
import * as Card from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { StackBadge } from "~/components/StackBadge";
import { ScrollArea } from "~/components/ui/scroll-area";
import AnimatedPageTitle from "../../_components/Animated/AnimatedPageTitle";
import AnimateTextIn from "../../_components/Animated/AnimateIn";
import { useTimeLine } from "../../_providers/GsapProvicer";
import AnimatePopUp from "../../_components/Animated/AnimatePopUp";
import { Button } from "~/components/ui/button";
import type { RouterOutputs } from "~/server/routers/_app";
export default function ProjectsPage(props: {
projects: RouterOutputs['projectv2']['listWithStack'],
descriptions: Record<string, ReactNode>,
}) {
const { projects, descriptions } = props;
useTimeLine(projects)
if (!projects?.length) {
return (
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground">
No projects yet.
</div>
);
}
return (
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
<AnimatedPageTitle position={0}><span>Projects I've Been</span><span> Working on</span> </AnimatedPageTitle>
<div className="pt-10" />
{projects.map((project, i) => (
<div id={project.id} key={i} className="scroll-mt-10">
<Card.AnimatedCard position={i + 1.2} key={project.id}>
<Card.CardHeader>
<div className="flex items-start justify-between gap-2 flex-wrap">
<AnimateTextIn once position={i + 1.4} animation="slide"><Card.CardTitle>{project.title}</Card.CardTitle></AnimateTextIn>
<div className="flex gap-2 flex-wrap">
{project.sourceType && (
<AnimatePopUp position={i + 2} duration={2} once>
<Badge variant={project.sourceType === "open" ? "secondary" : "outline"}>
{project.sourceType === "open" ? "Open Source" : "Closed Source"}
</Badge>
</AnimatePopUp>
)}
{project.releaseStatus && (
<Badge variant={project.releaseStatus === "released" ? "default" : "outline"}>
{project.releaseStatus === "released" ? "Released" : "Unreleased"}
</Badge>
)}
</div>
</div>
</Card.CardHeader>
{(project.description || project.sourceLink || project.releaseLink || project.techStack?.stackItems?.length) && (
<Card.CardContent className="flex flex-col gap-3">
{project.description && (
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
<AnimatePopUp once position={i + 1.4} duration={10}>
{descriptions[project.id] ?? project.description}
</AnimatePopUp>
</div>
)}
<div className="flex flex-row">
{project.techStack?.stackItems && project.techStack.stackItems.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{project.techStack.stackItems.map((item, k) => (
<AnimatePopUp key={k} position={(i + 2) + k * 0.5} once> <StackBadge key={item} item={item} /> </AnimatePopUp>
))}
</div>
)}
{(project.sourceLink || project.releaseLink) && (
<div className="ml-auto flex-col lg:flex-row justify-center gap-5">
{project.sourceLink &&
<Button variant='outline' className="cursor-pointer mb-3 lg:mb-0 lg:mr-3 min-w-18">
<a
href={project.sourceLink}
target="_blank"
rel="noopener noreferrer"
className='items-center'
>
Source
</a>
</Button>
}
{project.releaseLink &&
<Button variant='default' className="cursor-pointer min-w-18 items-center">
<a
href={project.releaseLink}
target="_blank"
rel="noopener noreferrer"
className='items-center'
>
Live
</a>
</Button>
}
</div>
)}
</div>
</Card.CardContent>
)}
</Card.AnimatedCard>
<div className="pt-5" />
</div>
))}
</ScrollArea>
);
}

View File

@@ -1,114 +1,40 @@
'use client' import { Suspense, type ReactNode } from "react";
import { MDXRemote } from "next-mdx-remote/rsc";
import rehypeHighlight from "rehype-highlight";
import remarkGfm from "remark-gfm";
import { servTrpc as trpc } from "../_trpc/ServerClient";
import { mdxComponents } from "~/components/mdx-components";
import Page from "./_components/Page";
import { trpc } from "~/app/_trpc/Client"; export default async function ProjectsPage() {
import * as Card from "~/components/ui/card"; const projects = await trpc.projectv2.listWithStack();
import { Badge } from "~/components/ui/badge";
import { StackBadge } from "~/components/StackBadge";
import Markdown from "react-markdown";
import { ScrollArea } from "~/components/ui/scroll-area";
import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle";
import AnimateTextIn from "../_components/Animated/AnimateIn";
import { useTimeLine } from "../_providers/GsapProvicer";
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
import { Button } from "~/components/ui/button";
export default function ProjectsPage() { // Render the MDX descriptions on the server so they exist at first paint.
const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery(); // The client tree (which runs the GSAP entrance via useTimeLine) only places
useTimeLine(projects) // these already-rendered nodes — it never invokes the MDX renderer itself, so
if (isLoading) { // the 'use client' boundary stays intact and the animations no longer play
return ( // against an un-rendered fallback.
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground"> const descriptions: Record<string, ReactNode> = {};
Loading... for (const project of projects ?? []) {
</div> if (!project.description?.trim()) continue;
); descriptions[project.id] = (
} <MDXRemote
source={project.description}
if (!projects?.length) { components={mdxComponents}
return ( options={{
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground"> mdxOptions: {
No projects yet. format: "mdx",
</div> remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeHighlight],
},
}}
/>
); );
} }
return ( return (
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10"> <Suspense>
<AnimatedPageTitle position={0}><span>Project I've Been</span><span> Working on</span> </AnimatedPageTitle> <Page projects={projects} descriptions={descriptions} />
<div className="pt-10" /> </Suspense>
{projects.map((project, i) => (
<div key={i}>
<Card.AnimatedCard position={i + 1.2} key={project.id}>
<Card.CardHeader>
<div className="flex items-start justify-between gap-2 flex-wrap">
<AnimateTextIn position={i + 1.4} animation="slide"><Card.CardTitle>{project.title}</Card.CardTitle></AnimateTextIn>
<div className="flex gap-2 flex-wrap">
{project.sourceType && (
<AnimatePopUp position={i + 2} duration={2}>
<Badge variant={project.sourceType === "open" ? "secondary" : "outline"}>
{project.sourceType === "open" ? "Open Source" : "Closed Source"}
</Badge>
</AnimatePopUp>
)}
{project.releaseStatus && (
<Badge variant={project.releaseStatus === "released" ? "default" : "outline"}>
{project.releaseStatus === "released" ? "Released" : "Unreleased"}
</Badge>
)}
</div>
</div>
</Card.CardHeader>
{(project.description || project.sourceLink || project.releaseLink || project.techStack?.stackItems?.length) && (
<Card.CardContent className="flex flex-col gap-3">
{project.description && (
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
<AnimatePopUp position={i + 1.4} duration={project.description.length / 20}>
<AnimateTextIn position={i + 1.5} animation="slide"><Markdown>{project.description}</Markdown></AnimateTextIn></AnimatePopUp>
</div>
)}
<div className="flex flex-row">
{project.techStack?.stackItems && project.techStack.stackItems.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{project.techStack.stackItems.map((item, k) => (
<AnimatePopUp key={k} position={(i + 2) + k * 0.5}> <StackBadge key={item} item={item} /> </AnimatePopUp>
))}
</div>
)}
{(project.sourceLink || project.releaseLink) && (
<div className="ml-auto flex-col lg:flex-row justify-center gap-5">
{project.sourceLink &&
<Button variant='outline' className="cursor-pointer mb-3 lg:mb-0 lg:mr-3 min-w-18">
<a
href={project.sourceLink}
target="_blank"
rel="noopener noreferrer"
className='items-center'
>
Source
</a>
</Button>
}
{project.releaseLink &&
<Button variant='default' className="cursor-pointer min-w-18 items-center">
<a
href={project.releaseLink}
target="_blank"
rel="noopener noreferrer"
className='items-center'
>
Live
</a>
</Button>
}
</div>
)}
</div>
</Card.CardContent>
)}
</Card.AnimatedCard>
<div className="pt-5" />
</div>
))}
</ScrollArea>
); );
} }

View File

@@ -0,0 +1,79 @@
"use client";
import { evaluate } from "@mdx-js/mdx";
import { MDXProvider, useMDXComponents } from "@mdx-js/react";
import type { MDXComponents } from "mdx/types";
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
import * as runtime from "react/jsx-runtime";
import rehypeHighlight from "rehype-highlight";
import remarkGfm from "remark-gfm";
import { mdxComponents } from "~/components/mdx-components";
type MdxModule = {
default: ComponentType<{ components?: MDXComponents }>;
};
type ClientMdxProps = {
source: string;
components?: MDXComponents;
format?: "md" | "mdx";
fallback?: ReactNode;
errorFallback?: (error: Error) => ReactNode;
};
export function ClientMdx({
source,
components = mdxComponents,
format = "md",
fallback = null,
errorFallback,
}: ClientMdxProps) {
const [Content, setContent] = useState<MdxModule["default"] | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
const trimmed = source.trim();
if (!trimmed) {
setContent(null);
setError(null);
return;
}
void evaluate(trimmed, {
...runtime,
baseUrl: import.meta.url,
format,
useMDXComponents,
rehypePlugins: [rehypeHighlight],
remarkPlugins: [remarkGfm],
})
.then((mod) => {
if (cancelled) return;
setContent(() => (mod as MdxModule).default);
setError(null);
})
.catch((nextError: unknown) => {
if (cancelled) return;
setContent(null);
setError(nextError instanceof Error ? nextError : new Error("Failed to render MDX"));
});
return () => {
cancelled = true;
};
}, [format, source]);
if (error) {
return errorFallback ? errorFallback(error) : <p>{source}</p>;
}
if (!Content) return <>{fallback}</>;
return (
<MDXProvider components={components}>
<Content components={components} />
</MDXProvider>
);
}

View File

@@ -125,6 +125,30 @@ const STACK_META: Record<string, { label: string; icon?: SvglIcon }> = {
dark: "https://svgl.app/library/neon.svg", dark: "https://svgl.app/library/neon.svg",
}, },
}, },
typescript: {
label: "TypeScript",
icon: {
light: "https://svgl.app/library/typescript.svg",
dark: "https://svgl.app/library/typescript.svg",
},
},
javafx: { label: "JavaFX" },
x11: { label: "X11" },
zig: {
label: "Zig",
icon: {
light: "https://svgl.app/library/zig.svg",
dark: "https://svgl.app/library/zig.svg",
},
},
libghostty: { label: "libghostty" },
zod: {
label: "Zod",
icon: {
light: "https://svgl.app/library/zod.svg",
dark: "https://svgl.app/library/zod.svg",
},
},
}; };
export function StackBadge({ item }: { item: string }) { export function StackBadge({ item }: { item: string }) {

View File

@@ -0,0 +1,197 @@
import Link from "next/link";
import {
Children,
isValidElement,
type ComponentPropsWithoutRef,
type ReactNode,
} from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
type CalloutVariant = "note" | "tip" | "warning";
const calloutStyles: Record<CalloutVariant, string> = {
note: "border-sky-500/40 bg-sky-500/10 text-sky-950 dark:text-sky-100",
tip: "border-emerald-500/40 bg-emerald-500/10 text-emerald-950 dark:text-emerald-100",
warning: "border-amber-500/40 bg-amber-500/10 text-amber-950 dark:text-amber-100",
};
function Callout({
title,
variant = "note",
children,
}: {
title?: string;
variant?: CalloutVariant;
children: ReactNode;
}) {
return (
<aside className={cn("my-6 rounded-md border px-4 py-3", calloutStyles[variant])}>
{title && <p className="mb-2 font-semibold">{title}</p>}
<div className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{children}</div>
</aside>
);
}
function Lead({ children }: { children: ReactNode }) {
return <span className="text-muted-foreground my-6 block text-lg leading-8">{children}</span>;
}
function TagList({ tags }: { tags: string[] }) {
return (
<div className="my-4 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
);
}
function ButtonLink({
href,
children,
variant = "default",
}: {
href: string;
children: ReactNode;
variant?: ComponentPropsWithoutRef<typeof Button>["variant"];
}) {
const isExternal = /^https?:\/\//.test(href);
return (
<Button asChild variant={variant}>
{isExternal ? (
<a href={href} target="_blank" rel="noreferrer">
{children}
</a>
) : (
<Link href={href}>{children}</Link>
)}
</Button>
);
}
function Figure({
src,
alt,
caption,
}: {
src: string;
alt: string;
caption?: string;
}) {
return (
<figure className="my-8">
<img src={src} alt={alt} className="w-full rounded-md border object-cover" />
{caption && <figcaption className="text-muted-foreground mt-2 text-center text-sm">{caption}</figcaption>}
</figure>
);
}
// A bare markdown image (![alt](src)) fills the prose width. Pass a numeric
// markdown title — ![alt](src "160") — to render it as a fixed-size, circular
// avatar instead (used by the CV header). Untitled images keep the old look.
function Img({ src, alt, title }: { src: string; alt?: string; title?: string }) {
const size = title && /^\d+$/.test(title) ? Number(title) : undefined;
if (size) {
return (
<img
src={src}
alt={alt ?? ""}
width={size}
height={size}
style={{ width: size, height: size }}
className="mx-auto !my-0 shrink-0 rounded-full object-cover ring-2 ring-foreground/10"
/>
);
}
return <img src={src} alt={alt ?? ""} className="w-full rounded-md border object-cover" />;
}
// Composable, border-less flex layout primitives for MDX.
//
// <Layout> — section wrapper, stacks its children with spacing
// <Row> — lays children side by side, wraps when too narrow
// <Column> — stacks its own children vertically
//
// Row/Column nest freely. A Row sizes its direct children into equal,
// flex-growing tracks with a min width, so two lists/Columns sit side by
// side and drop to stacked once they can't keep ~16rem each.
function Layout({ children }: { children: ReactNode }) {
return <div className="my-6 flex flex-col gap-6 [&>*]:my-0">{children}</div>;
}
function Row({ children }: { children: ReactNode }) {
return (
<div className="flex flex-wrap gap-x-8 gap-y-4 [&>*]:mt-0 [&>*]:min-w-[16rem] [&>*]:flex-1">
{children}
</div>
);
}
function Column({ children }: { children: ReactNode }) {
return <div className="flex min-w-0 flex-col gap-2 [&>*]:my-0">{children}</div>;
}
function PullQuote({ children }: { children: ReactNode }) {
return (
<blockquote className="border-primary my-8 border-l-4 pl-5 text-xl leading-8 font-medium">
{children}
</blockquote>
);
}
function ExternalLink(props: ComponentPropsWithoutRef<"a">) {
const { className, ...rest } = props;
return (
<a
{...rest}
target="_blank"
rel="noreferrer"
className={cn(
"text-sky-600 underline underline-offset-4 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300",
className,
)}
/>
);
}
const blockComponents = new Set<unknown>([
Callout,
Column,
Figure,
Layout,
PullQuote,
Row,
TagList,
]);
function Paragraph({ children }: { children: ReactNode }) {
const containsBlockComponent = Children.toArray(children).some(
(child) => isValidElement(child) && blockComponents.has(child.type),
);
if (containsBlockComponent) return <>{children}</>;
return <p>{children}</p>;
}
export const mdxComponents = {
a: ExternalLink,
p: Paragraph,
Badge,
ButtonLink,
Callout,
Column,
Figure,
img: Img,
Layout,
Lead,
PullQuote,
Row,
TagList,
};

View File

@@ -1,7 +1,8 @@
import { useGSAP } from "@gsap/react";import * as React from "react" "use client"
import * as React from "react"
import { useRef } from "react"; import { useRef } from "react";
import { useGsapContext } from "~/app/_providers/GsapProvicer";
import gsap from 'gsap' import gsap from 'gsap'
import { useReveal } from "~/app/_components/Animated/useReveal";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils"
function Card({ function Card({
@@ -26,29 +27,27 @@ function AnimatedCard({
className, className,
position = 0, position = 0,
size = "default", size = "default",
scrollOnly = false,
once = false,
...props ...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm", position: gsap.Position }) { }: React.ComponentProps<"div"> & { size?: "default" | "sm", position?: gsap.Position, scrollOnly?: boolean, once?: boolean }) {
const gsapContext = useGsapContext() const ref = useRef<HTMLDivElement | null>(null)
const ref = useRef<HTMLDivElement|null>(null) useReveal(ref, {
useGSAP(() => { position,
const rect = ref.current?.getBoundingClientRect() scrollOnly,
const isInView = rect && rect.top < window.innerHeight once,
const fromVars = { x: -100, opacity: 0, duration: 0.5 } debugId: `card-${position}`,
if (isInView) { // Start hidden via CSS (see className) so the server-rendered card never
gsapContext?.addAnimation(gsap.from(ref.current, fromVars), position) // flashes visible before GSAP runs; reveal animates *to* the shown state.
} else { makeReveal: (el) => gsap.to(el, { x: 0, opacity: 1, duration: 0.5, paused: true }),
const scroller = gsapContext?.getScroller() })
console.log('scroller:', scroller)
gsap.from(ref.current, { ...fromVars, scrollTrigger: { trigger: ref.current, start: 'top 85%', scroller } })
}
}, { dependencies: [] })
return ( return (
<div <div
ref={ref} ref={ref}
data-slot="card" data-slot="card"
data-size={size} data-size={size}
className={cn( className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl bg-opacity-60 backdrop-blur-sm", "opacity-0 -translate-x-[100px] group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl bg-opacity-60 backdrop-blur-sm",
className className
)} )}
{...props} {...props}

View File

@@ -7,6 +7,9 @@ export const env = createEnv({
* isn't built with invalid env vars. * isn't built with invalid env vars.
*/ */
server: { server: {
UPLOADTHING_TOKEN: z.string(),
BLOG_MDX_PREFIX: z.string().default("blog"),
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
DATABASE_URL_UNPOOLED: z.string().url(), DATABASE_URL_UNPOOLED: z.string().url(),
@@ -27,7 +30,11 @@ export const env = createEnv({
CLERK_SECRET_KEY: z.string(), CLERK_SECRET_KEY: z.string(),
ADMIN_USER_CLERK_ID: z.string(), ADMIN_USER_CLERK_ID: z.string(),
UPLOADTHING_TOKEN: z.string(), OPENAI_API_KEY: z.string(),
GOOGLE_SERVICE_ACCOUNT_EMAIL: z.string().email(),
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: z.string(),
GOOGLE_CALENDAR_ID: z.string(),
GREGOR_MEETING_EMAIL: z.string().email(),
NODE_ENV: z NODE_ENV: z
.enum(["development", "test", "production"]) .enum(["development", "test", "production"])
.default("development"), .default("development"),
@@ -49,6 +56,9 @@ export const env = createEnv({
* middlewares) or client-side so we need to destruct manually. * middlewares) or client-side so we need to destruct manually.
*/ */
runtimeEnv: { runtimeEnv: {
UPLOADTHING_TOKEN: process.env.UPLOADTHING_TOKEN,
BLOG_MDX_PREFIX: process.env.BLOG_MDX_PREFIX,
DATABASE_URL: process.env.DATABASE_URL, DATABASE_URL: process.env.DATABASE_URL,
DATABASE_URL_UNPOOLED: process.env.DATABASE_URL, DATABASE_URL_UNPOOLED: process.env.DATABASE_URL,
PGHOST: process.env.PGHOST, PGHOST: process.env.PGHOST,
@@ -65,7 +75,11 @@ export const env = createEnv({
POSTGRES_URL_NO_SSL: process.env.POSTGRES_URL_NO_SSL, POSTGRES_URL_NO_SSL: process.env.POSTGRES_URL_NO_SSL,
POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL, POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL,
ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID, ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID,
UPLOADTHING_TOKEN: process.env.UPLOADTHING_TOKEN, OPENAI_API_KEY: process.env.OPENAI_API_KEY,
GOOGLE_SERVICE_ACCOUNT_EMAIL: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: process.env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY,
GOOGLE_CALENDAR_ID: process.env.GOOGLE_CALENDAR_ID,
GREGOR_MEETING_EMAIL: process.env.GREGOR_MEETING_EMAIL,
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: process.env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID, NEXT_PUBLIC_ADMIN_USER_CLERK_ID: process.env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID,
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY, CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,

114
src/lib/ffmpeg/transcode.ts Normal file
View File

@@ -0,0 +1,114 @@
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";
// Single-threaded core: works without SharedArrayBuffer, so no COOP/COEP
// cross-origin-isolation headers are required. Slower than the MT core, which
// is fine for occasional admin-side transcodes.
const CORE_VERSION = "0.12.10";
// @ffmpeg/ffmpeg 0.12.x always spawns a `type: "module"` worker. A module
// worker can't `importScripts`, so the worker dynamically `import()`s the core —
// which means the core must be the ESM build, not UMD.
const CORE_BASE = `https://unpkg.com/@ffmpeg/core@${CORE_VERSION}/dist/esm`;
// Self-hosted worker, bundled from @ffmpeg/ffmpeg's ESM `worker.js` via the
// `build:ffmpeg-worker` package script (rerun it when bumping @ffmpeg/ffmpeg).
// The shipped UMD worker is unusable here: its
// dynamic import is webpack-compiled into a stub that throws MODULE_NOT_FOUND,
// and letting Next bundle the worker hits the same problem. Serving our own
// same-origin module worker keeps the native `import()` intact.
// Must be an absolute URL: the library resolves classWorkerURL against
// `import.meta.url`, which is a file:// base under Next, so a root-relative path
// would wrongly become `file:///ffmpeg/worker.js`.
const WORKER_PATH = "/ffmpeg/worker.js";
let instance: FFmpeg | null = null;
let loadPromise: Promise<FFmpeg> | null = null;
function load(onLog?: (msg: string) => void): Promise<FFmpeg> {
if (instance) return Promise.resolve(instance);
if (!loadPromise) {
loadPromise = (async () => {
const ffmpeg = new FFmpeg();
ffmpeg.on("log", ({ message }) => {
console.debug("[ffmpeg]", message);
onLog?.(message);
});
const ok = await ffmpeg.load({
coreURL: await toBlobURL(`${CORE_BASE}/ffmpeg-core.js`, "text/javascript"),
wasmURL: await toBlobURL(`${CORE_BASE}/ffmpeg-core.wasm`, "application/wasm"),
classWorkerURL: new URL(WORKER_PATH, window.location.origin).href,
});
if (!ok) throw new Error("ffmpeg.load() returned false — core failed to initialise");
instance = ffmpeg;
return ffmpeg;
})();
// If loading fails, clear the cached promise so the next click retries.
loadPromise.catch(() => {
loadPromise = null;
});
}
return loadPromise;
}
/**
* Transcodes a (FLAC) source URL to a streaming-friendly AAC/m4a file in the
* browser. Returns a File ready to hand to UploadThing's startUpload.
*/
export async function transcodeToAac(opts: {
sourceUrl: string;
outputName?: string;
bitrate?: string;
onProgress?: (ratio: number) => void;
onLog?: (msg: string) => void;
}): Promise<File> {
let lastLog = "";
const onLog = (msg: string) => {
if (msg.trim()) lastLog = msg;
opts.onLog?.(msg);
};
const ffmpeg = await load(onLog);
// `load` only wires the persisted instance's log handler on first init; make
// sure this call's logs are captured even when the instance was cached.
const logHandler = ({ message }: { message: string }) => onLog(message);
ffmpeg.on("log", logHandler);
const progressHandler = opts.onProgress
? ({ progress }: { progress: number }) =>
opts.onProgress?.(Math.min(1, Math.max(0, progress)))
: null;
if (progressHandler) ffmpeg.on("progress", progressHandler);
try {
// fetchFile pulls the source cross-origin; surface a clear message if the
// host (UploadThing) blocks it.
let sourceData: Uint8Array;
try {
sourceData = await fetchFile(opts.sourceUrl);
} catch (e) {
throw new Error(
`Could not fetch source file (CORS or network): ${e instanceof Error ? e.message : String(e)}`,
);
}
await ffmpeg.writeFile("input.flac", sourceData);
const code = await ffmpeg.exec([
"-i", "input.flac",
"-c:a", "aac",
"-b:a", opts.bitrate ?? "192k",
// Move the moov atom to the front so the player can start before the
// whole file has downloaded — the whole point of the stream version.
"-movflags", "+faststart",
"output.m4a",
]);
if (code !== 0) {
throw new Error(`ffmpeg exited with code ${code}: ${lastLog || "no log output"}`);
}
const data = await ffmpeg.readFile("output.m4a");
await ffmpeg.deleteFile("input.flac");
await ffmpeg.deleteFile("output.m4a");
// Copy into a fresh ArrayBuffer-backed view so the Blob typing is happy
// (readFile may be typed against a SharedArrayBuffer-backed Uint8Array).
const bytes = new Uint8Array(data as Uint8Array);
const blob = new Blob([bytes], { type: "audio/mp4" });
return new File([blob], opts.outputName ?? "stream.m4a", { type: "audio/mp4" });
} finally {
ffmpeg.off("log", logHandler);
if (progressHandler) ffmpeg.off("progress", progressHandler);
}
}

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import type { UseTRPCQueryResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs" import type { UseTRPCQueryResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs"
import { useEffect, useState } from "react" import { useEffect, useRef, useState } from "react"
function useRelationShipSuccess<T extends Record<string,any> & {id:string},K extends keyof T>( function useRelationShipSuccess<T extends Record<string,any> & {id:string},K extends keyof T>(
relationShipData: T[] | undefined, relationShipData: T[] | undefined,
@@ -56,3 +56,10 @@ export function makeUseRelationShipWithNameIndex<K extends string>(key:K) {
} }
} }
export function usePrevious<T>(value:T,initialValue:T) {
const ref = useRef(initialValue)
useEffect(() => {
ref.current = value
})
return ref.current;
}

View File

@@ -7,6 +7,9 @@ export const createMusicInputSchema = z.object({
fileUrl: z.string(), fileUrl: z.string(),
fileKey: z.string(), fileKey: z.string(),
fileName: z.string(), fileName: z.string(),
streamUrl: z.string().optional().nullable(),
streamKey: z.string().optional().nullable(),
streamName: z.string().optional().nullable(),
}) })
export const updateMusicInputSchema = z.object({ export const updateMusicInputSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
@@ -15,4 +18,14 @@ export const updateMusicInputSchema = z.object({
fileUrl: z.string().optional(), fileUrl: z.string().optional(),
fileKey: z.string().optional(), fileKey: z.string().optional(),
fileName: z.string().optional(), fileName: z.string().optional(),
streamUrl: z.string().optional().nullable(),
streamKey: z.string().optional().nullable(),
streamName: z.string().optional().nullable(),
})
export const setStreamInputSchema = z.object({
id: z.string().uuid(),
streamUrl: z.string(),
streamKey: z.string(),
streamName: z.string(),
}) })

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