26 Commits

Author SHA1 Message Date
65b9184a22 refactor animated background, fix type 2026-04-24 14:23:52 +02:00
ea7ddb8e51 fix admin redirect loop 2026-04-24 12:41:27 +02:00
bcefe397ca speedinsights next 2026-04-24 12:26:44 +02:00
8ce95f2b5c add speed insides 2026-04-24 12:23:31 +02:00
da43b31aa3 chat interface, validate user id 2026-04-24 12:11:29 +02:00
538d896b0e Merge branch 'blog' 2026-04-24 11:58:34 +02:00
be6df0c8ad blog editor 2026-04-24 11:58:19 +02:00
daab745c13 slop 2026-04-23 11:10:35 +02:00
c527391259 Merge branch 'projectpage' 2026-04-23 11:06:24 +02:00
404062904f finish up project page 2026-04-23 11:04:59 +02:00
4e8538552e Merge branch 'aiassistant' 2026-04-22 21:19:48 +02:00
64bd5c429e logged out chat modal, call to action 2026-04-22 21:05:53 +02:00
52e0a65113 hide google sign in 2026-04-22 19:31:09 +02:00
30e3dbb42b chat interface 2026-04-21 14:19:50 +02:00
caa9604704 early return on no sessionid 2026-03-31 15:47:41 +02:00
c62ee37538 prooompt engineering 2026-03-31 15:30:42 +02:00
ead9548744 server auth stuff, prooompt engineering 2026-03-31 15:29:47 +02:00
c5b3ee3875 get session by userid 2026-03-31 14:44:08 +02:00
2b5c105abb current time tool 2026-03-31 14:39:56 +02:00
e25fc39bac current time tool 2026-03-31 14:23:04 +02:00
e481fa66cd reapply layout effect fix for timeline cleanup 2026-03-31 14:16:26 +02:00
009d2b8d60 drop multitimeline support from gsap provider but keep scroller priority feature 2026-03-31 14:12:29 +02:00
d567fa3e02 fix chat 2026-03-31 14:03:41 +02:00
399d78e508 reset timeline cleanup needs to be returned from layout effect 2026-03-30 19:04:20 +02:00
bfc2bb1501 reset gsap provider to music page version 2026-03-30 18:54:19 +02:00
d7a9e53d9a revert some gsap provider changes 2026-03-30 18:50:05 +02:00
51 changed files with 2630 additions and 858 deletions

0
.codex Normal file
View File

756
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -66,6 +66,7 @@
"@trpc/server": "^11.12.0", "@trpc/server": "^11.12.0",
"@uiw/react-md-editor": "^4.0.11", "@uiw/react-md-editor": "^4.0.11",
"@uploadthing/react": "^7.3.3", "@uploadthing/react": "^7.3.3",
"@vercel/speed-insights": "^2.0.0",
"ai": "^6.0.116", "ai": "^6.0.116",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -77,10 +78,12 @@
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"glazejs": "^2.0.1", "glazejs": "^2.0.1",
"googleapis": "^171.4.0", "googleapis": "^171.4.0",
"gray-matter": "^4.0.3",
"gsap": "^3.14.2", "gsap": "^3.14.2",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next": "16.1.6", "next": "16.1.6",
"next-mdx-remote": "^6.0.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"postgres": "^3.4.8", "postgres": "^3.4.8",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
@@ -93,6 +96,7 @@
"recharts": "2.15.4", "recharts": "2.15.4",
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"shadcn": "^4.0.2", "shadcn": "^4.0.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",

View File

@@ -2,29 +2,28 @@
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog'
import ChatInterface from '~/app/chat/_components/ChatInterface' import ChatInterface from '~/app/chat/_components/ChatInterface'
import { useMessages } from '~/app/_providers/MessagesProvider';
import { Spinner } from '~/components/ui/spinner';
type DBMessage = { export default function ChatModal() {
id: string
role: 'user' | 'assistant'
content: string
}
interface ChatModalProps {
sessionId: string
initialMessages: DBMessage[]
}
export default function ChatModal({ sessionId, initialMessages }: ChatModalProps) {
const router = useRouter() const router = useRouter()
const {messages,session,isLoading,error} = useMessages()
return ( return (
<Dialog modal={true} open onOpenChange={() => router.back()}> <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"> <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"> <DialogHeader className="p-4 border-b shrink-0">
<DialogTitle>AI Recruiter</DialogTitle> <DialogTitle>Talk To My AI-Assistant</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-hidden min-h-0"> <div className="flex-1 overflow-hidden min-h-0">
<ChatInterface sessionId={sessionId} initialMessages={initialMessages} /> {!isLoading &&
<ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
}
{isLoading &&
<><Spinner/> Loading Messages...</>
}
{error &&
<div> {error} </div>
}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

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

View File

@@ -1,15 +0,0 @@
'use client'
import { Skeleton } from '~/components/ui/skeleton';
import ChatModal from './_components/ChatModal'
import { trpc } from '~/app/_trpc/Client'
export default function ChatModalPage() {
const { data: session, error, isLoading } = trpc.chat.getSession.useQuery();
return (
<>
{session && <ChatModal sessionId={session.id} initialMessages={session.messages} />}
{error && <div>{error.message}</div>}
{isLoading && <Skeleton />}
</>
)
}

View File

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

View File

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

View File

@@ -1,19 +1,22 @@
'use client' 'use client'
import Link from 'next/link' import Link from 'next/link'
import { MessageCircle } from 'lucide-react' import { MessageCircle } from 'lucide-react'
import { Show } from '@clerk/nextjs'
import { Button } from '~/components/ui/button' import { Button } from '~/components/ui/button'
import { usePathname } from 'next/navigation'
export default function ChatFAB() { export default function ChatFAB() {
const pathName = usePathname()
const isChat = pathName.indexOf('\/chat') > -1
return ( return (
<Show when="signed-in"> <>
<div className="fixed bottom-6 right-6 z-50"> {!isChat &&
<Button asChild size="icon" className="h-14 w-14 rounded-full shadow-lg"> <div className="fixed bottom-6 right-6 z-50">
<Link href="/chat"> <Button asChild size="icon" className="h-14 w-14 rounded-full shadow-lg">
<MessageCircle className="h-6 w-6" /> <Link href="/assistant">
</Link> <MessageCircle className="h-6 w-6" />
</Button> </Link>
</div> </Button>
</Show> </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"; import { createContext, useContext, type ReactNode } from "react";
interface ToString { interface ToString {
@@ -8,7 +7,7 @@ interface ToString {
export interface MutationInterface { export interface MutationInterface {
mutate: (params:{id:string}) => void mutate: (params: any) => void
error: ToString | null error: ToString | null
status: "error" | "idle" | "pending" | "success" status: "error" | "idle" | "pending" | "success"
} }

View File

@@ -0,0 +1,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 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 type { Control, FieldValues, Path } from "react-hook-form";
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form"; import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
export default function MdeFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, dataColorMode: "dark"|"light" }) { import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import {
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 ( return (
<FormField <FormField
control={params.control} control={params.control}
name={params.name} name={params.name}
render={({ field }) => ( render={({ field }) => {
<FormItem> const editor = (
<FormLabel> <FormItem className={cn(fullscreen && "mde-form-field-fullscreen")}>
Description <div className="flex shrink-0 items-center justify-between gap-2">
</FormLabel> <FormLabel>
<FormControl> {params.label}
<MDEditor </FormLabel>
value={field.value ? field.value : ""} <Button
onChange={field.onChange} type="button"
data-color-mode={params.dataColorMode} variant="outline"
/> size="icon-sm"
</FormControl> aria-label={fullscreen ? "Exit fullscreen editor" : "Open fullscreen editor"}
</FormItem> 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

@@ -24,7 +24,7 @@ export default function TopNav() {
</Button> </Button>
<Show when="signed-in"> <Show when="signed-in">
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline"> <Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
<a href="/chat"> Chat </a> <Link href="/chat"> Chat </Link>
</Button> </Button>
</Show> </Show>
</div> </div>

View File

@@ -3,7 +3,7 @@ import { useGSAP } from '@gsap/react'
import gsap from 'gsap' import gsap from 'gsap'
import { SplitText } from 'gsap/SplitText' import { SplitText } from 'gsap/SplitText'
import { ScrollTrigger, GSDevTools } from 'gsap/all' import { ScrollTrigger, GSDevTools } from 'gsap/all'
import { createContext, useCallback, useContext, useEffect, useRef, type ReactNode } from 'react' import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'
gsap.registerPlugin(useGSAP) gsap.registerPlugin(useGSAP)
gsap.registerPlugin(ScrollTrigger) gsap.registerPlugin(ScrollTrigger)
@@ -38,7 +38,7 @@ export const useTimeLine = (dep:any,all?:boolean) => {
} }
} }
},[dep]) },[dep])
useEffect(() => { useLayoutEffect(() => {
return () => { return () => {
gsapContext?.resetTimeline() gsapContext?.resetTimeline()
} }
@@ -49,18 +49,30 @@ export default function GsapProvider({ children }: { children: ReactNode }) {
const tl = useRef<gsap.core.Timeline | null>(null) const tl = useRef<gsap.core.Timeline | null>(null)
const scrollerRef = useRef<Element | Window | null>(null) const scrollerRef = useRef<Element | Window | null>(null)
const getScroller = useCallback(() => { const getScroller = useCallback(() => {
const cached = scrollerRef.current // const cached = scrollerRef.current
if (!cached || (cached instanceof Element && !document.contains(cached))) { // if (!cached || (cached instanceof Element && !document.contains(cached))) {
scrollerRef.current = document.querySelector('[data-slot="scroll-area-viewport"]') ?? window let scrollers = document.querySelectorAll('[data-slot="scroll-area-viewport"]')
} if (scrollers.length < 1) {
scrollerRef.current = window
} else {
let scrollerArray = Array.from(scrollers.values()).sort((a,b) => {
const s1 = a as HTMLDivElement;
const s2 = b as HTMLDivElement;
// using bitwise not (~~) to coerce NaN values to 0
const aPriority = ~~Number(s1.dataset?.scrollerPriority)
const bPriority = ~~Number(s2.dataset?.scrollerPriority)
return aPriority - bPriority;
})
let prioScroller = scrollerArray.pop();
scrollerRef.current = prioScroller || window;
}
// }
return scrollerRef.current return scrollerRef.current
}, []) }, [])
useGSAP(() => { useGSAP(() => {
if (!tl.current) { if (!tl.current) {
tl.current = gsap.timeline({ paused: true, id:'mainTimeline', onComplete: ()=>{ tl.current = gsap.timeline({ paused: true })
console.log('timeline revert')
gsap.getById('mainTimeline')?.revert()
} })
} }
return () => { console.log("gsap cleanup") } return () => { console.log("gsap cleanup") }
}) })

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

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

View File

@@ -1,5 +1,5 @@
'use server' 'use server'
import { clerkClient } from '@clerk/nextjs/server' import { clerkClient, auth } from '@clerk/nextjs/server'
import { google } from 'googleapis' import { google } from 'googleapis'
import { env } from '~/env' import { env } from '~/env'
@@ -10,7 +10,6 @@ export async function scheduleMeeting({
durationMinutes, durationMinutes,
attendeeEmail, attendeeEmail,
attendeeName, attendeeName,
userId,
}: { }: {
title: string title: string
description: string description: string
@@ -18,11 +17,11 @@ export async function scheduleMeeting({
durationMinutes: number durationMinutes: number
attendeeEmail?: string attendeeEmail?: string
attendeeName?: string attendeeName?: string
userId: string
}) { }) {
try { try {
const clerk = await clerkClient() 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 // Get admin's Google OAuth token to create the event on Gregor's calendar
const adminTokenResponse = await clerk.users.getUserOauthAccessToken( const adminTokenResponse = await clerk.users.getUserOauthAccessToken(
env.ADMIN_USER_CLERK_ID, env.ADMIN_USER_CLERK_ID,
@@ -37,16 +36,7 @@ export async function scheduleMeeting({
// Try to resolve visitor's Google email for the invite // Try to resolve visitor's Google email for the invite
let visitorEmail: string | undefined = attendeeEmail let visitorEmail: string | undefined = attendeeEmail
if (!visitorEmail) { if (!visitorEmail) {
try { visitorEmail = user?.emailAddresses.at(0)?.emailAddress ?? undefined
const visitorTokenResponse = await clerk.users.getUserOauthAccessToken(userId, 'oauth_google')
if (visitorTokenResponse.data[0]) {
const user = await clerk.users.getUser(userId)
const googleAccount = user.externalAccounts.find((a) => a.provider === 'google')
visitorEmail = googleAccount?.emailAddress ?? undefined
}
} catch {
// Visitor not signed in with Google — no invite
}
} }
const oAuth2Client = new google.auth.OAuth2() const oAuth2Client = new google.auth.OAuth2()
@@ -71,6 +61,7 @@ export async function scheduleMeeting({
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' }, end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
attendees, attendees,
}, },
sendNotifications: true
}) })
return { return {

View File

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

@@ -1,11 +1,7 @@
import { isAdmin } from '~/app/actions'
import { redirect } from 'next/navigation'
import { servTrpc } from '~/app/_trpc/ServerClient' import { servTrpc } from '~/app/_trpc/ServerClient'
import SystemPromptForm from './_components/SystemPromptForm' import SystemPromptForm from './_components/SystemPromptForm'
export default async function SystemPromptPage() { export default async function SystemPromptPage() {
if (!(await isAdmin())) redirect('/admin')
const prompt = await servTrpc.chat.getSystemPrompt() const prompt = await servTrpc.chat.getSystemPrompt()
return ( return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,14 @@
import { auth } from '@clerk/nextjs/server' import { auth } from '@clerk/nextjs/server'
import { createOpenAI } from '@ai-sdk/openai' import { createOpenAI } from '@ai-sdk/openai'
import { streamText, tool, convertToModelMessages, stepCountIs, type UIMessage } from 'ai' import { streamText, tool, convertToModelMessages, stepCountIs, type UIMessage } from 'ai'
import { z } from 'zod' import { success, z } from 'zod'
import { eq, and } from 'drizzle-orm' import { eq, and } from 'drizzle-orm'
import { env } from '~/env' import { env } from '~/env'
import { db } from '~/server/db' import { db } from '~/server/db'
import { chatSession, chatMessage } from '~/server/dbschema/schema' import { chatSession, chatMessage } from '~/server/dbschema/schema'
import { servTrpc } from '~/app/_trpc/ServerClient' import { servTrpc } from '~/app/_trpc/ServerClient'
import { scheduleMeeting } from '~/app/actions/scheduleMeeting' import { scheduleMeeting } from '~/app/actions/scheduleMeeting'
import currentTime from '~/app/actions/currentTime';
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY }) const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
@@ -45,15 +46,15 @@ export async function POST(req: Request) {
} }
const result = streamText({ const result = streamText({
model: openai('gpt-4o'), model: openai('gpt-5-mini'),
system: systemPrompt, system: systemPrompt,
messages: await convertToModelMessages(messages), messages: await convertToModelMessages(messages),
tools: { tools: {
scheduleMeeting: tool({ scheduleMeeting: tool({
description: 'Schedule a meeting with Gregor Lohaus and add it to his Google Calendar', description: 'Schedule a meeting with Gregor Lohaus and add it to his Google Calendar',
inputSchema: z.object({ inputSchema: z.object({
title: z.string().describe('Meeting title'), title: z.string().describe('Meeting title, make something up if not provided'),
description: z.string().describe('Meeting description / agenda'), description: z.string().describe('Meeting description / agenda, make something up if not provided'),
dateTime: z dateTime: z
.string() .string()
.describe( .describe(
@@ -64,16 +65,23 @@ export async function POST(req: Request) {
.int() .int()
.min(15) .min(15)
.max(120) .max(120)
.describe('Duration of the meeting in minutes'), .describe('Duration of the meeting in minutes, if none provided ask if 20 minutes is ok'),
attendeeEmail: z attendeeEmail: z
.string() .string()
.email() .email()
.optional() .optional()
.describe('Email of the visitor to invite (if provided)'), .describe('Optional Email of the visitor to invite (if provided)'),
attendeeName: z.string().optional().describe('Name of the visitor'), attendeeName: z.string().optional().describe('Name of the visitor'),
}), }),
execute: async (input) => scheduleMeeting({ ...input, userId }), 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), stopWhen: stepCountIs(5),
onFinish: async ({ text, finishReason }) => { onFinish: async ({ text, finishReason }) => {

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 BlogLayout({ children }: { children: React.ReactNode }) {
export default function RootLayout({ return <>{children}</>;
children,
}: Readonly<{ children: React.ReactNode}>) {
return (
<>
{children}
</>
)
} }

View File

@@ -1,12 +1,45 @@
'use client' import Link from "next/link";
import { servTrpc } from "~/app/_trpc/ServerClient";
import { Badge } from "~/components/ui/badge";
import { usePathname } from "next/navigation" export default async function BlogPage() {
const posts = await servTrpc.blog.list();
export default function Page() {
const pathName = usePathname()
return ( return (
<div> <main className="mx-auto max-w-2xl px-4 py-12">
{pathName} <h1 className="mb-8 text-3xl font-bold">Blog</h1>
</div> {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

@@ -1,23 +1,42 @@
'use client' 'use client'
import { useRef, useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useChat } from '@ai-sdk/react' import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai' import { DefaultChatTransport, type UIMessage } from 'ai'
import { Button } from '~/components/ui/button' import { Button } from '~/components/ui/button'
import { Textarea } from '~/components/ui/textarea' import { Textarea } from '~/components/ui/textarea'
import { cn } from '~/lib/utils' import { SignInButton } from '@clerk/nextjs'
import Markdown from 'react-markdown'; import {
import { AssistantMessage } from './AssistantMessage'; useGsapContext,
import { UserMessage } from './UserMessage'; } from '~/app/_providers/GsapProvicer';
import Messages from './Messages'
type DBMessage = { import { DeleteIcon } from 'lucide-react';
import { Spinner } from '~/components/ui/spinner';
import { useMessages } from '~/app/_providers/MessagesProvider';
interface DBMessage {
id: string id: string
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
} }
interface ChatInterfaceProps { interface ChatInterfaceProps {
sessionId: string sessionId?: string,
initialMessages: DBMessage[] 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[] { function toUIMessages(dbMessages: DBMessage[]): UIMessage[] {
@@ -28,60 +47,55 @@ function toUIMessages(dbMessages: DBMessage[]): UIMessage[] {
})) }))
} }
export default function ChatInterface({ sessionId, initialMessages }: ChatInterfaceProps) { 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 [input, setInput] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null) const { clearingChat, clearChat, refetchMessages } = useMessages();
const initialMessages = toUIMessages(dbMessages)
const { messages, sendMessage, status, error, clearError } = useChat({ addInitMessage(initialMessages)
const { messages, sendMessage, status, error, clearError, setMessages } = useChat({
transport: new DefaultChatTransport({ transport: new DefaultChatTransport({
api: '/api/chat', api: '/api/chat', body: { sessionId },
body: { sessionId },
}), }),
messages: toUIMessages(initialMessages), messages: initialMessages,
}) })
const isLoading = status === 'submitted' || status === 'streaming'
const hasError = status === 'error'
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) return () => {
}, [messages]) refetchMessages()
}
}, [])
const handleSend = () => { const handleSend = () => {
const text = input.trim() const text = input.trim()
if (!text || isLoading) return if (!text || status != 'ready' || clearingChat) return
setInput('') setInput('')
sendMessage({ text }) 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 ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-4 space-y-4"> {messages &&
{messages.length === 0 && ( <Messages status={status} messages={messages} />
<div className="text-center text-muted-foreground py-12"> }
<p className="text-base font-medium mb-1">Hi! I'm Gregor's AI recruiter assistant.</p>
<p className="text-sm">Ask me about his skills and experience, or schedule a meeting!</p>
</div>
)}
{messages.map((message) => (
<>
{message.role == 'assistant' && <AssistantMessage message={message}/>}
{message.role == 'user' && <UserMessage message={message}/>}
</>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-muted rounded-lg px-4 py-2 text-sm">
<span className="animate-pulse">Thinking</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{error && ( {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"> <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"> <span className="flex-1">
@@ -89,19 +103,20 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
? 'OpenAI quota exceeded. Please try again later.' ? 'OpenAI quota exceeded. Please try again later.'
: `Error: ${error.message}`} : `Error: ${error.message}`}
</span> </span>
<button <Button
type="button" type="button"
onClick={clearError} onClick={clearError}
className="shrink-0 opacity-60 hover:opacity-100" className="shrink-0 opacity-60 hover:opacity-100"
aria-label="Dismiss" variant='destructive'
> >
<DeleteIcon />
</button> </Button>
</div> </div>
)} )}
<div className="p-4 border-t flex gap-2"> <div className="p-4 border-t flex flex-row gap-2">
<Textarea <Textarea
name='message'
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
placeholder="Ask about Gregor's experience or schedule a meeting" placeholder="Ask about Gregor's experience or schedule a meeting"
@@ -114,14 +129,38 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
} }
}} }}
/> />
<Button <div className='flex flex-col gap-2'>
onClick={handleSend} <Button
disabled={isLoading || hasError || !input.trim()} onClick={handleSend}
className="self-end" disabled={status != "ready" || !input.trim()}
> >
Send Send
</Button> </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>
</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

@@ -1,27 +1,29 @@
'use client' 'use client'
import ChatInterface from './_components/ChatInterface' import ChatInterface from './_components/ChatInterface'
import { trpc } from '../_trpc/Client'; import AnimatedPageTitle from '../_components/Animated/AnimatedPageTitle';
import { Skeleton } from '~/components/ui/skeleton'; import { useTimeLine } from '../_providers/GsapProvicer';
import { useMessages } from '../_providers/MessagesProvider';
import { Spinner } from '~/components/ui/spinner';
export default function ChatPage() { export default function ChatPage() {
const { data: session, error, isLoading } = trpc.chat.getSession.useQuery(); const {messages,session,isLoading,error} = useMessages()
useTimeLine(messages)
return ( return (
<div className="container max-w-2xl mx-auto h-screen pt-10 pb-4 flex flex-col"> <div className="flex flex-col px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
<div className="flex flex-col flex-1 bg-background border rounded-lg overflow-hidden"> <AnimatedPageTitle position={0}>
<div className="p-4 border-b flex items-center justify-between"> <span>Talk To My </span> <span> AI-Assistant</span>
<div> </AnimatedPageTitle>
<h1 className="text-lg font-semibold">AI Recruiter</h1> <div className='flex items-center h-[80%] w-full my-auto w-full'>
<p className="text-xs text-muted-foreground"> {!isLoading &&
Chat with Gregor's AI assistant <ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
</p> }
{isLoading &&
<><Spinner/> Loading Messages...</>
}
{error &&
<div> {error} </div>
}
</div> </div>
</div>
<div className="flex-1 overflow-hidden">
{session && <ChatInterface sessionId={session?.id} initialMessages={session?.messages} /> }
{error && <div>{error.message}</div>}
{isLoading && <Skeleton/>}
</div>
</div>
</div> </div>
) )
} }

View File

@@ -11,10 +11,11 @@ import TrpcProvider from "./_trpc/TrpcProvider";
// const ThemeProvider = dynamic(() => import("./_providers/ThemeProvider"),{ssr:true}) // const ThemeProvider = dynamic(() => import("./_providers/ThemeProvider"),{ssr:true})
import ThemeProvider from './_providers/ThemeProvider' import ThemeProvider from './_providers/ThemeProvider'
import GsapProvider from "./_providers/GsapProvicer"; import GsapProvider from "./_providers/GsapProvicer";
import {MessagesProvider} from "./_providers/MessagesProvider";
import { CodeHighlightStyle } from "./_components/CodeHighlightSyle"; import { CodeHighlightStyle } from "./_components/CodeHighlightSyle";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import AnimatedBackGroundContainer from "./_components/Animated/AnimatedBackGroundContainer"; import AnimatedBackGroundContainer from "./_components/Animated/AnimatedBackGroundContainer";
import {SpeedInsights} from "@vercel/speed-insights/next"
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }); const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
@@ -36,6 +37,8 @@ export default async function RootLayout({
}: Readonly<{ children: React.ReactNode, modal: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode, modal: React.ReactNode }>) {
return ( return (
<>
<SpeedInsights/>
<ClerkProvider> <ClerkProvider>
<TrpcProvider> <TrpcProvider>
<GsapProvider> <GsapProvider>
@@ -45,19 +48,22 @@ export default async function RootLayout({
</head> </head>
<body className="flex flex-col bg-background text-foreground"> <body className="flex flex-col bg-background text-foreground">
<ThemeProvider> <ThemeProvider>
<AnimatedBackGroundContainer followSpeed={0.003} particleCount={100} orbitRadius={2000}> <MessagesProvider>
<TopNav /> <AnimatedBackGroundContainer followSpeed={0.003} particleCount={100} orbitRadius={2000}>
<main className="absolute lg:top-10 h-screen w-screen"> <TopNav />
{children} <main className="absolute lg:top-10 h-screen lg:h-[calc(100vh-var(--spacing)*10)] w-screen">
</main> {children}
{modal} </main>
</AnimatedBackGroundContainer> {modal}
</AnimatedBackGroundContainer>
<ChatFAB /> <ChatFAB />
</MessagesProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
</GsapProvider> </GsapProvider>
</TrpcProvider> </TrpcProvider>
</ClerkProvider> </ClerkProvider>
</>
); );
} }

View File

@@ -11,6 +11,7 @@ import AnimateTextIn from "../_components/Animated/AnimateIn";
import { useTimeLine } from "../_providers/GsapProvicer"; import { useTimeLine } from "../_providers/GsapProvicer";
import AnimatePopUp from "../_components/Animated/AnimatePopUp"; import AnimatePopUp from "../_components/Animated/AnimatePopUp";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import remarkGfm from "remark-gfm"
export default function ProjectsPage() { export default function ProjectsPage() {
const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery(); const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery();
@@ -33,10 +34,10 @@ export default function ProjectsPage() {
return ( return (
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10"> <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> <AnimatedPageTitle position={0}><span>Projects I've Been</span><span> Working on</span> </AnimatedPageTitle>
<div className="pt-10" /> <div className="pt-10" />
{projects.map((project, i) => ( {projects.map((project, i) => (
<div key={i}> <div id={project.id} key={i} className="scroll-mt-10">
<Card.AnimatedCard position={i + 1.2} key={project.id}> <Card.AnimatedCard position={i + 1.2} key={project.id}>
<Card.CardHeader> <Card.CardHeader>
<div className="flex items-start justify-between gap-2 flex-wrap"> <div className="flex items-start justify-between gap-2 flex-wrap">
@@ -61,8 +62,9 @@ export default function ProjectsPage() {
<Card.CardContent className="flex flex-col gap-3"> <Card.CardContent className="flex flex-col gap-3">
{project.description && ( {project.description && (
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground"> <div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
<AnimatePopUp position={i + 1.4} duration={project.description.length / 20}> <AnimatePopUp position={i + 1.4} duration={10}>
<AnimateTextIn position={i + 1.5} animation="slide"><Markdown>{project.description}</Markdown></AnimateTextIn></AnimatePopUp> <Markdown remarkPlugins={[remarkGfm]}>{project.description}</Markdown>
</AnimatePopUp>
</div> </div>
)} )}
<div className="flex flex-row"> <div className="flex flex-row">

View File

@@ -1,4 +1,4 @@
import { useGSAP } from "@gsap/react";import * as React from "react" import { useGSAP } from "@gsap/react"; import * as React from "react"
import { useRef } from "react"; import { useRef } from "react";
import { useGsapContext } from "~/app/_providers/GsapProvicer"; import { useGsapContext } from "~/app/_providers/GsapProvicer";
import gsap from 'gsap' import gsap from 'gsap'
@@ -26,20 +26,39 @@ function AnimatedCard({
className, className,
position = 0, position = 0,
size = "default", size = "default",
scrollOnly = false,
...props ...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm", position: gsap.Position }) { }: React.ComponentProps<"div"> & { size?: "default" | "sm", position?: gsap.Position, scrollOnly?: boolean }) {
const gsapContext = useGsapContext() const gsapContext = useGsapContext()
const ref = useRef<HTMLDivElement|null>(null) const ref = useRef<HTMLDivElement | null>(null)
useGSAP(() => { useGSAP(() => {
const rect = ref.current?.getBoundingClientRect() const rect = ref.current?.getBoundingClientRect()
const isInView = rect && rect.top < window.innerHeight 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 } const fromVars = { x: -100, opacity: 0, duration: 0.5 }
if (isInView) { if (isInView && !scrollOnly) {
gsapContext?.addAnimation(gsap.from(ref.current, fromVars), position) gsapContext?.addAnimation(gsap.from(ref.current, fromVars), position)
} else { } else {
const scroller = gsapContext?.getScroller() gsap.from(ref.current,
console.log('scroller:', scroller) {
gsap.from(ref.current, { ...fromVars, scrollTrigger: { trigger: ref.current, start: 'top 85%', scroller } }) ...fromVars,
scrollTrigger: {
trigger: ref.current,
start: 'top bottom',
end: 'bottom top',
toggleActions: "play reverse play reverse",
scroller
}
})
} }
}, { dependencies: [] }) }, { dependencies: [] })
return ( return (

View File

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

View File

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

View File

@@ -1,13 +1,16 @@
import { clerkMiddleware, createRouteMatcher, currentUser } from "@clerk/nextjs/server"; import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import { env } from "~/env"; import { env } from "~/env";
const isTenantAdminRoute = createRouteMatcher(['/admin(.*)']) const isTenantAdminRoute = createRouteMatcher(["/admin(.*)"]);
export default clerkMiddleware(async (auth,req) => {
export default clerkMiddleware(async (auth, req) => {
if (isTenantAdminRoute(req)) { if (isTenantAdminRoute(req)) {
console.log("running clerk middleware"); await auth.protect();
let userid = (await auth()).userId
if (userid != env.ADMIN_USER_CLERK_ID) { const { userId } = await auth();
await auth.protect() if (userId !== env.ADMIN_USER_CLERK_ID) {
return NextResponse.redirect(new URL("/", req.url));
} }
} }
}); });

View File

@@ -2,7 +2,7 @@
// https://orm.drizzle.team/docs/sql-schema-declaration // https://orm.drizzle.team/docs/sql-schema-declaration
import { relations, sql } from "drizzle-orm"; 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 * This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
@@ -104,6 +104,33 @@ export const music = createTable(
}) })
) )
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 messageRoleEnum = pgEnum('message_role', ['user', 'assistant'])
export const chatSession = createTable( export const chatSession = createTable(

View File

@@ -1,6 +1,7 @@
import type { inferRouterOutputs } from "@trpc/server"; import type { inferRouterOutputs } from "@trpc/server";
import { router } from "../trpc"; import { router } from "../trpc";
import type { inferReactQueryProcedureOptions } from "@trpc/react-query"; import type { inferReactQueryProcedureOptions } from "@trpc/react-query";
import { blogRouter } from "./blog";
import { projectRouter } from "./project"; import { projectRouter } from "./project";
import { techStackRouter } from "./techStack"; import { techStackRouter } from "./techStack";
import { cvCategoryRouter } from "./cvCategory"; import { cvCategoryRouter } from "./cvCategory";
@@ -11,6 +12,7 @@ import { cvCategory } from "../dbschema/schema";
import { chatRouter } from "./chat"; import { chatRouter } from "./chat";
export const trpcRouter = router({ export const trpcRouter = router({
blog: blogRouter,
project: trpcCrudRouterFromDrizzleEntity('project').router, project: trpcCrudRouterFromDrizzleEntity('project').router,
projectv2: projectRouter, projectv2: projectRouter,
techStack: trpcCrudRouterFromDrizzleEntity('techStack').router, techStack: trpcCrudRouterFromDrizzleEntity('techStack').router,

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

@@ -1,45 +1,69 @@
import { auth } from '@clerk/nextjs/server'
import { publicProcedure, router } from "../trpc"; import { publicProcedure, router } from "../trpc";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { db } from '~/server/db' import { db } from '~/server/db'
import { chatSession, systemSettings } from "../dbschema/schema"; import { chatMessage,
chatSession, systemSettings } from "../dbschema/schema";
import { isAdmin } from '~/app/actions'; import { isAdmin } from '~/app/actions';
import { z } from 'zod'; import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { clerkClient, auth } from '@clerk/nextjs/server'
export const chatRouter = router({ export const chatRouter = router({
getSession: publicProcedure.query(async () => { getSession: publicProcedure.query(async () => {
const { userId } = await auth();
if (!userId) {
throw new TRPCError({ message: "chat is only available to signed in users", code: 'UNAUTHORIZED' });
}
const clerk = await clerkClient()
const user = await clerk.users.getUser(userId)
let session = await db.query.chatSession.findFirst({
where(fields, operators) {
return operators.eq(fields.userId, user.id)
},
})
if (session !== undefined) {
return session;
}
let newSession = await db.insert(chatSession).values({ userId: user.id}).returning().execute().then((r) => r.at(0)); if (newSession == undefined) {
throw new TRPCError({ message: "failed to create session", code: "INTERNAL_SERVER_ERROR" });
}
session = await db.query.chatSession.findFirst({
where(fields, operators) {
return operators.eq(fields.userId, user.id)
},
})
if (session == undefined) {
throw new TRPCError({ message: "session not found", code: "NOT_FOUND" });
}
if (session !== undefined) {
return session;
}
}),
getMessages: publicProcedure.input(z.string()).query(async ({input}) => {
let res = await db.query.chatMessage.findMany({
where(fields,operators) {
return operators.eq(fields.sessionId,input)
}
})
return res;
}),
clearChat: publicProcedure.mutation(async () => {
console.log("deleting session")
const { userId } = await auth(); const { userId } = await auth();
if (userId == null) { if (userId == null) {
throw new TRPCError({message: "chat is only available to signed in users",code: 'UNAUTHORIZED'}); throw new TRPCError({ message: "chat is only available to signed in users", code: 'UNAUTHORIZED' });
} }
let session = await db.query.chatSession.findFirst({ let session = await db.query.chatSession.findFirst({
with: { with: {
messages: true messages: true
}, },
where(fields, operators) { where(fields, operators) {
return operators.eq(fields.userId,userId) return operators.eq(fields.userId, userId)
}, },
}) })
if (session !== undefined) { if (session != undefined) {
return session; db.delete(chatMessage).where(eq(chatMessage.sessionId,session.id)).execute()
}
let newSession = await db.insert(chatSession).values({userId: userId}).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({
with: {
messages: true
},
where(fields, operators) {
return operators.eq(fields.id,newSession.id)
},
})
if (session == undefined) {
throw new TRPCError({message: "session not found", code:"NOT_FOUND"});
}
if (session !== undefined) {
return session;
} }
}), }),
getSystemPrompt: publicProcedure.query(async () => { getSystemPrompt: publicProcedure.query(async () => {
const row = await db.select().from(systemSettings).limit(1).then((r) => r[0]) const row = await db.select().from(systemSettings).limit(1).then((r) => r[0])
@@ -51,3 +75,5 @@ export const chatRouter = router({
await db.insert(systemSettings).values({ systemPropmt: input.prompt }) await db.insert(systemSettings).values({ systemPropmt: input.prompt })
}), }),
}) })
export type ChatRouter = typeof chatRouter;

View File

@@ -1,12 +1,92 @@
import { publicProcedure, router } from "~/server/trpc"; import { publicProcedure, router } from "~/server/trpc";
import { db } from "~/server/db"; 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({ export const projectRouter = router({
listWithStack: publicProcedure.query(async () => { listWithStack: publicProcedure.query(async () => {
return db.query.project.findMany({ const projects = await db.query.project.findMany({
with: { with: {
techStack: true, 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),
};
}),
);
}), }),
}); });

View File

@@ -140,3 +140,47 @@
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */ 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%;
}