Compare commits
39 Commits
538d896b0e
...
additional
| Author | SHA1 | Date | |
|---|---|---|---|
| 85af4aec77 | |||
| 05740e122e | |||
| 95666e20e9 | |||
| 993137068e | |||
| 5755bd3184 | |||
| ca29bd5003 | |||
| 62f808b0cf | |||
| cb3ece4f99 | |||
| c5faf8fa57 | |||
| a7354ad774 | |||
| c742b8e457 | |||
| 4ce93a0466 | |||
| 0d79adb104 | |||
| 3e5be46503 | |||
| 73ba2b573d | |||
| c1fe73dbd0 | |||
| af4ff18917 | |||
| b59fb2b3af | |||
| 58dc4ce53f | |||
| 53bc70ab05 | |||
| 1b3f30cc90 | |||
| fd5063d1c4 | |||
| 303ac83fe2 | |||
| 91315730ac | |||
| 13649cd6dc | |||
| 1e0f033d07 | |||
| 39563b6740 | |||
| dcc4f47ccf | |||
| c8a9ab5984 | |||
| 54f108ac8d | |||
| 865ef0b316 | |||
| 63b0405a7a | |||
| 7aa1746f97 | |||
| f5e8b87846 | |||
| 65b9184a22 | |||
| ea7ddb8e51 | |||
| bcefe397ca | |||
| 8ce95f2b5c | |||
| da43b31aa3 |
2
.ignore
2
.ignore
@@ -1,2 +1,4 @@
|
|||||||
node_modules/**
|
node_modules/**
|
||||||
.next/**
|
.next/**
|
||||||
|
.worktrees
|
||||||
|
.clerk
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import "./src/env.js";
|
|||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
typescript: {
|
transpilePackages: ["next-mdx-remote"],
|
||||||
ignoreBuildErrors: true
|
typescript: {
|
||||||
}
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
108
package.json
108
package.json
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next 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 .",
|
||||||
@@ -19,16 +20,22 @@
|
|||||||
"test": "vitest --typecheck"
|
"test": "vitest --typecheck"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^3.0.41",
|
"@ai-sdk/openai": "^3.0.67",
|
||||||
"@ai-sdk/react": "^3.0.118",
|
"@ai-sdk/react": "^3.0.195",
|
||||||
"@clerk/nextjs": "^7.0.2",
|
"@clerk/nextjs": "^7.4.2",
|
||||||
"@electric-sql/pglite": "^0.3.16",
|
"@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/mdx": "^3.1.1",
|
||||||
|
"@mdx-js/react": "^3.1.1",
|
||||||
|
"@mdx-js/loader": "^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",
|
||||||
@@ -55,84 +62,87 @@
|
|||||||
"@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-md-editor": "^4.1.1",
|
||||||
"@uploadthing/react": "^7.3.3",
|
"@uploadthing/react": "^7.3.3",
|
||||||
"ai": "^6.0.116",
|
"@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",
|
||||||
"googleapis": "^171.4.0",
|
"googleapis": "^173.0.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"gsap": "^3.14.2",
|
"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-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",
|
"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"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
1
public/ffmpeg/worker.js
Normal file
1
public/ffmpeg/worker.js
Normal 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)};
|
||||||
@@ -1,62 +1,46 @@
|
|||||||
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 = 0,
|
position = 0,
|
||||||
tlId = undefined,
|
|
||||||
speed = 1,
|
speed = 1,
|
||||||
scrollOnly = false,
|
scrollOnly = false,
|
||||||
|
once = false,
|
||||||
|
debugId,
|
||||||
className
|
className
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode,
|
children: ReactNode,
|
||||||
animation?: "type" | "slide",
|
animation?: "type" | "slide",
|
||||||
position?: gsap.Position,
|
position?: gsap.Position,
|
||||||
tlId?: string,
|
|
||||||
scrollOnly?: boolean,
|
scrollOnly?: boolean,
|
||||||
|
once?: boolean,
|
||||||
|
debugId?: string,
|
||||||
speed?: number,
|
speed?: number,
|
||||||
className?: HTMLAttributes<HTMLDivElement>['className']
|
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 scroller = gsapContext?.getScroller()
|
once,
|
||||||
console.log(scroller)
|
debugId: debugId ?? `text-${position}`,
|
||||||
let viewportTop = 0
|
makeReveal: (node) => {
|
||||||
let viewportBottom = window.innerHeight
|
// The wrapper starts at opacity 0 (so there's no flash of unsplit text);
|
||||||
if (scroller && scroller instanceof Element) {
|
// reveal the wrapper and let the per-character tween do the animation.
|
||||||
const scrollerRect = scroller.getBoundingClientRect()
|
gsap.set(node, { opacity: 1 })
|
||||||
viewportTop = scrollerRect.top
|
const split = new SplitText(node, { type: 'chars' })
|
||||||
viewportBottom = scrollerRect.top + scrollerRect.height
|
const fromVars = animation === "slide"
|
||||||
}
|
? { opacity: 0, x: -10, duration: 0.2 * speed, stagger: { each: 0.08 * speed }, ease: 'bounce.inOut' }
|
||||||
const isInView = rect && rect.bottom > viewportTop && rect.top < viewportBottom
|
: { opacity: 0, duration: 0.01 * speed, stagger: { each: 0.04 * speed }, ease: 'bounce.inOut' }
|
||||||
console.log(isInView)
|
return gsap.from(split.chars, { ...fromVars, paused: true })
|
||||||
const chars = new SplitText(el.current, { type: 'chars' })
|
},
|
||||||
gsapContext?.addAnimation(gsap.to(el.current, { opacity: 100, duration: 0 }), 0, tlId)
|
})
|
||||||
const fromVars = animation === "slide"
|
|
||||||
? { opacity: 0, x: -10, duration: 0.2 * speed, stagger: { each: 0.08 * speed }, ease: 'bounce.inOut', onComplete: () => chars.revert() }
|
|
||||||
: { opacity: 0, duration: 0.01 * speed, stagger: { each: 0.04 * speed }, ease: 'bounce.inOut', onComplete: () => chars.revert() }
|
|
||||||
if (isInView && !scrollOnly) {
|
|
||||||
gsapContext?.addAnimation(gsap.from(chars.chars, fromVars), position, tlId)
|
|
||||||
} else {
|
|
||||||
gsap.from(chars.chars,
|
|
||||||
{
|
|
||||||
...fromVars,
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: el.current,
|
|
||||||
start: 'top bottom',
|
|
||||||
end: 'bottom top',
|
|
||||||
toggleActions: "play reverse play reverse",
|
|
||||||
scroller
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, { dependencies: [] })
|
|
||||||
return (
|
return (
|
||||||
<div ref={el} className={cn(className, "opacity-0")}>
|
<div ref={el} className={cn(className, "opacity-0")}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (0–1). 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 canvas = canvasRef.current;
|
||||||
const resize = () => {
|
const container = containerRef.current;
|
||||||
const canvas = canvasRef.current;
|
if (!canvas || !container) {
|
||||||
const container = containerRef.current;
|
return;
|
||||||
if (!canvas || !container) return;
|
}
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
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(
|
const canvas = canvasRef.current;
|
||||||
() => {
|
if (!canvas) {
|
||||||
if (!mounted) return;
|
return;
|
||||||
const canvas = canvasRef.current;
|
}
|
||||||
const container = containerRef.current;
|
|
||||||
if (!canvas || !container) return;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const tick = () => {
|
const ctx = canvas.getContext("2d");
|
||||||
const w = container.clientWidth;
|
if (!ctx) {
|
||||||
const h = container.clientHeight;
|
return;
|
||||||
frame.current++;
|
}
|
||||||
|
|
||||||
/* Anchor: smooth-follow cursor or drift on mobile */
|
const stopAnimation = () => {
|
||||||
if (isMobile.current) {
|
if (animationFrameRef.current !== null) {
|
||||||
mobileAnchor.current.x +=
|
window.cancelAnimationFrame(animationFrameRef.current);
|
||||||
(mobileTarget.current.x - mobileAnchor.current.x) * 0.008 * mobileSpeed;
|
animationFrameRef.current = null;
|
||||||
mobileAnchor.current.y +=
|
}
|
||||||
(mobileTarget.current.y - mobileAnchor.current.y) * 0.008 * mobileSpeed;
|
};
|
||||||
|
|
||||||
const dx = mobileTarget.current.x - mobileAnchor.current.x;
|
const draw = () => {
|
||||||
const dy = mobileTarget.current.y - mobileAnchor.current.y;
|
if (!isVisibleRef.current || prefersReducedMotionRef.current) {
|
||||||
if (Math.sqrt(dx * dx + dy * dy) < 30) {
|
animationFrameRef.current = null;
|
||||||
mobileTarget.current = {
|
return;
|
||||||
x: rand(w * 0.15, w * 0.85),
|
}
|
||||||
y: rand(h * 0.15, h * 0.85),
|
|
||||||
};
|
const { height, width } = canvasSizeRef.current;
|
||||||
}
|
if (width === 0 || height === 0) {
|
||||||
smoothMouse.current.x = mobileAnchor.current.x;
|
animationFrameRef.current = window.requestAnimationFrame(draw);
|
||||||
smoothMouse.current.y = mobileAnchor.current.y;
|
return;
|
||||||
} else {
|
}
|
||||||
smoothMouse.current.x +=
|
|
||||||
(mousePos.current.x - smoothMouse.current.x) * followSpeed;
|
frameRef.current += 1;
|
||||||
smoothMouse.current.y +=
|
|
||||||
(mousePos.current.y - smoothMouse.current.y) * followSpeed;
|
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),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cx = smoothMouse.current.x;
|
smoothMouseRef.current.x = mobileAnchorRef.current.x;
|
||||||
const cy = smoothMouse.current.y;
|
smoothMouseRef.current.y = mobileAnchorRef.current.y;
|
||||||
|
} else {
|
||||||
|
const desktopLerp = followSpeedRef.current;
|
||||||
|
smoothMouseRef.current.x +=
|
||||||
|
(mousePosRef.current.x - smoothMouseRef.current.x) * desktopLerp;
|
||||||
|
smoothMouseRef.current.y +=
|
||||||
|
(mousePosRef.current.y - smoothMouseRef.current.y) * desktopLerp;
|
||||||
|
}
|
||||||
|
|
||||||
/* Clear frame */
|
const centerX = smoothMouseRef.current.x;
|
||||||
ctx.clearRect(0, 0, w, h);
|
const centerY = smoothMouseRef.current.y;
|
||||||
|
const colors = particleColorsRef.current;
|
||||||
|
|
||||||
/* Draw each particle */
|
ctx.clearRect(0, 0, width, height);
|
||||||
particles.current.forEach((p) => {
|
|
||||||
p.angle += p.speed;
|
|
||||||
|
|
||||||
const wobble =
|
for (const particle of particlesRef.current) {
|
||||||
Math.sin(frame.current * p.wobbleSpeed + p.wobblePhase) * p.wobbleAmp;
|
particle.angle += particle.speed;
|
||||||
const r = p.radius + wobble;
|
|
||||||
|
|
||||||
const x = cx + Math.cos(p.angle) * r;
|
const wobble =
|
||||||
const y = cy + Math.sin(p.angle) * r;
|
Math.sin(frameRef.current * particle.wobbleSpeed + particle.wobblePhase) *
|
||||||
|
particle.wobbleAmp;
|
||||||
|
const radius = particle.radius + wobble;
|
||||||
|
const x = centerX + Math.cos(particle.angle) * radius;
|
||||||
|
const y = centerY + Math.sin(particle.angle) * radius;
|
||||||
|
|
||||||
/* Soft fade near viewport edges */
|
const edgeFade = Math.max(
|
||||||
const edgeFade = Math.max(
|
0,
|
||||||
0,
|
Math.min(
|
||||||
Math.min(x / 80, (w - x) / 80, y / 80, (h - y) / 80, 1),
|
x / EDGE_FADE_DISTANCE,
|
||||||
);
|
(width - x) / EDGE_FADE_DISTANCE,
|
||||||
if (edgeFade <= 0) return;
|
y / EDGE_FADE_DISTANCE,
|
||||||
|
(height - y) / EDGE_FADE_DISTANCE,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
ctx.globalAlpha = edgeFade;
|
if (edgeFade <= 0) {
|
||||||
ctx.fillStyle = palette.particles[p.colorIndex];
|
continue;
|
||||||
ctx.beginPath();
|
}
|
||||||
ctx.arc(x, y, p.size, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = edgeFade;
|
||||||
};
|
ctx.fillStyle = colors[particle.colorIndex] ?? colors[0] ?? "#ffffff";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, particle.size, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
gsap.ticker.add(tick);
|
ctx.globalAlpha = 1;
|
||||||
return () => {
|
animationFrameRef.current = window.requestAnimationFrame(draw);
|
||||||
gsap.ticker.remove(tick);
|
};
|
||||||
};
|
|
||||||
},
|
const startAnimation = () => {
|
||||||
{
|
if (
|
||||||
scope: containerRef,
|
animationFrameRef.current === null &&
|
||||||
dependencies: [mounted, isDark, followSpeed, mobileSpeed, orbitRadius, palette],
|
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 () => {
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
motionMedia.removeEventListener("change", handleMotionChange);
|
||||||
|
stopAnimation();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
/* ── 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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
134
src/app/_components/Animated/useReveal.ts
Normal file
134
src/app/_components/Animated/useReveal.ts
Normal 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: [] })
|
||||||
|
}
|
||||||
@@ -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}`} />
|
||||||
|
|||||||
53
src/app/_components/Form/Fields/IntInputFormField.tsx
Normal file
53
src/app/_components/Form/Fields/IntInputFormField.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ 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";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
import { ClientMdx } from "~/components/ClientMdx";
|
||||||
import {
|
import {
|
||||||
InternalLinkTextarea,
|
InternalLinkTextarea,
|
||||||
type AutocompleteTriggerConfig,
|
type AutocompleteTriggerConfig,
|
||||||
@@ -81,7 +82,7 @@ export default function MdeFormField<T extends FieldValues>(params: {
|
|||||||
),
|
),
|
||||||
preview: params.renderPreview
|
preview: params.renderPreview
|
||||||
? (source) => params.renderPreview?.(source) ?? <></>
|
? (source) => params.renderPreview?.(source) ?? <></>
|
||||||
: undefined,
|
: (source) => <ClientMdx source={source} fallback={source} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -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[] }) {
|
||||||
@@ -76,9 +99,9 @@ export default function MultiBooleanFormField<T extends FieldValues>(params: { c
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PopoverContent data-testid="multiboolean-content">
|
<PopoverContent data-testid="multiboolean-content">
|
||||||
<MultiBooleanFieldContext.Provider value={{checkedValues: checkedValues, setCheckedValue: setCheckedValues}}>
|
<MultiBooleanFieldContext.Provider value={{checkedValues: checkedValues, setCheckedValue: setCheckedValues}}>
|
||||||
<InnerMultiBooleanFormField onChange={field.onChange} options={params.options} />
|
<InnerMultiBooleanFormField onChange={field.onChange} options={params.options} />
|
||||||
</MultiBooleanFieldContext.Provider>
|
</MultiBooleanFieldContext.Provider>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -9,11 +9,51 @@ 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,14 +66,24 @@ 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,29 +120,174 @@ export default function GsapProvider({ children }: { children: ReactNode }) {
|
|||||||
// }
|
// }
|
||||||
return scrollerRef.current
|
return scrollerRef.current
|
||||||
}, [])
|
}, [])
|
||||||
|
const devToolsCreated = useRef(false)
|
||||||
useGSAP(() => {
|
useGSAP(() => {
|
||||||
if (!tl.current) {
|
if (!tl.current) {
|
||||||
tl.current = gsap.timeline({ paused: true })
|
tl.current = gsap.timeline({ paused: true })
|
||||||
}
|
}
|
||||||
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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { inferRouterOutputs } from '@trpc/server';
|
|||||||
import { useUser } from '@clerk/nextjs'
|
import { useUser } from '@clerk/nextjs'
|
||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
import { trpc } from '~/app/_trpc/Client'
|
import { trpc } from '~/app/_trpc/Client'
|
||||||
import { type ChatRouter } from '~/server/routers/chat'
|
import type { ChatRouter } from '~/server/routers/chat'
|
||||||
const MessageContext = createContext<{
|
const MessageContext = createContext<{
|
||||||
session?: inferRouterOutputs<ChatRouter>['getSession']
|
session?: inferRouterOutputs<ChatRouter>['getSession']
|
||||||
messages?: inferRouterOutputs<ChatRouter>['getMessages']
|
messages?: inferRouterOutputs<ChatRouter>['getMessages']
|
||||||
|
|||||||
@@ -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`}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
25
src/app/actions/cancelMeeting.ts
Normal file
25
src/app/actions/cancelMeeting.ts
Normal 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.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,34 @@
|
|||||||
'use server'
|
'use server'
|
||||||
import { clerkClient, auth } from '@clerk/nextjs/server'
|
|
||||||
import { google } from 'googleapis'
|
|
||||||
import { env } from '~/env'
|
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({
|
export async function scheduleMeeting({
|
||||||
title,
|
title,
|
||||||
@@ -19,56 +46,39 @@ export async function scheduleMeeting({
|
|||||||
attendeeName?: string
|
attendeeName?: string
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const clerk = await clerkClient()
|
const calendar = getGoogleCalendarClient()
|
||||||
const userAuth = await auth()
|
|
||||||
const user = await clerk.users.getUser(userAuth.userId?userAuth.userId:"")
|
|
||||||
// Get admin's Google OAuth token to create the event on Gregor's calendar
|
|
||||||
const adminTokenResponse = await clerk.users.getUserOauthAccessToken(
|
|
||||||
env.ADMIN_USER_CLERK_ID,
|
|
||||||
'oauth_google',
|
|
||||||
)
|
|
||||||
const adminToken = adminTokenResponse.data[0]
|
|
||||||
|
|
||||||
if (!adminToken?.token) {
|
|
||||||
return { success: false, error: 'Admin Google Calendar not connected. Ensure the admin account is linked with Google and has calendar scope enabled.' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to resolve visitor's Google email for the invite
|
|
||||||
let visitorEmail: string | undefined = attendeeEmail
|
|
||||||
if (!visitorEmail) {
|
|
||||||
visitorEmail = user?.emailAddresses.at(0)?.emailAddress ?? undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const oAuth2Client = new google.auth.OAuth2()
|
|
||||||
oAuth2Client.setCredentials({ access_token: adminToken.token })
|
|
||||||
const calendar = google.calendar({ version: 'v3', auth: oAuth2Client })
|
|
||||||
|
|
||||||
const startTime = new Date(dateTime)
|
const startTime = new Date(dateTime)
|
||||||
const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000)
|
const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000)
|
||||||
|
const attendeeNote = attendeeEmail
|
||||||
|
? `\n\nVisitor: ${attendeeName ?? 'Unknown'} <${attendeeEmail}>`
|
||||||
|
: ''
|
||||||
|
const eventDescription = `${description}${attendeeNote}`
|
||||||
|
|
||||||
const attendees: { email: string; displayName?: string }[] = []
|
const eventRequest = {
|
||||||
if (visitorEmail) {
|
summary: title,
|
||||||
attendees.push({ email: visitorEmail, displayName: attendeeName })
|
description: eventDescription,
|
||||||
|
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' },
|
||||||
|
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = await calendar.events.insert({
|
const event = await calendar.events.insert({
|
||||||
calendarId: 'primary',
|
calendarId: getGoogleCalendarId(),
|
||||||
sendUpdates: 'all',
|
requestBody: eventRequest,
|
||||||
requestBody: {
|
})
|
||||||
summary: title,
|
|
||||||
description,
|
const addToCalendarLink = createGoogleCalendarTemplateLink({
|
||||||
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' },
|
title,
|
||||||
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
|
description,
|
||||||
attendees,
|
startTime,
|
||||||
},
|
endTime,
|
||||||
sendNotifications: true
|
gregorEmail: env.GREGOR_MEETING_EMAIL,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
eventId: event.data.id,
|
eventId: event.data.id,
|
||||||
htmlLink: event.data.htmlLink,
|
addToCalendarLink,
|
||||||
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}${visitorEmail ? `. Invite sent to ${visitorEmail}.` : '.'}`,
|
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}.${attendeeEmail ? ` Visitor email noted: ${attendeeEmail}.` : ''} The add-to-calendar link invites ${env.GREGOR_MEETING_EMAIL}.`,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to schedule meeting:', error)
|
console.error('Failed to schedule meeting:', error)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import SimpleSidebarGroup from "~/components/ui/simple-sidebar-group";
|
|||||||
export default function AdminSideBar() {
|
export default function AdminSideBar() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sidebar variant="floating" className="h-[96%] mt-10 z-[51]">
|
<Sidebar variant="floating" className="h-full lg:h-[96%] lg:mt-10 z-51">
|
||||||
<SidebarTrigger className="absolute z-[52] left-65 top-100" />
|
<SidebarTrigger className="absolute z-52 left-65 top-100" />
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<ScrollArea>
|
<ScrollArea>
|
||||||
<SimpleSidebarGroup lable="CV">
|
<SimpleSidebarGroup lable="CV">
|
||||||
|
|||||||
98
src/app/admin/_components/MdxComponentReference.tsx
Normal file
98
src/app/admin/_components/MdxComponentReference.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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: "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"><</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ import { useEffect, useState } from 'react'
|
|||||||
import { MDXRemote } from 'next-mdx-remote'
|
import { MDXRemote } from 'next-mdx-remote'
|
||||||
import { serialize } from 'next-mdx-remote/serialize'
|
import { serialize } from 'next-mdx-remote/serialize'
|
||||||
import type { MDXRemoteSerializeResult } from 'next-mdx-remote'
|
import type { MDXRemoteSerializeResult } from 'next-mdx-remote'
|
||||||
import { mdxComponents } from '~/app/blog/_components/mdx-components'
|
import { mdxComponents } from '~/components/mdx-components'
|
||||||
|
|
||||||
export default function BlogMdxEditorPreview(params: { source: string }) {
|
export default function MdxEditorPreview(params: { source: string }) {
|
||||||
const [compiled, setCompiled] = useState<MDXRemoteSerializeResult | null>(null)
|
const [compiled, setCompiled] = useState<MDXRemoteSerializeResult | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
143
src/app/admin/_components/useMdxEditorFieldProps.tsx
Normal file
143
src/app/admin/_components/useMdxEditorFieldProps.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
'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: '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: ``,
|
||||||
|
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} />,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,15 +10,8 @@ import { TextInputFormField, MdeFormField } from '~/app/_components/Form/Fields'
|
|||||||
import { usePathname, useRouter } from 'next/navigation'
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
import type { RouterOutputs } from '~/server/routers/_app'
|
import type { RouterOutputs } from '~/server/routers/_app'
|
||||||
import MdxComponentReference from './MdxComponentReference'
|
import MdxComponentReference from '~/app/admin/_components/MdxComponentReference'
|
||||||
import BlogMdxEditorPreview from './BlogMdxEditorPreview'
|
import { useMdxEditorFieldProps } from '~/app/admin/_components/useMdxEditorFieldProps'
|
||||||
import {
|
|
||||||
AUTOCOMPLETE_CURSOR_MARKER,
|
|
||||||
linkSuggestionsToAutocomplete,
|
|
||||||
type AutocompleteTriggerConfig,
|
|
||||||
type InternalLinkSuggestion,
|
|
||||||
type MdeAutocompleteSuggestion,
|
|
||||||
} from '~/app/_components/Form/Fields/InternalLinkTextarea'
|
|
||||||
|
|
||||||
type BlogPost = RouterOutputs['blog']['bySlug']
|
type BlogPost = RouterOutputs['blog']['bySlug']
|
||||||
|
|
||||||
@@ -35,116 +28,6 @@ function parseTags(value: string | undefined): string[] {
|
|||||||
return value?.split(',').map((tag) => tag.trim()).filter(Boolean) ?? []
|
return value?.split(',').map((tag) => tag.trim()).filter(Boolean) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
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 blogAutocompleteSuggestions: 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: '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: ``,
|
|
||||||
detail: 'Markdown image',
|
|
||||||
group: 'Markdown',
|
|
||||||
trigger: '!',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const blogTriggerConfigs: 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),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function CreateUpdateBlogForm(params: { className?: string, entity?: BlogPost }) {
|
export default function CreateUpdateBlogForm(params: { className?: string, entity?: BlogPost }) {
|
||||||
const [slug, setSlug] = useState<string | undefined>(params.entity?.slug)
|
const [slug, setSlug] = useState<string | undefined>(params.entity?.slug)
|
||||||
const [originalSlug, setOriginalSlug] = useState<string | undefined>(params.entity?.slug)
|
const [originalSlug, setOriginalSlug] = useState<string | undefined>(params.entity?.slug)
|
||||||
@@ -162,12 +45,7 @@ export default function CreateUpdateBlogForm(params: { className?: string, entit
|
|||||||
})
|
})
|
||||||
const path = usePathname()
|
const path = usePathname()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const posts = trpc.blog.list.useQuery(undefined, { refetchInterval: 5000 })
|
const mdxEditorProps = useMdxEditorFieldProps()
|
||||||
const projects = trpc.projectv2.listWithStack.useQuery()
|
|
||||||
const autocompleteSuggestions = [
|
|
||||||
...linkSuggestionsToAutocomplete(internalLinkSuggestions({ posts: posts.data, projects: projects.data })),
|
|
||||||
...blogAutocompleteSuggestions,
|
|
||||||
]
|
|
||||||
|
|
||||||
const createMutation = trpc.blog.insert.useMutation({
|
const createMutation = trpc.blog.insert.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -226,9 +104,7 @@ export default function CreateUpdateBlogForm(params: { className?: string, entit
|
|||||||
name='content'
|
name='content'
|
||||||
label='Content'
|
label='Content'
|
||||||
dataColorMode={(theme as 'dark' | 'light') ?? 'dark'}
|
dataColorMode={(theme as 'dark' | 'light') ?? 'dark'}
|
||||||
autocompleteSuggestions={autocompleteSuggestions}
|
{...mdxEditorProps}
|
||||||
triggerConfigs={blogTriggerConfigs}
|
|
||||||
renderPreview={(source) => <BlogMdxEditorPreview source={source} />}
|
|
||||||
/>
|
/>
|
||||||
</FormScaffold>
|
</FormScaffold>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
|
|
||||||
|
|
||||||
const examples = [
|
|
||||||
{
|
|
||||||
name: "Lead",
|
|
||||||
description: "Intro paragraph with larger muted text.",
|
|
||||||
code: `<Lead>
|
|
||||||
Short opening summary for the post.
|
|
||||||
</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: "PullQuote",
|
|
||||||
description: "Large emphasized quote or takeaway.",
|
|
||||||
code: `<PullQuote>
|
|
||||||
A highlighted quote or strong takeaway.
|
|
||||||
</PullQuote>`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "TagList",
|
|
||||||
description: "Inline list of tag badges inside the post body.",
|
|
||||||
code: `<TagList tags={["nextjs", "mdx", "uploadthing"]} />`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Badge",
|
|
||||||
description: "Small inline label.",
|
|
||||||
code: `<Badge variant="outline">Next.js</Badge>`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function MdxComponentReference() {
|
|
||||||
return (
|
|
||||||
<section className="rounded-lg border p-4">
|
|
||||||
<h2 className="text-base font-semibold">MDX Components</h2>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
|
||||||
Components available inside blog post content. Type <code className="rounded bg-muted px-1">[[</code> for internal links or <code className="rounded bg-muted px-1"><</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>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
48
src/app/admin/chat/_components/ModelSelector.tsx
Normal file
48
src/app/admin/chat/_components/ModelSelector.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,22 +1,31 @@
|
|||||||
import { isAdmin } from '~/app/actions'
|
|
||||||
import { redirect } from 'next/navigation'
|
|
||||||
import { servTrpc } from '~/app/_trpc/ServerClient'
|
import { servTrpc } from '~/app/_trpc/ServerClient'
|
||||||
import SystemPromptForm from './_components/SystemPromptForm'
|
import SystemPromptForm from './_components/SystemPromptForm'
|
||||||
|
import ModelSelector from './_components/ModelSelector'
|
||||||
|
|
||||||
export default async function SystemPromptPage() {
|
export default async function SystemPromptPage() {
|
||||||
if (!(await isAdmin())) redirect('/admin')
|
|
||||||
|
|
||||||
const prompt = await servTrpc.chat.getSystemPrompt()
|
const prompt = await servTrpc.chat.getSystemPrompt()
|
||||||
|
const model = await servTrpc.chat.getModel()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-2xl p-6 flex flex-col gap-4">
|
<div className="w-full max-w-2xl p-6 flex flex-col gap-8">
|
||||||
<div>
|
<div className="flex flex-col gap-4">
|
||||||
<h1 className="text-lg font-semibold">AI System Prompt</h1>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<h1 className="text-lg font-semibold">AI Model</h1>
|
||||||
This prompt is sent to the model on every chat request.
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
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>
|
||||||
<SystemPromptForm initialValue={prompt} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { isAdmin } from "~/app/actions";
|
||||||
import { SidebarProvider } from "~/components/ui/sidebar";
|
import { SidebarProvider } from "~/components/ui/sidebar";
|
||||||
import AdminSideBar from "./_components/AdminSideBar";
|
import AdminSideBar from "./_components/AdminSideBar";
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
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>
|
<SidebarProvider>
|
||||||
|
|||||||
100
src/app/admin/music/_components/ConvertToStreamButton.tsx
Normal file
100
src/app/admin/music/_components/ConvertToStreamButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,10 +15,11 @@ 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>
|
||||||
))}
|
))}
|
||||||
</>}
|
</>}
|
||||||
<CollapsibleForm entityName="Track" form={UploadMusicForm}/>
|
<CollapsibleForm entityName="Track" form={UploadMusicForm}/>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { auth } from '@clerk/nextjs/server'
|
import { auth } from '@clerk/nextjs/server'
|
||||||
import { createOpenAI } from '@ai-sdk/openai'
|
import { createOpenAI } from '@ai-sdk/openai'
|
||||||
import { streamText, tool, convertToModelMessages, stepCountIs, type UIMessage } from 'ai'
|
import { streamText, convertToModelMessages, stepCountIs, type UIMessage } from 'ai'
|
||||||
import { success, z } from 'zod'
|
|
||||||
import { eq, and } from 'drizzle-orm'
|
import { eq, and } from 'drizzle-orm'
|
||||||
import { env } from '~/env'
|
import { env } from '~/env'
|
||||||
import { db } from '~/server/db'
|
import { db } from '~/server/db'
|
||||||
import { chatSession, chatMessage } from '~/server/dbschema/schema'
|
import { chatSession, chatMessage } from '~/server/dbschema/schema'
|
||||||
import { servTrpc } from '~/app/_trpc/ServerClient'
|
import { servTrpc } from '~/app/_trpc/ServerClient'
|
||||||
import { scheduleMeeting } from '~/app/actions/scheduleMeeting'
|
import { createChatTools } from '~/server/ai/tools'
|
||||||
import currentTime from '~/app/actions/currentTime';
|
|
||||||
|
|
||||||
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
|
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
|
||||||
|
|
||||||
@@ -31,7 +29,18 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
if (!session) return new Response('Session not found', { status: 404 })
|
if (!session) return new Response('Session not found', { status: 404 })
|
||||||
|
|
||||||
const systemPrompt = await servTrpc.chat.getSystemPrompt() || 'You are an AI recruiter assistant.'
|
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
|
// Save the latest user message
|
||||||
const lastMessage = messages[messages.length - 1]
|
const lastMessage = messages[messages.length - 1]
|
||||||
@@ -46,45 +55,17 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: openai('gpt-5-mini'),
|
model: openai(model),
|
||||||
system: systemPrompt,
|
system: systemPrompt,
|
||||||
messages: await convertToModelMessages(messages),
|
messages: await convertToModelMessages(messages),
|
||||||
tools: {
|
tools: createChatTools(),
|
||||||
scheduleMeeting: tool({
|
stopWhen: stepCountIs(3),
|
||||||
description: 'Schedule a meeting with Gregor Lohaus and add it to his Google Calendar',
|
|
||||||
inputSchema: z.object({
|
|
||||||
title: z.string().describe('Meeting title, make something up if not provided'),
|
|
||||||
description: z.string().describe('Meeting description / agenda, make something up if not provided'),
|
|
||||||
dateTime: z
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
'ISO 8601 datetime for the meeting start, e.g. 2025-04-01T10:00:00',
|
|
||||||
),
|
|
||||||
durationMinutes: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(15)
|
|
||||||
.max(120)
|
|
||||||
.describe('Duration of the meeting in minutes, if none provided ask if 20 minutes is ok'),
|
|
||||||
attendeeEmail: z
|
|
||||||
.string()
|
|
||||||
.email()
|
|
||||||
.optional()
|
|
||||||
.describe('Optional Email of the visitor to invite (if provided)'),
|
|
||||||
attendeeName: z.string().optional().describe('Name of the visitor'),
|
|
||||||
}),
|
|
||||||
execute: async (input) => scheduleMeeting({ ...input }),
|
|
||||||
}),
|
|
||||||
getCurrentUnixTime: tool({
|
|
||||||
description: 'Get the current unix time to reference for meeting dates',
|
|
||||||
inputSchema: z.object({
|
|
||||||
none: z.string().optional().describe("no inputs are needed")
|
|
||||||
}),
|
|
||||||
execute: async () => currentTime()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
stopWhen: stepCountIs(5),
|
|
||||||
onFinish: async ({ text, finishReason }) => {
|
onFinish: async ({ text, finishReason }) => {
|
||||||
|
console.log('[ai:chat:onFinish]', {
|
||||||
|
finishReason,
|
||||||
|
hasText: Boolean(text),
|
||||||
|
textLength: text.length,
|
||||||
|
})
|
||||||
if (text && finishReason === 'stop') {
|
if (text && finishReason === 'stop') {
|
||||||
await db.insert(chatMessage).values({
|
await db.insert(chatMessage).values({
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { MDXRemote } from "next-mdx-remote/rsc";
|
import { MDXRemote } from "next-mdx-remote/rsc";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import matter from "gray-matter";
|
||||||
import { servTrpc } from "~/app/_trpc/ServerClient";
|
import { servTrpc } from "~/app/_trpc/ServerClient";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { mdxComponents } from "../_components/mdx-components";
|
import { mdxComponents } from "~/components/mdx-components";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
@@ -12,37 +13,47 @@ type Props = {
|
|||||||
export default async function BlogPostPage({ params }: Props) {
|
export default async function BlogPostPage({ params }: Props) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
let post: Awaited<ReturnType<typeof servTrpc.blog.bySlug>>;
|
let post: Awaited<ReturnType<typeof servTrpc.blog.metadataBySlug>>;
|
||||||
try {
|
try {
|
||||||
post = await servTrpc.blog.bySlug(slug);
|
post = await servTrpc.blog.metadataBySlug(slug);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof TRPCError && e.code === "NOT_FOUND") notFound();
|
if (e instanceof TRPCError && e.code === "NOT_FOUND") notFound();
|
||||||
throw e;
|
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 (
|
return (
|
||||||
<main className="mx-auto max-w-2xl px-4 py-12">
|
<main className="mx-auto max-w-2xl px-4 py-12">
|
||||||
<header className="mb-8">
|
<header className="mb-8">
|
||||||
<h1 className="text-3xl font-bold">{post.title}</h1>
|
<h1 className="text-3xl font-bold">{title}</h1>
|
||||||
{post.date && (
|
{date && (
|
||||||
<time className="text-muted-foreground text-sm">
|
<time className="text-muted-foreground text-sm">
|
||||||
{new Date(post.date).toLocaleDateString("en-US", {
|
{new Date(date).toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
})}
|
})}
|
||||||
</time>
|
</time>
|
||||||
)}
|
)}
|
||||||
{post.tags.length > 0 && (
|
{tags.length > 0 && (
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
{post.tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<Badge key={tag} variant="outline">{tag}</Badge>
|
<Badge key={tag} variant="outline">{tag}</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<article className="prose dark:prose-invert max-w-none">
|
<article className="prose dark:prose-invert max-w-none">
|
||||||
<MDXRemote source={post.content} components={mdxComponents} />
|
<MDXRemote source={parsed.content} components={mdxComponents} />
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
import Markdown from "react-markdown";
|
import { ClientMdx } from "~/components/ClientMdx";
|
||||||
import { cn } from "~/lib/utils";
|
|
||||||
|
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 }) => {
|
export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||||
let message = props.message;
|
let message = props.message;
|
||||||
@@ -11,57 +29,90 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className=
|
className=
|
||||||
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-muted'
|
'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) => {
|
{message.parts.map((part, i) => {
|
||||||
if (part.type === 'text') {
|
if (part.type === 'text') {
|
||||||
return (
|
return (
|
||||||
<Markdown>
|
<ClientMdx key={i} source={part.text} fallback={part.text} />
|
||||||
{part.text}
|
|
||||||
</Markdown>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (part.type === 'tool-scheduleMeeting') {
|
if (part.type === 'tool-scheduleMeeting') {
|
||||||
const toolPart = part as unknown as {
|
const toolPart = part as unknown as {
|
||||||
type: 'tool-scheduleMeeting'
|
type: 'tool-scheduleMeeting'
|
||||||
state: string
|
state: string
|
||||||
input: unknown
|
input: unknown
|
||||||
output?: { success: boolean; message?: string; htmlLink?: string; error?: string }
|
output?: { success: boolean; error?: string }
|
||||||
}
|
}
|
||||||
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
|
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
|
||||||
return (
|
return (
|
||||||
<p key={i} className="text-xs opacity-70 italic">
|
<p key={i} className="text-xs opacity-70 italic">
|
||||||
Scheduling meeting…
|
Scheduling meeting…
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (toolPart.state === 'output-available' && toolPart.output) {
|
if (toolPart.state === 'output-available' && toolPart.output) {
|
||||||
const result = toolPart.output
|
const result = toolPart.output
|
||||||
return (
|
if (result.success) return null
|
||||||
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
|
return (
|
||||||
{result.success ? (
|
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
|
||||||
<span>
|
<span>✗ {result.error}</span>
|
||||||
✓ {result.message}{' '}
|
|
||||||
{result.htmlLink && (
|
|
||||||
<a
|
|
||||||
href={result.htmlLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
View event
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>✗ {result.error}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useChat } from '@ai-sdk/react'
|
import { useChat } from '@ai-sdk/react'
|
||||||
import { DefaultChatTransport, type UIMessage } from 'ai'
|
import { DefaultChatTransport, type UIMessage } from 'ai'
|
||||||
import { Button } from '~/components/ui/button'
|
import { Button } from '~/components/ui/button'
|
||||||
@@ -54,7 +54,7 @@ function addInitMessage(messageArray: UIMessage[]) {
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
parts: [{
|
parts: [{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: "Hi im gregors ai assistant,you can ask me to provide general information or to schedule a meeting."
|
text: "Hi, I'm Gregor's AI assistant. Ask me about his experience, projects, blog posts, or availability for a meeting."
|
||||||
}],
|
}],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -83,14 +83,21 @@ function AuthenticatedChatInterface({ dbMessages, sessionId }: ChatInterfaceProp
|
|||||||
sendMessage({ text })
|
sendMessage({ text })
|
||||||
}
|
}
|
||||||
const gsapContext = useGsapContext()
|
const gsapContext = useGsapContext()
|
||||||
|
const didInitialScroll = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let scroller = gsapContext?.getScroller()
|
const scroller = gsapContext?.getScroller()
|
||||||
if (scroller instanceof Window) {
|
if (!scroller || scroller instanceof Window) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
console.log(scroller?.scrollHeight)
|
// Jump instantly on first open so the chat starts pinned to the bottom;
|
||||||
scroller?.scrollTo({ behavior: 'smooth', top: scroller.scrollHeight })
|
// animate subsequent updates. Defer a frame so the messages have laid out
|
||||||
}, [messages])
|
// (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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{messages &&
|
{messages &&
|
||||||
@@ -114,7 +121,7 @@ function AuthenticatedChatInterface({ dbMessages, sessionId }: ChatInterfaceProp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-4 border-t flex flex-row gap-2">
|
<div className="p-4 border-t flex flex-row gap-2 shrink-0">
|
||||||
<Textarea
|
<Textarea
|
||||||
name='message'
|
name='message'
|
||||||
value={input}
|
value={input}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ScrollArea } from '~/components/ui/scroll-area';
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
const Messages = memo(({messages,status}: { messages: UIMessage[],status:ChatStatus}) => {
|
const Messages = memo(({messages,status}: { messages: UIMessage[],status:ChatStatus}) => {
|
||||||
return (
|
return (
|
||||||
<ScrollArea data-scroller-priority='1' className="w-full h-[90%] max-w-4xl mx-auto">
|
<ScrollArea data-scroller-priority='1' className="w-full flex-1 min-h-0 max-w-4xl mx-auto">
|
||||||
{messages.map((message, i) => (
|
{messages.map((message, i) => (
|
||||||
<Card.AnimatedCard scrollOnly={true} key={i}>
|
<Card.AnimatedCard scrollOnly={true} key={i}>
|
||||||
<Card.CardContent>
|
<Card.CardContent>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const UserMessage = (props:{message: UIMessage}) => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className=
|
className=
|
||||||
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-primary'
|
'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}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ export default function ChatPage() {
|
|||||||
const {messages,session,isLoading,error} = useMessages()
|
const {messages,session,isLoading,error} = useMessages()
|
||||||
useTimeLine(messages)
|
useTimeLine(messages)
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
|
<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}>
|
<AnimatedPageTitle position={0}>
|
||||||
<span>Talk To My </span> <span> AI-Assistant</span>
|
<span>Talk To My </span> <span> AI-Assistant</span>
|
||||||
</AnimatedPageTitle>
|
</AnimatedPageTitle>
|
||||||
<div className='flex items-center h-[80%] w-full my-auto w-full'>
|
<div className='flex flex-1 min-h-0 w-full'>
|
||||||
{!isLoading &&
|
{!isLoading &&
|
||||||
<ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
|
<ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,43 @@
|
|||||||
'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 AnimatePopUp from "~/app/_components/Animated/AnimatePopUp"
|
||||||
|
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
|
||||||
return (
|
return (
|
||||||
<Card className={cn(props.layout == "row" ? "w-full" : "","gsapan")}>
|
<AnimatedCard position={position} className={cn(layout == "row" ? "w-full" : "", "h-screen")}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<AnimateTextIn once position={position + 0.2} animation="slide" debugId={`cv-category-title:${category.name}:${position + 0.2}`}>
|
||||||
{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(layout == "row" ? "flex flex-row flex-wrap justify-center lg:justify-between" : "flex flex-col", "gap-4", "overflow-scroll")}>
|
||||||
{category.data?.cvEntry.map((entry) => (
|
<ScrollArea>
|
||||||
<CvEntry className={props.layout == "row" ? "w-full lg:w-fit" : undefined} key={entry.id} initialData={entry}/>
|
{entries.map((entry, i) => (
|
||||||
))}
|
<AnimatePopUp position={position + 0.4 + i * 0.2} debugId={`cv-entry-wrapper:${category.name}:${entry.title}:${position + 0.4 + i * 0.2}`} key={entry.id}>
|
||||||
</CardContent>
|
<CvEntry position={position + 0.4 + i * 0.2} entry={entry} description={descriptions[entry.id]} className={layout == "row" ? "w-full lg:w-fit" : undefined} />
|
||||||
:
|
</AnimatePopUp>
|
||||||
<></>
|
))}
|
||||||
}
|
</ScrollArea>
|
||||||
</Card>
|
</CardContent>
|
||||||
)
|
:
|
||||||
|
<></>
|
||||||
|
}
|
||||||
|
</AnimatedCard>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,52 @@
|
|||||||
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 }: {
|
||||||
|
entry: CvEntryData,
|
||||||
|
description?: ReactNode,
|
||||||
|
className?: string,
|
||||||
|
position?: number
|
||||||
}) {
|
}) {
|
||||||
const query = trpc.entryv2.getById.useQuery(params.initialData.id);
|
|
||||||
const { data, isError, error } = query
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Card className={className ? cn("w-fit", className) : "w-fit"}>
|
||||||
{
|
{entry.title ?
|
||||||
data ?
|
<CardHeader>
|
||||||
<>
|
<AnimateTextIn position={position} animation="slide" debugId={`cv-entry-title:${entry.title}:${position}`}>
|
||||||
<Card className={params.className ? cn("w-fit", params.className) : "w-fit"}>
|
<CardTitle> {entry.title} </CardTitle>
|
||||||
{
|
</AnimateTextIn>
|
||||||
data.title ?
|
</CardHeader> :
|
||||||
<CardHeader>
|
<></>
|
||||||
<CardTitle> {data.title} </CardTitle>
|
|
||||||
</CardHeader> :
|
|
||||||
<></>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
data.description ?
|
|
||||||
<CardContent className="text-sm lg:text-base">
|
|
||||||
<div>
|
|
||||||
<Markdown rehypePlugins={[rehypeHighlight, rehypeRaw]}>{data.description}</Markdown>
|
|
||||||
</div>
|
|
||||||
</CardContent> :
|
|
||||||
<></>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
!data.hideDates ?
|
|
||||||
<CardFooter className="text-sm">
|
|
||||||
{`von ${format((new Date()).setTime(Date.parse(data.fromTime)), 'M. yyyy')} bis zum ${format((new Date()).setTime(Date.parse(data.toTime)), 'M. yyyy')}`}
|
|
||||||
</CardFooter> :
|
|
||||||
<></>
|
|
||||||
}
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
</>
|
{entry.description ?
|
||||||
|
<CardContent className="text-sm lg:text-base">
|
||||||
|
{/* Fade the description in place instead of collapsing its height:
|
||||||
|
the outer entry pop-up (CvCategory) measures height:auto when it
|
||||||
|
plays, so the description must stay laid out at full height or the
|
||||||
|
entry reveals too short. */}
|
||||||
|
<AnimatedDiv once position={position + 0.2} className="opacity-0" opacity={1} duration={0.5} debugId={`cv-entry-description:${entry.title}:${position + 0.2}`}>
|
||||||
|
<article className="prose prose-zinc dark:prose-invert max-w-none">
|
||||||
|
{description ?? entry.description}
|
||||||
|
</article>
|
||||||
|
</AnimatedDiv>
|
||||||
|
</CardContent> :
|
||||||
|
<></>
|
||||||
|
}
|
||||||
|
{!entry.hideDates ?
|
||||||
|
<CardFooter className="text-sm">
|
||||||
|
<AnimateTextIn position={position + 0.4} debugId={`cv-entry-dates:${entry.title}:${position + 0.4}`}>
|
||||||
|
{`von ${format(new Date(entry.fromTime), 'M. yyyy')} bis zum ${format(new Date(entry.toTime), 'M. yyyy')}`}
|
||||||
|
</AnimateTextIn>
|
||||||
|
</CardFooter> :
|
||||||
|
<></>
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/app/cv/_components/Page.tsx
Normal file
59
src/app/cv/_components/Page.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
'use client'
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Sidebar, SidebarContent, SidebarProvider } from "~/components/ui/sidebar";
|
||||||
|
import type { RouterOutputs } from "~/server/routers/_app"
|
||||||
|
import SidebarTriggerDisappearsOnMobile from "./SidebarTriggerDisappearsOnMobile";
|
||||||
|
import CvCategory from "./CvCategory";
|
||||||
|
import { useTimeLine } from "~/app/_providers/GsapProvicer";
|
||||||
|
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")
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SidebarProvider>
|
||||||
|
{sidebarCategories.length > 0 &&
|
||||||
|
<>
|
||||||
|
<SidebarTriggerDisappearsOnMobile />
|
||||||
|
<Sidebar>
|
||||||
|
<SidebarContent className="p-2 lg:pt-[3.2rem]">
|
||||||
|
{sidebarCategories.map((cat, i) => (
|
||||||
|
<CvCategory layout="col" position={i} category={cat} descriptions={descriptions} 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.map((cat, i) => (
|
||||||
|
<CvCategory layout="row" position={i} category={cat} descriptions={descriptions} 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.length > 0 ? "lg:w-1/2" : ""} h-full gap-4`}>
|
||||||
|
{col1Categories.map((cat, i) => (
|
||||||
|
<CvCategory layout="col" position={i} category={cat} descriptions={descriptions} key={cat.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div id="col2" className={`flex flex-col w-full ${col2Categories.length > 0 ? "lg:w-1/2" : ""} h-full gap-4`}>
|
||||||
|
{col2Categories.map((cat, i) => (
|
||||||
|
<CvCategory layout="col" position={i} category={cat} descriptions={descriptions} key={cat.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarProvider>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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: "md",
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import TrpcProvider from "./_trpc/TrpcProvider";
|
|||||||
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 {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' });
|
||||||
|
|
||||||
|
|
||||||
@@ -37,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>
|
||||||
@@ -47,14 +50,16 @@ export default async function RootLayout({
|
|||||||
<body className="flex flex-col bg-background text-foreground">
|
<body className="flex flex-col bg-background text-foreground">
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<MessagesProvider>
|
<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 lg:h-[calc(100vh-var(--spacing)*10)] 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 />
|
<ChatFAB />
|
||||||
|
</MusicPlayerProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
@@ -62,5 +67,6 @@ export default async function RootLayout({
|
|||||||
</GsapProvider>
|
</GsapProvider>
|
||||||
</TrpcProvider>
|
</TrpcProvider>
|
||||||
</ClerkProvider>
|
</ClerkProvider>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
190
src/app/music/_components/AudioPlayer.tsx
Normal file
190
src/app/music/_components/AudioPlayer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/app/music/_components/MusicMiniPlayer.tsx
Normal file
73
src/app/music/_components/MusicMiniPlayer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
314
src/app/music/_components/MusicPlayerProvider.tsx
Normal file
314
src/app/music/_components/MusicPlayerProvider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/app/music/_components/Page.tsx
Normal file
68
src/app/music/_components/Page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/app/music/_components/PlayerControls.tsx
Normal file
40
src/app/music/_components/PlayerControls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
return (
|
||||||
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
|
<Suspense>
|
||||||
export default function MusicPage() {
|
<Page tracks={tracks} />
|
||||||
const { data: tracks, isLoading } = trpc.music.list.useQuery();
|
</Suspense>
|
||||||
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 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
111
src/app/projects/_components/Page.tsx
Normal file
111
src/app/projects/_components/Page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,116 +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";
|
|
||||||
import remarkGfm from "remark-gfm"
|
|
||||||
|
|
||||||
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: "md",
|
||||||
</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 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 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={10}>
|
|
||||||
<Markdown remarkPlugins={[remarkGfm]}>{project.description}</Markdown>
|
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/components/ClientMdx.tsx
Normal file
79
src/components/ClientMdx.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Children, isValidElement, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
import {
|
||||||
|
Children,
|
||||||
|
isValidElement,
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
@@ -95,12 +100,19 @@ function PullQuote({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ExternalLink(props: ComponentPropsWithoutRef<"a">) {
|
function ExternalLink(props: ComponentPropsWithoutRef<"a">) {
|
||||||
const href = props.href ?? "";
|
const { className, ...rest } = props;
|
||||||
const isExternal = /^https?:\/\//.test(href);
|
|
||||||
|
|
||||||
if (!isExternal) return <a {...props} />;
|
return (
|
||||||
|
<a
|
||||||
return <a {...props} target="_blank" rel="noreferrer" />;
|
{...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, Figure, PullQuote, TagList]);
|
const blockComponents = new Set<unknown>([Callout, Figure, PullQuote, TagList]);
|
||||||
@@ -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({
|
||||||
@@ -27,47 +28,26 @@ function AnimatedCard({
|
|||||||
position = 0,
|
position = 0,
|
||||||
size = "default",
|
size = "default",
|
||||||
scrollOnly = false,
|
scrollOnly = false,
|
||||||
|
once = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm", position?: gsap.Position, scrollOnly?: boolean }) {
|
}: 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)
|
||||||
useGSAP(() => {
|
useReveal(ref, {
|
||||||
const rect = ref.current?.getBoundingClientRect()
|
position,
|
||||||
const scroller = gsapContext?.getScroller()
|
scrollOnly,
|
||||||
console.log(scroller)
|
once,
|
||||||
let viewportTop = 0
|
debugId: `card-${position}`,
|
||||||
let viewportBottom = window.innerHeight
|
// Start hidden via CSS (see className) so the server-rendered card never
|
||||||
if (scroller && scroller instanceof Element) {
|
// flashes visible before GSAP runs; reveal animates *to* the shown state.
|
||||||
const scrollerRect = scroller.getBoundingClientRect()
|
makeReveal: (el) => gsap.to(el, { x: 0, opacity: 1, duration: 0.5, paused: true }),
|
||||||
viewportTop = scrollerRect.top
|
})
|
||||||
viewportBottom = scrollerRect.top + scrollerRect.height
|
|
||||||
}
|
|
||||||
const isInView = rect && rect.bottom > viewportTop && rect.top < viewportBottom
|
|
||||||
console.log(isInView)
|
|
||||||
const fromVars = { x: -100, opacity: 0, duration: 0.5 }
|
|
||||||
if (isInView && !scrollOnly) {
|
|
||||||
gsapContext?.addAnimation(gsap.from(ref.current, fromVars), position)
|
|
||||||
} else {
|
|
||||||
gsap.from(ref.current,
|
|
||||||
{
|
|
||||||
...fromVars,
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: ref.current,
|
|
||||||
start: 'top bottom',
|
|
||||||
end: 'bottom top',
|
|
||||||
toggleActions: "play reverse play reverse",
|
|
||||||
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}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ 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(),
|
||||||
OPENAI_API_KEY: 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"),
|
||||||
@@ -72,6 +76,10 @@ export const env = createEnv({
|
|||||||
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,
|
||||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
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
114
src/lib/ffmpeg/transcode.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { generateUploadButton, generateUploadDropzone } from "@uploadthing/react";
|
import { generateReactHelpers, generateUploadButton, generateUploadDropzone } from "@uploadthing/react";
|
||||||
import type { FileRouter } from "~/server/uploadthing";
|
import type { FileRouter } from "~/server/uploadthing";
|
||||||
|
|
||||||
export const UploadButton = generateUploadButton<FileRouter>();
|
export const UploadButton = generateUploadButton<FileRouter>();
|
||||||
export const UploadDropzone = generateUploadDropzone<FileRouter>();
|
export const UploadDropzone = generateUploadDropzone<FileRouter>();
|
||||||
|
|
||||||
|
export const { useUploadThing } = generateReactHelpers<FileRouter>();
|
||||||
|
|||||||
17
src/proxy.ts
17
src/proxy.ts
@@ -1,13 +1,16 @@
|
|||||||
import { clerkMiddleware, createRouteMatcher, currentUser } from "@clerk/nextjs/server";
|
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
|
|
||||||
const isTenantAdminRoute = createRouteMatcher(['/admin(.*)'])
|
const isTenantAdminRoute = createRouteMatcher(["/admin(.*)"]);
|
||||||
export default clerkMiddleware(async (auth,req) => {
|
|
||||||
|
export default clerkMiddleware(async (auth, req) => {
|
||||||
if (isTenantAdminRoute(req)) {
|
if (isTenantAdminRoute(req)) {
|
||||||
console.log("running clerk middleware");
|
await auth.protect();
|
||||||
let userid = (await auth()).userId
|
|
||||||
if (userid != env.ADMIN_USER_CLERK_ID) {
|
const { userId } = await auth();
|
||||||
await auth.protect()
|
if (userId !== env.ADMIN_USER_CLERK_ID) {
|
||||||
|
return NextResponse.redirect(new URL("/", req.url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
586
src/server/ai/tools.ts
Normal file
586
src/server/ai/tools.ts
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { tool } from "ai";
|
||||||
|
import { desc } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { cancelMeeting } from "~/app/actions/cancelMeeting";
|
||||||
|
import { scheduleMeeting } from "~/app/actions/scheduleMeeting";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { blogPost, music } from "~/server/dbschema/schema";
|
||||||
|
import { getGoogleCalendarClient, getGoogleCalendarId } from "~/server/googleCalendar";
|
||||||
|
|
||||||
|
const contentTypeSchema = z.enum(["cv", "project", "blog", "music"]);
|
||||||
|
|
||||||
|
type ContentType = z.infer<typeof contentTypeSchema>;
|
||||||
|
|
||||||
|
type SearchResult = {
|
||||||
|
type: ContentType;
|
||||||
|
title: string;
|
||||||
|
snippet: string;
|
||||||
|
url: string;
|
||||||
|
score: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectWithStack = Awaited<ReturnType<typeof loadProjects>>[number];
|
||||||
|
|
||||||
|
function stripMarkup(value: string | null | undefined) {
|
||||||
|
return (value ?? "")
|
||||||
|
.replace(/```[\s\S]*?```/g, " ")
|
||||||
|
.replace(/`([^`]+)`/g, "$1")
|
||||||
|
.replace(/<[^>]+>/g, " ")
|
||||||
|
.replace(/[#*_~[\]()>-]/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(value: string) {
|
||||||
|
return Array.from(new Set(value.toLowerCase().match(/[a-z0-9+#.-]+/g) ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreText(query: string, title: string, body: string, extraTerms: string[] = []) {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const titleLower = title.toLowerCase();
|
||||||
|
const bodyLower = body.toLowerCase();
|
||||||
|
const tokens = tokenize(query);
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (normalizedQuery && titleLower.includes(normalizedQuery)) score += 40;
|
||||||
|
if (normalizedQuery && bodyLower.includes(normalizedQuery)) score += 20;
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (titleLower.includes(token)) score += 12;
|
||||||
|
if (bodyLower.includes(token)) score += 6;
|
||||||
|
if (extraTerms.some((term) => term.toLowerCase() === token)) score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectCatalogTerms = new Set(["project", "projects", "portfolio", "work"]);
|
||||||
|
const genericQuestionTerms = new Set([
|
||||||
|
"a",
|
||||||
|
"about",
|
||||||
|
"all",
|
||||||
|
"any",
|
||||||
|
"are",
|
||||||
|
"can",
|
||||||
|
"current",
|
||||||
|
"do",
|
||||||
|
"give",
|
||||||
|
"have",
|
||||||
|
"list",
|
||||||
|
"me",
|
||||||
|
"of",
|
||||||
|
"on",
|
||||||
|
"show",
|
||||||
|
"site",
|
||||||
|
"tell",
|
||||||
|
"the",
|
||||||
|
"there",
|
||||||
|
"these",
|
||||||
|
"this",
|
||||||
|
"what",
|
||||||
|
"which",
|
||||||
|
"you",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isProjectCatalogQuery(query: string) {
|
||||||
|
const tokens = tokenize(query);
|
||||||
|
if (!tokens.some((token) => projectCatalogTerms.has(token))) return false;
|
||||||
|
return tokens.every((token) => projectCatalogTerms.has(token) || genericQuestionTerms.has(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
function snippet(value: string, query: string, maxLength = 240) {
|
||||||
|
const text = stripMarkup(value);
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
|
||||||
|
const tokens = tokenize(query);
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
const firstMatch = tokens
|
||||||
|
.map((token) => lower.indexOf(token))
|
||||||
|
.filter((index) => index >= 0)
|
||||||
|
.sort((a, b) => a - b)[0] ?? 0;
|
||||||
|
const start = Math.max(0, firstMatch - 60);
|
||||||
|
const excerpt = text.slice(start, start + maxLength).trim();
|
||||||
|
|
||||||
|
return `${start > 0 ? "..." : ""}${excerpt}${start + maxLength < text.length ? "..." : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueByUrl(results: SearchResult[]) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return results.filter((result) => {
|
||||||
|
const key = `${result.type}:${result.url}:${result.title}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCvEntries() {
|
||||||
|
const categories = await db.query.cvCategory.findMany({
|
||||||
|
orderBy: (fields, { asc }) => [asc(fields.layoutPosition), asc(fields.name)],
|
||||||
|
with: {
|
||||||
|
cvEntry: {
|
||||||
|
orderBy: (fields, { desc }) => [desc(fields.toTime), desc(fields.fromTime)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return categories.flatMap((category) =>
|
||||||
|
category.cvEntry.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
categoryName: category.name ?? "CV",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
return db.query.project.findMany({
|
||||||
|
orderBy: (fields, { asc }) => [asc(fields.orderPos), asc(fields.title), asc(fields.id)],
|
||||||
|
with: {
|
||||||
|
techStack: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSearchResults(query: string, types: ContentType[]) {
|
||||||
|
const selected = new Set(types);
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
|
||||||
|
if (selected.has("cv")) {
|
||||||
|
const entries = await loadCvEntries();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const body = stripMarkup(`${entry.categoryName} ${entry.description ?? ""}`);
|
||||||
|
const score = scoreText(query, entry.title, body);
|
||||||
|
if (score > 0 || !query.trim()) {
|
||||||
|
results.push({
|
||||||
|
type: "cv",
|
||||||
|
title: entry.title,
|
||||||
|
snippet: snippet(body, query),
|
||||||
|
url: "/cv",
|
||||||
|
score,
|
||||||
|
metadata: {
|
||||||
|
category: entry.categoryName,
|
||||||
|
fromTime: entry.fromTime,
|
||||||
|
toTime: entry.toTime,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.has("project")) {
|
||||||
|
const projects = await loadProjects();
|
||||||
|
const catalogQuery = isProjectCatalogQuery(query);
|
||||||
|
for (const [index, item] of projects.entries()) {
|
||||||
|
const stackItems = item.techStack?.stackItems ?? [];
|
||||||
|
const body = stripMarkup(`${item.description ?? ""} ${stackItems.join(" ")}`);
|
||||||
|
const score = catalogQuery ? 1000 - index : scoreText(query, item.title, body, stackItems);
|
||||||
|
if (score > 0 || !query.trim() || catalogQuery) {
|
||||||
|
results.push({
|
||||||
|
type: "project",
|
||||||
|
title: item.title,
|
||||||
|
snippet: snippet(body || item.title, query),
|
||||||
|
url: `/projects#${item.id}`,
|
||||||
|
score,
|
||||||
|
metadata: {
|
||||||
|
id: item.id,
|
||||||
|
stackItems,
|
||||||
|
sourceType: item.sourceType,
|
||||||
|
releaseStatus: item.releaseStatus,
|
||||||
|
sourceLink: item.sourceLink,
|
||||||
|
releaseLink: item.releaseLink,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.has("blog")) {
|
||||||
|
const posts = await db
|
||||||
|
.select({
|
||||||
|
slug: blogPost.slug,
|
||||||
|
title: blogPost.title,
|
||||||
|
date: blogPost.date,
|
||||||
|
description: blogPost.description,
|
||||||
|
tags: blogPost.tags,
|
||||||
|
})
|
||||||
|
.from(blogPost)
|
||||||
|
.orderBy(desc(blogPost.date), desc(blogPost.createdAt));
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
const tags = post.tags ?? [];
|
||||||
|
const body = stripMarkup(`${post.description ?? ""} ${tags.join(" ")}`);
|
||||||
|
const score = scoreText(query, post.title, body, tags);
|
||||||
|
if (score > 0 || !query.trim()) {
|
||||||
|
results.push({
|
||||||
|
type: "blog",
|
||||||
|
title: post.title,
|
||||||
|
snippet: snippet(body || post.title, query),
|
||||||
|
url: `/blog/${post.slug}`,
|
||||||
|
score,
|
||||||
|
metadata: {
|
||||||
|
slug: post.slug,
|
||||||
|
date: post.date,
|
||||||
|
tags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.has("music")) {
|
||||||
|
const tracks = await db.select().from(music).orderBy(desc(music.createdAt));
|
||||||
|
for (const track of tracks) {
|
||||||
|
const body = stripMarkup(track.description);
|
||||||
|
const score = scoreText(query, track.title, body);
|
||||||
|
if (score > 0 || !query.trim()) {
|
||||||
|
results.push({
|
||||||
|
type: "music",
|
||||||
|
title: track.title,
|
||||||
|
snippet: snippet(body || track.fileName, query),
|
||||||
|
url: "/music",
|
||||||
|
score,
|
||||||
|
metadata: {
|
||||||
|
id: track.id,
|
||||||
|
fileName: track.fileName,
|
||||||
|
hasStream: Boolean(track.streamUrl),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueByUrl(results).sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectMatches(projectItem: ProjectWithStack, idOrTitle: string) {
|
||||||
|
const normalized = idOrTitle.trim().toLowerCase();
|
||||||
|
const title = projectItem.title.toLowerCase();
|
||||||
|
return projectItem.id === idOrTitle || title === normalized || title.includes(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchedTerms(text: string, terms: string[]) {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
return terms.filter((term) => lower.includes(term.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(value: string | undefined, fallback: Date) {
|
||||||
|
if (!value?.trim()) return fallback;
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? fallback : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeTimeZone(value: string | undefined) {
|
||||||
|
const timeZone = value?.trim() || "Europe/Berlin";
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date());
|
||||||
|
return timeZone;
|
||||||
|
} catch {
|
||||||
|
return "Europe/Berlin";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyEmail(value: string) {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(
|
||||||
|
start: Date,
|
||||||
|
end: Date,
|
||||||
|
busy: Array<{ start: Date; end: Date }>,
|
||||||
|
) {
|
||||||
|
return busy.some((item) => start < item.end && end > item.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getZonedParts(date: Date, timeZone: string) {
|
||||||
|
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
weekday: "short",
|
||||||
|
hourCycle: "h23",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
const parts = Object.fromEntries(formatter.formatToParts(date).map((part) => [part.type, part.value]));
|
||||||
|
return {
|
||||||
|
weekday: parts.weekday ?? "",
|
||||||
|
hour: Number(parts.hour ?? 0),
|
||||||
|
minute: Number(parts.minute ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideWorkingHours(start: Date, end: Date, timeZone: string, workdayStartHour: number, workdayEndHour: number) {
|
||||||
|
const startParts = getZonedParts(start, timeZone);
|
||||||
|
const endParts = getZonedParts(end, timeZone);
|
||||||
|
const weekend = startParts.weekday === "Sat" || startParts.weekday === "Sun";
|
||||||
|
const startMinutes = startParts.hour * 60 + startParts.minute;
|
||||||
|
const endMinutes = endParts.hour * 60 + endParts.minute;
|
||||||
|
|
||||||
|
return !weekend && startMinutes >= workdayStartHour * 60 && endMinutes <= workdayEndHour * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function availabilitySlots({
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
busy,
|
||||||
|
durationMinutes,
|
||||||
|
timeZone,
|
||||||
|
workdayStartHour,
|
||||||
|
workdayEndHour,
|
||||||
|
}: {
|
||||||
|
rangeStart: Date;
|
||||||
|
rangeEnd: Date;
|
||||||
|
busy: Array<{ start: Date; end: Date }>;
|
||||||
|
durationMinutes: number;
|
||||||
|
timeZone: string;
|
||||||
|
workdayStartHour: number;
|
||||||
|
workdayEndHour: number;
|
||||||
|
}) {
|
||||||
|
const slots: Array<{ start: string; end: string }> = [];
|
||||||
|
const stepMinutes = 30;
|
||||||
|
const durationMs = durationMinutes * 60 * 1000;
|
||||||
|
const cursor = new Date(Math.ceil(rangeStart.getTime() / (stepMinutes * 60 * 1000)) * stepMinutes * 60 * 1000);
|
||||||
|
|
||||||
|
while (cursor.getTime() + durationMs <= rangeEnd.getTime() && slots.length < 10) {
|
||||||
|
const end = new Date(cursor.getTime() + durationMs);
|
||||||
|
if (
|
||||||
|
isInsideWorkingHours(cursor, end, timeZone, workdayStartHour, workdayEndHour)
|
||||||
|
&& !overlaps(cursor, end, busy)
|
||||||
|
) {
|
||||||
|
slots.push({ start: cursor.toISOString(), end: end.toISOString() });
|
||||||
|
}
|
||||||
|
cursor.setMinutes(cursor.getMinutes() + stepMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logAvailability(requestId: string, message: string, data?: Record<string, unknown>) {
|
||||||
|
console.log(`[ai:getAvailability:${requestId}] ${message}`, data ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChatTools() {
|
||||||
|
return {
|
||||||
|
scheduleMeeting: tool({
|
||||||
|
description: "Schedule a meeting with Gregor Lohaus and add it to his Google Calendar. Use getAvailability first when the user asks for a meeting at a flexible or uncertain time.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
title: z.string().describe("Meeting title, make something up if not provided"),
|
||||||
|
description: z.string().describe("Meeting description / agenda, make something up if not provided"),
|
||||||
|
dateTime: z
|
||||||
|
.string()
|
||||||
|
.describe("ISO 8601 datetime for the meeting start, e.g. 2026-06-18T10:00:00+02:00"),
|
||||||
|
durationMinutes: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(15)
|
||||||
|
.max(120)
|
||||||
|
.describe("Duration of the meeting in minutes, if none provided ask if 20 minutes is ok"),
|
||||||
|
attendeeEmail: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Optional email of the visitor to invite, if provided"),
|
||||||
|
attendeeName: z.string().optional().describe("Name of the visitor"),
|
||||||
|
}),
|
||||||
|
execute: async (input) => {
|
||||||
|
if (input.attendeeEmail && !isLikelyEmail(input.attendeeEmail)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "The attendee email does not look valid. Ask the visitor to provide a valid email address before scheduling.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduleMeeting({ ...input });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
cancelMeeting: tool({
|
||||||
|
description: "Remove a previously scheduled meeting from Gregor's dedicated availability calendar. Use only when you have the exact eventId returned by scheduleMeeting.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
eventId: z.string().min(1).describe("Google Calendar event id returned by a previous scheduleMeeting tool call."),
|
||||||
|
}),
|
||||||
|
execute: async ({ eventId }) => cancelMeeting({ eventId }),
|
||||||
|
}),
|
||||||
|
searchSiteContent: tool({
|
||||||
|
description: "Search Gregor Lohaus's own website content across CV entries, projects, blog posts, and music. Use this for questions about Gregor's work, skills, writing, projects, or site content. For broad questions about Gregor's projects, use types ['project'] so the tool returns the project catalog.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
query: z.string().describe("Search query, skill, technology, topic, or phrase."),
|
||||||
|
types: z.array(contentTypeSchema).optional().describe("Optional content types to search. Omit to search all site content."),
|
||||||
|
limit: z.number().int().min(1).max(12).optional().describe("Maximum number of results to return."),
|
||||||
|
}),
|
||||||
|
execute: async ({ query, types, limit }) => {
|
||||||
|
const results = await buildSearchResults(query, types?.length ? types : ["cv", "project", "blog", "music"]);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
query,
|
||||||
|
results: results.slice(0, limit ?? 8).map(({ score, ...result }) => result),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getRelevantExperience: tool({
|
||||||
|
description: "Find Gregor's most relevant CV entries and projects for a role, skill set, seniority, or domain. Use this for recruiter-style qualification questions.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
role: z.string().optional().describe("Role or job title, such as full-stack engineer or React Native developer."),
|
||||||
|
skills: z.array(z.string()).optional().describe("Technologies, tools, or skills to match."),
|
||||||
|
domain: z.string().optional().describe("Product or business domain to match, if any."),
|
||||||
|
seniority: z.string().optional().describe("Seniority or responsibility level to match, if any."),
|
||||||
|
limit: z.number().int().min(1).max(10).optional().describe("Maximum matching entries to return."),
|
||||||
|
}),
|
||||||
|
execute: async ({ role, skills, domain, seniority, limit }) => {
|
||||||
|
const terms = [role, domain, seniority, ...(skills ?? [])].filter((value): value is string => Boolean(value?.trim()));
|
||||||
|
const query = terms.join(" ");
|
||||||
|
const results = await buildSearchResults(query, ["cv", "project"]);
|
||||||
|
const selected = results.slice(0, limit ?? 6);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
query,
|
||||||
|
matches: selected.map(({ score, ...result }) => ({
|
||||||
|
...result,
|
||||||
|
matchedTerms: matchedTerms(`${result.title} ${result.snippet} ${(result.metadata?.stackItems as string[] | undefined)?.join(" ") ?? ""}`, terms),
|
||||||
|
whyRelevant: result.type === "project"
|
||||||
|
? "Project match based on title, description, and technology stack."
|
||||||
|
: "CV match based on experience title, category, and description.",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getProjectDetails: tool({
|
||||||
|
description: "Get detailed information for one of Gregor's projects, including description, stack, source link, release link, and project page URL.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
idOrTitle: z.string().min(1).describe("Project id, exact title, or partial project title."),
|
||||||
|
}),
|
||||||
|
execute: async ({ idOrTitle }) => {
|
||||||
|
const projects = await loadProjects();
|
||||||
|
const found = projects.find((item) => projectMatches(item, idOrTitle));
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `No project matched "${idOrTitle}".`,
|
||||||
|
suggestions: projects.slice(0, 5).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
url: `/projects#${item.id}`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
project: {
|
||||||
|
id: found.id,
|
||||||
|
title: found.title,
|
||||||
|
description: stripMarkup(found.description),
|
||||||
|
sourceType: found.sourceType,
|
||||||
|
sourceLink: found.sourceLink,
|
||||||
|
releaseStatus: found.releaseStatus,
|
||||||
|
releaseLink: found.releaseLink,
|
||||||
|
stackItems: found.techStack?.stackItems ?? [],
|
||||||
|
url: `/projects#${found.id}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getAvailability: tool({
|
||||||
|
description: "Check Gregor's Google Calendar availability and suggest open meeting slots. Use this directly for questions like 'when is the next open spot?' or 'what times are available?'. If no date range is provided, it checks from now. Use before scheduling when the requested time is flexible.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
fromDateTime: z.string().optional().describe("ISO 8601 range start. Defaults to now."),
|
||||||
|
toDateTime: z.string().optional().describe("ISO 8601 range end. Defaults to 14 days after the range start."),
|
||||||
|
durationMinutes: z.number().int().min(15).max(120).optional().describe("Desired meeting duration. Defaults to 30 minutes."),
|
||||||
|
timeZone: z.string().optional().describe("IANA time zone for working-hours filtering. Defaults to Europe/Berlin."),
|
||||||
|
workdayStartHour: z.number().int().min(0).max(23).optional().describe("Earliest local start hour. Defaults to 9."),
|
||||||
|
workdayEndHour: z.number().int().min(1).max(24).optional().describe("Latest local end hour. Defaults to 17."),
|
||||||
|
}),
|
||||||
|
execute: async (input) => {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
logAvailability(requestId, "start", {
|
||||||
|
input,
|
||||||
|
});
|
||||||
|
|
||||||
|
const durationMinutes = input.durationMinutes ?? 30;
|
||||||
|
const timeZone = safeTimeZone(input.timeZone);
|
||||||
|
const workdayStartHour = input.workdayStartHour ?? 9;
|
||||||
|
const workdayEndHour = Math.max(input.workdayEndHour ?? 17, workdayStartHour + 1);
|
||||||
|
const rangeStart = parseDate(input.fromDateTime, new Date());
|
||||||
|
const defaultEnd = new Date(rangeStart.getTime() + 14 * 24 * 60 * 60 * 1000);
|
||||||
|
const requestedEnd = parseDate(input.toDateTime, defaultEnd);
|
||||||
|
const maxEnd = new Date(rangeStart.getTime() + 31 * 24 * 60 * 60 * 1000);
|
||||||
|
const rangeEnd = requestedEnd <= rangeStart ? defaultEnd : requestedEnd > maxEnd ? maxEnd : requestedEnd;
|
||||||
|
logAvailability(requestId, "resolved range", {
|
||||||
|
durationMinutes,
|
||||||
|
timeZone,
|
||||||
|
workdayStartHour,
|
||||||
|
workdayEndHour,
|
||||||
|
rangeStart: rangeStart.toISOString(),
|
||||||
|
rangeEnd: rangeEnd.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const calendar = getGoogleCalendarClient();
|
||||||
|
const calendarId = getGoogleCalendarId();
|
||||||
|
logAvailability(requestId, "service account calendar ready", {
|
||||||
|
calendarId,
|
||||||
|
});
|
||||||
|
|
||||||
|
let busy: Array<{ start: Date; end: Date }>;
|
||||||
|
try {
|
||||||
|
logAvailability(requestId, "freebusy request");
|
||||||
|
const response = await calendar.freebusy.query({
|
||||||
|
requestBody: {
|
||||||
|
timeMin: rangeStart.toISOString(),
|
||||||
|
timeMax: rangeEnd.toISOString(),
|
||||||
|
timeZone,
|
||||||
|
items: [{ id: calendarId }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
busy = (response.data.calendars?.[calendarId]?.busy ?? [])
|
||||||
|
.map((item) => ({
|
||||||
|
start: parseDate(item.start ?? undefined, rangeStart),
|
||||||
|
end: parseDate(item.end ?? undefined, rangeStart),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.end > item.start);
|
||||||
|
logAvailability(requestId, "freebusy response", {
|
||||||
|
busyCount: busy.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[ai:getAvailability:${requestId}] freebusy failed`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Failed to read Gregor's Google Calendar availability.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableSlots = availabilitySlots({
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
busy,
|
||||||
|
durationMinutes,
|
||||||
|
timeZone,
|
||||||
|
workdayStartHour,
|
||||||
|
workdayEndHour,
|
||||||
|
});
|
||||||
|
logAvailability(requestId, "complete", {
|
||||||
|
busyCount: busy.length,
|
||||||
|
availableSlotCount: availableSlots.length,
|
||||||
|
firstAvailableSlot: availableSlots[0],
|
||||||
|
});
|
||||||
|
const nextAvailableSlot = availableSlots[0] ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
range: {
|
||||||
|
start: rangeStart.toISOString(),
|
||||||
|
end: rangeEnd.toISOString(),
|
||||||
|
timeZone,
|
||||||
|
},
|
||||||
|
durationMinutes,
|
||||||
|
busy: busy.map((item) => ({
|
||||||
|
start: item.start.toISOString(),
|
||||||
|
end: item.end.toISOString(),
|
||||||
|
})),
|
||||||
|
nextAvailableSlot,
|
||||||
|
availableSlots,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ export const cvEntry = createTable(
|
|||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull().$type<Date>(),
|
.notNull().$type<Date>(),
|
||||||
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
|
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
|
||||||
|
position: d.integer(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ export const cvEntryRelations = relations(cvEntry, ({one}) => ({
|
|||||||
|
|
||||||
export const sourceTypeEnum = pgEnum('source_type',['open','closed'])
|
export const sourceTypeEnum = pgEnum('source_type',['open','closed'])
|
||||||
export const releaseStatus = pgEnum('release_status',['released','unreleased'])
|
export const releaseStatus = pgEnum('release_status',['released','unreleased'])
|
||||||
export const stackItemEnum = pgEnum('stack_item',['drizzle','postgres','nextjs','react','servercomponents','php','laravel','reactnative','expo','mysql','nginx','protobuf','grpc','java','graalvm','spring','aws','s3','react-native','linux','debian','htmx','neon'])
|
export const stackItemEnum = pgEnum('stack_item',['drizzle','postgres','nextjs','react','servercomponents','php','laravel','reactnative','expo','mysql','nginx','protobuf','grpc','java','graalvm','spring','aws','s3','react-native','linux','debian','htmx','neon','typescript','javafx','x11','zig','libghostty','zod'])
|
||||||
|
|
||||||
export const project = createTable(
|
export const project = createTable(
|
||||||
"project",
|
"project",
|
||||||
@@ -68,6 +69,7 @@ export const project = createTable(
|
|||||||
releaseStatus: releaseStatus(),
|
releaseStatus: releaseStatus(),
|
||||||
releaseLink: d.varchar({length: 200}),
|
releaseLink: d.varchar({length: 200}),
|
||||||
stackId: d.uuid(),
|
stackId: d.uuid(),
|
||||||
|
orderPos: d.integer(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,9 +94,14 @@ export const music = createTable(
|
|||||||
id: d.uuid().primaryKey().notNull(),
|
id: d.uuid().primaryKey().notNull(),
|
||||||
title: d.varchar({ length: 100 }).notNull(),
|
title: d.varchar({ length: 100 }).notNull(),
|
||||||
description: d.text(),
|
description: d.text(),
|
||||||
|
// Original high-quality upload (e.g. FLAC), offered as a download.
|
||||||
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
|
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
|
||||||
fileKey: d.varchar("file_key", { length: 200 }).notNull(),
|
fileKey: d.varchar("file_key", { length: 200 }).notNull(),
|
||||||
fileName: d.varchar("file_name", { length: 200 }).notNull(),
|
fileName: d.varchar("file_name", { length: 200 }).notNull(),
|
||||||
|
// Lighter, streaming-friendly transcode (e.g. AAC) used by the player.
|
||||||
|
streamUrl: d.varchar("stream_url", { length: 500 }),
|
||||||
|
streamKey: d.varchar("stream_key", { length: 200 }),
|
||||||
|
streamName: d.varchar("stream_name", { length: 200 }),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp({ withTimezone: true })
|
.timestamp({ withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
@@ -168,6 +175,7 @@ export const chatMessageRelations = relations(chatMessage, ({ one }) => ({
|
|||||||
export const systemSettings = createTable(
|
export const systemSettings = createTable(
|
||||||
"systemSetting",
|
"systemSetting",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
systemPropmt: d.text()
|
systemPropmt: d.text(),
|
||||||
|
model: d.text()
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
20
src/server/googleCalendar.ts
Normal file
20
src/server/googleCalendar.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { google } from "googleapis";
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
const calendarScope = "https://www.googleapis.com/auth/calendar";
|
||||||
|
|
||||||
|
export function getGoogleCalendarClient() {
|
||||||
|
const auth = new google.auth.JWT({
|
||||||
|
email: env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
||||||
|
key: env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY.replace(/\\n/g, "\n"),
|
||||||
|
scopes: [calendarScope],
|
||||||
|
});
|
||||||
|
|
||||||
|
return google.calendar({ version: "v3", auth });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGoogleCalendarId() {
|
||||||
|
return env.GOOGLE_CALENDAR_ID;
|
||||||
|
}
|
||||||
@@ -305,6 +305,25 @@ export const blogRouter = router({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
metadataBySlug: publicProcedure.input(z.string()).query(async ({ input: slug }) => {
|
||||||
|
const post = await db.query.blogPost.findFirst({
|
||||||
|
where(fields, operators) {
|
||||||
|
return operators.eq(fields.slug, slug);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${slug}" not found` });
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: post.slug,
|
||||||
|
title: post.title,
|
||||||
|
date: post.date,
|
||||||
|
description: post.description,
|
||||||
|
tags: post.tags ?? [],
|
||||||
|
fileUrl: post.fileUrl,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
syncFromUploadThing: publicProcedure.mutation(async () => {
|
syncFromUploadThing: publicProcedure.mutation(async () => {
|
||||||
await assertAdmin();
|
await assertAdmin();
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,34 @@ import { isAdmin } from '~/app/actions';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { clerkClient, auth } from '@clerk/nextjs/server'
|
import { clerkClient, auth } from '@clerk/nextjs/server'
|
||||||
|
import { env } from '~/env'
|
||||||
|
|
||||||
|
export const DEFAULT_MODEL = 'gpt-5-mini'
|
||||||
|
|
||||||
|
// Models returned by the OpenAI API that aren't usable for chat completions.
|
||||||
|
const NON_CHAT_MODEL = /embedding|image|audio|realtime|transcribe|tts|whisper|moderation|dall-e|search|codex|instruct/
|
||||||
|
|
||||||
|
async function readSettings() {
|
||||||
|
return db.select().from(systemSettings).limit(1).then((r) => r[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeSettings(values: { systemPropmt?: string | null; model?: string | null }) {
|
||||||
|
const current = await readSettings()
|
||||||
|
await db.delete(systemSettings)
|
||||||
|
await db.insert(systemSettings).values({
|
||||||
|
systemPropmt: values.systemPropmt ?? current?.systemPropmt ?? null,
|
||||||
|
model: values.model ?? current?.model ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const chatRouter = router({
|
export const chatRouter = router({
|
||||||
getSession: publicProcedure.query(async () => {
|
getSession: publicProcedure.query(async () => {
|
||||||
const clerk = await clerkClient()
|
|
||||||
const { userId } = await auth();
|
const { userId } = await auth();
|
||||||
const user = await clerk.users.getUser(userId?userId:"")
|
if (!userId) {
|
||||||
if (user == undefined) {
|
|
||||||
throw new TRPCError({ message: "chat is only available to signed in users", code: 'UNAUTHORIZED' });
|
throw new TRPCError({ message: "chat is only available to signed in users", code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
const clerk = await clerkClient()
|
||||||
|
const user = await clerk.users.getUser(userId)
|
||||||
let session = await db.query.chatSession.findFirst({
|
let session = await db.query.chatSession.findFirst({
|
||||||
where(fields, operators) {
|
where(fields, operators) {
|
||||||
return operators.eq(fields.userId, user.id)
|
return operators.eq(fields.userId, user.id)
|
||||||
@@ -66,13 +86,34 @@ export const chatRouter = router({
|
|||||||
|
|
||||||
}),
|
}),
|
||||||
getSystemPrompt: publicProcedure.query(async () => {
|
getSystemPrompt: publicProcedure.query(async () => {
|
||||||
const row = await db.select().from(systemSettings).limit(1).then((r) => r[0])
|
const row = await readSettings()
|
||||||
return row?.systemPropmt ?? ''
|
return row?.systemPropmt ?? ''
|
||||||
}),
|
}),
|
||||||
updateSystemPrompt: publicProcedure.input(z.object({ prompt: z.string() })).mutation(async ({ input }) => {
|
updateSystemPrompt: publicProcedure.input(z.object({ prompt: z.string() })).mutation(async ({ input }) => {
|
||||||
if (!(await isAdmin())) throw new TRPCError({ code: 'FORBIDDEN' })
|
if (!(await isAdmin())) throw new TRPCError({ code: 'FORBIDDEN' })
|
||||||
await db.delete(systemSettings)
|
await writeSettings({ systemPropmt: input.prompt })
|
||||||
await db.insert(systemSettings).values({ systemPropmt: input.prompt })
|
}),
|
||||||
|
getModel: publicProcedure.query(async () => {
|
||||||
|
const row = await readSettings()
|
||||||
|
return row?.model ?? DEFAULT_MODEL
|
||||||
|
}),
|
||||||
|
listModels: publicProcedure.query(async () => {
|
||||||
|
if (!(await isAdmin())) throw new TRPCError({ code: 'FORBIDDEN' })
|
||||||
|
const res = await fetch('https://api.openai.com/v1/models', {
|
||||||
|
headers: { Authorization: `Bearer ${env.OPENAI_API_KEY}` },
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `failed to fetch models (${res.status})` })
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as { data: { id: string }[] }
|
||||||
|
return json.data
|
||||||
|
.map((m) => m.id)
|
||||||
|
.filter((id) => (id.startsWith('gpt') || /^o\d/.test(id) || id.startsWith('chatgpt')) && !NON_CHAT_MODEL.test(id))
|
||||||
|
.sort()
|
||||||
|
}),
|
||||||
|
updateModel: publicProcedure.input(z.object({ model: z.string() })).mutation(async ({ input }) => {
|
||||||
|
if (!(await isAdmin())) throw new TRPCError({ code: 'FORBIDDEN' })
|
||||||
|
await writeSettings({ model: input.model })
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,22 @@ export const cvCategoryRouter = router({
|
|||||||
console.log(res);
|
console.log(res);
|
||||||
return res;
|
return res;
|
||||||
}),
|
}),
|
||||||
|
// Single round-trip for the whole CV page: every category (across all layout
|
||||||
|
// positions) with its entries already populated. Lets the page fetch-then-render
|
||||||
|
// instead of waterfalling per-category/per-entry queries, so all content is
|
||||||
|
// present before the entrance animation runs.
|
||||||
|
listAllWithEntries: publicProcedure.query(async () => {
|
||||||
|
const res = await db.query.cvCategory.findMany({
|
||||||
|
with: {
|
||||||
|
cvEntry: {
|
||||||
|
orderBy: (t, { desc }) => desc(t.toTime),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return res;
|
||||||
|
}),
|
||||||
getById: publicProcedure.input(z.string()).query(async ({input}) => {
|
getById: publicProcedure.input(z.string()).query(async ({input}) => {
|
||||||
const res = await db.query.cvCategory.findFirst({
|
const res = await db.query.cvCategory.findFirst({
|
||||||
with: {
|
|
||||||
cvEntry: true
|
|
||||||
},
|
|
||||||
where(fields, operators) {
|
where(fields, operators) {
|
||||||
return operators.eq(fields.id, input)
|
return operators.eq(fields.id, input)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,4 +13,14 @@ export const cvEntryRouter = router({
|
|||||||
})
|
})
|
||||||
return res;
|
return res;
|
||||||
}),
|
}),
|
||||||
|
byCategoryAndToDateDescending: publicProcedure.input(z.string()).query(async ({input}) => {
|
||||||
|
const res = await db.query.cvEntry.findMany({
|
||||||
|
with: {
|
||||||
|
category: true
|
||||||
|
},
|
||||||
|
where: (fields, {eq}) => eq(fields.categoryId,input),
|
||||||
|
orderBy: (t,{desc}) => desc(t.toTime),
|
||||||
|
})
|
||||||
|
return res;
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { isAdmin } from "~/app/actions";
|
import { isAdmin } from "~/app/actions";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createMusicInputSchema, updateMusicInputSchema } from "~/lib/trpc/music/schemas";
|
import { createMusicInputSchema, updateMusicInputSchema, setStreamInputSchema } from "~/lib/trpc/music/schemas";
|
||||||
import { utapi } from "../uploadthing";
|
import { utapi } from "../uploadthing";
|
||||||
export const musicRouter = router({
|
export const musicRouter = router({
|
||||||
list: publicProcedure.query(async () => {
|
list: publicProcedure.query(async () => {
|
||||||
@@ -33,6 +33,22 @@ export const musicRouter = router({
|
|||||||
const { id, ...data } = input;
|
const { id, ...data } = input;
|
||||||
return db.update(music).set(data).where(eq(music.id, id)).returning();
|
return db.update(music).set(data).where(eq(music.id, id)).returning();
|
||||||
}),
|
}),
|
||||||
|
setStream: publicProcedure
|
||||||
|
.input(setStreamInputSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const admin = await isAdmin();
|
||||||
|
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
|
||||||
|
const { id, ...data } = input;
|
||||||
|
const existing = await db.select().from(music).where(eq(music.id, id)).limit(1);
|
||||||
|
const prev = existing.at(0);
|
||||||
|
if (!prev) throw new TRPCError({ code: "NOT_FOUND", message: "Track not found" });
|
||||||
|
const res = await db.update(music).set(data).where(eq(music.id, id)).returning();
|
||||||
|
// Drop the previous stream transcode so we don't orphan it on UploadThing.
|
||||||
|
if (prev.streamKey && prev.streamKey !== data.streamKey) {
|
||||||
|
utapi.deleteFiles(prev.streamKey);
|
||||||
|
}
|
||||||
|
return res.at(0);
|
||||||
|
}),
|
||||||
delete: publicProcedure
|
delete: publicProcedure
|
||||||
.input(z.object({id:z.string().uuid()}))
|
.input(z.object({id:z.string().uuid()}))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
@@ -41,7 +57,8 @@ export const musicRouter = router({
|
|||||||
let res = await db.delete(music).where(eq(music.id, input.id)).returning();
|
let res = await db.delete(music).where(eq(music.id, input.id)).returning();
|
||||||
let ret = res.at(0)
|
let ret = res.at(0)
|
||||||
if (ret) {
|
if (ret) {
|
||||||
utapi.deleteFiles(ret.fileKey)
|
const keys = [ret.fileKey, ret.streamKey].filter((k): k is string => !!k);
|
||||||
|
utapi.deleteFiles(keys)
|
||||||
}
|
}
|
||||||
return ret;
|
return ret;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { publicProcedure, router } from "~/server/trpc";
|
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
|
import { publicProcedure, router } from "~/server/trpc";
|
||||||
|
|
||||||
type ReadmeRequest = {
|
type ReadmeRequest = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -71,6 +71,11 @@ async function fetchReadme(sourceLink: string) {
|
|||||||
export const projectRouter = router({
|
export const projectRouter = router({
|
||||||
listWithStack: publicProcedure.query(async () => {
|
listWithStack: publicProcedure.query(async () => {
|
||||||
const projects = await db.query.project.findMany({
|
const projects = await db.query.project.findMany({
|
||||||
|
orderBy: (project, { asc }) => [
|
||||||
|
asc(project.orderPos),
|
||||||
|
asc(project.title),
|
||||||
|
asc(project.id),
|
||||||
|
],
|
||||||
with: {
|
with: {
|
||||||
techStack: true,
|
techStack: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { initTRPC } from "@trpc/server"
|
import { initTRPC } from "@trpc/server"
|
||||||
|
import superjson from "superjson"
|
||||||
|
|
||||||
const t = initTRPC.create();
|
const t = initTRPC.create({
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
export const router = t.router;
|
export const router = t.router;
|
||||||
export const publicProcedure = t.procedure;
|
export const publicProcedure = t.procedure;
|
||||||
|
|||||||
Reference in New Issue
Block a user