Compare commits
1 Commits
ca29bd5003
...
projectpag
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ef0b27c50 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,5 +46,4 @@ yarn-error.log*
|
||||
.idea
|
||||
# clerk configuration (can include secrets)
|
||||
/.clerk/
|
||||
.worktrees
|
||||
.claudesession
|
||||
|
||||
34
README.md
34
README.md
@@ -1,13 +1,29 @@
|
||||
# My Personal Website
|
||||
# Create T3 App
|
||||
|
||||
## Using:
|
||||
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
|
||||
|
||||
- nextjs
|
||||
- trpc
|
||||
- neon
|
||||
- uploadthing
|
||||
- drizzle
|
||||
- gsap
|
||||
- openai
|
||||
## What's next? How do I make an app with this?
|
||||
|
||||
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
|
||||
|
||||
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
|
||||
|
||||
- [Next.js](https://nextjs.org)
|
||||
- [NextAuth.js](https://next-auth.js.org)
|
||||
- [Prisma](https://prisma.io)
|
||||
- [Drizzle](https://orm.drizzle.team)
|
||||
- [Tailwind CSS](https://tailwindcss.com)
|
||||
- [tRPC](https://trpc.io)
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
|
||||
|
||||
- [Documentation](https://create.t3.gg/)
|
||||
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
|
||||
|
||||
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
|
||||
|
||||
## How do I deploy this?
|
||||
|
||||
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
|
||||
|
||||
@@ -6,10 +6,9 @@ import "./src/env.js";
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
transpilePackages: ["next-mdx-remote"],
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
109
package.json
109
package.json
@@ -5,7 +5,6 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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:unsafe": "biome check --write --unsafe .",
|
||||
"check:write": "biome check --write .",
|
||||
@@ -20,22 +19,14 @@
|
||||
"test": "vitest --typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^3.0.67",
|
||||
"@ai-sdk/react": "^3.0.195",
|
||||
"@clerk/nextjs": "^7.4.2",
|
||||
"@electric-sql/pglite": "^0.4.6",
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
"@clerk/nextjs": "^7.0.2",
|
||||
"@electric-sql/pglite": "^0.3.16",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@fortawesome/react-fontawesome": "^3.3.1",
|
||||
"@fortawesome/react-fontawesome": "^3.2.0",
|
||||
"@gsap/react": "^2.1.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@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",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@neondatabase/serverless": "^1.0.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
@@ -62,87 +53,77 @@
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@t3-oss/env-nextjs": "^0.13.11",
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-query-next-experimental": "^5.100.14",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-query-next-experimental": "^5.91.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@trpc/client": "^11.17.0",
|
||||
"@trpc/next": "^11.17.0",
|
||||
"@trpc/react-query": "^11.17.0",
|
||||
"@trpc/server": "^11.17.0",
|
||||
"@types/mdx": "^2.0.14",
|
||||
"@uiw/react-md-editor": "^4.1.1",
|
||||
"@uploadthing/react": "^7.3.3",
|
||||
"@vercel/speed-insights": "^2.0.0",
|
||||
"ai": "^6.0.193",
|
||||
"@trpc/client": "^11.12.0",
|
||||
"@trpc/next": "^11.12.0",
|
||||
"@trpc/react-query": "^11.12.0",
|
||||
"@trpc/server": "^11.12.0",
|
||||
"@uiw/react-md-editor": "^4.0.11",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-format": "^4.0.14",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"glazejs": "^2.0.1",
|
||||
"googleapis": "^173.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"gsap": "^3.15.0",
|
||||
"gsap": "^3.14.2",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "16.2.6",
|
||||
"next-mdx-remote": "^6.0.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"postgres": "^3.4.9",
|
||||
"postgres": "^3.4.8",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.6",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.77.0",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"recharts": "3.8.1",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.7.2",
|
||||
"recharts": "2.15.4",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn": "^4.10.0",
|
||||
"shadcn": "^4.0.2",
|
||||
"sonner": "^2.0.7",
|
||||
"superjson": "^2.2.6",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-motion": "^1.1.1",
|
||||
"type-fest": "^5.7.0",
|
||||
"uploadthing": "^7.7.4",
|
||||
"type-fest": "^5.4.4",
|
||||
"vaul": "^1.1.2",
|
||||
"wavesurfer.js": "^7.12.8",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@biomejs/biome": "2.4.6",
|
||||
"@swc/jest": "^0.2.39",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/node": "^25.4.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jest": "^30.4.2",
|
||||
"jest-environment-jsdom": "^30.4.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"jest": "^30.3.0",
|
||||
"jest-environment-jsdom": "^30.3.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"next-router-mock": "^1.0.5",
|
||||
"pg-mem": "^3.0.14",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.1.8"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1 +0,0 @@
|
||||
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,31 +0,0 @@
|
||||
'use client'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog'
|
||||
import ChatInterface from '~/app/chat/_components/ChatInterface'
|
||||
import { useMessages } from '~/app/_providers/MessagesProvider';
|
||||
import { Spinner } from '~/components/ui/spinner';
|
||||
|
||||
export default function ChatModal() {
|
||||
const router = useRouter()
|
||||
const {messages,session,isLoading,error} = useMessages()
|
||||
return (
|
||||
<Dialog modal={true} open onOpenChange={() => router.back()}>
|
||||
<DialogContent className="w-full max-w-full rounded-none sm:max-w-full h-[100svh] lg:max-w-3xl lg:rounded-xl lg:h-[80vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="p-4 border-b shrink-0">
|
||||
<DialogTitle>Talk To My AI-Assistant</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
{!isLoading &&
|
||||
<ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
|
||||
}
|
||||
{isLoading &&
|
||||
<><Spinner/> Loading Messages...</>
|
||||
}
|
||||
{error &&
|
||||
<div> {error} </div>
|
||||
}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
'use client'
|
||||
import ChatModal from './_components/ChatModal'
|
||||
|
||||
export default function AssistantModalPage() {
|
||||
return (
|
||||
<ChatModal/>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
"use client"
|
||||
import { useRef, type HTMLAttributes, type ReactNode } from "react";
|
||||
import { SplitText } from "gsap/SplitText";
|
||||
import gsap from 'gsap'
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useReveal } from "./useReveal";
|
||||
|
||||
const AnimateTextIn = ({
|
||||
children,
|
||||
animation = "type",
|
||||
position = 0,
|
||||
speed = 1,
|
||||
scrollOnly = false,
|
||||
once = false,
|
||||
debugId,
|
||||
className
|
||||
}: {
|
||||
children: ReactNode,
|
||||
animation?: "type" | "slide",
|
||||
position?: gsap.Position,
|
||||
scrollOnly?: boolean,
|
||||
once?: boolean,
|
||||
debugId?: string,
|
||||
speed?: number,
|
||||
className?: HTMLAttributes<HTMLDivElement>['className']
|
||||
}) => {
|
||||
const el = useRef<HTMLDivElement>(null)
|
||||
useReveal(el, {
|
||||
position,
|
||||
scrollOnly,
|
||||
once,
|
||||
debugId: debugId ?? `text-${position}`,
|
||||
makeReveal: (node) => {
|
||||
// The wrapper starts at opacity 0 (so there's no flash of unsplit text);
|
||||
// reveal the wrapper and let the per-character tween do the animation.
|
||||
gsap.set(node, { opacity: 1 })
|
||||
const split = new SplitText(node, { type: 'chars' })
|
||||
const fromVars = animation === "slide"
|
||||
? { opacity: 0, x: -10, duration: 0.2 * speed, stagger: { each: 0.08 * speed }, ease: 'bounce.inOut' }
|
||||
: { opacity: 0, duration: 0.01 * speed, stagger: { each: 0.04 * speed }, ease: 'bounce.inOut' }
|
||||
return gsap.from(split.chars, { ...fromVars, paused: true })
|
||||
},
|
||||
})
|
||||
return (
|
||||
<div ref={el} className={cn(className, "opacity-0")}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimateTextIn;
|
||||
@@ -1,28 +0,0 @@
|
||||
import { type HTMLAttributes, type ReactNode } from "react";
|
||||
import AnimatedDiv from "./AnimatedDiv";
|
||||
import { cn } from "~/lib/utils";
|
||||
const AnimatePopUp = ({
|
||||
children,
|
||||
position,
|
||||
className,
|
||||
duration=1,
|
||||
ease='elastic',
|
||||
scrollOnly=false,
|
||||
once=false,
|
||||
debugId,
|
||||
}:{
|
||||
children:ReactNode
|
||||
position:gsap.Position,
|
||||
className?:HTMLAttributes<HTMLDivElement>['className']
|
||||
duration?:number,
|
||||
ease?:gsap.EaseString|gsap.EaseFunction,
|
||||
scrollOnly?:boolean,
|
||||
once?:boolean,
|
||||
debugId?:string,
|
||||
}) => {
|
||||
return (
|
||||
<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} />
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimatePopUp;
|
||||
@@ -1,435 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
const PALETTES = {
|
||||
dark: {
|
||||
particles: [
|
||||
"rgba(255,255,255,0.70)",
|
||||
"rgba(255,255,255,0.45)",
|
||||
"rgba(180,180,180,0.50)",
|
||||
"rgba(200,200,200,0.35)",
|
||||
"rgba(255,255,255,0.22)",
|
||||
],
|
||||
grainOpacity: 0.05,
|
||||
},
|
||||
light: {
|
||||
particles: [
|
||||
"rgba(0,0,0,0.55)",
|
||||
"rgba(0,0,0,0.35)",
|
||||
"rgba(60,60,60,0.40)",
|
||||
"rgba(80,80,80,0.25)",
|
||||
"rgba(0,0,0,0.18)",
|
||||
],
|
||||
grainOpacity: 0.03,
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface Particle {
|
||||
angle: number;
|
||||
radius: number;
|
||||
speed: number;
|
||||
size: number;
|
||||
colorIndex: number;
|
||||
wobbleAmp: number;
|
||||
wobbleSpeed: number;
|
||||
wobblePhase: number;
|
||||
}
|
||||
|
||||
interface CanvasSize {
|
||||
dpr: number;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface AnimatedBackgroundContainerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
particleCount?: number;
|
||||
orbitRadius?: number;
|
||||
followSpeed?: 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({
|
||||
children,
|
||||
className = "",
|
||||
particleCount = 60,
|
||||
orbitRadius = 240,
|
||||
followSpeed = 0.06,
|
||||
mobileSpeed = 1,
|
||||
}: AnimatedBackgroundContainerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const frameRef = useRef(0);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const isMobileRef = useRef(false);
|
||||
const isVisibleRef = useRef(true);
|
||||
const prefersReducedMotionRef = useRef(false);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const mousePosRef = useRef({ x: 0, y: 0 });
|
||||
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 isDark = resolvedTheme === undefined || resolvedTheme === "dark";
|
||||
const palette = isDark ? PALETTES.dark : PALETTES.light;
|
||||
|
||||
useEffect(() => {
|
||||
particleColorsRef.current = palette.particles;
|
||||
}, [palette]);
|
||||
|
||||
useEffect(() => {
|
||||
followSpeedRef.current = followSpeed;
|
||||
}, [followSpeed]);
|
||||
|
||||
useEffect(() => {
|
||||
mobileSpeedRef.current = mobileSpeed;
|
||||
}, [mobileSpeed]);
|
||||
|
||||
useEffect(() => {
|
||||
particlesRef.current = Array.from({ length: particleCount }, () =>
|
||||
createParticle(orbitRadius),
|
||||
);
|
||||
}, [particleCount, orbitRadius]);
|
||||
|
||||
const seedPositions = useCallback(() => {
|
||||
const { height, width } = canvasSizeRef.current;
|
||||
if (width === 0 || height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
mousePosRef.current = { x: centerX, y: centerY };
|
||||
smoothMouseRef.current = { x: centerX, y: centerY };
|
||||
mobileAnchorRef.current = { x: centerX, y: centerY };
|
||||
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();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resizeCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DEVICE_PIXEL_RATIO);
|
||||
const nextWidth = Math.round(width * dpr);
|
||||
const nextHeight = Math.round(height * dpr);
|
||||
|
||||
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(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.addEventListener("mousemove", handleMouseMove, { passive: true });
|
||||
return () => container.removeEventListener("mousemove", handleMouseMove);
|
||||
}, [handleMouseMove]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stopAnimation = () => {
|
||||
if (animationFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
if (!isVisibleRef.current || prefersReducedMotionRef.current) {
|
||||
animationFrameRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const { height, width } = canvasSizeRef.current;
|
||||
if (width === 0 || height === 0) {
|
||||
animationFrameRef.current = window.requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
|
||||
frameRef.current += 1;
|
||||
|
||||
if (isMobileRef.current) {
|
||||
const mobileLerp = 0.008 * mobileSpeedRef.current;
|
||||
mobileAnchorRef.current.x +=
|
||||
(mobileTargetRef.current.x - mobileAnchorRef.current.x) * mobileLerp;
|
||||
mobileAnchorRef.current.y +=
|
||||
(mobileTargetRef.current.y - mobileAnchorRef.current.y) * mobileLerp;
|
||||
|
||||
const dx = mobileTargetRef.current.x - mobileAnchorRef.current.x;
|
||||
const dy = mobileTargetRef.current.y - mobileAnchorRef.current.y;
|
||||
if (Math.hypot(dx, dy) < MOBILE_TARGET_DISTANCE) {
|
||||
mobileTargetRef.current = {
|
||||
x: rand(width * 0.15, width * 0.85),
|
||||
y: rand(height * 0.15, height * 0.85),
|
||||
};
|
||||
}
|
||||
|
||||
smoothMouseRef.current.x = mobileAnchorRef.current.x;
|
||||
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;
|
||||
}
|
||||
|
||||
const centerX = smoothMouseRef.current.x;
|
||||
const centerY = smoothMouseRef.current.y;
|
||||
const colors = particleColorsRef.current;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
for (const particle of particlesRef.current) {
|
||||
particle.angle += particle.speed;
|
||||
|
||||
const wobble =
|
||||
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;
|
||||
|
||||
const edgeFade = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
x / EDGE_FADE_DISTANCE,
|
||||
(width - x) / EDGE_FADE_DISTANCE,
|
||||
y / EDGE_FADE_DISTANCE,
|
||||
(height - y) / EDGE_FADE_DISTANCE,
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
if (edgeFade <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
animationFrameRef.current = window.requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const startAnimation = () => {
|
||||
if (
|
||||
animationFrameRef.current === null &&
|
||||
isVisibleRef.current &&
|
||||
!prefersReducedMotionRef.current
|
||||
) {
|
||||
animationFrameRef.current = window.requestAnimationFrame(draw);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
isVisibleRef.current = document.visibilityState === "visible";
|
||||
|
||||
if (isVisibleRef.current) {
|
||||
startAnimation();
|
||||
} else {
|
||||
stopAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
const motionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const handleMotionChange = () => {
|
||||
prefersReducedMotionRef.current = motionMedia.matches;
|
||||
|
||||
if (prefersReducedMotionRef.current) {
|
||||
stopAnimation();
|
||||
ctx.clearRect(0, 0, canvasSizeRef.current.width, canvasSizeRef.current.height);
|
||||
} else {
|
||||
startAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
isVisibleRef.current = document.visibilityState === "visible";
|
||||
prefersReducedMotionRef.current = motionMedia.matches;
|
||||
startAnimation();
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
motionMedia.addEventListener("change", handleMotionChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
motionMedia.removeEventListener("change", handleMotionChange);
|
||||
stopAnimation();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{
|
||||
position: "relative",
|
||||
minHeight: "100vh",
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
transition: "background-color 0.6s ease",
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
opacity: palette.grainOpacity,
|
||||
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")`,
|
||||
backgroundRepeat: "repeat",
|
||||
backgroundSize: "180px 180px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ position: "relative", zIndex: 2 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client"
|
||||
import gsap from "gsap";
|
||||
import { type HTMLAttributes, type ReactNode, useRef } from "react";
|
||||
import { useReveal } from "./useReveal";
|
||||
|
||||
const AnimatedDiv = (
|
||||
{
|
||||
children,
|
||||
position,
|
||||
className,
|
||||
animationMode = 'to',
|
||||
scrollOnly = false,
|
||||
once = false,
|
||||
debugId,
|
||||
...tweenVars
|
||||
}:
|
||||
gsap.TweenVars & {
|
||||
children: ReactNode,
|
||||
position: gsap.Position,
|
||||
animationMode?: 'from' | 'to',
|
||||
scrollOnly?: boolean,
|
||||
once?: boolean,
|
||||
debugId?: string,
|
||||
className?: HTMLAttributes<HTMLDivElement>['className']
|
||||
}
|
||||
) => {
|
||||
const div = useRef<HTMLDivElement>(null)
|
||||
useReveal(div, {
|
||||
position,
|
||||
scrollOnly,
|
||||
once,
|
||||
debugId,
|
||||
makeReveal: (el) =>
|
||||
animationMode === 'from'
|
||||
? gsap.from(el, { ...tweenVars, paused: true })
|
||||
: gsap.to(el, { ...tweenVars, paused: true }),
|
||||
})
|
||||
return (
|
||||
<div ref={div} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimatedDiv;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useGSAP } from "@gsap/react"; import { useEffect, useLayoutEffect, useRef,type ReactNode } from "react";
|
||||
import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
||||
import { SplitText } from "gsap/SplitText";
|
||||
import gsap from 'gsap'
|
||||
const AnimatedPageTitle = (
|
||||
{ children, position }: { children: ReactNode, position:gsap.Position }
|
||||
) => {
|
||||
const el = useRef<HTMLHeadingElement>(null)
|
||||
const gsapContext = useGsapContext();
|
||||
useLayoutEffect(() => {
|
||||
const split = new SplitText(el.current, { type: "lines,chars", autoSplit:true })
|
||||
gsapContext?.addAnimation(gsap.to(el.current, { opacity: 100 }),position)
|
||||
gsapContext?.addAnimation(gsap.from(split.chars, { id: 'titlesplit',
|
||||
stagger: 0.05, rotate: -90, opacity: 0, x: -10, onComplete: () => {split.revert()}
|
||||
}),'>')
|
||||
},[])
|
||||
return (
|
||||
<h1 className="text-4xl break-keep opacity-0 font-bold text-balance w-full" ref={el}> {children} </h1>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimatedPageTitle;
|
||||
@@ -1,134 +0,0 @@
|
||||
'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: [] })
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
'use client'
|
||||
import Link from 'next/link'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { usePathname } from 'next/navigation'
|
||||
export default function ChatFAB() {
|
||||
const pathName = usePathname()
|
||||
const isChat = pathName.indexOf('\/chat') > -1
|
||||
return (
|
||||
<>
|
||||
{!isChat &&
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<Button asChild size="icon" className="h-14 w-14 rounded-full shadow-lg">
|
||||
<Link href="/assistant">
|
||||
<MessageCircle className="h-6 w-6" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export default function FormScaffold<T extends FieldValues,>(params: {
|
||||
}) {
|
||||
const { form, onSubmit, title, id, className, children } = params
|
||||
return (
|
||||
<Card.Card className={className ? className : "w-full"}>
|
||||
<Card.Card className={className ? className : "w-5/6 lg:w-1/2"}>
|
||||
<Card.CardHeader>
|
||||
<Card.CardTitle>
|
||||
<DependentText bool={id ? true : false} true={`Update ${title}`} false={`Create ${title}`} />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UseTRPCMutationResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs";
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
interface ToString {
|
||||
@@ -7,7 +8,7 @@ interface ToString {
|
||||
|
||||
|
||||
export interface MutationInterface {
|
||||
mutate: (params: any) => void
|
||||
mutate: (params:{id:string}) => void
|
||||
error: ToString | null
|
||||
status: "error" | "idle" | "pending" | "success"
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, useMemo, useRef, useState, type KeyboardEvent, type TextareaHTMLAttributes } from 'react'
|
||||
|
||||
export type InternalLinkSuggestion = {
|
||||
label: string
|
||||
href: string
|
||||
group: string
|
||||
}
|
||||
|
||||
export type MdeAutocompleteSuggestion = {
|
||||
label: string
|
||||
value: string
|
||||
detail: string
|
||||
group: string
|
||||
trigger: string
|
||||
}
|
||||
|
||||
export const AUTOCOMPLETE_CURSOR_MARKER = '{{cursor}}'
|
||||
|
||||
export type AutocompleteTriggerConfig = {
|
||||
trigger: string
|
||||
label: string
|
||||
isQueryValid?: (query: string) => boolean
|
||||
}
|
||||
|
||||
type ActiveToken = {
|
||||
start: number
|
||||
end: number
|
||||
query: string
|
||||
trigger: MdeAutocompleteSuggestion['trigger']
|
||||
}
|
||||
|
||||
const defaultTriggerConfigs: AutocompleteTriggerConfig[] = [
|
||||
{
|
||||
trigger: '[[',
|
||||
label: 'Internal links',
|
||||
isQueryValid: (query) => !query.includes(']'),
|
||||
},
|
||||
{
|
||||
trigger: '<',
|
||||
label: 'MDX components',
|
||||
isQueryValid: (query) => !/[\s>]/.test(query),
|
||||
},
|
||||
]
|
||||
|
||||
function findActiveToken(
|
||||
value: string,
|
||||
cursor: number,
|
||||
triggerConfigs: AutocompleteTriggerConfig[],
|
||||
): ActiveToken | null {
|
||||
const beforeCursor = value.slice(0, cursor)
|
||||
const activeTrigger = triggerConfigs
|
||||
.map((config) => ({ config, start: beforeCursor.lastIndexOf(config.trigger) }))
|
||||
.filter((candidate) => candidate.start !== -1)
|
||||
.sort((a, b) => b.start - a.start)[0]
|
||||
|
||||
if (!activeTrigger) return null
|
||||
|
||||
const query = beforeCursor.slice(activeTrigger.start + activeTrigger.config.trigger.length)
|
||||
if (query.includes('\n')) return null
|
||||
if (activeTrigger.config.isQueryValid && !activeTrigger.config.isQueryValid(query)) return null
|
||||
|
||||
return {
|
||||
start: activeTrigger.start,
|
||||
end: cursor,
|
||||
query,
|
||||
trigger: activeTrigger.config.trigger,
|
||||
}
|
||||
}
|
||||
|
||||
export function linkSuggestionsToAutocomplete(suggestions: InternalLinkSuggestion[]): MdeAutocompleteSuggestion[] {
|
||||
return suggestions.map((suggestion) => ({
|
||||
label: suggestion.label,
|
||||
value: `[${suggestion.label}](${suggestion.href})`,
|
||||
detail: suggestion.href,
|
||||
group: suggestion.group,
|
||||
trigger: '[[',
|
||||
}))
|
||||
}
|
||||
|
||||
export const InternalLinkTextarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
suggestions: MdeAutocompleteSuggestion[]
|
||||
triggerConfigs?: AutocompleteTriggerConfig[]
|
||||
}>(({ suggestions, triggerConfigs, value, onChange, onKeyDown, ...props }, ref) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const [token, setToken] = useState<ActiveToken | null>(null)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
function setRefs(element: HTMLTextAreaElement | null) {
|
||||
textareaRef.current = element
|
||||
if (typeof ref === 'function') ref(element)
|
||||
else if (ref) ref.current = element
|
||||
}
|
||||
|
||||
const resolvedTriggerConfigs = useMemo(() => {
|
||||
const configured = triggerConfigs?.length ? triggerConfigs : defaultTriggerConfigs
|
||||
const merged = new Map(configured.map((config) => [config.trigger, config]))
|
||||
|
||||
for (const suggestion of suggestions) {
|
||||
if (!merged.has(suggestion.trigger)) {
|
||||
merged.set(suggestion.trigger, {
|
||||
trigger: suggestion.trigger,
|
||||
label: suggestion.trigger,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(merged.values()).sort((a, b) => b.trigger.length - a.trigger.length)
|
||||
}, [suggestions, triggerConfigs])
|
||||
|
||||
const triggerLabels = useMemo(
|
||||
() => new Map(resolvedTriggerConfigs.map((config) => [config.trigger, config.label])),
|
||||
[resolvedTriggerConfigs],
|
||||
)
|
||||
|
||||
const matches = useMemo(() => {
|
||||
if (!token) return []
|
||||
const query = token.query.toLowerCase()
|
||||
return suggestions
|
||||
.filter((suggestion) => suggestion.trigger === token.trigger)
|
||||
.filter((suggestion) => {
|
||||
const haystack = `${suggestion.group} ${suggestion.label} ${suggestion.detail}`.toLowerCase()
|
||||
return haystack.includes(query)
|
||||
})
|
||||
.slice(0, 8)
|
||||
}, [suggestions, token])
|
||||
|
||||
function updateToken(textarea: HTMLTextAreaElement) {
|
||||
const nextToken = findActiveToken(textarea.value, textarea.selectionStart, resolvedTriggerConfigs)
|
||||
setToken(nextToken)
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
|
||||
function insertSuggestion(textarea: HTMLTextAreaElement, suggestion: MdeAutocompleteSuggestion) {
|
||||
if (!token) return
|
||||
|
||||
const markerIndex = suggestion.value.indexOf(AUTOCOMPLETE_CURSOR_MARKER)
|
||||
const insertedValue = markerIndex === -1
|
||||
? suggestion.value
|
||||
: suggestion.value.replace(AUTOCOMPLETE_CURSOR_MARKER, '')
|
||||
const cursor = token.start + (markerIndex === -1 ? insertedValue.length : markerIndex)
|
||||
const nextValue = `${textarea.value.slice(0, token.start)}${insertedValue}${textarea.value.slice(token.end)}`
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
|
||||
nativeInputValueSetter?.call(textarea, nextValue)
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
textarea.setSelectionRange(cursor, cursor)
|
||||
setToken(null)
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (token && matches.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((index) => (index + 1) % matches.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((index) => (index - 1 + matches.length) % matches.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
const suggestion = matches[selectedIndex]
|
||||
if (suggestion) insertSuggestion(event.currentTarget, suggestion)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setToken(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
onKeyDown?.(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<textarea
|
||||
{...props}
|
||||
ref={setRefs}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
onChange?.(event)
|
||||
updateToken(event.currentTarget)
|
||||
}}
|
||||
onClick={(event) => updateToken(event.currentTarget)}
|
||||
onKeyUp={(event) => {
|
||||
if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(event.key)) return
|
||||
updateToken(event.currentTarget)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{token && matches.length > 0 && (
|
||||
<div className='bg-popover text-popover-foreground absolute left-3 top-12 z-50 w-80 overflow-hidden rounded-md border shadow-md'>
|
||||
<div className='border-b px-3 py-2 text-xs text-muted-foreground'>
|
||||
{triggerLabels.get(token.trigger) ?? token.trigger} for {token.trigger}{token.query}
|
||||
</div>
|
||||
<div className='max-h-64 overflow-y-auto py-1'>
|
||||
{matches.map((suggestion, index) => (
|
||||
<button
|
||||
key={`${suggestion.trigger}:${suggestion.group}:${suggestion.label}`}
|
||||
type='button'
|
||||
className={`flex w-full flex-col px-3 py-2 text-left text-sm ${index === selectedIndex ? 'bg-muted' : ''}`}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
if (textareaRef.current) insertSuggestion(textareaRef.current, suggestion)
|
||||
}}
|
||||
>
|
||||
<span className='font-medium'>{suggestion.label}</span>
|
||||
<span className='text-xs text-muted-foreground'>{suggestion.group} - {suggestion.detail}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
InternalLinkTextarea.displayName = 'InternalLinkTextarea'
|
||||
@@ -1,100 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import MDEditor from "@uiw/react-md-editor";
|
||||
import { Maximize2, Minimize2 } from "lucide-react";
|
||||
import { useEffect, useState, type ReactElement, type TextareaHTMLAttributes } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { ClientMdx } from "~/components/ClientMdx";
|
||||
import {
|
||||
InternalLinkTextarea,
|
||||
type AutocompleteTriggerConfig,
|
||||
type MdeAutocompleteSuggestion,
|
||||
} from "./InternalLinkTextarea";
|
||||
|
||||
export default function MdeFormField<T extends FieldValues>(params: {
|
||||
control: Control<T>,
|
||||
name: Path<T>,
|
||||
label: string,
|
||||
dataColorMode: "dark"|"light",
|
||||
autocompleteSuggestions?: MdeAutocompleteSuggestion[],
|
||||
triggerConfigs?: AutocompleteTriggerConfig[],
|
||||
renderPreview?: (source: string) => ReactElement,
|
||||
}) {
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreen) return
|
||||
|
||||
const originalOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = "hidden"
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow
|
||||
}
|
||||
}, [fullscreen])
|
||||
|
||||
export default function MdeFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, dataColorMode: "dark"|"light" }) {
|
||||
return (
|
||||
<FormField
|
||||
control={params.control}
|
||||
name={params.name}
|
||||
render={({ field }) => {
|
||||
const editor = (
|
||||
<FormItem className={cn(fullscreen && "mde-form-field-fullscreen")}>
|
||||
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||
<FormLabel>
|
||||
{params.label}
|
||||
</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={fullscreen ? "Exit fullscreen editor" : "Open fullscreen editor"}
|
||||
onClick={() => setFullscreen((value) => !value)}
|
||||
>
|
||||
{fullscreen ? <Minimize2 /> : <Maximize2 />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl className={cn(fullscreen && "min-h-0 flex-1")}>
|
||||
<MDEditor
|
||||
className={cn(fullscreen && "mde-form-field-editor-fullscreen min-h-0 flex-1")}
|
||||
height={fullscreen ? "calc(100vh - 72px)" : undefined}
|
||||
visibleDragbar={!fullscreen}
|
||||
value={field.value ? field.value : ""}
|
||||
onChange={field.onChange}
|
||||
data-color-mode={params.dataColorMode}
|
||||
commandsFilter={(command) => command.name === "fullscreen" ? false : command}
|
||||
components={{
|
||||
textarea: (props) => (
|
||||
<InternalLinkTextarea
|
||||
{...(props as TextareaHTMLAttributes<HTMLTextAreaElement>)}
|
||||
suggestions={params.autocompleteSuggestions ?? []}
|
||||
triggerConfigs={params.triggerConfigs}
|
||||
/>
|
||||
),
|
||||
preview: params.renderPreview
|
||||
? (source) => params.renderPreview?.(source) ?? <></>
|
||||
: (source) => <ClientMdx source={source} fallback={source} />,
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)
|
||||
|
||||
if (fullscreen && mounted) {
|
||||
return createPortal(editor, document.body)
|
||||
}
|
||||
|
||||
return editor
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Description
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<MDEditor
|
||||
value={field.value ? field.value : ""}
|
||||
onChange={field.onChange}
|
||||
data-color-mode={params.dataColorMode}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { CheckedState } from "@radix-ui/react-checkbox";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { createContext,useContext, useState, type KeyboardEventHandler } from "react";
|
||||
import { createContext,useContext, useState } from "react";
|
||||
import { useFormContext, type Control, type ControllerRenderProps, type FieldValues, type Path } from "react-hook-form";
|
||||
import type { Entries } from "type-fest";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -17,7 +16,6 @@ const MultiBooleanFieldContext = createContext<MultiBooleanFieldContextProps|und
|
||||
|
||||
function InnerMultiBooleanFormField(params: { options: string[], onChange: (arg0: string[]) => void }) {
|
||||
const context = useContext(MultiBooleanFieldContext)
|
||||
const [searchBuffer, setSearchBuffer] = useState<string>("")
|
||||
if (context === undefined) {
|
||||
return (<></>)
|
||||
}
|
||||
@@ -45,40 +43,19 @@ function InnerMultiBooleanFormField(params: { options: string[], onChange: (arg0
|
||||
}
|
||||
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 (
|
||||
<ScrollArea onKeyDown={handleKeyDown} className="flex h-60">
|
||||
<>
|
||||
{
|
||||
params.options.map((opt) => (
|
||||
opt.startsWith(searchBuffer) && <FormItem key={opt}>
|
||||
<div className="flex flex-row justify-between py-2 border-b">
|
||||
<FormItem key={opt}>
|
||||
<div className="flex flex-row justify-between py-2 border-b-1">
|
||||
<FormLabel>{opt}</FormLabel>
|
||||
<Checkbox data-testid="multiboolean-checkbox" checked={checked(opt)} onCheckedChange={onCheckedItemChange(opt)} />
|
||||
</div>
|
||||
</FormItem>
|
||||
))
|
||||
}
|
||||
</ScrollArea>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default function MultiBooleanFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, options: string[], defaultValues?: string[] }) {
|
||||
@@ -99,9 +76,9 @@ export default function MultiBooleanFormField<T extends FieldValues>(params: { c
|
||||
</PopoverTrigger>
|
||||
<FormControl>
|
||||
<PopoverContent data-testid="multiboolean-content">
|
||||
<MultiBooleanFieldContext.Provider value={{checkedValues: checkedValues, setCheckedValue: setCheckedValues}}>
|
||||
<InnerMultiBooleanFormField onChange={field.onChange} options={params.options} />
|
||||
</MultiBooleanFieldContext.Provider>
|
||||
<MultiBooleanFieldContext.Provider value={{checkedValues: checkedValues, setCheckedValue: setCheckedValues}}>
|
||||
<InnerMultiBooleanFormField onChange={field.onChange} options={params.options} />
|
||||
</MultiBooleanFieldContext.Provider>
|
||||
</PopoverContent>
|
||||
</FormControl>
|
||||
</Popover>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { default as BooleanFormField } from './BooleanFormField'
|
||||
export { default as TextInputFormField } from './TextInputFormField'
|
||||
export { default as IntInputFormField } from './IntInputFormField'
|
||||
export { default as MultiBooleanFormField } from './MultiBooleanFormField'
|
||||
export { default as SelectFormField } from './SelectFormField'
|
||||
export { default as MdeFormField } from './MdeFormField'
|
||||
|
||||
@@ -4,18 +4,10 @@ import { Moon, Sun } from "lucide-react"
|
||||
import { useEffect } from "react"
|
||||
type Props = {activeTheme:string|undefined}
|
||||
const ThemeIcon = (props:Props) => {
|
||||
return (
|
||||
<>
|
||||
{props.activeTheme && props.activeTheme == 'dark' &&
|
||||
<Sun/>
|
||||
}
|
||||
{props.activeTheme && props.activeTheme == 'light' &&
|
||||
<Moon/>
|
||||
}
|
||||
{!props.activeTheme &&
|
||||
<Sun/>
|
||||
}
|
||||
</>
|
||||
)
|
||||
if (props.activeTheme == "dark") {
|
||||
return (<Sun/>)
|
||||
} else {
|
||||
return (<Moon/>)
|
||||
}
|
||||
}
|
||||
export default ThemeIcon;
|
||||
|
||||
@@ -8,9 +8,6 @@ import ThemeIcon from "./ThemeIcon"
|
||||
|
||||
export function ThemeSwitch() {
|
||||
const { setTheme, theme } = useTheme()
|
||||
if (!theme) {
|
||||
setTheme('dark')
|
||||
}
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme == "dark" ? "light" : "dark")
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ThemeSwitch } from "./ThemeSwitch"
|
||||
|
||||
export default function TopNav() {
|
||||
return (
|
||||
<div className="fixed backdrop-blur-md lg:w-full right-0 z-50">
|
||||
<div className="fixed lg:w-full right-0 z-50 lg:bg-background">
|
||||
<nav className="flex flex-col-reverse lg:flex-row flex-wrap w-20 lg:w-full outline-1 lg:h-10 h-full">
|
||||
<div className="flex flex-wrap lg:h-full w-20 lg:w-fit lg:flex-row">
|
||||
<Button className="flex h-10 lg:h-full w-full lg:w-20" asChild variant="outline">
|
||||
@@ -19,14 +19,6 @@ export default function TopNav() {
|
||||
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
|
||||
<Link href={"/projects"}> Projects </Link>
|
||||
</Button>
|
||||
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
|
||||
<Link href={"/music"}> Music </Link>
|
||||
</Button>
|
||||
<Show when="signed-in">
|
||||
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
|
||||
<Link href="/chat"> Chat </Link>
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse flex-wrap lg:h-full w-20 lg:w-fit lg:flex-row lg:ml-auto">
|
||||
<AdminWrap>
|
||||
@@ -52,13 +44,7 @@ export default function TopNav() {
|
||||
<Show when="signed-in">
|
||||
<Button asChild className="flex h-10 lg:h-full cursor-pointer lg:w-20 content-center" variant={"outline"}>
|
||||
<div>
|
||||
<UserButton
|
||||
userProfileProps={{
|
||||
additionalOAuthScopes: {
|
||||
google: ['https://www.googleapis.com/auth/calendar'],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<UserButton />
|
||||
</div>
|
||||
</Button>
|
||||
</Show>
|
||||
|
||||
@@ -1,293 +1,18 @@
|
||||
'use client'
|
||||
import { useGSAP } from '@gsap/react'
|
||||
import gsap from 'gsap'
|
||||
import { SplitText } from 'gsap/SplitText'
|
||||
import { ScrollTrigger, GSDevTools } from 'gsap/all'
|
||||
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
|
||||
gsap.registerPlugin(useGSAP)
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
gsap.registerPlugin(SplitText)
|
||||
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<{
|
||||
// Add a real animation (with its own duration) to the entrance timeline.
|
||||
addAnimation: (
|
||||
animation: gsap.core.TimelineChild,
|
||||
position: gsap.Position
|
||||
) => 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,
|
||||
resumeTimeline: () => void,
|
||||
getScroller: () => Element | Window | null
|
||||
} | null>(null)
|
||||
const GsapContext = createContext<typeof globalThis.gsap | null>(null)
|
||||
|
||||
export function useGsapContext() {
|
||||
return useContext(GsapContext)
|
||||
}
|
||||
|
||||
export const useTimeLine = (dep:any,all?:boolean) => {
|
||||
const gsapContext = useGsapContext()
|
||||
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) {
|
||||
let acc = true;
|
||||
let allDepsSatisfied = dep.reduce((p,c) => c !== undefined && p ,acc )
|
||||
if (allDepsSatisfied) {
|
||||
if (GSAP_DEBUG) console.log("[cv-debug][useTimeLine:resume-all]")
|
||||
gsapContext?.resumeTimeline()
|
||||
}
|
||||
} else {
|
||||
if (dep) {
|
||||
if (GSAP_DEBUG) console.log("[cv-debug][useTimeLine:resume]")
|
||||
gsapContext?.resumeTimeline()
|
||||
}
|
||||
}
|
||||
},[dep])
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
gsapContext?.resetTimeline()
|
||||
}
|
||||
},[])
|
||||
}
|
||||
|
||||
export default function GsapProvider({ children }: { children: ReactNode }) {
|
||||
const tl = useRef<gsap.core.Timeline | null>(null)
|
||||
const scrollerRef = useRef<Element | Window | null>(null)
|
||||
const getScroller = useCallback(() => {
|
||||
// const cached = scrollerRef.current
|
||||
// if (!cached || (cached instanceof Element && !document.contains(cached))) {
|
||||
let scrollers = document.querySelectorAll('[data-slot="scroll-area-viewport"]')
|
||||
if (scrollers.length < 1) {
|
||||
scrollerRef.current = window
|
||||
} else {
|
||||
let scrollerArray = Array.from(scrollers.values()).sort((a,b) => {
|
||||
const s1 = a as HTMLDivElement;
|
||||
const s2 = b as HTMLDivElement;
|
||||
// using bitwise not (~~) to coerce NaN values to 0
|
||||
const aPriority = ~~Number(s1.dataset?.scrollerPriority)
|
||||
const bPriority = ~~Number(s2.dataset?.scrollerPriority)
|
||||
return aPriority - bPriority;
|
||||
})
|
||||
let prioScroller = scrollerArray.pop();
|
||||
scrollerRef.current = prioScroller || window;
|
||||
}
|
||||
|
||||
// }
|
||||
return scrollerRef.current
|
||||
}, [])
|
||||
const devToolsCreated = useRef(false)
|
||||
useGSAP(() => {
|
||||
if (!tl.current) {
|
||||
tl.current = gsap.timeline({ paused: true })
|
||||
}
|
||||
if (GSAP_DEBUG && tl.current && !devToolsCreated.current) {
|
||||
devToolsCreated.current = true
|
||||
GSDevTools.create({ animation: tl.current })
|
||||
}
|
||||
return () => { if (GSAP_DEBUG) console.log("gsap cleanup") }
|
||||
})
|
||||
|
||||
// Handoff: fire registered callbacks once, when the entrance finishes.
|
||||
const readyFired = useRef(false)
|
||||
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(() => {
|
||||
if (GSAP_DEBUG) {
|
||||
console.log("[cv-debug][gsap:reset]", {
|
||||
duration: tl.current?.duration(),
|
||||
progress: tl.current?.progress(),
|
||||
})
|
||||
}
|
||||
tl.current?.kill()
|
||||
tl.current?.revert()
|
||||
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})
|
||||
},[])
|
||||
|
||||
const resumeTimeline = useCallback(() => {
|
||||
const t = tl.current
|
||||
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)
|
||||
}, [])
|
||||
export default function GsapProvider({children}:{children:ReactNode}) {
|
||||
return (
|
||||
<GsapContext.Provider value={{ addAnimation, schedule, onReady, requestRefresh: scheduleRefresh, resetTimeline, resumeTimeline, getScroller }}>
|
||||
<GsapContext.Provider value={gsap}>
|
||||
{children}
|
||||
</GsapContext.Provider>
|
||||
)
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
'use client'
|
||||
import type { inferRouterOutputs } from '@trpc/server';
|
||||
import { useUser } from '@clerk/nextjs'
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { trpc } from '~/app/_trpc/Client'
|
||||
import type { ChatRouter } from '~/server/routers/chat'
|
||||
const MessageContext = createContext<{
|
||||
session?: inferRouterOutputs<ChatRouter>['getSession']
|
||||
messages?: inferRouterOutputs<ChatRouter>['getMessages']
|
||||
refetchMessages: () => void
|
||||
clearChat: (callback?: () => void) => void
|
||||
error: string|null
|
||||
isLoading: boolean
|
||||
clearingChat: boolean
|
||||
clearedChat: boolean
|
||||
}>({
|
||||
session: undefined,
|
||||
messages: undefined,
|
||||
refetchMessages: () => undefined,
|
||||
clearChat: () => undefined,
|
||||
error: null,
|
||||
isLoading: true,
|
||||
clearingChat: false,
|
||||
clearedChat: false
|
||||
})
|
||||
export const useMessages = () => useContext(MessageContext)
|
||||
export const MessagesProvider = ({children}:{children:ReactNode}) => {
|
||||
const [error,setError] = useState<string|null>(null)
|
||||
const [isLoading,setIsLoading] = useState<boolean>(true)
|
||||
const { isLoaded, isSignedIn } = useUser()
|
||||
const { data: session,error:sessionError,isLoading:sessionLoading} = trpc.chat.getSession.useQuery(undefined, {
|
||||
enabled: isSignedIn === true,
|
||||
})
|
||||
const { data: messages, refetch, error:messageError, isLoading:messagesLoading } = trpc.chat.getMessages.useQuery(session?.id ? session.id : "", {
|
||||
enabled: isSignedIn === true && session?.id != undefined,
|
||||
})
|
||||
const { mutate ,isPending:clearingChat,isSuccess:clearedChat } = trpc.chat.clearChat.useMutation()
|
||||
const utils = trpc.useUtils()
|
||||
const refetchMessages = () => {
|
||||
if (!isSignedIn) {
|
||||
return;
|
||||
}
|
||||
utils.chat.getMessages.invalidate()
|
||||
refetch()
|
||||
}
|
||||
const clearChat = (callback?: () => void) => {
|
||||
if (!isSignedIn) {
|
||||
if (callback) {
|
||||
callback()
|
||||
}
|
||||
return;
|
||||
}
|
||||
mutate(undefined,{onSuccess: () => {
|
||||
if (callback) {
|
||||
callback()
|
||||
}
|
||||
utils.chat.getMessages.invalidate()
|
||||
}})
|
||||
}
|
||||
useEffect(() => {
|
||||
if (isSignedIn !== true) {
|
||||
setError(null)
|
||||
return;
|
||||
}
|
||||
messageError && setError(messageError.message)
|
||||
sessionError && setError(sessionError.message)
|
||||
},[messageError,sessionError,isSignedIn])
|
||||
useEffect(() => {
|
||||
if (!isLoaded) {
|
||||
setIsLoading(true)
|
||||
return;
|
||||
}
|
||||
if (isSignedIn !== true) {
|
||||
setIsLoading(false)
|
||||
return;
|
||||
}
|
||||
setIsLoading(sessionLoading || messagesLoading)
|
||||
},[isLoaded,isSignedIn,sessionLoading,messagesLoading])
|
||||
return (
|
||||
<MessageContext.Provider value={
|
||||
{
|
||||
session: isSignedIn === true ? session : undefined,
|
||||
messages: isSignedIn === true ? messages : undefined,
|
||||
refetchMessages,
|
||||
clearChat,
|
||||
error,
|
||||
isLoading,
|
||||
clearingChat,
|
||||
clearedChat
|
||||
}
|
||||
}>
|
||||
{children}
|
||||
</MessageContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
'use client'
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
export default function ThemeProvider({children}:{children: React.ReactNode}) {
|
||||
return (
|
||||
<NextThemesProvider disableTransitionOnChange attribute="class" defaultTheme="dark">
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
const [mounted,setMounted] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
})
|
||||
if (mounted) {
|
||||
return (
|
||||
<NextThemesProvider disableTransitionOnChange nonce="test" attribute="class" defaultTheme="dark">
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
import { httpBatchLink } from "@trpc/client";
|
||||
import { trpcRouter } from "~/server/routers/_app";
|
||||
|
||||
export const servTrpc = trpcRouter.createCaller({});
|
||||
function getBaseUrl() {
|
||||
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,7 +3,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'
|
||||
import { httpBatchLink } from "@trpc/client";
|
||||
import React, { useState } from "react"
|
||||
import superjson from "superjson";
|
||||
import { trpc } from "./Client";
|
||||
import getBaseUrl from "~/app/_trpc/GetBaseUrl";
|
||||
let clientQueryClient: QueryClient | undefined = undefined;
|
||||
@@ -34,7 +33,6 @@ export default function TrpcProvider({ children }: { children: React.ReactNode }
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${baseUrl}/api/trpc`,
|
||||
transformer: superjson,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -4,6 +4,5 @@ import { env } from "~/env"
|
||||
|
||||
export async function isAdmin() {
|
||||
const userid = (await auth()).userId
|
||||
console.log(userid)
|
||||
return (userid == env.ADMIN_USER_CLERK_ID)
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export default function currentTime() {
|
||||
let now = Date.now();
|
||||
console.log(now);
|
||||
return {
|
||||
success: true,
|
||||
time: now
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
'use server'
|
||||
import { clerkClient, auth } from '@clerk/nextjs/server'
|
||||
import { google } from 'googleapis'
|
||||
import { env } from '~/env'
|
||||
|
||||
export async function scheduleMeeting({
|
||||
title,
|
||||
description,
|
||||
dateTime,
|
||||
durationMinutes,
|
||||
attendeeEmail,
|
||||
attendeeName,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
dateTime: string
|
||||
durationMinutes: number
|
||||
attendeeEmail?: string
|
||||
attendeeName?: string
|
||||
}) {
|
||||
try {
|
||||
const clerk = await clerkClient()
|
||||
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 endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000)
|
||||
|
||||
const attendees: { email: string; displayName?: string }[] = []
|
||||
if (visitorEmail) {
|
||||
attendees.push({ email: visitorEmail, displayName: attendeeName })
|
||||
}
|
||||
|
||||
const event = await calendar.events.insert({
|
||||
calendarId: 'primary',
|
||||
sendUpdates: 'all',
|
||||
requestBody: {
|
||||
summary: title,
|
||||
description,
|
||||
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' },
|
||||
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
|
||||
attendees,
|
||||
},
|
||||
sendNotifications: true
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
eventId: event.data.id,
|
||||
htmlLink: event.data.htmlLink,
|
||||
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}${visitorEmail ? `. Invite sent to ${visitorEmail}.` : '.'}`,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to schedule meeting:', error)
|
||||
return { success: false, error: 'Failed to schedule meeting. Please try again.' }
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger } from "~/components/ui/sidebar";
|
||||
import SimpleSidebarGroup from "~/components/ui/simple-sidebar-group";
|
||||
|
||||
export default function AdminSideBar() {
|
||||
export default async function AdminSideBar() {
|
||||
return (
|
||||
<>
|
||||
<Sidebar variant="floating" className="h-full lg:h-[96%] lg:mt-10 z-51">
|
||||
<SidebarTrigger className="absolute z-52 left-65 top-100" />
|
||||
<SidebarProvider>
|
||||
<Sidebar className="z-[51]">
|
||||
<SidebarTrigger className="absolute z-[52] left-65 top-100" />
|
||||
<SidebarContent>
|
||||
<ScrollArea>
|
||||
<SimpleSidebarGroup lable="CV">
|
||||
<Link href={"/admin/cv/category/create"}> Create Category </Link>
|
||||
<Link href={"/admin/cv/entry/create"}> Create Entry </Link>
|
||||
@@ -21,19 +20,12 @@ export default function AdminSideBar() {
|
||||
<Link href={"/admin/project/techStack/create"}> Create Stack </Link>
|
||||
<Link href={"/admin/project/list"}> Project List </Link>
|
||||
</SimpleSidebarGroup>
|
||||
<SimpleSidebarGroup lable="Music">
|
||||
<Link href={"/admin/music"}> Manage Music </Link>
|
||||
</SimpleSidebarGroup>
|
||||
<SimpleSidebarGroup lable="Blog">
|
||||
<Link href={"/admin/blog/create"}> Create Post </Link>
|
||||
<Link href={"/admin/blog/list"}> Post List </Link>
|
||||
<Link href={"/"}> Some Blog Action </Link>
|
||||
</SimpleSidebarGroup>
|
||||
<SimpleSidebarGroup lable="Chat">
|
||||
<Link href={"/admin/chat"}> System Prompt </Link>
|
||||
</SimpleSidebarGroup>
|
||||
</ScrollArea>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { MDXRemote } from 'next-mdx-remote'
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
import type { MDXRemoteSerializeResult } from 'next-mdx-remote'
|
||||
import { mdxComponents } from '~/components/mdx-components'
|
||||
|
||||
export default function MdxEditorPreview(params: { source: string }) {
|
||||
const [compiled, setCompiled] = useState<MDXRemoteSerializeResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const timeout = window.setTimeout(() => {
|
||||
void serialize(params.source, {
|
||||
parseFrontmatter: false,
|
||||
mdxOptions: {
|
||||
remarkPlugins: [],
|
||||
rehypePlugins: [],
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
setCompiled(result)
|
||||
setError(null)
|
||||
})
|
||||
.catch((nextError: unknown) => {
|
||||
if (cancelled) return
|
||||
setCompiled(null)
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to compile MDX preview')
|
||||
})
|
||||
}, 200)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}, [params.source])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive'>
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!compiled) {
|
||||
return <div className='text-muted-foreground p-4 text-sm'>Rendering preview...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<article className='prose dark:prose-invert max-w-none'>
|
||||
<MDXRemote {...compiled} components={mdxComponents} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
'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} />,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
'use client'
|
||||
import { trpc } from '~/app/_trpc/Client'
|
||||
import { useParams } from 'next/navigation'
|
||||
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
|
||||
|
||||
export default function Page() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const { data } = trpc.blog.bySlug.useQuery(slug)
|
||||
if (data) return <CreateUpdateBlogForm entity={data} />
|
||||
return <></>
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
'use client'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { trpc } from '~/app/_trpc/Client'
|
||||
import { FormScaffold } from '~/app/_components/Form/Components'
|
||||
import { FormMutationContextProvider } from '~/app/_components/Form/Components/MutationProvider'
|
||||
import { useState } from 'react'
|
||||
import { TextInputFormField, MdeFormField } from '~/app/_components/Form/Fields'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useTheme } from 'next-themes'
|
||||
import type { RouterOutputs } from '~/server/routers/_app'
|
||||
import MdxComponentReference from '~/app/admin/_components/MdxComponentReference'
|
||||
import { useMdxEditorFieldProps } from '~/app/admin/_components/useMdxEditorFieldProps'
|
||||
|
||||
type BlogPost = RouterOutputs['blog']['bySlug']
|
||||
|
||||
const blogPostSchema = z.object({
|
||||
slug: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
date: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
tags: z.string().optional(),
|
||||
content: z.string(),
|
||||
})
|
||||
|
||||
function parseTags(value: string | undefined): string[] {
|
||||
return value?.split(',').map((tag) => tag.trim()).filter(Boolean) ?? []
|
||||
}
|
||||
|
||||
export default function CreateUpdateBlogForm(params: { className?: string, entity?: BlogPost }) {
|
||||
const [slug, setSlug] = useState<string | undefined>(params.entity?.slug)
|
||||
const [originalSlug, setOriginalSlug] = useState<string | undefined>(params.entity?.slug)
|
||||
const { theme } = useTheme()
|
||||
const form = useForm<z.infer<typeof blogPostSchema>>({
|
||||
resolver: zodResolver(blogPostSchema),
|
||||
defaultValues: {
|
||||
slug: params.entity?.slug ?? '',
|
||||
title: params.entity?.title ?? '',
|
||||
date: params.entity?.date ?? '',
|
||||
description: params.entity?.description ?? '',
|
||||
tags: params.entity?.tags?.join(', ') ?? '',
|
||||
content: params.entity?.content ?? '',
|
||||
},
|
||||
})
|
||||
const path = usePathname()
|
||||
const router = useRouter()
|
||||
const mdxEditorProps = useMdxEditorFieldProps()
|
||||
|
||||
const createMutation = trpc.blog.insert.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data[0]) {
|
||||
setSlug(data[0].slug)
|
||||
setOriginalSlug(data[0].slug)
|
||||
}
|
||||
},
|
||||
})
|
||||
const updateMutation = trpc.blog.update.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data[0]) {
|
||||
setSlug(data[0].slug)
|
||||
setOriginalSlug(data[0].slug)
|
||||
}
|
||||
},
|
||||
})
|
||||
const deleteMutation = trpc.blog.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
if (path.includes('list')) { router.refresh(); return }
|
||||
router.back()
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(values: z.infer<typeof blogPostSchema>) {
|
||||
const input = { ...values, tags: parseTags(values.tags) }
|
||||
if (slug && originalSlug) {
|
||||
updateMutation.mutate({ ...input, originalSlug })
|
||||
} else {
|
||||
createMutation.mutate(input)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormMutationContextProvider value={{
|
||||
createMutation: createMutation,
|
||||
updateMutation: updateMutation,
|
||||
deleteMutation: deleteMutation,
|
||||
}}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<MdxComponentReference />
|
||||
<FormScaffold
|
||||
form={form}
|
||||
onSubmit={onSubmit}
|
||||
title='Blog Post'
|
||||
id={slug}
|
||||
className={params.className}
|
||||
>
|
||||
<TextInputFormField control={form.control} name='slug' label='Slug' />
|
||||
<TextInputFormField control={form.control} name='title' label='Title' />
|
||||
<TextInputFormField control={form.control} name='date' label='Date (YYYY-MM-DD)' />
|
||||
<TextInputFormField control={form.control} name='description' label='Description' />
|
||||
<TextInputFormField control={form.control} name='tags' label='Tags (comma separated)' />
|
||||
<MdeFormField
|
||||
control={form.control}
|
||||
name='content'
|
||||
label='Content'
|
||||
dataColorMode={(theme as 'dark' | 'light') ?? 'dark'}
|
||||
{...mdxEditorProps}
|
||||
/>
|
||||
</FormScaffold>
|
||||
</div>
|
||||
</FormMutationContextProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
'use client'
|
||||
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
|
||||
|
||||
export default function Page() {
|
||||
return <CreateUpdateBlogForm />
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
'use client'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '~/app/_trpc/Client'
|
||||
import * as Card from '~/components/ui/card'
|
||||
import { CollapsibleForm } from '~/app/_components/Form/Components'
|
||||
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
|
||||
import { Badge } from '~/components/ui/badge'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
export default function BlogListPage() {
|
||||
const posts = trpc.blog.list.useQuery(undefined, { refetchInterval: 5000 })
|
||||
const syncMutation = trpc.blog.syncFromUploadThing.useMutation({
|
||||
onSuccess: () => posts.refetch(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='w-5/6 lg:w-1/2 flex flex-col gap-3'>
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => syncMutation.mutate(undefined)}
|
||||
disabled={syncMutation.status === 'pending'}
|
||||
>
|
||||
<RefreshCw />
|
||||
Sync
|
||||
</Button>
|
||||
</div>
|
||||
{syncMutation.data && (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Synced {syncMutation.data.created} created, {syncMutation.data.updated} updated, {syncMutation.data.skipped} skipped.
|
||||
</p>
|
||||
)}
|
||||
{posts.data == undefined ?
|
||||
<div className='gsapan' /> :
|
||||
<>
|
||||
{posts.data.map((post) => (
|
||||
<Card.Card className='gsapan' key={post.slug}>
|
||||
<Link href={`/admin/blog/${post.slug}`}>
|
||||
<Card.CardHeader>
|
||||
<Card.CardTitle>{post.title}</Card.CardTitle>
|
||||
{post.date && <p className='text-sm text-muted-foreground'>{post.date}</p>}
|
||||
{post.description && <p className='text-sm text-muted-foreground'>{post.description}</p>}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
{post.tags.map((tag) => (
|
||||
<Badge key={tag} variant='outline'>{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.CardHeader>
|
||||
</Link>
|
||||
</Card.Card>
|
||||
))}
|
||||
<CollapsibleForm entityName='Blog Post' form={CreateUpdateBlogForm} />
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
'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,39 +0,0 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { Textarea } from '~/components/ui/textarea'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { trpc } from '~/app/_trpc/Client'
|
||||
|
||||
export default function SystemPromptForm({ initialValue }: { initialValue: string }) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const mutation = trpc.chat.updateSystemPrompt.useMutation({
|
||||
onSuccess: () => setSaved(true),
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setSaved(false)
|
||||
mutation.mutate({ prompt: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 w-full">
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => { setValue(e.target.value); setSaved(false) }}
|
||||
rows={16}
|
||||
className="font-mono text-sm resize-y"
|
||||
placeholder="Enter the system prompt for the AI recruiter..."
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
{saved && <span className="text-sm text-muted-foreground">Saved</span>}
|
||||
{mutation.error && <span className="text-sm text-destructive">{mutation.error.message}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { servTrpc } from '~/app/_trpc/ServerClient'
|
||||
import SystemPromptForm from './_components/SystemPromptForm'
|
||||
import ModelSelector from './_components/ModelSelector'
|
||||
|
||||
export default async function SystemPromptPage() {
|
||||
const prompt = await servTrpc.chat.getSystemPrompt()
|
||||
const model = await servTrpc.chat.getModel()
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl p-6 flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">AI Model</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The OpenAI model used to respond to chat requests.
|
||||
</p>
|
||||
</div>
|
||||
<ModelSelector initialValue={model} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">AI System Prompt</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This prompt is sent to the model on every chat request.
|
||||
</p>
|
||||
</div>
|
||||
<SystemPromptForm initialValue={prompt} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,13 +14,13 @@ import { SelectItem } from '~/components/ui/select';
|
||||
import {FormMutationContextProvider} from '~/app/_components/Form/Components/MutationProvider';
|
||||
export default function CreateUpdateCvCategoryForm(params: { className?: string, entity?: IterableElement<RouterOutputs['category']['select']> }) {
|
||||
const schemas = entitySchemas('cvCategory')
|
||||
const [id, setId] = useState<string | undefined>(params.entity?.id)
|
||||
const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
|
||||
const form = useForm<z.infer<typeof schemas.insert>>({
|
||||
resolver: zodResolver(schemas.insert),
|
||||
defaultValues: {
|
||||
id: params.entity?.id || crypto.randomUUID(),
|
||||
name: params.entity?.name || "",
|
||||
layoutPosition: params.entity?.layoutPosition || "col1"
|
||||
id: params.entity ? params.entity.id : crypto.randomUUID(),
|
||||
name: params.entity ? params.entity.name : "",
|
||||
layoutPosition: params.entity ? params.entity.layoutPosition : "col1"
|
||||
}
|
||||
})
|
||||
let path = usePathname()
|
||||
|
||||
@@ -11,8 +11,13 @@ import CreateUpdateCvCategoryForm from "../_components/CreateUpdateForm";
|
||||
export default function CvPage() {
|
||||
const categories = trpc.category.select.useQuery({}, { refetchInterval: 1000 });
|
||||
const entires = trpc.entry.select.useSuspenseQuery({}, { refetchInterval: 1000 })
|
||||
const gsap = useGsapContext()
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
useGSAP(() => {
|
||||
gsap?.from('.gsapan', { x: -100, opacity: 0, duration: 0.5, stagger: { each: 0.3 } });
|
||||
}, { scope: container, dependencies: [categories.status], revertOnUpdate: true });
|
||||
return (
|
||||
<>
|
||||
<div ref={container} className="w-5/6 lg:w-1/2 flex flex-col gap-3">
|
||||
{categories.data == undefined ?
|
||||
<div className="gsapan"></div>
|
||||
:
|
||||
@@ -59,6 +64,6 @@ export default function CvPage() {
|
||||
<CollapsibleForm entityName="Category" form={CreateUpdateCvCategoryForm} />
|
||||
</>
|
||||
}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ import { useState } from 'react';
|
||||
import { SelectFormField, TextInputFormField, MdeFormField, CalenderFormField, BooleanFormField } from '~/app/_components/Form/Fields'
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
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 }) {
|
||||
const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
|
||||
const { theme } = useTheme()
|
||||
@@ -36,7 +34,6 @@ export default function CreateUpdateCvEntryForm(params: { className?: string, en
|
||||
})
|
||||
let path = usePathname()
|
||||
let router = useRouter()
|
||||
const mdxEditorProps = useMdxEditorFieldProps()
|
||||
const createMutation = trpc.entry.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) })
|
||||
const updateMutation = trpc.entry.update.useMutation({ onSuccess: makeOnSuccess('update', form) })
|
||||
const deleteMutation = trpc.entry.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
|
||||
@@ -54,8 +51,6 @@ export default function CreateUpdateCvEntryForm(params: { className?: string, en
|
||||
updateMutation:updateMutation,
|
||||
deleteMutation:deleteMutation
|
||||
}}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<MdxComponentReference />
|
||||
<FormScaffold
|
||||
form={form}
|
||||
onSubmit={onSubmit}
|
||||
@@ -75,12 +70,11 @@ export default function CreateUpdateCvEntryForm(params: { className?: string, en
|
||||
}
|
||||
</SelectFormField>
|
||||
<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"} {...mdxEditorProps} />
|
||||
<MdeFormField control={form.control} name='description' label='Description' dataColorMode={(theme as "dark" | "light") ? (theme as "dark" | "light") : "dark"} />
|
||||
<CalenderFormField control={form.control} name='fromTime' label='From Date' />
|
||||
<CalenderFormField control={form.control} name='toTime' label='To Date' />
|
||||
<BooleanFormField control={form.control} name='hideDates' label='Hide Dates' />
|
||||
</FormScaffold>
|
||||
</div>
|
||||
</FormMutationContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,9 +8,13 @@ import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
||||
|
||||
export default function CvPage() {
|
||||
const entires = trpc.entry.select.useQuery({});
|
||||
const gsap = useGsapContext()
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
useGSAP(() => {
|
||||
gsap?.from('.gsapan', { x: -100, opacity: 0, duration: 0.5, stagger: { each: 0.3 } })
|
||||
}, { scope: container, dependencies: [entires.status], revertOnUpdate: true });
|
||||
return (
|
||||
<>
|
||||
<div ref={container} className="w-5/6 lg:w-1/2 flex flex-col gap-3">
|
||||
{entires.data == undefined ?
|
||||
<div className="gsapan"></div>
|
||||
:
|
||||
@@ -36,6 +40,6 @@ export default function CvPage() {
|
||||
})}
|
||||
</>
|
||||
}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { isAdmin } from "~/app/actions";
|
||||
import { SidebarProvider } from "~/components/ui/sidebar";
|
||||
import AdminSideBar from "./_components/AdminSideBar";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
'use server'
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
import AdminSideBar from "./_components/AdminSideBar";
|
||||
|
||||
export default async function Admin({children}: Readonly<{children: React.ReactNode}>) {
|
||||
if (!(await isAdmin())) redirect("/");
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarProvider>
|
||||
<AdminSideBar/>
|
||||
<ScrollArea className="px-10 lg:px-0 w-full h-screen pb-10 max-w-4xl mx-auto pt-10">
|
||||
<main className="absolute flex items-center content-center justify-center flex-wrap w-[100vw] left-0 top-15">
|
||||
{children}
|
||||
</ScrollArea>
|
||||
</SidebarProvider>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/app/_trpc/Client";
|
||||
import { UploadDropzone } from "~/lib/uploadthing";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { FormScaffold } from "~/app/_components/Form/Components";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { RouterOutputs } from "~/server/routers/_app";
|
||||
import type { IterableElement } from "type-fest";
|
||||
import { Toaster } from "~/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
import { FormMutationContextProvider } from "~/app/_components/Form/Components/MutationProvider";
|
||||
import { TextInputFormField } from "~/app/_components/Form/Fields";
|
||||
import { createMusicInputSchema } from "~/lib/trpc/music/schemas";
|
||||
export default function CreateUpdateMusicForm(props: {
|
||||
entity?: IterableElement<RouterOutputs['music']['list']>,
|
||||
className?: string
|
||||
}) {
|
||||
const entity = props.entity;
|
||||
const [id, setId] = useState<string | undefined>(entity?.id)
|
||||
const utils = trpc.useUtils();
|
||||
const form = useForm<z.infer<typeof createMusicInputSchema>>({
|
||||
resolver: zodResolver(createMusicInputSchema),
|
||||
defaultValues: {
|
||||
id: entity?.id || crypto.randomUUID(),
|
||||
title: entity?.title || "",
|
||||
description: entity?.description || "",
|
||||
fileUrl: entity?.fileUrl,
|
||||
fileKey: entity?.fileKey,
|
||||
fileName: entity?.fileName,
|
||||
}
|
||||
})
|
||||
|
||||
const createMutation = trpc.music.create.useMutation({
|
||||
onSuccess: (values) => {
|
||||
setId(values?.id);
|
||||
utils.music.list.invalidate();
|
||||
},
|
||||
onError: (e) => {
|
||||
toast(e.message)
|
||||
}
|
||||
})
|
||||
const updateMutation = trpc.music.update.useMutation({
|
||||
onSuccess: (_) => {
|
||||
utils.music.list.invalidate();
|
||||
},
|
||||
onError: (e) => {
|
||||
toast(e.message)
|
||||
}
|
||||
})
|
||||
const deleteMutation = trpc.music.delete.useMutation({
|
||||
onSuccess: (_) => {
|
||||
utils.music.list.invalidate();
|
||||
},
|
||||
onError: (e) => {
|
||||
toast(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
function onSubmit(values: z.infer<typeof createMusicInputSchema>) {
|
||||
id ?
|
||||
updateMutation.mutate(values) :
|
||||
createMutation.mutate(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster />
|
||||
<FormMutationContextProvider value={{
|
||||
createMutation: createMutation,
|
||||
updateMutation: updateMutation,
|
||||
deleteMutation: deleteMutation
|
||||
}}>
|
||||
<FormScaffold form={form} onSubmit={onSubmit} title='Music' id={id} className={props.className}>
|
||||
<TextInputFormField control={form.control} name='title' label='Title'/>
|
||||
<TextInputFormField control={form.control} name='description' label='Description'/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Audio File</Label>
|
||||
<UploadDropzone
|
||||
endpoint="musicUploader"
|
||||
config={{mode: 'auto'}}
|
||||
onUploadError={(e) => {
|
||||
toast(e.message)
|
||||
}}
|
||||
onClientUploadComplete={(res) => {
|
||||
console.log(res)
|
||||
if (res[0]) {
|
||||
form.setValue('fileKey',res[0].serverData.fileKey);
|
||||
form.setValue('fileName',res[0].serverData.fileName);
|
||||
form.setValue('title',res[0].serverData.fileName);
|
||||
form.setValue('description',res[0].serverData.fileName);
|
||||
form.setValue('fileUrl',res[0].serverData.fileUrl);
|
||||
}
|
||||
console.log(form.getValues());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormScaffold>
|
||||
</FormMutationContextProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from "~/app/_trpc/Client";
|
||||
import * as Card from "~/components/ui/card";
|
||||
import UploadMusicForm from "./_components/UploadMusicForm";
|
||||
import ConvertToStreamButton from "./_components/ConvertToStreamButton";
|
||||
import { CollapsibleForm } from "~/app/_components/Form/Components";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function AdminMusicPage() {
|
||||
const { data: tracks } = trpc.music.list.useQuery();
|
||||
useEffect(() => {console.log(tracks)}, [tracks])
|
||||
return (
|
||||
<div className="w-5/6 lg:w-1/2 flex flex-col gap-3">
|
||||
{tracks && <>
|
||||
{tracks.map((t) => (
|
||||
<Card.Card key={t.id}>
|
||||
<Card.CardContent className="flex flex-col gap-4">
|
||||
<UploadMusicForm entity={t} className="w-full"/>
|
||||
<ConvertToStreamButton track={t} />
|
||||
</Card.CardContent>
|
||||
</Card.Card>
|
||||
))}
|
||||
</>}
|
||||
<CollapsibleForm entityName="Track" form={UploadMusicForm}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
'use server'
|
||||
|
||||
import { Show } from "@clerk/nextjs";
|
||||
|
||||
export default async function AdminPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center">
|
||||
<div>
|
||||
hello admin
|
||||
</div>
|
||||
</main>
|
||||
<Show when="signed-in">
|
||||
<main className="flex min-h-screen flex-col items-center justify-center">
|
||||
<div>
|
||||
hello admin
|
||||
</div>
|
||||
</main>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,14 +8,12 @@ import type { IterableElement } from 'type-fest'
|
||||
import { entitySchemas, makeOnSuccess } from "~/lib/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { RouterOutputs } from '~/server/routers/_app';
|
||||
import { SelectFormField, TextInputFormField, MdeFormField, IntInputFormField } from '~/app/_components/Form/Fields'
|
||||
import { SelectFormField, TextInputFormField, MdeFormField } from '~/app/_components/Form/Fields'
|
||||
import { FormScaffold } from '~/app/_components/Form/Components';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { makeUseRelationShipWithNameIndex } from '~/lib/hooks';
|
||||
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']> }) {
|
||||
const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
|
||||
const { theme } = useTheme()
|
||||
@@ -31,13 +29,11 @@ export default function CreateUpdateProjectForm(params: { className?: string, en
|
||||
releaseStatus: params.entity ? params.entity.releaseStatus : "unreleased",
|
||||
releaseLink: params.entity ? params.entity.releaseLink : "",
|
||||
sourceType: params.entity ? params.entity.sourceType : "open",
|
||||
sourceLink: params.entity ? params.entity.sourceLink : "",
|
||||
orderPos: params.entity ? params.entity.orderPos : 0
|
||||
sourceLink: params.entity ? params.entity.sourceLink : ""
|
||||
}
|
||||
})
|
||||
let path = usePathname()
|
||||
let router = useRouter()
|
||||
const mdxEditorProps = useMdxEditorFieldProps()
|
||||
const createMutation = trpc.project.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) })
|
||||
const updateMutation = trpc.project.update.useMutation({ onSuccess: makeOnSuccess('update', form) })
|
||||
const deleteMutation = trpc.project.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
|
||||
@@ -53,8 +49,6 @@ export default function CreateUpdateProjectForm(params: { className?: string, en
|
||||
updateMutation: updateMutation,
|
||||
deleteMutation: deleteMutation
|
||||
}}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<MdxComponentReference />
|
||||
<FormScaffold
|
||||
form={form}
|
||||
onSubmit={onSubmit}
|
||||
@@ -74,7 +68,7 @@ export default function CreateUpdateProjectForm(params: { className?: string, en
|
||||
}
|
||||
</SelectFormField>
|
||||
<TextInputFormField control={form.control} name='title' label='Title' />
|
||||
<MdeFormField control={form.control} name='description' label='Description' dataColorMode={(theme as "dark" | "light") ?? "dark"} {...mdxEditorProps} />
|
||||
<MdeFormField control={form.control} name='description' label='Description' dataColorMode={(theme as "dark" | "light") ?? "dark"} />
|
||||
<SelectFormField control={form.control} name='sourceType' label='Source Type' defaultValue={'open'} placeholder='open' >
|
||||
<SelectItem value="open"> open </SelectItem>
|
||||
<SelectItem value="closed"> closed </SelectItem>
|
||||
@@ -85,9 +79,7 @@ export default function CreateUpdateProjectForm(params: { className?: string, en
|
||||
<SelectItem value="unreleased"> unreleased </SelectItem>
|
||||
</SelectFormField>
|
||||
<TextInputFormField control={form.control} label='Release Link' name='releaseLink' />
|
||||
<IntInputFormField control={form.control} label='Order Position' name='orderPos'/>
|
||||
</FormScaffold>
|
||||
</div>
|
||||
</FormMutationContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function ProjectList() {
|
||||
const projects = trpc.project.select.useQuery({}, { refetchInterval: 1000 })
|
||||
const techStacks = trpc.techStack.select.useSuspenseQuery({}, { refetchInterval: 1000 })
|
||||
return (
|
||||
<>
|
||||
<div className="w-5/6 lg:w-1/2 flex flex-col gap-3">
|
||||
{
|
||||
projects.data == undefined ?
|
||||
<></> :
|
||||
@@ -55,6 +55,6 @@ export default function ProjectList() {
|
||||
<CollapsibleForm entityName="Project" form={CreateUpdateProjectForm} />
|
||||
</>
|
||||
}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function CreateUpdateStackForm(params: { className?: string, enti
|
||||
id={id}
|
||||
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>
|
||||
</FormMutationContextProvider>
|
||||
</>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { createOpenAI } from '@ai-sdk/openai'
|
||||
import { streamText, tool, convertToModelMessages, stepCountIs, type UIMessage } from 'ai'
|
||||
import { success, z } from 'zod'
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { env } from '~/env'
|
||||
import { db } from '~/server/db'
|
||||
import { chatSession, chatMessage } from '~/server/dbschema/schema'
|
||||
import { servTrpc } from '~/app/_trpc/ServerClient'
|
||||
import { scheduleMeeting } from '~/app/actions/scheduleMeeting'
|
||||
import currentTime from '~/app/actions/currentTime';
|
||||
|
||||
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { userId } = await auth()
|
||||
if (userId == null) return new Response('Unauthorized', { status: 401 })
|
||||
|
||||
const { messages, sessionId } = (await req.json()) as {
|
||||
messages: UIMessage[]
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
// Verify this session belongs to the authenticated user
|
||||
const session = await db
|
||||
.select()
|
||||
.from(chatSession)
|
||||
.where(and(eq(chatSession.id, sessionId), eq(chatSession.userId, userId)))
|
||||
.limit(1)
|
||||
.then((r) => r[0])
|
||||
|
||||
if (!session) return new Response('Session not found', { status: 404 })
|
||||
|
||||
const systemPrompt = await servTrpc.chat.getSystemPrompt() || 'You are an AI recruiter assistant.'
|
||||
const model = await servTrpc.chat.getModel()
|
||||
|
||||
// Save the latest user message
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (lastMessage?.role === 'user') {
|
||||
const content = lastMessage.parts
|
||||
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
|
||||
.map((p) => p.text)
|
||||
.join('')
|
||||
if (content) {
|
||||
await db.insert(chatMessage).values({ sessionId, role: 'user', content })
|
||||
}
|
||||
}
|
||||
|
||||
const result = streamText({
|
||||
model: openai(model),
|
||||
system: systemPrompt,
|
||||
messages: await convertToModelMessages(messages),
|
||||
tools: {
|
||||
scheduleMeeting: tool({
|
||||
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 }) => {
|
||||
if (text && finishReason === 'stop') {
|
||||
await db.insert(chatMessage).values({
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return result.toUIMessageStreamResponse()
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createRouteHandler } from "uploadthing/next";
|
||||
import { fileRouter } from "~/server/uploadthing";
|
||||
|
||||
export const { GET, POST } = createRouteHandler({
|
||||
router: fileRouter,
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function AssistantPage() {
|
||||
redirect('/chat')
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { MDXRemote } from "next-mdx-remote/rsc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import matter from "gray-matter";
|
||||
import { servTrpc } from "~/app/_trpc/ServerClient";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { mdxComponents } from "~/components/mdx-components";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ slug: string }>;
|
||||
};
|
||||
|
||||
export default async function BlogPostPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
|
||||
let post: Awaited<ReturnType<typeof servTrpc.blog.metadataBySlug>>;
|
||||
try {
|
||||
post = await servTrpc.blog.metadataBySlug(slug);
|
||||
} catch (e) {
|
||||
if (e instanceof TRPCError && e.code === "NOT_FOUND") notFound();
|
||||
throw e;
|
||||
}
|
||||
|
||||
const response = await fetch(post.fileUrl, { next: { revalidate: 3600 } });
|
||||
if (!response.ok) notFound();
|
||||
|
||||
const parsed = matter(await response.text());
|
||||
const tags = Array.isArray(parsed.data.tags)
|
||||
? parsed.data.tags.map((tag) => String(tag).trim()).filter(Boolean)
|
||||
: post.tags;
|
||||
const title = typeof parsed.data.title === "string" ? parsed.data.title : post.title;
|
||||
const date = typeof parsed.data.date === "string" ? parsed.data.date : post.date;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-12">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold">{title}</h1>
|
||||
{date && (
|
||||
<time className="text-muted-foreground text-sm">
|
||||
{new Date(date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</time>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<article className="prose dark:prose-invert max-w-none">
|
||||
<MDXRemote source={parsed.content} components={mdxComponents} />
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
export default function BlogLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
'use client'
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode}>) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,45 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { servTrpc } from "~/app/_trpc/ServerClient";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
'use client'
|
||||
|
||||
export default async function BlogPage() {
|
||||
const posts = await servTrpc.blog.list();
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
export default function Page() {
|
||||
const pathName = usePathname()
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-12">
|
||||
<h1 className="mb-8 text-3xl font-bold">Blog</h1>
|
||||
{posts.length === 0 ? (
|
||||
<p className="text-muted-foreground">No posts yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-6">
|
||||
{posts.map((post) => (
|
||||
<li key={post.slug}>
|
||||
<Link href={`/blog/${post.slug}`} className="group block">
|
||||
<h2 className="text-xl font-semibold group-hover:underline">{post.title}</h2>
|
||||
{post.date && (
|
||||
<time className="text-muted-foreground text-sm">
|
||||
{new Date(post.date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</time>
|
||||
)}
|
||||
{post.description && (
|
||||
<p className="text-muted-foreground mt-1">{post.description}</p>
|
||||
)}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{post.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
<div>
|
||||
{pathName}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import { ClientMdx } from "~/components/ClientMdx";
|
||||
|
||||
export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||
let message = props.message;
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className='flex justify-start'
|
||||
>
|
||||
<div
|
||||
className=
|
||||
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-muted'
|
||||
>
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<ClientMdx key={i} source={part.text} fallback={part.text} />
|
||||
)
|
||||
}
|
||||
if (part.type === 'tool-scheduleMeeting') {
|
||||
const toolPart = part as unknown as {
|
||||
type: 'tool-scheduleMeeting'
|
||||
state: string
|
||||
input: unknown
|
||||
output?: { success: boolean; message?: string; htmlLink?: string; error?: string }
|
||||
}
|
||||
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
|
||||
return (
|
||||
<p key={i} className="text-xs opacity-70 italic">
|
||||
Scheduling meeting…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (toolPart.state === 'output-available' && toolPart.output) {
|
||||
const result = toolPart.output
|
||||
return (
|
||||
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
|
||||
{result.success ? (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport, type UIMessage } from 'ai'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Textarea } from '~/components/ui/textarea'
|
||||
import { SignInButton } from '@clerk/nextjs'
|
||||
import {
|
||||
useGsapContext,
|
||||
} from '~/app/_providers/GsapProvicer';
|
||||
import Messages from './Messages'
|
||||
import { DeleteIcon } from 'lucide-react';
|
||||
import { Spinner } from '~/components/ui/spinner';
|
||||
import { useMessages } from '~/app/_providers/MessagesProvider';
|
||||
interface DBMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
sessionId?: string,
|
||||
dbMessages: DBMessage[],
|
||||
}
|
||||
|
||||
function SignInChatPrompt() {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 text-center">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold">Sign in to use the chat</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You need to be signed in before you can talk to Gregor's AI assistant.
|
||||
</p>
|
||||
</div>
|
||||
<SignInButton mode="modal">
|
||||
<Button type="button">Sign in</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function toUIMessages(dbMessages: DBMessage[]): UIMessage[] {
|
||||
return dbMessages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
parts: [{ type: 'text' as const, text: m.content }],
|
||||
}))
|
||||
}
|
||||
|
||||
function addInitMessage(messageArray: UIMessage[]) {
|
||||
if (messageArray.at(0)?.id != 'init') {
|
||||
messageArray.unshift({
|
||||
id: "init",
|
||||
role: 'assistant',
|
||||
parts: [{
|
||||
type: 'text',
|
||||
text: "Hi im gregors ai assistant,you can ask me to provide general information or to schedule a meeting."
|
||||
}],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function AuthenticatedChatInterface({ dbMessages, sessionId }: ChatInterfaceProps & { sessionId: string }) {
|
||||
const [input, setInput] = useState('')
|
||||
const { clearingChat, clearChat, refetchMessages } = useMessages();
|
||||
const initialMessages = toUIMessages(dbMessages)
|
||||
addInitMessage(initialMessages)
|
||||
const { messages, sendMessage, status, error, clearError, setMessages } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat', body: { sessionId },
|
||||
}),
|
||||
messages: initialMessages,
|
||||
})
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
refetchMessages()
|
||||
}
|
||||
}, [])
|
||||
const handleSend = () => {
|
||||
const text = input.trim()
|
||||
if (!text || status != 'ready' || clearingChat) return
|
||||
setInput('')
|
||||
sendMessage({ text })
|
||||
}
|
||||
const gsapContext = useGsapContext()
|
||||
useEffect(() => {
|
||||
let scroller = gsapContext?.getScroller()
|
||||
if (scroller instanceof Window) {
|
||||
return;
|
||||
}
|
||||
console.log(scroller?.scrollHeight)
|
||||
scroller?.scrollTo({ behavior: 'smooth', top: scroller.scrollHeight })
|
||||
}, [messages])
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{messages &&
|
||||
<Messages status={status} messages={messages} />
|
||||
}
|
||||
{error && (
|
||||
<div className="mx-4 mb-2 flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<span className="flex-1">
|
||||
{error.message.includes('quota') || error.message.includes('429')
|
||||
? 'OpenAI quota exceeded. Please try again later.'
|
||||
: `Error: ${error.message}`}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={clearError}
|
||||
className="shrink-0 opacity-60 hover:opacity-100"
|
||||
variant='destructive'
|
||||
>
|
||||
<DeleteIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 border-t flex flex-row gap-2">
|
||||
<Textarea
|
||||
name='message'
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Ask about Gregor's experience or schedule a meeting…"
|
||||
className="resize-none"
|
||||
rows={2}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={status != "ready" || !input.trim()}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={() => {
|
||||
clearChat(() => {
|
||||
let messages: UIMessage[] = [];
|
||||
addInitMessage(messages);
|
||||
setMessages(messages)
|
||||
})
|
||||
}}
|
||||
disabled={status != "ready" || clearingChat}
|
||||
>
|
||||
{clearingChat ?
|
||||
<Spinner /> :
|
||||
"Clear Chat"
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChatInterface({ dbMessages, sessionId }: ChatInterfaceProps) {
|
||||
if (sessionId == undefined) {
|
||||
return <SignInChatPrompt />
|
||||
}
|
||||
return <AuthenticatedChatInterface sessionId={sessionId} dbMessages={dbMessages} />
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { type ChatStatus, type UIMessage } from 'ai'
|
||||
import * as Card from "~/components/ui/card"
|
||||
import { UserMessage } from './UserMessage';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
import { ScrollArea } from '~/components/ui/scroll-area';
|
||||
import { memo } from 'react';
|
||||
const Messages = memo(({messages,status}: { messages: UIMessage[],status:ChatStatus}) => {
|
||||
return (
|
||||
<ScrollArea data-scroller-priority='1' className="w-full h-[90%] max-w-4xl mx-auto">
|
||||
{messages.map((message, i) => (
|
||||
<Card.AnimatedCard scrollOnly={true} key={i}>
|
||||
<Card.CardContent>
|
||||
{message.role == 'assistant' && <AssistantMessage message={message} />}
|
||||
{message.role == 'user' && <UserMessage message={message} />}
|
||||
</Card.CardContent>
|
||||
</Card.AnimatedCard>
|
||||
))}
|
||||
{status == 'submitted' &&
|
||||
<Card.AnimatedCard scrollOnly={true}>
|
||||
<Card.CardContent>
|
||||
<AssistantMessage message={{
|
||||
id:"",
|
||||
role:"assistant",
|
||||
parts:[{
|
||||
type:'text',
|
||||
text:'Thinking ...'
|
||||
}]
|
||||
}}/>
|
||||
</Card.CardContent>
|
||||
</Card.AnimatedCard>
|
||||
|
||||
}
|
||||
</ScrollArea>)
|
||||
})
|
||||
|
||||
export default Messages;
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { UIMessage } from "ai"
|
||||
|
||||
export const UserMessage = (props:{message: UIMessage}) => {
|
||||
let message = props.message.parts.reduce((acc, part) => {
|
||||
if (part.type == 'text') {
|
||||
return acc + part.text
|
||||
}
|
||||
return acc
|
||||
},"");
|
||||
return (
|
||||
<div
|
||||
key={props.message.id}
|
||||
className='flex justify-end'
|
||||
>
|
||||
<div
|
||||
className=
|
||||
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-primary'
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
'use client'
|
||||
import ChatInterface from './_components/ChatInterface'
|
||||
import AnimatedPageTitle from '../_components/Animated/AnimatedPageTitle';
|
||||
import { useTimeLine } from '../_providers/GsapProvicer';
|
||||
import { useMessages } from '../_providers/MessagesProvider';
|
||||
import { Spinner } from '~/components/ui/spinner';
|
||||
|
||||
export default function ChatPage() {
|
||||
const {messages,session,isLoading,error} = useMessages()
|
||||
useTimeLine(messages)
|
||||
return (
|
||||
<div className="flex flex-col px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
|
||||
<AnimatedPageTitle position={0}>
|
||||
<span>Talk To My </span> <span> AI-Assistant</span>
|
||||
</AnimatedPageTitle>
|
||||
<div className='flex items-center h-[80%] w-full my-auto w-full'>
|
||||
{!isLoading &&
|
||||
<ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
|
||||
}
|
||||
{isLoading &&
|
||||
<><Spinner/> Loading Messages...</>
|
||||
}
|
||||
{error &&
|
||||
<div> {error} </div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +1,35 @@
|
||||
import type { ReactNode } from "react"
|
||||
'use client'
|
||||
import { trpc } from "~/app/_trpc/Client"
|
||||
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 { cn } from "~/lib/utils"
|
||||
import { AnimatedCard, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||
import { cn } from "~/lib/utils"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||
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 = {
|
||||
category: CvCategoryData,
|
||||
layout: "row" | "col",
|
||||
position?: number,
|
||||
descriptions: Record<string, ReactNode>,
|
||||
initialData: ArrayElement<RouterOutputs['categoryv2']['listByLayoutPosition']>,
|
||||
layout: "row"|"col",
|
||||
children?: React.ReactElement<Parameters<typeof CvEntry>>
|
||||
}
|
||||
export default function CvCategory({ category, layout, position = 0, descriptions }: CvCategoryProps) {
|
||||
const entries = category.cvEntry
|
||||
return (
|
||||
<AnimatedCard position={position} className={cn(layout == "row" ? "w-full" : "", "h-screen")}>
|
||||
<CardHeader>
|
||||
<AnimateTextIn once position={position + 0.2} animation="slide" debugId={`cv-category-title:${category.name}:${position + 0.2}`}>
|
||||
<CardTitle>{category.name}</CardTitle>
|
||||
</AnimateTextIn>
|
||||
</CardHeader>
|
||||
{entries.length > 0 ?
|
||||
<CardContent className={cn(layout == "row" ? "flex flex-row flex-wrap justify-center lg:justify-between" : "flex flex-col", "gap-4", "overflow-scroll")}>
|
||||
<ScrollArea>
|
||||
{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}>
|
||||
<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>
|
||||
</CardContent>
|
||||
:
|
||||
<></>
|
||||
}
|
||||
</AnimatedCard>
|
||||
)
|
||||
export default function CvCategory(props:CvCategoryProps) {
|
||||
const category = trpc.categoryv2.getById.useQuery(props.initialData? props.initialData.id : "");
|
||||
return (
|
||||
<Card className={cn(props.layout == "row" ? "w-full" : "","gsapan")}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{category.data?.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{(category.data?.cvEntry.length ? category.data?.cvEntry.length : 0 ) > 0 ?
|
||||
<CardContent className={cn(props.layout == "row" ? "flex flex-row flex-wrap justify-center lg:justify-between" : "flex flex-col","gap-4","overflow-scroll")}>
|
||||
{category.data?.cvEntry.map((entry) => (
|
||||
<CvEntry className={props.layout == "row" ? "w-full lg:w-fit" : undefined} key={entry.id} initialData={entry}/>
|
||||
))}
|
||||
</CardContent>
|
||||
:
|
||||
<></>
|
||||
}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,52 +1,68 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { trpc } from "~/app/_trpc/Client"
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"
|
||||
import { cn } from "~/lib/utils"
|
||||
import { Skeleton } from "~/components/ui/skeleton"
|
||||
import { cn, type Defined } from "~/lib/utils"
|
||||
import Markdown from 'react-markdown'
|
||||
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 AnimateTextIn from "~/app/_components/Animated/AnimateIn"
|
||||
import AnimatedDiv from "~/app/_components/Animated/AnimatedDiv"
|
||||
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
|
||||
export default function CvEntry(params: {
|
||||
initialData: ArrayElement<Defined<RouterOutputs['categoryv2']['getById']>['cvEntry']>,
|
||||
className?: string
|
||||
}) {
|
||||
const query = trpc.entryv2.getById.useQuery(params.initialData.id);
|
||||
const { data, isError, error } = query
|
||||
return (
|
||||
<Card className={className ? cn("w-fit", className) : "w-fit"}>
|
||||
{entry.title ?
|
||||
<CardHeader>
|
||||
<AnimateTextIn position={position} animation="slide" debugId={`cv-entry-title:${entry.title}:${position}`}>
|
||||
<CardTitle> {entry.title} </CardTitle>
|
||||
</AnimateTextIn>
|
||||
</CardHeader> :
|
||||
<></>
|
||||
<>
|
||||
{
|
||||
data ?
|
||||
<>
|
||||
<Card className={params.className ? cn("w-fit", params.className) : "w-fit"}>
|
||||
{
|
||||
data.title ?
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
'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,42 +1,97 @@
|
||||
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";
|
||||
|
||||
export default async function CvPage() {
|
||||
const cv = await trpc.categoryv2.listAllWithEntries();
|
||||
|
||||
// Render the MDX descriptions on the server so they exist at first paint.
|
||||
// The client tree (which runs the GSAP entrance via useTimeLine) only places
|
||||
// these already-rendered nodes — it never invokes the MDX renderer itself, so
|
||||
// the 'use client' boundary stays intact and the animations no longer play
|
||||
// against an un-rendered fallback.
|
||||
const descriptions: Record<string, ReactNode> = {};
|
||||
for (const category of cv ?? []) {
|
||||
for (const entry of category.cvEntry) {
|
||||
if (!entry.description?.trim()) continue;
|
||||
descriptions[entry.id] = (
|
||||
<MDXRemote
|
||||
source={entry.description}
|
||||
components={mdxComponents}
|
||||
options={{
|
||||
mdxOptions: {
|
||||
format: "md",
|
||||
remarkPlugins: [remarkGfm],
|
||||
rehypePlugins: [rehypeHighlight],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
'use client'
|
||||
import { useGSAP } from "@gsap/react";
|
||||
import { useGsapContext } from "../_providers/GsapProvicer";
|
||||
import { trpc } from "../_trpc/Client";
|
||||
import { useRef } from "react";
|
||||
import { SidebarContent, SidebarProvider, Sidebar } from "~/components/ui/sidebar";
|
||||
import SidebarTriggerDisappearsOnMobile from "./_components/SidebarTriggerDisappearsOnMobile";
|
||||
import CvCategory from "./_components/CvCategory";
|
||||
export default function CvPage() {
|
||||
const sidebarCategories = trpc.categoryv2.listByLayoutPosition.useQuery("sidebar");
|
||||
const col1Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col1");
|
||||
const headerCategories = trpc.categoryv2.listByLayoutPosition.useQuery("header");
|
||||
const col2Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col2");
|
||||
const gsap = useGsapContext()
|
||||
const container = useRef<HTMLDivElement>(null)
|
||||
enum Direction {
|
||||
Left = 1,
|
||||
Up,
|
||||
Right,
|
||||
Down
|
||||
}
|
||||
const nextGsapConf = (direction: Direction) => {
|
||||
switch (direction) {
|
||||
case Direction.Left:
|
||||
return { x: -100, opacity: 0, duration: 0.5 }
|
||||
case Direction.Up:
|
||||
return { y: -100, opacity: 0, duration: 0.5 }
|
||||
case Direction.Right:
|
||||
return { x: 100, opacity: 0, duration: 0.5 }
|
||||
case Direction.Down:
|
||||
return { y: 100, opacity: 0, duration: 0.5 }
|
||||
}
|
||||
}
|
||||
|
||||
useGSAP(() => {
|
||||
const items = gsap?.utils.toArray<GSAPTweenTarget>('.gsapan');
|
||||
const tl = gsap?.timeline();
|
||||
let dir = Direction.Left;
|
||||
items?.forEach(item => {
|
||||
tl?.from(item, nextGsapConf(dir))
|
||||
if (dir == Direction.Down) {
|
||||
dir = Direction.Left
|
||||
} else {
|
||||
dir = dir + 1
|
||||
}
|
||||
})
|
||||
}, { scope: container, dependencies: [headerCategories.data, sidebarCategories.data], revertOnUpdate: true })
|
||||
return (
|
||||
<Suspense>
|
||||
<Page cv={cv} descriptions={descriptions} />
|
||||
</Suspense>
|
||||
<>
|
||||
<SidebarProvider ref={container}>
|
||||
{(sidebarCategories.data?.length ? sidebarCategories.data?.length : 0) > 0 ?
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,18 +5,14 @@ import { ClerkProvider } from "@clerk/nextjs";
|
||||
import { config } from "@fortawesome/fontawesome-svg-core"
|
||||
import "@fortawesome/fontawesome-svg-core/styles.css"
|
||||
import TopNav from "./_components/TopNav";
|
||||
import ChatFAB from "./_components/ChatFAB";
|
||||
import TrpcProvider from "./_trpc/TrpcProvider";
|
||||
// import dynamic from "next/dynamic";
|
||||
// const ThemeProvider = dynamic(() => import("./_providers/ThemeProvider"),{ssr:true})
|
||||
import ThemeProvider from './_providers/ThemeProvider'
|
||||
import GsapProvider from "./_providers/GsapProvicer";
|
||||
import {MessagesProvider} from "./_providers/MessagesProvider";
|
||||
import { MusicPlayerProvider } from "./music/_components/MusicPlayerProvider";
|
||||
import { CodeHighlightStyle } from "./_components/CodeHighlightSyle";
|
||||
import { cn } from "~/lib/utils";
|
||||
import AnimatedBackGroundContainer from "./_components/Animated/AnimatedBackGroundContainer";
|
||||
import {SpeedInsights} from "@vercel/speed-insights/next"
|
||||
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||
|
||||
|
||||
@@ -32,14 +28,13 @@ const geist = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
});
|
||||
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
modal
|
||||
}: Readonly<{ children: React.ReactNode, modal: React.ReactNode }>) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SpeedInsights/>
|
||||
<ClerkProvider>
|
||||
<TrpcProvider>
|
||||
<GsapProvider>
|
||||
@@ -49,24 +44,16 @@ export default async function RootLayout({
|
||||
</head>
|
||||
<body className="flex flex-col bg-background text-foreground">
|
||||
<ThemeProvider>
|
||||
<MessagesProvider>
|
||||
<MusicPlayerProvider>
|
||||
<AnimatedBackGroundContainer followSpeed={0.003} particleCount={100} orbitRadius={2000}>
|
||||
<TopNav />
|
||||
<main className="absolute lg:top-10 h-screen lg:h-[calc(100vh-var(--spacing)*10)] w-screen">
|
||||
{children}
|
||||
</main>
|
||||
{modal}
|
||||
</AnimatedBackGroundContainer>
|
||||
<ChatFAB />
|
||||
</MusicPlayerProvider>
|
||||
</MessagesProvider>
|
||||
<TopNav />
|
||||
<main className="absolute lg:top-10 h-screen w-screen">
|
||||
{children}
|
||||
</main>
|
||||
{modal}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
</GsapProvider>
|
||||
</TrpcProvider>
|
||||
</ClerkProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
'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,13 +0,0 @@
|
||||
import { Suspense } from "react";
|
||||
import { servTrpc as trpc } from "../_trpc/ServerClient";
|
||||
import Page from "./_components/Page";
|
||||
|
||||
export default async function MusicPage() {
|
||||
const tracks = await trpc.music.list();
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<Page tracks={tracks} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
'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,40 +1,93 @@
|
||||
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";
|
||||
'use client'
|
||||
|
||||
export default async function ProjectsPage() {
|
||||
const projects = await trpc.projectv2.listWithStack();
|
||||
import { trpc } from "~/app/_trpc/Client";
|
||||
import * as Card from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { StackBadge } from "~/components/StackBadge";
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
// Render the MDX descriptions on the server so they exist at first paint.
|
||||
// The client tree (which runs the GSAP entrance via useTimeLine) only places
|
||||
// these already-rendered nodes — it never invokes the MDX renderer itself, so
|
||||
// the 'use client' boundary stays intact and the animations no longer play
|
||||
// against an un-rendered fallback.
|
||||
const descriptions: Record<string, ReactNode> = {};
|
||||
for (const project of projects ?? []) {
|
||||
if (!project.description?.trim()) continue;
|
||||
descriptions[project.id] = (
|
||||
<MDXRemote
|
||||
source={project.description}
|
||||
components={mdxComponents}
|
||||
options={{
|
||||
mdxOptions: {
|
||||
format: "md",
|
||||
remarkPlugins: [remarkGfm],
|
||||
rehypePlugins: [rehypeHighlight],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
export default function ProjectsPage() {
|
||||
const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!projects?.length) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground">
|
||||
No projects yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<Page projects={projects} descriptions={descriptions} />
|
||||
</Suspense>
|
||||
<div className="w-full max-w-4xl mx-auto px-4 py-8 flex flex-col gap-4">
|
||||
{projects.map((project) => (
|
||||
<Card.Card key={project.id}>
|
||||
<Card.CardHeader>
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<Card.CardTitle>{project.title}</Card.CardTitle>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{project.sourceType && (
|
||||
<Badge variant={project.sourceType === "open" ? "secondary" : "outline"}>
|
||||
{project.sourceType === "open" ? "Open Source" : "Closed Source"}
|
||||
</Badge>
|
||||
)}
|
||||
{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">
|
||||
<Markdown>{project.description}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{(project.sourceLink || project.releaseLink) && (
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{project.sourceLink && (
|
||||
<a
|
||||
href={project.sourceLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4 transition-colors"
|
||||
>
|
||||
Source
|
||||
</a>
|
||||
)}
|
||||
{project.releaseLink && (
|
||||
<a
|
||||
href={project.releaseLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4 transition-colors"
|
||||
>
|
||||
Live
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{project.techStack?.stackItems && project.techStack.stackItems.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{project.techStack.stackItems.map((item) => (
|
||||
<StackBadge key={item} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.CardContent>
|
||||
)}
|
||||
</Card.Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"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,30 +125,6 @@ const STACK_META: Record<string, { label: string; icon?: SvglIcon }> = {
|
||||
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 }) {
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type CalloutVariant = "note" | "tip" | "warning";
|
||||
|
||||
const calloutStyles: Record<CalloutVariant, string> = {
|
||||
note: "border-sky-500/40 bg-sky-500/10 text-sky-950 dark:text-sky-100",
|
||||
tip: "border-emerald-500/40 bg-emerald-500/10 text-emerald-950 dark:text-emerald-100",
|
||||
warning: "border-amber-500/40 bg-amber-500/10 text-amber-950 dark:text-amber-100",
|
||||
};
|
||||
|
||||
function Callout({
|
||||
title,
|
||||
variant = "note",
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
variant?: CalloutVariant;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<aside className={cn("my-6 rounded-md border px-4 py-3", calloutStyles[variant])}>
|
||||
{title && <p className="mb-2 font-semibold">{title}</p>}
|
||||
<div className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{children}</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Lead({ children }: { children: ReactNode }) {
|
||||
return <span className="text-muted-foreground my-6 block text-lg leading-8">{children}</span>;
|
||||
}
|
||||
|
||||
function TagList({ tags }: { tags: string[] }) {
|
||||
return (
|
||||
<div className="my-4 flex flex-wrap gap-1.5">
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonLink({
|
||||
href,
|
||||
children,
|
||||
variant = "default",
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
variant?: ComponentPropsWithoutRef<typeof Button>["variant"];
|
||||
}) {
|
||||
const isExternal = /^https?:\/\//.test(href);
|
||||
|
||||
return (
|
||||
<Button asChild variant={variant}>
|
||||
{isExternal ? (
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
<Link href={href}>{children}</Link>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({
|
||||
src,
|
||||
alt,
|
||||
caption,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
caption?: string;
|
||||
}) {
|
||||
return (
|
||||
<figure className="my-8">
|
||||
<img src={src} alt={alt} className="w-full rounded-md border object-cover" />
|
||||
{caption && <figcaption className="text-muted-foreground mt-2 text-center text-sm">{caption}</figcaption>}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function PullQuote({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<blockquote className="border-primary my-8 border-l-4 pl-5 text-xl leading-8 font-medium">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalLink(props: ComponentPropsWithoutRef<"a">) {
|
||||
const href = props.href ?? "";
|
||||
const isExternal = /^https?:\/\//.test(href);
|
||||
|
||||
if (!isExternal) return <a {...props} />;
|
||||
|
||||
return <a {...props} target="_blank" rel="noreferrer" />;
|
||||
}
|
||||
|
||||
const blockComponents = new Set<unknown>([Callout, Figure, PullQuote, TagList]);
|
||||
|
||||
function Paragraph({ children }: { children: ReactNode }) {
|
||||
const containsBlockComponent = Children.toArray(children).some(
|
||||
(child) => isValidElement(child) && blockComponents.has(child.type),
|
||||
);
|
||||
|
||||
if (containsBlockComponent) return <>{children}</>;
|
||||
|
||||
return <p>{children}</p>;
|
||||
}
|
||||
|
||||
export const mdxComponents = {
|
||||
a: ExternalLink,
|
||||
p: Paragraph,
|
||||
Badge,
|
||||
ButtonLink,
|
||||
Callout,
|
||||
Figure,
|
||||
Lead,
|
||||
PullQuote,
|
||||
TagList,
|
||||
};
|
||||
@@ -1,8 +1,5 @@
|
||||
"use client"
|
||||
import * as React from "react"
|
||||
import { useRef } from "react";
|
||||
import gsap from 'gsap'
|
||||
import { useReveal } from "~/app/_components/Animated/useReveal";
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function Card({
|
||||
@@ -15,39 +12,7 @@ function Card({
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AnimatedCard({
|
||||
className,
|
||||
position = 0,
|
||||
size = "default",
|
||||
scrollOnly = false,
|
||||
once = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm", position?: gsap.Position, scrollOnly?: boolean, once?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
useReveal(ref, {
|
||||
position,
|
||||
scrollOnly,
|
||||
once,
|
||||
debugId: `card-${position}`,
|
||||
// Start hidden via CSS (see className) so the server-rendered card never
|
||||
// flashes visible before GSAP runs; reveal animates *to* the shown state.
|
||||
makeReveal: (el) => gsap.to(el, { x: 0, opacity: 1, duration: 0.5, paused: true }),
|
||||
})
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"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",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -135,5 +100,4 @@ export {
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
AnimatedCard
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { cn } from "~/lib/utils"
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
@@ -7,9 +7,6 @@ export const env = createEnv({
|
||||
* isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
UPLOADTHING_TOKEN: z.string(),
|
||||
BLOG_MDX_PREFIX: z.string().default("blog"),
|
||||
|
||||
DATABASE_URL: z.string().url(),
|
||||
DATABASE_URL_UNPOOLED: z.string().url(),
|
||||
|
||||
@@ -30,7 +27,6 @@ export const env = createEnv({
|
||||
|
||||
CLERK_SECRET_KEY: z.string(),
|
||||
ADMIN_USER_CLERK_ID: z.string(),
|
||||
OPENAI_API_KEY: z.string(),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
@@ -52,9 +48,6 @@ export const env = createEnv({
|
||||
* middlewares) or client-side so we need to destruct manually.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
UPLOADTHING_TOKEN: process.env.UPLOADTHING_TOKEN,
|
||||
BLOG_MDX_PREFIX: process.env.BLOG_MDX_PREFIX,
|
||||
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DATABASE_URL_UNPOOLED: process.env.DATABASE_URL,
|
||||
PGHOST: process.env.PGHOST,
|
||||
@@ -71,7 +64,6 @@ export const env = createEnv({
|
||||
POSTGRES_URL_NO_SSL: process.env.POSTGRES_URL_NO_SSL,
|
||||
POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL,
|
||||
ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: process.env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID,
|
||||
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { UseTRPCQueryResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
function useRelationShipSuccess<T extends Record<string,any> & {id:string},K extends keyof T>(
|
||||
relationShipData: T[] | undefined,
|
||||
@@ -56,10 +56,3 @@ export function makeUseRelationShipWithNameIndex<K extends string>(key:K) {
|
||||
}
|
||||
}
|
||||
|
||||
export function usePrevious<T>(value:T,initialValue:T) {
|
||||
const ref = useRef(initialValue)
|
||||
useEffect(() => {
|
||||
ref.current = value
|
||||
})
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import z from "zod"
|
||||
|
||||
export const createMusicInputSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
title: z.string().min(1).max(100),
|
||||
description: z.string().optional(),
|
||||
fileUrl: z.string(),
|
||||
fileKey: 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({
|
||||
id: z.string().uuid(),
|
||||
title: z.string().min(1).max(100).optional(),
|
||||
description: z.string().optional(),
|
||||
fileUrl: z.string().optional(),
|
||||
fileKey: 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,7 +0,0 @@
|
||||
import { generateReactHelpers, generateUploadButton, generateUploadDropzone } from "@uploadthing/react";
|
||||
import type { FileRouter } from "~/server/uploadthing";
|
||||
|
||||
export const UploadButton = generateUploadButton<FileRouter>();
|
||||
export const UploadDropzone = generateUploadDropzone<FileRouter>();
|
||||
|
||||
export const { useUploadThing } = generateReactHelpers<FileRouter>();
|
||||
17
src/proxy.ts
17
src/proxy.ts
@@ -1,16 +1,13 @@
|
||||
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { clerkMiddleware, createRouteMatcher, currentUser } from "@clerk/nextjs/server";
|
||||
import { env } from "~/env";
|
||||
|
||||
const isTenantAdminRoute = createRouteMatcher(["/admin(.*)"]);
|
||||
|
||||
export default clerkMiddleware(async (auth, req) => {
|
||||
const isTenantAdminRoute = createRouteMatcher(['/admin(.*)'])
|
||||
export default clerkMiddleware(async (auth,req) => {
|
||||
if (isTenantAdminRoute(req)) {
|
||||
await auth.protect();
|
||||
|
||||
const { userId } = await auth();
|
||||
if (userId !== env.ADMIN_USER_CLERK_ID) {
|
||||
return NextResponse.redirect(new URL("/", req.url));
|
||||
console.log("running clerk middleware");
|
||||
let userid = (await auth()).userId
|
||||
if (userid != env.ADMIN_USER_CLERK_ID) {
|
||||
await auth.protect()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// https://orm.drizzle.team/docs/sql-schema-declaration
|
||||
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { index, pgEnum, pgSchema, pgTableCreator, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { index, pgEnum, pgSchema, pgTableCreator } from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||
@@ -43,7 +43,6 @@ export const cvEntry = createTable(
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull().$type<Date>(),
|
||||
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
|
||||
position: d.integer(),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -56,7 +55,7 @@ export const cvEntryRelations = relations(cvEntry, ({one}) => ({
|
||||
|
||||
export const sourceTypeEnum = pgEnum('source_type',['open','closed'])
|
||||
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','typescript','javafx','x11','zig','libghostty','zod'])
|
||||
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 project = createTable(
|
||||
"project",
|
||||
@@ -69,7 +68,6 @@ export const project = createTable(
|
||||
releaseStatus: releaseStatus(),
|
||||
releaseLink: d.varchar({length: 200}),
|
||||
stackId: d.uuid(),
|
||||
orderPos: d.integer(),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -87,95 +85,3 @@ export const techStack = createTable(
|
||||
stackItems: stackItemEnum().array()
|
||||
})
|
||||
)
|
||||
|
||||
export const music = createTable(
|
||||
"music",
|
||||
(d) => ({
|
||||
id: d.uuid().primaryKey().notNull(),
|
||||
title: d.varchar({ length: 100 }).notNull(),
|
||||
description: d.text(),
|
||||
// Original high-quality upload (e.g. FLAC), offered as a download.
|
||||
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
|
||||
fileKey: d.varchar("file_key", { 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
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull()
|
||||
.$type<Date>(),
|
||||
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
|
||||
})
|
||||
)
|
||||
|
||||
export const blogPost = createTable(
|
||||
"blog_post",
|
||||
(d) => ({
|
||||
id: d.uuid().primaryKey().defaultRandom(),
|
||||
slug: d.varchar({ length: 200 }).notNull(),
|
||||
title: d.varchar({ length: 200 }).notNull(),
|
||||
date: d.varchar({ length: 20 }),
|
||||
description: d.text(),
|
||||
tags: d.text().array(),
|
||||
fileKey: d.varchar("file_key", { length: 200 }).notNull(),
|
||||
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
|
||||
fileName: d.varchar("file_name", { length: 255 }).notNull(),
|
||||
customId: d.varchar("custom_id", { length: 255 }).notNull(),
|
||||
createdAt: d
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull()
|
||||
.$type<Date>(),
|
||||
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
|
||||
}),
|
||||
(t) => [
|
||||
uniqueIndex("blog_post_slug_idx").on(t.slug),
|
||||
uniqueIndex("blog_post_file_key_idx").on(t.fileKey),
|
||||
uniqueIndex("blog_post_custom_id_idx").on(t.customId),
|
||||
],
|
||||
)
|
||||
|
||||
export const messageRoleEnum = pgEnum('message_role', ['user', 'assistant'])
|
||||
|
||||
export const chatSession = createTable(
|
||||
"chat_session",
|
||||
(d) => ({
|
||||
id: d.uuid().primaryKey().defaultRandom(),
|
||||
userId: d.varchar({ length: 255 }).notNull(),
|
||||
createdAt: d.timestamp({ withTimezone: true }).default(sql`CURRENT_TIMESTAMP`).notNull().$type<Date>(),
|
||||
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
|
||||
})
|
||||
)
|
||||
|
||||
export const chatSessionRelations = relations(chatSession, ({ many }) => ({
|
||||
messages: many(chatMessage),
|
||||
}))
|
||||
|
||||
export const chatMessage = createTable(
|
||||
"chat_message",
|
||||
(d) => ({
|
||||
id: d.uuid().primaryKey().defaultRandom(),
|
||||
sessionId: d.uuid('session_id').notNull(),
|
||||
role: messageRoleEnum().notNull(),
|
||||
content: d.text().notNull(),
|
||||
createdAt: d.timestamp({ withTimezone: true }).default(sql`CURRENT_TIMESTAMP`).notNull().$type<Date>(),
|
||||
})
|
||||
)
|
||||
|
||||
export const chatMessageRelations = relations(chatMessage, ({ one }) => ({
|
||||
session: one(chatSession, {
|
||||
fields: [chatMessage.sessionId],
|
||||
references: [chatSession.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export const systemSettings = createTable(
|
||||
"systemSetting",
|
||||
(d) => ({
|
||||
systemPropmt: d.text(),
|
||||
model: d.text()
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import { router } from "../trpc";
|
||||
import type { inferReactQueryProcedureOptions } from "@trpc/react-query";
|
||||
import { blogRouter } from "./blog";
|
||||
import { projectRouter } from "./project";
|
||||
import { techStackRouter } from "./techStack";
|
||||
import { cvCategoryRouter } from "./cvCategory";
|
||||
import { cvEntryRouter } from "./cvEntry";
|
||||
import { musicRouter } from "./music";
|
||||
import { trpcCrudRouterFromDrizzleEntity } from "../lib";
|
||||
import { cvCategory } from "../dbschema/schema";
|
||||
import { chatRouter } from "./chat";
|
||||
|
||||
export const trpcRouter = router({
|
||||
blog: blogRouter,
|
||||
project: trpcCrudRouterFromDrizzleEntity('project').router,
|
||||
projectv2: projectRouter,
|
||||
techStack: trpcCrudRouterFromDrizzleEntity('techStack').router,
|
||||
@@ -21,8 +17,6 @@ export const trpcRouter = router({
|
||||
categoryv2: cvCategoryRouter,
|
||||
entry: trpcCrudRouterFromDrizzleEntity('cvEntry').router,
|
||||
entryv2: cvEntryRouter,
|
||||
music: musicRouter,
|
||||
chat: chatRouter
|
||||
});
|
||||
|
||||
export type TrpcRouter = typeof trpcRouter;
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { desc, eq, or } from "drizzle-orm";
|
||||
import matter from "gray-matter";
|
||||
import { UTApi, UTFile } from "uploadthing/server";
|
||||
import z from "zod";
|
||||
import { isAdmin } from "~/app/actions";
|
||||
import { env } from "~/env.js";
|
||||
import { db } from "~/server/db";
|
||||
import { blogPost } from "~/server/dbschema/schema";
|
||||
import { publicProcedure, router } from "~/server/trpc";
|
||||
|
||||
const utapi = new UTApi({ token: env.UPLOADTHING_TOKEN });
|
||||
|
||||
const blogPostInput = z.object({
|
||||
slug: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
date: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
type BlogPostInput = z.infer<typeof blogPostInput>;
|
||||
|
||||
type UploadThingFile = Awaited<ReturnType<typeof utapi.listFiles>>["files"][number];
|
||||
|
||||
function cleanPrefix(): string {
|
||||
return env.BLOG_MDX_PREFIX.trim().replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function blogFileName(slug: string): string {
|
||||
const prefix = cleanPrefix();
|
||||
return prefix ? `${prefix}-${slug}.mdx` : `${slug}.mdx`;
|
||||
}
|
||||
|
||||
function blogCustomId(slug: string): string {
|
||||
const prefix = cleanPrefix();
|
||||
const id = crypto.randomUUID();
|
||||
return prefix ? `${prefix}:${slug}:${id}` : `${slug}:${id}`;
|
||||
}
|
||||
|
||||
function optionalText(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function frontmatterText(value: unknown): string | null {
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||
return optionalText(typeof value === "string" ? value : undefined);
|
||||
}
|
||||
|
||||
function normalizeTags(tags: unknown): string[] {
|
||||
const values = Array.isArray(tags)
|
||||
? tags
|
||||
: typeof tags === "string"
|
||||
? tags.split(",")
|
||||
: [];
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
values
|
||||
.map((tag) => String(tag).trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function createMdxContent(input: BlogPostInput): string {
|
||||
const date = optionalText(input.date);
|
||||
const description = optionalText(input.description);
|
||||
const tags = normalizeTags(input.tags);
|
||||
const frontmatter: Record<string, unknown> = { slug: input.slug, title: input.title };
|
||||
if (date) frontmatter.date = date;
|
||||
if (description) frontmatter.description = description;
|
||||
if (tags.length > 0) frontmatter.tags = tags;
|
||||
return matter.stringify(input.content, frontmatter);
|
||||
}
|
||||
|
||||
function summaryFromInput(input: BlogPostInput) {
|
||||
return {
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
date: optionalText(input.date),
|
||||
description: optionalText(input.description),
|
||||
tags: normalizeTags(input.tags),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertAdmin() {
|
||||
const admin = await isAdmin();
|
||||
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
|
||||
}
|
||||
|
||||
async function uploadMdx(input: BlogPostInput) {
|
||||
const mdxContent = createMdxContent(input);
|
||||
const customId = blogCustomId(input.slug);
|
||||
const file = new UTFile([mdxContent], blogFileName(input.slug), {
|
||||
customId,
|
||||
type: "text/plain",
|
||||
});
|
||||
const result = await utapi.uploadFiles(file);
|
||||
|
||||
if (result.error) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error.message });
|
||||
}
|
||||
|
||||
if (!result.data.ufsUrl) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "UploadThing did not return a file URL" });
|
||||
}
|
||||
|
||||
return {
|
||||
fileKey: result.data.key,
|
||||
fileUrl: result.data.ufsUrl,
|
||||
fileName: result.data.name,
|
||||
customId: result.data.customId ?? customId,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMdx(fileUrl: string): Promise<string> {
|
||||
const res = await fetch(fileUrl, { next: { revalidate: 3600 } });
|
||||
if (!res.ok) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to fetch MDX file" });
|
||||
return res.text();
|
||||
}
|
||||
|
||||
function fileUrl(file: Pick<UploadThingFile, "key">): string {
|
||||
return `https://utfs.io/f/${file.key}`;
|
||||
}
|
||||
|
||||
function slugFromFileName(name: string): string {
|
||||
const prefix = cleanPrefix();
|
||||
const withoutExtension = name.replace(/\.mdx?$/, "");
|
||||
if (prefix && withoutExtension.startsWith(`${prefix}-`)) return withoutExtension.slice(prefix.length + 1);
|
||||
if (prefix && withoutExtension.startsWith(`${prefix}/`)) return withoutExtension.slice(prefix.length + 1);
|
||||
return withoutExtension;
|
||||
}
|
||||
|
||||
function slugFromCustomId(customId: string): string {
|
||||
const prefix = cleanPrefix();
|
||||
const value = prefix && customId.startsWith(`${prefix}:`)
|
||||
? customId.slice(prefix.length + 1)
|
||||
: customId;
|
||||
return value.split(":")[0] ?? value;
|
||||
}
|
||||
|
||||
function fileMatchesPrefix(file: Pick<UploadThingFile, "name">): boolean {
|
||||
if (!/\.mdx?$/.test(file.name)) return false;
|
||||
|
||||
const prefix = cleanPrefix();
|
||||
if (!prefix) return true;
|
||||
|
||||
return file.name.startsWith(`${prefix}-`) || file.name.startsWith(`${prefix}/`);
|
||||
}
|
||||
|
||||
function metadataFromFile(file: UploadThingFile, raw: string) {
|
||||
const parsed = matter(raw);
|
||||
const fallbackSlug = file.customId ? slugFromCustomId(file.customId) : slugFromFileName(file.name);
|
||||
|
||||
return {
|
||||
slug: String(parsed.data.slug ?? fallbackSlug),
|
||||
title: String(parsed.data.title ?? fallbackSlug),
|
||||
date: frontmatterText(parsed.data.date),
|
||||
description: frontmatterText(parsed.data.description),
|
||||
tags: normalizeTags(parsed.data.tags),
|
||||
fileKey: file.key,
|
||||
fileUrl: fileUrl(file),
|
||||
fileName: file.name,
|
||||
customId: file.customId ?? blogCustomId(fallbackSlug),
|
||||
};
|
||||
}
|
||||
|
||||
async function listAllFiles(): Promise<readonly UploadThingFile[]> {
|
||||
const files: UploadThingFile[] = [];
|
||||
let offset = 0;
|
||||
const limit = 500;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const page = await utapi.listFiles({ limit, offset });
|
||||
files.push(...page.files);
|
||||
hasMore = page.hasMore;
|
||||
offset += limit;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export const blogRouter = router({
|
||||
insert: publicProcedure
|
||||
.input(blogPostInput)
|
||||
.mutation(async ({ input }) => {
|
||||
await assertAdmin();
|
||||
|
||||
const existing = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.slug, input.slug);
|
||||
},
|
||||
});
|
||||
if (existing) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` });
|
||||
|
||||
const uploaded = await uploadMdx(input);
|
||||
|
||||
try {
|
||||
await db.insert(blogPost).values({
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
date: optionalText(input.date),
|
||||
description: optionalText(input.description),
|
||||
tags: normalizeTags(input.tags),
|
||||
...uploaded,
|
||||
});
|
||||
return [summaryFromInput(input)];
|
||||
} catch (error) {
|
||||
await utapi.deleteFiles(uploaded.fileKey);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
update: publicProcedure
|
||||
.input(blogPostInput.extend({ originalSlug: z.string().min(1) }))
|
||||
.mutation(async ({ input }) => {
|
||||
await assertAdmin();
|
||||
|
||||
const existing = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.slug, input.originalSlug);
|
||||
},
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.originalSlug}" not found` });
|
||||
|
||||
if (input.slug !== input.originalSlug) {
|
||||
const slugConflict = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.slug, input.slug);
|
||||
},
|
||||
});
|
||||
if (slugConflict) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` });
|
||||
}
|
||||
|
||||
const uploaded = await uploadMdx(input);
|
||||
|
||||
try {
|
||||
await db.update(blogPost).set({
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
date: optionalText(input.date),
|
||||
description: optionalText(input.description),
|
||||
tags: normalizeTags(input.tags),
|
||||
...uploaded,
|
||||
}).where(eq(blogPost.id, existing.id));
|
||||
|
||||
await utapi.deleteFiles(existing.fileKey);
|
||||
return [summaryFromInput(input)];
|
||||
} catch (error) {
|
||||
await utapi.deleteFiles(uploaded.fileKey);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
await assertAdmin();
|
||||
|
||||
const post = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.slug, input.id);
|
||||
},
|
||||
});
|
||||
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.id}" not found` });
|
||||
|
||||
await db.delete(blogPost).where(eq(blogPost.id, post.id));
|
||||
await utapi.deleteFiles(post.fileKey);
|
||||
return [];
|
||||
}),
|
||||
|
||||
list: publicProcedure.query(async () => {
|
||||
return 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));
|
||||
}),
|
||||
|
||||
bySlug: 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` });
|
||||
|
||||
const raw = await fetchMdx(post.fileUrl);
|
||||
const { content, data } = matter(raw);
|
||||
|
||||
return {
|
||||
slug: post.slug,
|
||||
content,
|
||||
title: (data.title as string | undefined) ?? post.title,
|
||||
date: frontmatterText(data.date) ?? post.date,
|
||||
description: frontmatterText(data.description) ?? post.description,
|
||||
tags: normalizeTags(data.tags).length > 0 ? normalizeTags(data.tags) : (post.tags ?? []),
|
||||
};
|
||||
}),
|
||||
|
||||
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 () => {
|
||||
await assertAdmin();
|
||||
|
||||
const files = (await listAllFiles()).filter(fileMatchesPrefix);
|
||||
const seenFileKeys = new Set<string>();
|
||||
const seenSlugs = new Set<string>();
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let deleted = 0;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const raw = await fetchMdx(fileUrl(file));
|
||||
const metadata = metadataFromFile(file, raw);
|
||||
seenFileKeys.add(file.key);
|
||||
seenSlugs.add(metadata.slug);
|
||||
const existing = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return or(operators.eq(fields.fileKey, file.key), operators.eq(fields.slug, metadata.slug));
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await db.update(blogPost).set(metadata).where(eq(blogPost.id, existing.id));
|
||||
updated += 1;
|
||||
} else {
|
||||
await db.insert(blogPost).values(metadata);
|
||||
created += 1;
|
||||
}
|
||||
} catch {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const posts = await db.select({
|
||||
id: blogPost.id,
|
||||
fileKey: blogPost.fileKey,
|
||||
slug: blogPost.slug,
|
||||
}).from(blogPost);
|
||||
|
||||
const stalePostIds = posts
|
||||
.filter((post) => !seenFileKeys.has(post.fileKey) && !seenSlugs.has(post.slug))
|
||||
.map((post) => post.id);
|
||||
|
||||
for (const id of stalePostIds) {
|
||||
await db.delete(blogPost).where(eq(blogPost.id, id));
|
||||
deleted += 1;
|
||||
}
|
||||
|
||||
return { created, updated, skipped, deleted };
|
||||
}),
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import { publicProcedure, router } from "../trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { db } from '~/server/db'
|
||||
import { chatMessage,
|
||||
chatSession, systemSettings } from "../dbschema/schema";
|
||||
import { isAdmin } from '~/app/actions';
|
||||
import { z } from 'zod';
|
||||
import { eq } from 'drizzle-orm';
|
||||
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({
|
||||
getSession: publicProcedure.query(async () => {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
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({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.userId, user.id)
|
||||
},
|
||||
})
|
||||
if (session !== undefined) {
|
||||
return session;
|
||||
}
|
||||
let newSession = await db.insert(chatSession).values({ userId: user.id}).returning().execute().then((r) => r.at(0)); if (newSession == undefined) {
|
||||
throw new TRPCError({ message: "failed to create session", code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
session = await db.query.chatSession.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.userId, user.id)
|
||||
},
|
||||
})
|
||||
if (session == undefined) {
|
||||
throw new TRPCError({ message: "session not found", code: "NOT_FOUND" });
|
||||
}
|
||||
if (session !== undefined) {
|
||||
return session;
|
||||
}
|
||||
}),
|
||||
getMessages: publicProcedure.input(z.string()).query(async ({input}) => {
|
||||
let res = await db.query.chatMessage.findMany({
|
||||
where(fields,operators) {
|
||||
return operators.eq(fields.sessionId,input)
|
||||
}
|
||||
})
|
||||
return res;
|
||||
}),
|
||||
clearChat: publicProcedure.mutation(async () => {
|
||||
console.log("deleting session")
|
||||
const { userId } = await auth();
|
||||
if (userId == null) {
|
||||
throw new TRPCError({ message: "chat is only available to signed in users", code: 'UNAUTHORIZED' });
|
||||
}
|
||||
let session = await db.query.chatSession.findFirst({
|
||||
with: {
|
||||
messages: true
|
||||
},
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.userId, userId)
|
||||
},
|
||||
})
|
||||
if (session != undefined) {
|
||||
db.delete(chatMessage).where(eq(chatMessage.sessionId,session.id)).execute()
|
||||
}
|
||||
|
||||
}),
|
||||
getSystemPrompt: publicProcedure.query(async () => {
|
||||
const row = await readSettings()
|
||||
return row?.systemPropmt ?? ''
|
||||
}),
|
||||
updateSystemPrompt: publicProcedure.input(z.object({ prompt: z.string() })).mutation(async ({ input }) => {
|
||||
if (!(await isAdmin())) throw new TRPCError({ code: 'FORBIDDEN' })
|
||||
await writeSettings({ 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 })
|
||||
}),
|
||||
})
|
||||
|
||||
export type ChatRouter = typeof chatRouter;
|
||||
@@ -13,22 +13,11 @@ export const cvCategoryRouter = router({
|
||||
console.log(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}) => {
|
||||
const res = await db.query.cvCategory.findFirst({
|
||||
with: {
|
||||
cvEntry: true
|
||||
},
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.id, input)
|
||||
},
|
||||
|
||||
@@ -13,14 +13,4 @@ export const cvEntryRouter = router({
|
||||
})
|
||||
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;
|
||||
})
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user