47 Commits

Author SHA1 Message Date
538d896b0e Merge branch 'blog' 2026-04-24 11:58:34 +02:00
be6df0c8ad blog editor 2026-04-24 11:58:19 +02:00
daab745c13 slop 2026-04-23 11:10:35 +02:00
c527391259 Merge branch 'projectpage' 2026-04-23 11:06:24 +02:00
404062904f finish up project page 2026-04-23 11:04:59 +02:00
4e8538552e Merge branch 'aiassistant' 2026-04-22 21:19:48 +02:00
64bd5c429e logged out chat modal, call to action 2026-04-22 21:05:53 +02:00
52e0a65113 hide google sign in 2026-04-22 19:31:09 +02:00
30e3dbb42b chat interface 2026-04-21 14:19:50 +02:00
caa9604704 early return on no sessionid 2026-03-31 15:47:41 +02:00
c62ee37538 prooompt engineering 2026-03-31 15:30:42 +02:00
ead9548744 server auth stuff, prooompt engineering 2026-03-31 15:29:47 +02:00
c5b3ee3875 get session by userid 2026-03-31 14:44:08 +02:00
2b5c105abb current time tool 2026-03-31 14:39:56 +02:00
e25fc39bac current time tool 2026-03-31 14:23:04 +02:00
e481fa66cd reapply layout effect fix for timeline cleanup 2026-03-31 14:16:26 +02:00
009d2b8d60 drop multitimeline support from gsap provider but keep scroller priority feature 2026-03-31 14:12:29 +02:00
d567fa3e02 fix chat 2026-03-31 14:03:41 +02:00
399d78e508 reset timeline cleanup needs to be returned from layout effect 2026-03-30 19:04:20 +02:00
bfc2bb1501 reset gsap provider to music page version 2026-03-30 18:54:19 +02:00
d7a9e53d9a revert some gsap provider changes 2026-03-30 18:50:05 +02:00
18abc4b3f7 chat fab 2026-03-30 18:10:09 +02:00
34dc53a8e9 ai modal 2026-03-30 18:09:55 +02:00
10b3f989c8 log userid 2026-03-30 17:06:15 +02:00
348ed790e2 Admin, cant 'use server' 2026-03-30 14:34:28 +02:00
c7de58a4b8 Admin sidebar cant be async 2026-03-30 14:28:45 +02:00
a51b313aba force admin dynamic 2026-03-30 14:23:57 +02:00
363a91dd7d Merge branch 'projectpage' 2026-03-30 14:14:30 +02:00
dfaba3a24e animation stuff 2026-03-30 14:13:04 +02:00
9c5aec01e0 fix create techstack form 2026-03-14 18:40:23 +01:00
03399de14f Merge branch 'musicpage' 2026-03-14 18:38:10 +01:00
095acc216a remove example data 2026-03-14 18:37:58 +01:00
288616548a no scroll trigger markers 2026-03-14 18:37:02 +01:00
57978d81e1 scroll triggers 2026-03-14 18:35:04 +01:00
b5291caa6e working animations 2026-03-13 22:24:12 +01:00
4293eef824 working animations 2026-03-13 22:18:44 +01:00
d71bb5d26e css reset 2026-03-13 19:44:49 +01:00
e0e32d16e2 reasonable animation system 2026-03-13 19:42:22 +01:00
166ae50c49 music and animation system 2026-03-13 15:49:53 +01:00
9b48661a6a update readme 2026-03-13 10:37:47 +01:00
4916a2fc7b Merge branch 'projectpage' 2026-03-10 21:01:36 +01:00
b0fb481cf2 gitignore worktrees 2026-03-10 21:00:39 +01:00
495bd52c5b neon stack item 2026-03-10 20:59:46 +01:00
bf2085fcce project description 2026-03-10 20:57:09 +01:00
61e016d829 project description 2026-03-10 20:35:30 +01:00
e77eda0220 project page 2026-03-10 20:20:47 +01:00
fc4fab9478 fix adminwrap issue 2026-03-10 19:52:00 +01:00
77 changed files with 4183 additions and 543 deletions

0
.codex Normal file
View File

2
.gitignore vendored
View File

@@ -46,3 +46,5 @@ yarn-error.log*
.idea
# clerk configuration (can include secrets)
/.clerk/
.worktrees
.claudesession

View File

@@ -1,29 +1,13 @@
# Create T3 App
# My Personal Website
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
## Using:
## What's next? How do I make an app with this?
- nextjs
- trpc
- neon
- uploadthing
- drizzle
- gsap
- openai
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.

844
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,8 @@
"test": "vitest --typecheck"
},
"dependencies": {
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/react": "^3.0.118",
"@clerk/nextjs": "^7.0.2",
"@electric-sql/pglite": "^0.3.16",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
@@ -54,6 +56,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@t3-oss/env-nextjs": "^0.13.10",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-next-experimental": "^5.91.0",
"@testing-library/user-event": "^14.6.1",
@@ -62,6 +65,8 @@
"@trpc/react-query": "^11.12.0",
"@trpc/server": "^11.12.0",
"@uiw/react-md-editor": "^4.0.11",
"@uploadthing/react": "^7.3.3",
"ai": "^6.0.116",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -71,10 +76,13 @@
"drizzle-zod": "^0.8.3",
"embla-carousel-react": "^8.6.0",
"glazejs": "^2.0.1",
"googleapis": "^171.4.0",
"gray-matter": "^4.0.3",
"gsap": "^3.14.2",
"input-otp": "^1.4.2",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"next-mdx-remote": "^6.0.0",
"next-themes": "^0.4.6",
"postgres": "^3.4.8",
"radix-ui": "^1.4.3",
@@ -87,12 +95,14 @@
"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.0.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-motion": "^1.1.1",
"type-fest": "^5.4.4",
"uploadthing": "^7.7.4",
"vaul": "^1.1.2",
"zod": "^4.3.6"
},

View File

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

View File

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

View File

@@ -1,7 +1,10 @@
import { isAdmin } from "~/app/actions"
export default async function AdminWrap({children,}: Readonly<{ children: React.ReactNode }>) {
if (await isAdmin()) {
// "use client"
// import { isAdmin } from "~/app/actions"
import { useUser } from "@clerk/nextjs"
import { env } from "~/env"
export default function AdminWrap({children,}: Readonly<{ children: React.ReactNode }>) {
const user = useUser();
if (user.isSignedIn && user.user.id == env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID) {
return <>{children}</>
}
return (<></>)

View File

@@ -0,0 +1,67 @@
import { useGSAP } from "@gsap/react";
import { useRef, type HTMLAttributes, type ReactNode } from "react";
import { useGsapContext } from "~/app/_providers/GsapProvicer";
import { SplitText } from "gsap/SplitText";
import gsap from 'gsap'
import { cn } from "~/lib/utils";
const AnimateTextIn = ({
children,
animation = "type",
position = 0,
tlId = undefined,
speed = 1,
scrollOnly = false,
className
}: {
children: ReactNode,
animation?: "type" | "slide",
position?: gsap.Position,
tlId?: string,
scrollOnly?: boolean,
speed?: number,
className?: HTMLAttributes<HTMLDivElement>['className']
}) => {
const el = useRef<HTMLDivElement>(null)
const gsapContext = useGsapContext();
useGSAP(() => {
const rect = el.current?.getBoundingClientRect()
const scroller = gsapContext?.getScroller()
console.log(scroller)
let viewportTop = 0
let viewportBottom = window.innerHeight
if (scroller && scroller instanceof Element) {
const scrollerRect = scroller.getBoundingClientRect()
viewportTop = scrollerRect.top
viewportBottom = scrollerRect.top + scrollerRect.height
}
const isInView = rect && rect.bottom > viewportTop && rect.top < viewportBottom
console.log(isInView)
const chars = new SplitText(el.current, { type: 'chars' })
gsapContext?.addAnimation(gsap.to(el.current, { opacity: 100, duration: 0 }), 0, tlId)
const fromVars = animation === "slide"
? { opacity: 0, x: -10, duration: 0.2 * speed, stagger: { each: 0.08 * speed }, ease: 'bounce.inOut', onComplete: () => chars.revert() }
: { opacity: 0, duration: 0.01 * speed, stagger: { each: 0.04 * speed }, ease: 'bounce.inOut', onComplete: () => chars.revert() }
if (isInView && !scrollOnly) {
gsapContext?.addAnimation(gsap.from(chars.chars, fromVars), position, tlId)
} else {
gsap.from(chars.chars,
{
...fromVars,
scrollTrigger: {
trigger: el.current,
start: 'top bottom',
end: 'bottom top',
toggleActions: "play reverse play reverse",
scroller
}
})
}
}, { dependencies: [] })
return (
<div ref={el} className={cn(className, "opacity-0")}>
{children}
</div>
)
}
export default AnimateTextIn;

View File

@@ -0,0 +1,22 @@
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'
}:{
children:ReactNode
position:gsap.Position,
className?:HTMLAttributes<HTMLDivElement>['className']
duration?:number,
ease?:gsap.EaseString|gsap.EaseFunction
}) => {
return (
<AnimatedDiv children={children} position={position} className={cn(className,'h-0 translate-y-[50] overflow-hidden')} height='auto' y={0} overflow='' ease={ease} duration={duration} />
)
}
export default AnimatePopUp;

View File

@@ -0,0 +1,302 @@
"use client";
import React, { useRef, useEffect, useCallback, useState } from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { useTheme } from "next-themes";
/* ─────────────────────────────────────────────
* Config — grayscale palettes
* ───────────────────────────────────────────── */
const PALETTES = {
dark: {
base: "#0a0a0a",
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)",
],
},
light: {
base: "#f5f5f5",
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)",
],
},
} as const;
/* ─────────────────────────────────────────────
* Helpers
* ───────────────────────────────────────────── */
const isMobileDevice = (): boolean => {
if (typeof window === "undefined") return false;
return window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 768;
};
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
/* ─────────────────────────────────────────────
* Particle
* ───────────────────────────────────────────── */
interface Particle {
angle: number;
radius: number;
speed: number;
size: number;
colorIndex: number;
wobbleAmp: number;
wobbleSpeed: number;
wobblePhase: number;
}
const spawnParticle = (): Particle => ({
angle: rand(0, Math.PI * 2),
radius: rand(30, 240),
speed: rand(0.003, 0.002) * (Math.random() > 0.5 ? 1 : -1),
size: rand(1.2, 4),
colorIndex: Math.floor(rand(0, 5)),
wobbleAmp: rand(6, 30),
wobbleSpeed: rand(0.008, 0.035),
wobblePhase: rand(0, Math.PI * 2),
});
/* ─────────────────────────────────────────────
* Component
* ───────────────────────────────────────────── */
interface AnimatedBackgroundContainerProps {
children: React.ReactNode;
className?: string;
/** Number of orbiting particles. Default 60 */
particleCount?: number;
/** Max orbit radius in px — controls how far particles spread from the cursor. Default 240 */
orbitRadius?: number;
/** How quickly particles catch up to cursor (01). Default 0.06 */
followSpeed?: number;
/** Speed multiplier for mobile random anchor drift. Default 1 */
mobileSpeed?: number;
}
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 mousePos = useRef({ x: 0, y: 0 });
const smoothMouse = useRef({ x: 0, y: 0 });
const mobileAnchor = useRef({ x: 0, y: 0 });
const mobileTarget = useRef({ x: 0, y: 0 });
const isMobile = useRef(false);
const particles = useRef<Particle[]>([]);
const frame = useRef(0);
const [mounted, setMounted] = useState(false);
const { resolvedTheme } = useTheme();
let isDark = resolvedTheme === "dark";
if (resolvedTheme == undefined) {
isDark = true;
}
const palette = isDark ? PALETTES.dark : PALETTES.light;
/* Spawn particles */
useEffect(() => {
const minR = Math.max(10, orbitRadius * 0.12);
particles.current = Array.from({ length: particleCount }, () => ({
...spawnParticle(),
radius: rand(minR, orbitRadius),
wobbleAmp: rand(orbitRadius * 0.025, orbitRadius * 0.12),
}));
}, [particleCount, orbitRadius]);
/* Detect mobile & seed positions */
useEffect(() => {
setMounted(true);
isMobile.current = isMobileDevice();
if (containerRef.current) {
const cx = containerRef.current.clientWidth / 2;
const cy = containerRef.current.clientHeight / 2;
mousePos.current = { x: cx, y: cy };
smoothMouse.current = { x: cx, y: cy };
mobileAnchor.current = { x: cx, y: cy };
mobileTarget.current = {
x: rand(cx * 0.4, cx * 1.6),
y: rand(cy * 0.4, cy * 1.6),
};
}
}, []);
/* Resize canvas to match container */
useEffect(() => {
const resize = () => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const dpr = window.devicePixelRatio || 1;
const w = container.clientWidth;
const h = container.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext("2d");
if (ctx) ctx.scale(dpr, dpr);
};
resize();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
/* Mouse tracking (desktop only) */
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!containerRef.current || isMobile.current) return;
const rect = containerRef.current.getBoundingClientRect();
mousePos.current = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener("mousemove", handleMouseMove, { passive: true });
return () => el.removeEventListener("mousemove", handleMouseMove);
}, [handleMouseMove]);
/* ── GSAP ticker — draw loop ── */
useGSAP(
() => {
if (!mounted) return;
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const tick = () => {
const w = container.clientWidth;
const h = container.clientHeight;
frame.current++;
/* Anchor: smooth-follow cursor or drift on mobile */
if (isMobile.current) {
mobileAnchor.current.x +=
(mobileTarget.current.x - mobileAnchor.current.x) * 0.008 * mobileSpeed;
mobileAnchor.current.y +=
(mobileTarget.current.y - mobileAnchor.current.y) * 0.008 * mobileSpeed;
const dx = mobileTarget.current.x - mobileAnchor.current.x;
const dy = mobileTarget.current.y - mobileAnchor.current.y;
if (Math.sqrt(dx * dx + dy * dy) < 30) {
mobileTarget.current = {
x: rand(w * 0.15, w * 0.85),
y: rand(h * 0.15, h * 0.85),
};
}
smoothMouse.current.x = mobileAnchor.current.x;
smoothMouse.current.y = mobileAnchor.current.y;
} else {
smoothMouse.current.x +=
(mousePos.current.x - smoothMouse.current.x) * followSpeed;
smoothMouse.current.y +=
(mousePos.current.y - smoothMouse.current.y) * followSpeed;
}
const cx = smoothMouse.current.x;
const cy = smoothMouse.current.y;
/* Clear frame */
ctx.clearRect(0, 0, w, h);
/* Draw each particle */
particles.current.forEach((p) => {
p.angle += p.speed;
const wobble =
Math.sin(frame.current * p.wobbleSpeed + p.wobblePhase) * p.wobbleAmp;
const r = p.radius + wobble;
const x = cx + Math.cos(p.angle) * r;
const y = cy + Math.sin(p.angle) * r;
/* Soft fade near viewport edges */
const edgeFade = Math.max(
0,
Math.min(x / 80, (w - x) / 80, y / 80, (h - y) / 80, 1),
);
if (edgeFade <= 0) return;
ctx.globalAlpha = edgeFade;
ctx.fillStyle = palette.particles[p.colorIndex];
ctx.beginPath();
ctx.arc(x, y, p.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1;
};
gsap.ticker.add(tick);
return () => {
gsap.ticker.remove(tick);
};
},
{
scope: containerRef,
dependencies: [mounted, isDark, followSpeed, mobileSpeed, orbitRadius, palette],
},
);
/* ── Render ── */
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",
}}
/>
{/* Grain texture */}
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
zIndex: 1,
opacity: isDark ? 0.05 : 0.03,
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>
);
}

View File

@@ -0,0 +1,41 @@
import gsap from "gsap";
import { type HTMLAttributes,
type ReactNode, useLayoutEffect, useRef } from "react";
import { useGsapContext } from "~/app/_providers/GsapProvicer";
const AnimatedDiv = (
{
children,
position,
className,
animationMode='to',
...tweenVars
}:
gsap.TweenVars & {
children:ReactNode,
position:gsap.Position,
animationMode?: 'from'|'to',
className?:HTMLAttributes<HTMLDivElement>['className']
}
) => {
const div = useRef<HTMLDivElement>(null);
const gsapContext = useGsapContext()
useLayoutEffect(() => {
let tween:gsap.core.Tween;
switch(animationMode) {
case 'from':
tween = gsap.from(div.current,tweenVars);
break;
case 'to':
tween = gsap.to(div.current,tweenVars);
break;
}
gsapContext?.addAnimation(tween,position)
},[])
return (
<div ref={div} className={className}>
{children}
</div>
)
}
export default AnimatedDiv;

View File

@@ -0,0 +1,22 @@
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;

View File

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

View File

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

View File

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

View File

@@ -1,25 +1,99 @@
"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";
export default function MdeFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, dataColorMode: "dark"|"light" }) {
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import {
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])
return (
<FormField
control={params.control}
name={params.name}
render={({ field }) => (
<FormItem>
<FormLabel>
Description
</FormLabel>
<FormControl>
<MDEditor
value={field.value ? field.value : ""}
onChange={field.onChange}
data-color-mode={params.dataColorMode}
/>
</FormControl>
</FormItem>
)}
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) ?? <></>
: undefined,
}}
/>
</FormControl>
</FormItem>
)
if (fullscreen && mounted) {
return createPortal(editor, document.body)
}
return editor
}}
/>
)
}

View File

@@ -4,10 +4,18 @@ import { Moon, Sun } from "lucide-react"
import { useEffect } from "react"
type Props = {activeTheme:string|undefined}
const ThemeIcon = (props:Props) => {
if (props.activeTheme == "dark") {
return (<Sun/>)
} else {
return (<Moon/>)
}
return (
<>
{props.activeTheme && props.activeTheme == 'dark' &&
<Sun/>
}
{props.activeTheme && props.activeTheme == 'light' &&
<Moon/>
}
{!props.activeTheme &&
<Sun/>
}
</>
)
}
export default ThemeIcon;

View File

@@ -8,6 +8,9 @@ import ThemeIcon from "./ThemeIcon"
export function ThemeSwitch() {
const { setTheme, theme } = useTheme()
if (!theme) {
setTheme('dark')
}
const toggleTheme = () => {
setTheme(theme == "dark" ? "light" : "dark")
}

View File

@@ -1,12 +1,13 @@
"use client"
import Link from "next/link"
import AdminWrap from "./AdminWrap"
import { Show, SignInButton, SignOutButton, SignUpButton, UserButton } from "@clerk/nextjs"
import { ClerkLoaded, Show, SignInButton, SignOutButton, SignUpButton, UserButton } from "@clerk/nextjs"
import { Button } from "~/components/ui/button"
import { ThemeSwitch } from "./ThemeSwitch"
export default function TopNav() {
return (
<div className="fixed lg:w-full right-0 z-50 lg:bg-background">
<div className="fixed backdrop-blur-md lg:w-full right-0 z-50">
<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">
@@ -18,6 +19,14 @@ 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>
@@ -39,13 +48,21 @@ export default function TopNav() {
</Button>
</Show>
<ThemeSwitch />
<Show when="signed-in">
<Button asChild className="flex h-10 lg:h-full cursor-pointer lg:w-20 content-center" variant={"outline"}>
<div>
<UserButton />
</div>
</Button>
</Show>
<ClerkLoaded>
<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'],
},
}}
/>
</div>
</Button>
</Show>
</ClerkLoaded>
</div>
</nav>
</div>

View File

@@ -1,18 +1,98 @@
'use client'
import { useGSAP } from '@gsap/react'
import gsap from 'gsap'
import { createContext, useContext, type ReactNode } from 'react'
import { SplitText } from 'gsap/SplitText'
import { ScrollTrigger, GSDevTools } from 'gsap/all'
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'
gsap.registerPlugin(useGSAP)
const GsapContext = createContext<typeof globalThis.gsap | null>(null)
gsap.registerPlugin(ScrollTrigger)
gsap.registerPlugin(SplitText)
gsap.registerPlugin(GSDevTools)
const GsapContext = createContext<{
addAnimation: (
animation: gsap.core.TimelineChild,
position: gsap.Position
) => void,
resetTimeline: () => void,
resumeTimeline: () => void,
getScroller: () => Element | Window | null
} | null>(null)
export function useGsapContext() {
return useContext(GsapContext)
}
export default function GsapProvider({children}:{children:ReactNode}) {
export const useTimeLine = (dep:any,all?:boolean) => {
const gsapContext = useGsapContext()
useEffect(() => {
if (dep instanceof Array && all) {
let acc = true;
let allDepsSatisfied = dep.reduce((p,c) => c !== undefined && p ,acc )
if (allDepsSatisfied) {
gsapContext?.resumeTimeline()
}
} else {
if (dep) {
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
}, [])
useGSAP(() => {
if (!tl.current) {
tl.current = gsap.timeline({ paused: true })
}
return () => { console.log("gsap cleanup") }
})
const addAnimation = useCallback((animation: gsap.core.TimelineChild, position: gsap.Position) => {
console.log("add animation to:", position, tl.current !== undefined)
tl.current?.add(animation, position);
},[])
const resetTimeline = useCallback(() => {
tl.current?.kill()
tl.current?.revert()
ScrollTrigger.getAll().forEach(st => st.kill())
tl.current = gsap.timeline({paused:true})
},[])
const resumeTimeline = useCallback(() => {
console.log("resuming timeline:",tl.current)
tl.current?.resume()
},[])
return (
<GsapContext.Provider value={gsap}>
<GsapContext.Provider value={{ addAnimation, resetTimeline, resumeTimeline, getScroller }}>
{children}
</GsapContext.Provider>
)

View File

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

View File

@@ -1,23 +1,10 @@
'use client'
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export default function ThemeProvider({children}:{children: React.ReactNode}) {
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}
</>
)
}
return (
<NextThemesProvider disableTransitionOnChange attribute="class" defaultTheme="dark">
{children}
</NextThemesProvider>
)
}

View File

@@ -4,5 +4,6 @@ import { env } from "~/env"
export async function isAdmin() {
const userid = (await auth()).userId
console.log(userid)
return (userid == env.ADMIN_USER_CLERK_ID)
}

View File

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

View File

@@ -0,0 +1,77 @@
'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.' }
}
}

View File

@@ -1,14 +1,15 @@
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 async function AdminSideBar() {
export default function AdminSideBar() {
return (
<>
<SidebarProvider>
<Sidebar className="z-[51]">
<Sidebar variant="floating" className="h-[96%] mt-10 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>
@@ -20,12 +21,19 @@ export default async function AdminSideBar() {
<Link href={"/admin/project/techStack/create"}> Create Stack </Link>
<Link href={"/admin/project/list"}> Project List </Link>
</SimpleSidebarGroup>
<SimpleSidebarGroup lable="Blog">
<Link href={"/"}> Some Blog Action </Link>
<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>
</SimpleSidebarGroup>
<SimpleSidebarGroup lable="Chat">
<Link href={"/admin/chat"}> System Prompt </Link>
</SimpleSidebarGroup>
</ScrollArea>
</SidebarContent>
</Sidebar>
</SidebarProvider>
</>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,237 @@
'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 './MdxComponentReference'
import BlogMdxEditorPreview from './BlogMdxEditorPreview'
import {
AUTOCOMPLETE_CURSOR_MARKER,
linkSuggestionsToAutocomplete,
type AutocompleteTriggerConfig,
type InternalLinkSuggestion,
type MdeAutocompleteSuggestion,
} from '~/app/_components/Form/Fields/InternalLinkTextarea'
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) ?? []
}
function internalLinkSuggestions(params: {
posts?: RouterOutputs['blog']['list'],
projects?: RouterOutputs['projectv2']['listWithStack'],
}): InternalLinkSuggestion[] {
const postLinks = params.posts?.map((post) => ({
label: post.title,
href: `/blog/${post.slug}`,
group: 'Blog',
})) ?? []
const projectLinks = params.projects?.map((project) => ({
label: project.title,
href: `/projects#${project.id}`,
group: 'Project',
})) ?? []
return [...postLinks, ...projectLinks]
}
const blogAutocompleteSuggestions: MdeAutocompleteSuggestion[] = [
{
label: 'Lead',
value: `<Lead>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Lead>`,
detail: 'Intro paragraph with larger muted text.',
group: 'Component',
trigger: '<',
},
{
label: 'Callout note',
value: `<Callout title="Heads up" variant="note">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
detail: 'Highlighted note block.',
group: 'Component',
trigger: '<',
},
{
label: 'Callout tip',
value: `<Callout title="Tip" variant="tip">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
detail: 'Highlighted tip block.',
group: 'Component',
trigger: '<',
},
{
label: 'Callout warning',
value: `<Callout title="Careful" variant="warning">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
detail: 'Highlighted warning block.',
group: 'Component',
trigger: '<',
},
{
label: 'ButtonLink',
value: `<ButtonLink href="${AUTOCOMPLETE_CURSOR_MARKER}">\nView projects\n</ButtonLink>`,
detail: 'Button-styled internal or external link.',
group: 'Component',
trigger: '<',
},
{
label: 'Figure',
value: `<Figure\n src="${AUTOCOMPLETE_CURSOR_MARKER}"\n alt="Describe the image"\n caption="Optional caption"\n/>`,
detail: 'Image with optional caption.',
group: 'Component',
trigger: '<',
},
{
label: 'PullQuote',
value: `<PullQuote>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</PullQuote>`,
detail: 'Large emphasized quote.',
group: 'Component',
trigger: '<',
},
{
label: 'TagList',
value: `<TagList tags={[${AUTOCOMPLETE_CURSOR_MARKER}]} />`,
detail: 'Inline list of tag badges.',
group: 'Component',
trigger: '<',
},
{
label: 'Badge',
value: `<Badge variant="outline">${AUTOCOMPLETE_CURSOR_MARKER}</Badge>`,
detail: 'Small inline label.',
group: 'Component',
trigger: '<',
},
{
label: 'Image',
value: `![Image](${AUTOCOMPLETE_CURSOR_MARKER})`,
detail: 'Markdown image',
group: 'Markdown',
trigger: '!',
},
]
const blogTriggerConfigs: AutocompleteTriggerConfig[] = [
{
trigger: '[[',
label: 'Internal links',
isQueryValid: (query) => !query.includes(']'),
},
{
trigger: '<',
label: 'MDX components',
isQueryValid: (query) => !/[\s>]/.test(query),
},
{
trigger: '!',
label: 'Markdown',
isQueryValid: (query) => !/[\s\)]/.test(query),
},
]
export default function CreateUpdateBlogForm(params: { className?: string, entity?: BlogPost }) {
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 posts = trpc.blog.list.useQuery(undefined, { refetchInterval: 5000 })
const projects = trpc.projectv2.listWithStack.useQuery()
const autocompleteSuggestions = [
...linkSuggestionsToAutocomplete(internalLinkSuggestions({ posts: posts.data, projects: projects.data })),
...blogAutocompleteSuggestions,
]
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'}
autocompleteSuggestions={autocompleteSuggestions}
triggerConfigs={blogTriggerConfigs}
renderPreview={(source) => <BlogMdxEditorPreview source={source} />}
/>
</FormScaffold>
</div>
</FormMutationContextProvider>
)
}

View File

@@ -0,0 +1,91 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
const examples = [
{
name: "Lead",
description: "Intro paragraph with larger muted text.",
code: `<Lead>
Short opening summary for the post.
</Lead>`,
},
{
name: "Callout",
description: "Highlighted note, tip, or warning block.",
code: `<Callout title="Heads up" variant="note">
Important context for readers.
</Callout>
<Callout title="Tip" variant="tip">
A practical recommendation.
</Callout>
<Callout title="Careful" variant="warning">
A caveat or tradeoff.
</Callout>`,
},
{
name: "ButtonLink",
description: "Button-styled internal or external link.",
code: `<ButtonLink href="/projects">
View projects
</ButtonLink>
<ButtonLink href="https://example.com" variant="outline">
External resource
</ButtonLink>`,
},
{
name: "Figure",
description: "Image with optional caption.",
code: `<Figure
src="https://example.com/image.jpg"
alt="Describe the image"
caption="Optional caption"
/>`,
},
{
name: "PullQuote",
description: "Large emphasized quote or takeaway.",
code: `<PullQuote>
A highlighted quote or strong takeaway.
</PullQuote>`,
},
{
name: "TagList",
description: "Inline list of tag badges inside the post body.",
code: `<TagList tags={["nextjs", "mdx", "uploadthing"]} />`,
},
{
name: "Badge",
description: "Small inline label.",
code: `<Badge variant="outline">Next.js</Badge>`,
},
];
export default function MdxComponentReference() {
return (
<section className="rounded-lg border p-4">
<h2 className="text-base font-semibold">MDX Components</h2>
<p className="text-muted-foreground mt-1 text-sm">
Components available inside blog post content. Type <code className="rounded bg-muted px-1">[[</code> for internal links or <code className="rounded bg-muted px-1">&lt;</code> for component snippets.
</p>
<Accordion type="single" collapsible className="mt-3">
{examples.map((example) => (
<AccordionItem key={example.name} value={example.name}>
<AccordionTrigger>
<span>
<span className="block">{example.name}</span>
<span className="text-muted-foreground block text-xs font-normal">{example.description}</span>
</span>
</AccordionTrigger>
<AccordionContent>
<pre className="bg-muted overflow-x-auto rounded-md p-3 text-xs">
<code>{example.code}</code>
</pre>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</section>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
import { isAdmin } from '~/app/actions'
import { redirect } from 'next/navigation'
import { servTrpc } from '~/app/_trpc/ServerClient'
import SystemPromptForm from './_components/SystemPromptForm'
export default async function SystemPromptPage() {
if (!(await isAdmin())) redirect('/admin')
const prompt = await servTrpc.chat.getSystemPrompt()
return (
<div className="w-full max-w-2xl p-6 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>
)
}

View File

@@ -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 ? params.entity.id : undefined)
const [id, setId] = useState<string | undefined>(params.entity?.id)
const form = useForm<z.infer<typeof schemas.insert>>({
resolver: zodResolver(schemas.insert),
defaultValues: {
id: params.entity ? params.entity.id : crypto.randomUUID(),
name: params.entity ? params.entity.name : "",
layoutPosition: params.entity ? params.entity.layoutPosition : "col1"
id: params.entity?.id || crypto.randomUUID(),
name: params.entity?.name || "",
layoutPosition: params.entity?.layoutPosition || "col1"
}
})
let path = usePathname()

View File

@@ -11,13 +11,8 @@ 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>
:
@@ -47,7 +42,7 @@ export default function CvPage() {
entires[0].filter((e) => { return e.categoryId == cat.id }).length > 0 ? (
<>
{entires[0].filter((e) => { return e.categoryId == cat.id }).map((entry) => (
<CollapsibleForm entityName="Entry" form={CreateUpdateCvEntryForm} entity={entry} entityLabelIndex="title" />
<CollapsibleForm key={entry.id} entityName="Entry" form={CreateUpdateCvEntryForm} entity={entry} entityLabelIndex="title" />
))}
</>
) : (<></>)
@@ -64,6 +59,6 @@ export default function CvPage() {
<CollapsibleForm entityName="Category" form={CreateUpdateCvCategoryForm} />
</>
}
</div>
</>
)
}

View File

@@ -8,13 +8,9 @@ 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>
:
@@ -40,6 +36,6 @@ export default function CvPage() {
})}
</>
}
</div>
</>
)
}

View File

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

View File

@@ -0,0 +1,105 @@
'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>
</>
);
}

View File

@@ -0,0 +1,26 @@
'use client'
import { trpc } from "~/app/_trpc/Client";
import * as Card from "~/components/ui/card";
import UploadMusicForm from "./_components/UploadMusicForm";
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>
<UploadMusicForm entity={t} className="w-full"/>
</Card.CardContent>
</Card.Card>
))}
</>}
<CollapsibleForm entityName="Track" form={UploadMusicForm}/>
</div>
);
}

View File

@@ -8,19 +8,23 @@ 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 } 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';
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()
const schemas = entitySchemas('project')
const { data: stacks, id: stackId, name: stackName, success: stacksSuccess, error: stackError } = makeUseRelationShipWithNameIndex('stackItems')(trpc.techStack.select.useQuery({}), id, (items) => { return items ? items.join('-') : "" })
const form = useForm<z.infer<typeof schemas.insert>>({
resolver: zodResolver(schemas.insert),
defaultValues: {
id: id ? id : crypto.randomUUID(),
title: params.entity ? params.entity.title : "",
description: params.entity ? params.entity.description : "",
stackId: params.entity ? params.entity.stackId : stacksSuccess ? stacks?.at(0)?.id : "",
releaseStatus: params.entity ? params.entity.releaseStatus : "unreleased",
releaseLink: params.entity ? params.entity.releaseLink : "",
@@ -64,6 +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"} />
<SelectFormField control={form.control} name='sourceType' label='Source Type' defaultValue={'open'} placeholder='open' >
<SelectItem value="open"> open </SelectItem>
<SelectItem value="closed"> closed </SelectItem>

View File

@@ -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 ?
<></> :
@@ -38,7 +38,7 @@ export default function ProjectList() {
techStacks[0].filter((e) => { return e.id == project.stackId }).length > 0 ? (
<>
{techStacks[0].filter((e) => { return e.id == project.stackId }).map((stack) => (
<CollapsibleForm entityName="Stack" form={CreateUpdateStackForm} entity={stack} entityLabelIndex="stackItems"/>
<CollapsibleForm key={stack.id} entityName="Stack" form={CreateUpdateStackForm} entity={stack} entityLabelIndex="stackItems"/>
))}
</>
) : (<></>)
@@ -55,6 +55,6 @@ export default function ProjectList() {
<CollapsibleForm entityName="Project" form={CreateUpdateProjectForm} />
</>
}
</div>
</>
)
}

View File

@@ -31,7 +31,7 @@ export default function CreateUpdateStackForm(params: { className?: string, enti
const deleteMutation = trpc.techStack.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
function onSubmit(values: z.infer<typeof schemas.insert>) {
setSubmitted(true)
params.entity ?
id ?
updateMutation.mutate(values) :
createMutation.mutate(values);
}

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

@@ -0,0 +1,99 @@
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.'
// 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('gpt-5-mini'),
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()
}

View File

@@ -0,0 +1,6 @@
import { createRouteHandler } from "uploadthing/next";
import { fileRouter } from "~/server/uploadthing";
export const { GET, POST } = createRouteHandler({
router: fileRouter,
});

View File

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

View File

@@ -0,0 +1,49 @@
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import { TRPCError } from "@trpc/server";
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.bySlug>>;
try {
post = await servTrpc.blog.bySlug(slug);
} catch (e) {
if (e instanceof TRPCError && e.code === "NOT_FOUND") notFound();
throw e;
}
return (
<main className="mx-auto max-w-2xl px-4 py-12">
<header className="mb-8">
<h1 className="text-3xl font-bold">{post.title}</h1>
{post.date && (
<time className="text-muted-foreground text-sm">
{new Date(post.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
)}
{post.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{post.tags.map((tag) => (
<Badge key={tag} variant="outline">{tag}</Badge>
))}
</div>
)}
</header>
<article className="prose dark:prose-invert max-w-none">
<MDXRemote source={post.content} components={mdxComponents} />
</article>
</main>
);
}

View File

@@ -0,0 +1,128 @@
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,
};

View File

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

View File

@@ -1,12 +1,45 @@
'use client'
import Link from "next/link";
import { servTrpc } from "~/app/_trpc/ServerClient";
import { Badge } from "~/components/ui/badge";
import { usePathname } from "next/navigation"
export default async function BlogPage() {
const posts = await servTrpc.blog.list();
export default function Page() {
const pathName = usePathname()
return (
<div>
{pathName}
</div>
)
<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>
);
}

View File

@@ -0,0 +1,68 @@
import type { UIMessage } from "ai";
import Markdown from "react-markdown";
import { cn } from "~/lib/utils";
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 (
<Markdown>
{part.text}
</Markdown>
)
}
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>
)
}

View File

@@ -0,0 +1,166 @@
'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} />
}

View File

@@ -0,0 +1,36 @@
import { type ChatStatus, type UIMessage } from 'ai'
import * as Card from "~/components/ui/card"
import { UserMessage } from './UserMessage';
import { AssistantMessage } from './AssistantMessage';
import { ScrollArea } from '~/components/ui/scroll-area';
import { memo } from 'react';
const Messages = memo(({messages,status}: { messages: UIMessage[],status:ChatStatus}) => {
return (
<ScrollArea data-scroller-priority='1' className="w-full 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;

View File

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

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

@@ -0,0 +1,29 @@
'use client'
import ChatInterface from './_components/ChatInterface'
import AnimatedPageTitle from '../_components/Animated/AnimatedPageTitle';
import { useTimeLine } from '../_providers/GsapProvicer';
import { useMessages } from '../_providers/MessagesProvider';
import { Spinner } from '~/components/ui/spinner';
export default function ChatPage() {
const {messages,session,isLoading,error} = useMessages()
useTimeLine(messages)
return (
<div className="flex flex-col px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
<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>
)
}

View File

@@ -1,17 +1,18 @@
'use client'
import { useGSAP } from "@gsap/react";
import { useGsapContext } from "../_providers/GsapProvicer";
import { useGsapContext,useTimeLine } 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";
import gsap from 'gsap'
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 gsapContext = useGsapContext()
const container = useRef<HTMLDivElement>(null)
enum Direction {
Left = 1,
@@ -31,12 +32,12 @@ export default function CvPage() {
return { y: 100, opacity: 0, duration: 0.5 }
}
}
useTimeLine(col2Categories)
useGSAP(() => {
const items = gsap?.utils.toArray<GSAPTweenTarget>('.gsapan');
const tl = gsap?.timeline();
let dir = Direction.Left;
items?.forEach(item => {
tl?.from(item, nextGsapConf(dir))
gsapContext?.addAnimation(gsap.from(item, nextGsapConf(dir)),0)
if (dir == Direction.Down) {
dir = Direction.Left
} else {
@@ -47,7 +48,7 @@ export default function CvPage() {
return (
<>
<SidebarProvider ref={container}>
{(sidebarCategories.data?.length ? sidebarCategories.data?.length : 0) > 0 ?
{sidebarCategories.data &&
<>
<SidebarTriggerDisappearsOnMobile />
<Sidebar className="gsapan ">
@@ -61,8 +62,7 @@ export default function CvPage() {
})}
</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]">

View File

@@ -5,13 +5,16 @@ 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 { CodeHighlightStyle } from "./_components/CodeHighlightSyle";
import { cn } from "~/lib/utils";
import AnimatedBackGroundContainer from "./_components/Animated/AnimatedBackGroundContainer";
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
@@ -28,7 +31,6 @@ const geist = Geist({
variable: "--font-geist-sans",
});
export default async function RootLayout({
children,
modal
@@ -44,11 +46,16 @@ export default async function RootLayout({
</head>
<body className="flex flex-col bg-background text-foreground">
<ThemeProvider>
<TopNav />
<main className="absolute lg:top-10 h-screen w-screen">
{children}
</main>
{modal}
<MessagesProvider>
<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 />
</MessagesProvider>
</ThemeProvider>
</body>
</html>

63
src/app/music/page.tsx Normal file
View File

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

View File

@@ -1,12 +1,116 @@
'use client'
import { usePathname } from "next/navigation"
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";
import { ScrollArea } from "~/components/ui/scroll-area";
import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle";
import AnimateTextIn from "../_components/Animated/AnimateIn";
import { useTimeLine } from "../_providers/GsapProvicer";
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
import { Button } from "~/components/ui/button";
import remarkGfm from "remark-gfm"
export default function ProjectsPage() {
const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery();
useTimeLine(projects)
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>
);
}
export default function Page() {
const pathName = usePathname()
return (
<div>
{pathName}
</div>
)
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
<AnimatedPageTitle position={0}><span>Project 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 position={i + 1.4} animation="slide"><Card.CardTitle>{project.title}</Card.CardTitle></AnimateTextIn>
<div className="flex gap-2 flex-wrap">
{project.sourceType && (
<AnimatePopUp position={i + 2} duration={2}>
<Badge variant={project.sourceType === "open" ? "secondary" : "outline"}>
{project.sourceType === "open" ? "Open Source" : "Closed Source"}
</Badge>
</AnimatePopUp>
)}
{project.releaseStatus && (
<Badge variant={project.releaseStatus === "released" ? "default" : "outline"}>
{project.releaseStatus === "released" ? "Released" : "Unreleased"}
</Badge>
)}
</div>
</div>
</Card.CardHeader>
{(project.description || project.sourceLink || project.releaseLink || project.techStack?.stackItems?.length) && (
<Card.CardContent className="flex flex-col gap-3">
{project.description && (
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
<AnimatePopUp position={i + 1.4} duration={10}>
<Markdown remarkPlugins={[remarkGfm]}>{project.description}</Markdown>
</AnimatePopUp>
</div>
)}
<div className="flex flex-row">
{project.techStack?.stackItems && project.techStack.stackItems.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{project.techStack.stackItems.map((item, k) => (
<AnimatePopUp key={k} position={(i + 2) + k * 0.5}> <StackBadge key={item} item={item} /> </AnimatePopUp>
))}
</div>
)}
{(project.sourceLink || project.releaseLink) && (
<div className="ml-auto flex-col lg:flex-row justify-center gap-5">
{project.sourceLink &&
<Button variant='outline' className="cursor-pointer mb-3 lg:mb-0 lg:mr-3 min-w-18">
<a
href={project.sourceLink}
target="_blank"
rel="noopener noreferrer"
className='items-center'
>
Source
</a>
</Button>
}
{project.releaseLink &&
<Button variant='default' className="cursor-pointer min-w-18 items-center">
<a
href={project.releaseLink}
target="_blank"
rel="noopener noreferrer"
className='items-center'
>
Live
</a>
</Button>
}
</div>
)}
</div>
</Card.CardContent>
)}
</Card.AnimatedCard>
<div className="pt-5" />
</div>
))}
</ScrollArea>
);
}

View File

@@ -0,0 +1,156 @@
import { Badge } from "~/components/ui/badge";
interface SvglIcon {
light: string;
dark: string;
}
const STACK_META: Record<string, { label: string; icon?: SvglIcon }> = {
drizzle: {
label: "Drizzle ORM",
icon: {
light: "https://svgl.app/library/drizzle-orm_light.svg",
dark: "https://svgl.app/library/drizzle-orm_dark.svg",
},
},
postgres: {
label: "PostgreSQL",
icon: {
light: "https://svgl.app/library/postgresql.svg",
dark: "https://svgl.app/library/postgresql.svg",
},
},
nextjs: {
label: "Next.js",
icon: {
light: "https://svgl.app/library/nextjs_icon_dark.svg",
dark: "https://svgl.app/library/nextjs_icon_dark.svg",
},
},
react: {
label: "React",
icon: {
light: "https://svgl.app/library/react_light.svg",
dark: "https://svgl.app/library/react_dark.svg",
},
},
servercomponents: { label: "Server Components" },
php: {
label: "PHP",
icon: {
light: "https://svgl.app/library/php.svg",
dark: "https://svgl.app/library/php_dark.svg",
},
},
laravel: {
label: "Laravel",
icon: {
light: "https://svgl.app/library/laravel.svg",
dark: "https://svgl.app/library/laravel.svg",
},
},
reactnative: {
label: "React Native",
icon: {
light: "https://svgl.app/library/react_light.svg",
dark: "https://svgl.app/library/react_dark.svg",
},
},
"react-native": {
label: "React Native",
icon: {
light: "https://svgl.app/library/react_light.svg",
dark: "https://svgl.app/library/react_dark.svg",
},
},
expo: {
label: "Expo",
icon: {
light: "https://svgl.app/library/expo.svg",
dark: "https://svgl.app/library/expo.svg",
},
},
mysql: {
label: "MySQL",
icon: {
light: "https://svgl.app/library/mysql-icon-light.svg",
dark: "https://svgl.app/library/mysql-icon-dark.svg",
},
},
nginx: {
label: "Nginx",
icon: {
light: "https://svgl.app/library/nginx.svg",
dark: "https://svgl.app/library/nginx.svg",
},
},
protobuf: { label: "Protobuf" },
grpc: { label: "gRPC" },
java: {
label: "Java",
icon: {
light: "https://svgl.app/library/java.svg",
dark: "https://svgl.app/library/java.svg",
},
},
graalvm: { label: "GraalVM" },
spring: {
label: "Spring",
icon: {
light: "https://svgl.app/library/spring.svg",
dark: "https://svgl.app/library/spring.svg",
},
},
aws: {
label: "AWS",
icon: {
light: "https://svgl.app/library/aws_light.svg",
dark: "https://svgl.app/library/aws_dark.svg",
},
},
s3: { label: "Amazon S3" },
linux: {
label: "Linux",
icon: {
light: "https://svgl.app/library/linux.svg",
dark: "https://svgl.app/library/linux.svg",
},
},
debian: { label: "Debian" },
htmx: { label: "HTMX" },
neon: {
label: "Neon",
icon: {
light: "https://svgl.app/library/neon.svg",
dark: "https://svgl.app/library/neon.svg",
},
},
};
export function StackBadge({ item }: { item: string }) {
const meta = STACK_META[item] ?? { label: item };
return (
<Badge variant="outline">
{meta.icon && (
<>
<img
src={meta.icon.light}
alt=""
width={12}
height={12}
className="dark:hidden shrink-0"
/>
<img
src={meta.icon.dark}
alt=""
width={12}
height={12}
className="hidden dark:block shrink-0"
/>
</>
)}
{meta.label}
</Badge>
);
}

View File

@@ -1,5 +1,7 @@
import * as React from "react"
import { useGSAP } from "@gsap/react"; import * as React from "react"
import { useRef } from "react";
import { useGsapContext } from "~/app/_providers/GsapProvicer";
import gsap from 'gsap'
import { cn } from "~/lib/utils"
function Card({
@@ -12,7 +14,60 @@ 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",
"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,
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm", position?: gsap.Position, scrollOnly?: boolean }) {
const gsapContext = useGsapContext()
const ref = useRef<HTMLDivElement | null>(null)
useGSAP(() => {
const rect = ref.current?.getBoundingClientRect()
const scroller = gsapContext?.getScroller()
console.log(scroller)
let viewportTop = 0
let viewportBottom = window.innerHeight
if (scroller && scroller instanceof Element) {
const scrollerRect = scroller.getBoundingClientRect()
viewportTop = scrollerRect.top
viewportBottom = scrollerRect.top + scrollerRect.height
}
const isInView = rect && rect.bottom > viewportTop && rect.top < viewportBottom
console.log(isInView)
const fromVars = { x: -100, opacity: 0, duration: 0.5 }
if (isInView && !scrollOnly) {
gsapContext?.addAnimation(gsap.from(ref.current, fromVars), position)
} else {
gsap.from(ref.current,
{
...fromVars,
scrollTrigger: {
trigger: ref.current,
start: 'top bottom',
end: 'bottom top',
toggleActions: "play reverse play reverse",
scroller
}
})
}
}, { dependencies: [] })
return (
<div
ref={ref}
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}
@@ -100,4 +155,5 @@ export {
CardAction,
CardDescription,
CardContent,
AnimatedCard
}

View File

@@ -0,0 +1,10 @@
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 }

View File

@@ -7,6 +7,9 @@ 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(),
@@ -27,6 +30,7 @@ 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"),
@@ -38,6 +42,7 @@ export const env = createEnv({
* `NEXT_PUBLIC_`.
*/
client: {
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: z.string(),
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string()
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
@@ -47,6 +52,9 @@ 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,
@@ -63,6 +71,8 @@ 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,
NODE_ENV: process.env.NODE_ENV,

View File

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

View File

@@ -0,0 +1,18 @@
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(),
})
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(),
})

5
src/lib/uploadthing.ts Normal file
View File

@@ -0,0 +1,5 @@
import { generateUploadButton, generateUploadDropzone } from "@uploadthing/react";
import type { FileRouter } from "~/server/uploadthing";
export const UploadButton = generateUploadButton<FileRouter>();
export const UploadDropzone = generateUploadDropzone<FileRouter>();

View File

@@ -4,6 +4,7 @@ import { env } from "~/env";
const isTenantAdminRoute = createRouteMatcher(['/admin(.*)'])
export default clerkMiddleware(async (auth,req) => {
if (isTenantAdminRoute(req)) {
console.log("running clerk middleware");
let userid = (await auth()).userId
if (userid != env.ADMIN_USER_CLERK_ID) {
await auth.protect()

View File

@@ -2,7 +2,7 @@
// https://orm.drizzle.team/docs/sql-schema-declaration
import { relations, sql } from "drizzle-orm";
import { index, pgEnum, pgSchema, pgTableCreator } from "drizzle-orm/pg-core";
import { index, pgEnum, pgSchema, pgTableCreator, uniqueIndex } from "drizzle-orm/pg-core";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
@@ -55,13 +55,14 @@ 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'])
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",
(d) => ({
id: d.uuid().primaryKey().notNull(),
title: d.varchar({length: 50}).notNull(),
description: d.text(),
sourceType: sourceTypeEnum(),
sourceLink: d.varchar({length: 200}),
releaseStatus: releaseStatus(),
@@ -84,3 +85,89 @@ 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(),
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
fileKey: d.varchar("file_key", { length: 200 }).notNull(),
fileName: d.varchar("file_name", { length: 200 }).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 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()
})
)

View File

@@ -1,14 +1,18 @@
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,
@@ -17,6 +21,8 @@ export const trpcRouter = router({
categoryv2: cvCategoryRouter,
entry: trpcCrudRouterFromDrizzleEntity('cvEntry').router,
entryv2: cvEntryRouter,
music: musicRouter,
chat: chatRouter
});
export type TrpcRouter = typeof trpcRouter;

360
src/server/routers/blog.ts Normal file
View File

@@ -0,0 +1,360 @@
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 ?? []),
};
}),
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 };
}),
});

View File

@@ -0,0 +1,79 @@
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'
export const chatRouter = router({
getSession: publicProcedure.query(async () => {
const clerk = await clerkClient()
const { userId } = await auth();
const user = await clerk.users.getUser(userId?userId:"")
if (user == undefined) {
throw new TRPCError({ message: "chat is only available to signed in users", code: 'UNAUTHORIZED' });
}
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 db.select().from(systemSettings).limit(1).then((r) => r[0])
return row?.systemPropmt ?? ''
}),
updateSystemPrompt: publicProcedure.input(z.object({ prompt: z.string() })).mutation(async ({ input }) => {
if (!(await isAdmin())) throw new TRPCError({ code: 'FORBIDDEN' })
await db.delete(systemSettings)
await db.insert(systemSettings).values({ systemPropmt: input.prompt })
}),
})
export type ChatRouter = typeof chatRouter;

View File

@@ -0,0 +1,48 @@
import { publicProcedure, router } from "~/server/trpc";
import { db } from "~/server/db";
import { music } from "~/server/dbschema/schema";
import { eq } from "drizzle-orm";
import { isAdmin } from "~/app/actions";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createMusicInputSchema, updateMusicInputSchema } from "~/lib/trpc/music/schemas";
import { utapi } from "../uploadthing";
export const musicRouter = router({
list: publicProcedure.query(async () => {
let res = await db.select().from(music).orderBy(music.createdAt);
console.log(res);
return res;
}),
create: publicProcedure
.input(
createMusicInputSchema
)
.mutation(async ({ input }) => {
const admin = await isAdmin();
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
let res = await db.insert(music).values(input).returning();
return res.at(0);
}),
update: publicProcedure
.input(
updateMusicInputSchema
)
.mutation(async ({ input }) => {
const admin = await isAdmin();
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
const { id, ...data } = input;
return db.update(music).set(data).where(eq(music.id, id)).returning();
}),
delete: publicProcedure
.input(z.object({id:z.string().uuid()}))
.mutation(async ({ input }) => {
const admin = await isAdmin();
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
let res = await db.delete(music).where(eq(music.id, input.id)).returning();
let ret = res.at(0)
if (ret) {
utapi.deleteFiles(ret.fileKey)
}
return ret;
}),
});

View File

@@ -1,4 +1,92 @@
import { router } from "~/server/trpc";
import { publicProcedure, router } from "~/server/trpc";
import { db } from "~/server/db";
type ReadmeRequest = {
url: string;
};
function getReadmeRequest(sourceLink: string): ReadmeRequest | null {
let url: URL;
try {
url = new URL(sourceLink);
} catch {
return null;
}
const pathParts = url.pathname.split("/").filter(Boolean);
const [owner, repo] = pathParts;
if (!owner || !repo) {
return null;
}
const repoName = repo.replace(/\.git$/, "");
if (url.hostname === "github.com" || url.hostname === "www.github.com") {
return {
url: `https://raw.githubusercontent.com/${owner}/${repoName}/main/README.md`,
};
}
if (url.hostname.includes("gitea.")) {
return {
url: `${url.origin}/${owner}/${repoName}/raw/branch/main/README.md`,
};
}
return null;
}
async function fetchReadme(sourceLink: string) {
const readmeRequest = getReadmeRequest(sourceLink);
if (!readmeRequest) {
return null;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(readmeRequest.url, {
headers: {
Accept: "text/plain",
},
signal: controller.signal,
});
if (!response.ok) {
return null;
}
return await response.text();
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
export const projectRouter = router({
listWithStack: publicProcedure.query(async () => {
const projects = await db.query.project.findMany({
with: {
techStack: true,
},
});
return Promise.all(
projects.map(async (project) => {
if (project.description?.length !== 0 || !project.sourceLink) {
return project;
}
return {
...project,
description: await fetchReadme(project.sourceLink),
};
}),
);
}),
});

22
src/server/uploadthing.ts Normal file
View File

@@ -0,0 +1,22 @@
import { createUploadthing, type FileRouter as UploadThingFileRouter } from "uploadthing/next";
import { UTApi } from 'uploadthing/server'
import { isAdmin } from "~/app/actions";
const f = createUploadthing();
export const fileRouter = {
musicUploader: f({ audio: { maxFileSize: "64MB", maxFileCount: 1 } })
.middleware(async () => {
const admin = await isAdmin();
if (!admin) throw new Error("Unauthorized");
return {};
})
.onUploadComplete(async ({ file }) => {
console.log(file)
return { fileUrl: file.ufsUrl, fileKey: file.key, fileName: file.name };
}),
} satisfies UploadThingFileRouter ;
export type FileRouter = typeof fileRouter;
export const utapi = new UTApi();

View File

@@ -139,4 +139,48 @@
* {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}
.cl-button__google {
display: none
}
.mde-form-field-fullscreen {
position: fixed;
inset: 0;
z-index: 100000;
display: flex !important;
height: 100vh;
width: 100vw;
flex-direction: column;
overflow: hidden;
background: var(--background);
padding: 1rem;
}
.mde-form-field-editor-fullscreen.w-md-editor {
flex: 1 1 auto;
min-height: 0;
height: calc(100vh - 72px) !important;
}
.mde-form-field-editor-fullscreen .w-md-editor-toolbar {
flex: 0 0 auto;
}
.mde-form-field-editor-fullscreen .w-md-editor-content {
flex: 1 1 auto;
min-height: 0;
height: auto !important;
overflow: hidden;
}
.mde-form-field-editor-fullscreen .w-md-editor-input,
.mde-form-field-editor-fullscreen .w-md-editor-area,
.mde-form-field-editor-fullscreen .w-md-editor-text,
.mde-form-field-editor-fullscreen .w-md-editor-text-pre,
.mde-form-field-editor-fullscreen .w-md-editor-text-input,
.mde-form-field-editor-fullscreen .w-md-editor-preview {
min-height: 0;
height: 100%;
}

View File

@@ -1,7 +1,8 @@
import type { Config } from "tailwindcss"
const config = {
plugins: [
require("tailwindcss-motion")
require("tailwindcss-motion"),
require("@tailwindcss/typography")
]
} satisfies Config