Compare commits
1 Commits
main
...
9d3912b271
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d3912b271 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -47,4 +47,3 @@ yarn-error.log*
|
||||
# clerk configuration (can include secrets)
|
||||
/.clerk/
|
||||
.worktrees
|
||||
.claudesession
|
||||
|
||||
34
README.md
34
README.md
@@ -1,13 +1,29 @@
|
||||
# My Personal Website
|
||||
# Create T3 App
|
||||
|
||||
## Using:
|
||||
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
|
||||
|
||||
- nextjs
|
||||
- trpc
|
||||
- neon
|
||||
- uploadthing
|
||||
- drizzle
|
||||
- gsap
|
||||
- openai
|
||||
## What's next? How do I make an app with this?
|
||||
|
||||
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
|
||||
|
||||
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
|
||||
|
||||
- [Next.js](https://nextjs.org)
|
||||
- [NextAuth.js](https://next-auth.js.org)
|
||||
- [Prisma](https://prisma.io)
|
||||
- [Drizzle](https://orm.drizzle.team)
|
||||
- [Tailwind CSS](https://tailwindcss.com)
|
||||
- [tRPC](https://trpc.io)
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
|
||||
|
||||
- [Documentation](https://create.t3.gg/)
|
||||
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
|
||||
|
||||
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
|
||||
|
||||
## How do I deploy this?
|
||||
|
||||
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
|
||||
|
||||
@@ -65,8 +65,6 @@
|
||||
"@trpc/react-query": "^11.12.0",
|
||||
"@trpc/server": "^11.12.0",
|
||||
"@uiw/react-md-editor": "^4.0.11",
|
||||
"@uploadthing/react": "^7.3.3",
|
||||
"@vercel/speed-insights": "^2.0.0",
|
||||
"ai": "^6.0.116",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -78,12 +76,10 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"glazejs": "^2.0.1",
|
||||
"googleapis": "^171.4.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"gsap": "^3.14.2",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.6",
|
||||
"next-mdx-remote": "^6.0.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"postgres": "^3.4.8",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -96,14 +92,12 @@
|
||||
"recharts": "2.15.4",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn": "^4.0.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-motion": "^1.1.1",
|
||||
"type-fest": "^5.4.4",
|
||||
"uploadthing": "^7.7.4",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
'use client'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog'
|
||||
import ChatInterface from '~/app/chat/_components/ChatInterface'
|
||||
import { useMessages } from '~/app/_providers/MessagesProvider';
|
||||
import { Spinner } from '~/components/ui/spinner';
|
||||
|
||||
export default function ChatModal() {
|
||||
const router = useRouter()
|
||||
const {messages,session,isLoading,error} = useMessages()
|
||||
return (
|
||||
<Dialog modal={true} open onOpenChange={() => router.back()}>
|
||||
<DialogContent className="w-full max-w-full rounded-none sm:max-w-full h-[100svh] lg:max-w-3xl lg:rounded-xl lg:h-[80vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="p-4 border-b shrink-0">
|
||||
<DialogTitle>Talk To My AI-Assistant</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
{!isLoading &&
|
||||
<ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
|
||||
}
|
||||
{isLoading &&
|
||||
<><Spinner/> Loading Messages...</>
|
||||
}
|
||||
{error &&
|
||||
<div> {error} </div>
|
||||
}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
'use client'
|
||||
import ChatModal from './_components/ChatModal'
|
||||
|
||||
export default function AssistantModalPage() {
|
||||
return (
|
||||
<ChatModal/>
|
||||
)
|
||||
}
|
||||
32
src/app/@modal/(.)chat/_components/ChatModal.tsx
Normal file
32
src/app/@modal/(.)chat/_components/ChatModal.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog'
|
||||
import ChatInterface from '~/app/chat/_components/ChatInterface'
|
||||
|
||||
type DBMessage = {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface ChatModalProps {
|
||||
sessionId: string
|
||||
initialMessages: DBMessage[]
|
||||
}
|
||||
|
||||
export default function ChatModal({ sessionId, initialMessages }: ChatModalProps) {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={() => router.back()}>
|
||||
<DialogContent className="max-w-2xl h-[80vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="p-4 border-b shrink-0">
|
||||
<DialogTitle>AI Recruiter</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
<ChatInterface sessionId={sessionId} initialMessages={initialMessages} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
34
src/app/@modal/(.)chat/page.tsx
Normal file
34
src/app/@modal/(.)chat/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { asc, desc, eq } from 'drizzle-orm'
|
||||
import { db } from '~/server/db'
|
||||
import { chatMessage, chatSession } from '~/server/dbschema/schema'
|
||||
import ChatModal from './_components/ChatModal'
|
||||
|
||||
export default async function ChatModalPage() {
|
||||
const { userId } = await auth()
|
||||
if (!userId) redirect('/')
|
||||
|
||||
let session = await db
|
||||
.select()
|
||||
.from(chatSession)
|
||||
.where(eq(chatSession.userId, userId))
|
||||
.orderBy(desc(chatSession.createdAt))
|
||||
.limit(1)
|
||||
.then((r) => r[0])
|
||||
|
||||
if (!session) {
|
||||
const [created] = await db.insert(chatSession).values({ userId }).returning()
|
||||
session = created
|
||||
}
|
||||
|
||||
if (!session) redirect('/')
|
||||
|
||||
const messages = await db
|
||||
.select()
|
||||
.from(chatMessage)
|
||||
.where(eq(chatMessage.sessionId, session.id))
|
||||
.orderBy(asc(chatMessage.createdAt))
|
||||
|
||||
return <ChatModal sessionId={session.id} initialMessages={messages} />
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useGSAP } from "@gsap/react";
|
||||
import { useRef, type HTMLAttributes, type ReactNode } from "react";
|
||||
import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
||||
import { SplitText } from "gsap/SplitText";
|
||||
import gsap from 'gsap'
|
||||
import { cn } from "~/lib/utils";
|
||||
const AnimateTextIn = ({
|
||||
children,
|
||||
animation = "type",
|
||||
position = 0,
|
||||
tlId = undefined,
|
||||
speed = 1,
|
||||
scrollOnly = false,
|
||||
className
|
||||
}: {
|
||||
children: ReactNode,
|
||||
animation?: "type" | "slide",
|
||||
position?: gsap.Position,
|
||||
tlId?: string,
|
||||
scrollOnly?: boolean,
|
||||
speed?: number,
|
||||
className?: HTMLAttributes<HTMLDivElement>['className']
|
||||
}) => {
|
||||
const el = useRef<HTMLDivElement>(null)
|
||||
const gsapContext = useGsapContext();
|
||||
useGSAP(() => {
|
||||
const rect = el.current?.getBoundingClientRect()
|
||||
const scroller = gsapContext?.getScroller()
|
||||
console.log(scroller)
|
||||
let viewportTop = 0
|
||||
let viewportBottom = window.innerHeight
|
||||
if (scroller && scroller instanceof Element) {
|
||||
const scrollerRect = scroller.getBoundingClientRect()
|
||||
viewportTop = scrollerRect.top
|
||||
viewportBottom = scrollerRect.top + scrollerRect.height
|
||||
}
|
||||
const isInView = rect && rect.bottom > viewportTop && rect.top < viewportBottom
|
||||
console.log(isInView)
|
||||
const chars = new SplitText(el.current, { type: 'chars' })
|
||||
gsapContext?.addAnimation(gsap.to(el.current, { opacity: 100, duration: 0 }), 0, tlId)
|
||||
const fromVars = animation === "slide"
|
||||
? { opacity: 0, x: -10, duration: 0.2 * speed, stagger: { each: 0.08 * speed }, ease: 'bounce.inOut', onComplete: () => chars.revert() }
|
||||
: { opacity: 0, duration: 0.01 * speed, stagger: { each: 0.04 * speed }, ease: 'bounce.inOut', onComplete: () => chars.revert() }
|
||||
if (isInView && !scrollOnly) {
|
||||
gsapContext?.addAnimation(gsap.from(chars.chars, fromVars), position, tlId)
|
||||
} else {
|
||||
gsap.from(chars.chars,
|
||||
{
|
||||
...fromVars,
|
||||
scrollTrigger: {
|
||||
trigger: el.current,
|
||||
start: 'top bottom',
|
||||
end: 'bottom top',
|
||||
toggleActions: "play reverse play reverse",
|
||||
scroller
|
||||
}
|
||||
})
|
||||
}
|
||||
}, { dependencies: [] })
|
||||
return (
|
||||
<div ref={el} className={cn(className, "opacity-0")}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimateTextIn;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { type HTMLAttributes, type ReactNode } from "react";
|
||||
import AnimatedDiv from "./AnimatedDiv";
|
||||
import { cn } from "~/lib/utils";
|
||||
const AnimatePopUp = ({
|
||||
children,
|
||||
position,
|
||||
className,
|
||||
duration=1,
|
||||
ease='elastic'
|
||||
}:{
|
||||
children:ReactNode
|
||||
position:gsap.Position,
|
||||
className?:HTMLAttributes<HTMLDivElement>['className']
|
||||
duration?:number,
|
||||
ease?:gsap.EaseString|gsap.EaseFunction
|
||||
}) => {
|
||||
return (
|
||||
<AnimatedDiv children={children} position={position} className={cn(className,'h-0 translate-y-[50] overflow-hidden')} height='auto' y={0} overflow='' ease={ease} duration={duration} />
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimatePopUp;
|
||||
@@ -1,346 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
const PALETTES = {
|
||||
dark: {
|
||||
particles: [
|
||||
"rgba(255,255,255,0.70)",
|
||||
"rgba(255,255,255,0.45)",
|
||||
"rgba(180,180,180,0.50)",
|
||||
"rgba(200,200,200,0.35)",
|
||||
"rgba(255,255,255,0.22)",
|
||||
],
|
||||
grainOpacity: 0.05,
|
||||
},
|
||||
light: {
|
||||
particles: [
|
||||
"rgba(0,0,0,0.55)",
|
||||
"rgba(0,0,0,0.35)",
|
||||
"rgba(60,60,60,0.40)",
|
||||
"rgba(80,80,80,0.25)",
|
||||
"rgba(0,0,0,0.18)",
|
||||
],
|
||||
grainOpacity: 0.03,
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface Particle {
|
||||
angle: number;
|
||||
radius: number;
|
||||
speed: number;
|
||||
size: number;
|
||||
colorIndex: number;
|
||||
wobbleAmp: number;
|
||||
wobbleSpeed: number;
|
||||
wobblePhase: number;
|
||||
}
|
||||
|
||||
interface AnimatedBackgroundContainerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
particleCount?: number;
|
||||
orbitRadius?: number;
|
||||
followSpeed?: number;
|
||||
mobileSpeed?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PARTICLE_COLORS: readonly string[] = PALETTES.dark.particles;
|
||||
const PARTICLE_COLOR_COUNT = DEFAULT_PARTICLE_COLORS.length;
|
||||
|
||||
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||
|
||||
const isMobileDevice = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 768;
|
||||
};
|
||||
|
||||
const createParticle = (orbitRadius: number): Particle => {
|
||||
const minRadius = Math.max(10, orbitRadius * 0.12);
|
||||
|
||||
return {
|
||||
angle: rand(0, Math.PI * 2),
|
||||
radius: rand(minRadius, orbitRadius),
|
||||
speed: rand(0.002, 0.003) * (Math.random() > 0.5 ? 1 : -1),
|
||||
size: rand(1.2, 4),
|
||||
colorIndex: Math.floor(rand(0, PARTICLE_COLOR_COUNT)),
|
||||
wobbleAmp: rand(orbitRadius * 0.025, orbitRadius * 0.12),
|
||||
wobbleSpeed: rand(0.008, 0.035),
|
||||
wobblePhase: rand(0, Math.PI * 2),
|
||||
};
|
||||
};
|
||||
|
||||
export default function AnimatedBackgroundContainer({
|
||||
children,
|
||||
className = "",
|
||||
particleCount = 60,
|
||||
orbitRadius = 240,
|
||||
followSpeed = 0.06,
|
||||
mobileSpeed = 1,
|
||||
}: AnimatedBackgroundContainerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const frameRef = useRef(0);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const isMobileRef = useRef(false);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const mousePosRef = useRef({ x: 0, y: 0 });
|
||||
const smoothMouseRef = useRef({ x: 0, y: 0 });
|
||||
const mobileAnchorRef = useRef({ x: 0, y: 0 });
|
||||
const mobileTargetRef = useRef({ x: 0, y: 0 });
|
||||
const followSpeedRef = useRef(followSpeed);
|
||||
const mobileSpeedRef = useRef(mobileSpeed);
|
||||
const particleColorsRef = useRef<readonly string[]>(DEFAULT_PARTICLE_COLORS);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme === undefined || resolvedTheme === "dark";
|
||||
const palette = isDark ? PALETTES.dark : PALETTES.light;
|
||||
|
||||
useEffect(() => {
|
||||
particleColorsRef.current = palette.particles;
|
||||
}, [palette]);
|
||||
|
||||
useEffect(() => {
|
||||
followSpeedRef.current = followSpeed;
|
||||
}, [followSpeed]);
|
||||
|
||||
useEffect(() => {
|
||||
mobileSpeedRef.current = mobileSpeed;
|
||||
}, [mobileSpeed]);
|
||||
|
||||
useEffect(() => {
|
||||
particlesRef.current = Array.from({ length: particleCount }, () =>
|
||||
createParticle(orbitRadius),
|
||||
);
|
||||
}, [particleCount, orbitRadius]);
|
||||
|
||||
const seedPositions = useCallback(() => {
|
||||
const 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(() => {
|
||||
setMounted(true);
|
||||
isMobileRef.current = isMobileDevice();
|
||||
resizeCanvas();
|
||||
|
||||
const handleResize = () => {
|
||||
isMobileRef.current = isMobileDevice();
|
||||
resizeCanvas();
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [resizeCanvas]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
seedPositions();
|
||||
}, [mounted, seedPositions]);
|
||||
|
||||
const handleMouseMove = useCallback((event: MouseEvent) => {
|
||||
const container = containerRef.current;
|
||||
if (!container || isMobileRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
mousePosRef.current = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.addEventListener("mousemove", handleMouseMove, { passive: true });
|
||||
return () => container.removeEventListener("mousemove", handleMouseMove);
|
||||
}, [handleMouseMove]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
frameRef.current += 1;
|
||||
|
||||
if (isMobileRef.current) {
|
||||
const mobileLerp = 0.008 * mobileSpeedRef.current;
|
||||
mobileAnchorRef.current.x +=
|
||||
(mobileTargetRef.current.x - mobileAnchorRef.current.x) * mobileLerp;
|
||||
mobileAnchorRef.current.y +=
|
||||
(mobileTargetRef.current.y - mobileAnchorRef.current.y) * mobileLerp;
|
||||
|
||||
const dx = mobileTargetRef.current.x - mobileAnchorRef.current.x;
|
||||
const dy = mobileTargetRef.current.y - mobileAnchorRef.current.y;
|
||||
if (Math.hypot(dx, dy) < 30) {
|
||||
mobileTargetRef.current = {
|
||||
x: rand(width * 0.15, width * 0.85),
|
||||
y: rand(height * 0.15, height * 0.85),
|
||||
};
|
||||
}
|
||||
|
||||
smoothMouseRef.current = { ...mobileAnchorRef.current };
|
||||
} else {
|
||||
const desktopLerp = followSpeedRef.current;
|
||||
smoothMouseRef.current.x +=
|
||||
(mousePosRef.current.x - smoothMouseRef.current.x) * desktopLerp;
|
||||
smoothMouseRef.current.y +=
|
||||
(mousePosRef.current.y - smoothMouseRef.current.y) * desktopLerp;
|
||||
}
|
||||
|
||||
const centerX = smoothMouseRef.current.x;
|
||||
const centerY = smoothMouseRef.current.y;
|
||||
const colors = particleColorsRef.current;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
for (const particle of particlesRef.current) {
|
||||
particle.angle += particle.speed;
|
||||
|
||||
const wobble =
|
||||
Math.sin(frameRef.current * particle.wobbleSpeed + particle.wobblePhase) *
|
||||
particle.wobbleAmp;
|
||||
const radius = particle.radius + wobble;
|
||||
const x = centerX + Math.cos(particle.angle) * radius;
|
||||
const y = centerY + Math.sin(particle.angle) * radius;
|
||||
|
||||
const edgeFade = Math.max(
|
||||
0,
|
||||
Math.min(x / 80, (width - x) / 80, y / 80, (height - y) / 80, 1),
|
||||
);
|
||||
|
||||
if (edgeFade <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.globalAlpha = edgeFade;
|
||||
ctx.fillStyle = colors[particle.colorIndex] ?? colors[0] ?? "#ffffff";
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, particle.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
animationFrameRef.current = window.requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
animationFrameRef.current = window.requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [mounted]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{
|
||||
position: "relative",
|
||||
minHeight: "100vh",
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
transition: "background-color 0.6s ease",
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
opacity: palette.grainOpacity,
|
||||
pointerEvents: "none",
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: "repeat",
|
||||
backgroundSize: "180px 180px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ position: "relative", zIndex: 2 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import gsap from "gsap";
|
||||
import { type HTMLAttributes,
|
||||
type ReactNode, useLayoutEffect, useRef } from "react";
|
||||
import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
||||
const AnimatedDiv = (
|
||||
{
|
||||
children,
|
||||
position,
|
||||
className,
|
||||
animationMode='to',
|
||||
...tweenVars
|
||||
}:
|
||||
gsap.TweenVars & {
|
||||
children:ReactNode,
|
||||
position:gsap.Position,
|
||||
animationMode?: 'from'|'to',
|
||||
className?:HTMLAttributes<HTMLDivElement>['className']
|
||||
}
|
||||
) => {
|
||||
const div = useRef<HTMLDivElement>(null);
|
||||
const gsapContext = useGsapContext()
|
||||
useLayoutEffect(() => {
|
||||
let tween:gsap.core.Tween;
|
||||
switch(animationMode) {
|
||||
case 'from':
|
||||
tween = gsap.from(div.current,tweenVars);
|
||||
break;
|
||||
case 'to':
|
||||
tween = gsap.to(div.current,tweenVars);
|
||||
break;
|
||||
}
|
||||
gsapContext?.addAnimation(tween,position)
|
||||
},[])
|
||||
return (
|
||||
<div ref={div} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimatedDiv;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useGSAP } from "@gsap/react"; import { useEffect, useLayoutEffect, useRef,type ReactNode } from "react";
|
||||
import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
||||
import { SplitText } from "gsap/SplitText";
|
||||
import gsap from 'gsap'
|
||||
const AnimatedPageTitle = (
|
||||
{ children, position }: { children: ReactNode, position:gsap.Position }
|
||||
) => {
|
||||
const el = useRef<HTMLHeadingElement>(null)
|
||||
const gsapContext = useGsapContext();
|
||||
useLayoutEffect(() => {
|
||||
const split = new SplitText(el.current, { type: "lines,chars", autoSplit:true })
|
||||
gsapContext?.addAnimation(gsap.to(el.current, { opacity: 100 }),position)
|
||||
gsapContext?.addAnimation(gsap.from(split.chars, { id: 'titlesplit',
|
||||
stagger: 0.05, rotate: -90, opacity: 0, x: -10, onComplete: () => {split.revert()}
|
||||
}),'>')
|
||||
},[])
|
||||
return (
|
||||
<h1 className="text-4xl break-keep opacity-0 font-bold text-balance w-full" ref={el}> {children} </h1>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimatedPageTitle;
|
||||
@@ -1,22 +0,0 @@
|
||||
'use client'
|
||||
import Link from 'next/link'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { usePathname } from 'next/navigation'
|
||||
export default function ChatFAB() {
|
||||
const pathName = usePathname()
|
||||
const isChat = pathName.indexOf('\/chat') > -1
|
||||
return (
|
||||
<>
|
||||
{!isChat &&
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<Button asChild size="icon" className="h-14 w-14 rounded-full shadow-lg">
|
||||
<Link href="/assistant">
|
||||
<MessageCircle className="h-6 w-6" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UseTRPCMutationResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs";
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
interface ToString {
|
||||
@@ -7,7 +8,7 @@ interface ToString {
|
||||
|
||||
|
||||
export interface MutationInterface {
|
||||
mutate: (params: any) => void
|
||||
mutate: (params:{id:string}) => void
|
||||
error: ToString | null
|
||||
status: "error" | "idle" | "pending" | "success"
|
||||
}
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, useMemo, useRef, useState, type KeyboardEvent, type TextareaHTMLAttributes } from 'react'
|
||||
|
||||
export type InternalLinkSuggestion = {
|
||||
label: string
|
||||
href: string
|
||||
group: string
|
||||
}
|
||||
|
||||
export type MdeAutocompleteSuggestion = {
|
||||
label: string
|
||||
value: string
|
||||
detail: string
|
||||
group: string
|
||||
trigger: string
|
||||
}
|
||||
|
||||
export const AUTOCOMPLETE_CURSOR_MARKER = '{{cursor}}'
|
||||
|
||||
export type AutocompleteTriggerConfig = {
|
||||
trigger: string
|
||||
label: string
|
||||
isQueryValid?: (query: string) => boolean
|
||||
}
|
||||
|
||||
type ActiveToken = {
|
||||
start: number
|
||||
end: number
|
||||
query: string
|
||||
trigger: MdeAutocompleteSuggestion['trigger']
|
||||
}
|
||||
|
||||
const defaultTriggerConfigs: AutocompleteTriggerConfig[] = [
|
||||
{
|
||||
trigger: '[[',
|
||||
label: 'Internal links',
|
||||
isQueryValid: (query) => !query.includes(']'),
|
||||
},
|
||||
{
|
||||
trigger: '<',
|
||||
label: 'MDX components',
|
||||
isQueryValid: (query) => !/[\s>]/.test(query),
|
||||
},
|
||||
]
|
||||
|
||||
function findActiveToken(
|
||||
value: string,
|
||||
cursor: number,
|
||||
triggerConfigs: AutocompleteTriggerConfig[],
|
||||
): ActiveToken | null {
|
||||
const beforeCursor = value.slice(0, cursor)
|
||||
const activeTrigger = triggerConfigs
|
||||
.map((config) => ({ config, start: beforeCursor.lastIndexOf(config.trigger) }))
|
||||
.filter((candidate) => candidate.start !== -1)
|
||||
.sort((a, b) => b.start - a.start)[0]
|
||||
|
||||
if (!activeTrigger) return null
|
||||
|
||||
const query = beforeCursor.slice(activeTrigger.start + activeTrigger.config.trigger.length)
|
||||
if (query.includes('\n')) return null
|
||||
if (activeTrigger.config.isQueryValid && !activeTrigger.config.isQueryValid(query)) return null
|
||||
|
||||
return {
|
||||
start: activeTrigger.start,
|
||||
end: cursor,
|
||||
query,
|
||||
trigger: activeTrigger.config.trigger,
|
||||
}
|
||||
}
|
||||
|
||||
export function linkSuggestionsToAutocomplete(suggestions: InternalLinkSuggestion[]): MdeAutocompleteSuggestion[] {
|
||||
return suggestions.map((suggestion) => ({
|
||||
label: suggestion.label,
|
||||
value: `[${suggestion.label}](${suggestion.href})`,
|
||||
detail: suggestion.href,
|
||||
group: suggestion.group,
|
||||
trigger: '[[',
|
||||
}))
|
||||
}
|
||||
|
||||
export const InternalLinkTextarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
suggestions: MdeAutocompleteSuggestion[]
|
||||
triggerConfigs?: AutocompleteTriggerConfig[]
|
||||
}>(({ suggestions, triggerConfigs, value, onChange, onKeyDown, ...props }, ref) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const [token, setToken] = useState<ActiveToken | null>(null)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
function setRefs(element: HTMLTextAreaElement | null) {
|
||||
textareaRef.current = element
|
||||
if (typeof ref === 'function') ref(element)
|
||||
else if (ref) ref.current = element
|
||||
}
|
||||
|
||||
const resolvedTriggerConfigs = useMemo(() => {
|
||||
const configured = triggerConfigs?.length ? triggerConfigs : defaultTriggerConfigs
|
||||
const merged = new Map(configured.map((config) => [config.trigger, config]))
|
||||
|
||||
for (const suggestion of suggestions) {
|
||||
if (!merged.has(suggestion.trigger)) {
|
||||
merged.set(suggestion.trigger, {
|
||||
trigger: suggestion.trigger,
|
||||
label: suggestion.trigger,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(merged.values()).sort((a, b) => b.trigger.length - a.trigger.length)
|
||||
}, [suggestions, triggerConfigs])
|
||||
|
||||
const triggerLabels = useMemo(
|
||||
() => new Map(resolvedTriggerConfigs.map((config) => [config.trigger, config.label])),
|
||||
[resolvedTriggerConfigs],
|
||||
)
|
||||
|
||||
const matches = useMemo(() => {
|
||||
if (!token) return []
|
||||
const query = token.query.toLowerCase()
|
||||
return suggestions
|
||||
.filter((suggestion) => suggestion.trigger === token.trigger)
|
||||
.filter((suggestion) => {
|
||||
const haystack = `${suggestion.group} ${suggestion.label} ${suggestion.detail}`.toLowerCase()
|
||||
return haystack.includes(query)
|
||||
})
|
||||
.slice(0, 8)
|
||||
}, [suggestions, token])
|
||||
|
||||
function updateToken(textarea: HTMLTextAreaElement) {
|
||||
const nextToken = findActiveToken(textarea.value, textarea.selectionStart, resolvedTriggerConfigs)
|
||||
setToken(nextToken)
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
|
||||
function insertSuggestion(textarea: HTMLTextAreaElement, suggestion: MdeAutocompleteSuggestion) {
|
||||
if (!token) return
|
||||
|
||||
const markerIndex = suggestion.value.indexOf(AUTOCOMPLETE_CURSOR_MARKER)
|
||||
const insertedValue = markerIndex === -1
|
||||
? suggestion.value
|
||||
: suggestion.value.replace(AUTOCOMPLETE_CURSOR_MARKER, '')
|
||||
const cursor = token.start + (markerIndex === -1 ? insertedValue.length : markerIndex)
|
||||
const nextValue = `${textarea.value.slice(0, token.start)}${insertedValue}${textarea.value.slice(token.end)}`
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
|
||||
nativeInputValueSetter?.call(textarea, nextValue)
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
textarea.setSelectionRange(cursor, cursor)
|
||||
setToken(null)
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (token && matches.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((index) => (index + 1) % matches.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((index) => (index - 1 + matches.length) % matches.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
const suggestion = matches[selectedIndex]
|
||||
if (suggestion) insertSuggestion(event.currentTarget, suggestion)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setToken(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
onKeyDown?.(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<textarea
|
||||
{...props}
|
||||
ref={setRefs}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
onChange?.(event)
|
||||
updateToken(event.currentTarget)
|
||||
}}
|
||||
onClick={(event) => updateToken(event.currentTarget)}
|
||||
onKeyUp={(event) => {
|
||||
if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(event.key)) return
|
||||
updateToken(event.currentTarget)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{token && matches.length > 0 && (
|
||||
<div className='bg-popover text-popover-foreground absolute left-3 top-12 z-50 w-80 overflow-hidden rounded-md border shadow-md'>
|
||||
<div className='border-b px-3 py-2 text-xs text-muted-foreground'>
|
||||
{triggerLabels.get(token.trigger) ?? token.trigger} for {token.trigger}{token.query}
|
||||
</div>
|
||||
<div className='max-h-64 overflow-y-auto py-1'>
|
||||
{matches.map((suggestion, index) => (
|
||||
<button
|
||||
key={`${suggestion.trigger}:${suggestion.group}:${suggestion.label}`}
|
||||
type='button'
|
||||
className={`flex w-full flex-col px-3 py-2 text-left text-sm ${index === selectedIndex ? 'bg-muted' : ''}`}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
if (textareaRef.current) insertSuggestion(textareaRef.current, suggestion)
|
||||
}}
|
||||
>
|
||||
<span className='font-medium'>{suggestion.label}</span>
|
||||
<span className='text-xs text-muted-foreground'>{suggestion.group} - {suggestion.detail}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
InternalLinkTextarea.displayName = 'InternalLinkTextarea'
|
||||
@@ -1,99 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import MDEditor from "@uiw/react-md-editor";
|
||||
import { Maximize2, Minimize2 } from "lucide-react";
|
||||
import { useEffect, useState, type ReactElement, type TextareaHTMLAttributes } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
InternalLinkTextarea,
|
||||
type AutocompleteTriggerConfig,
|
||||
type MdeAutocompleteSuggestion,
|
||||
} from "./InternalLinkTextarea";
|
||||
|
||||
export default function MdeFormField<T extends FieldValues>(params: {
|
||||
control: Control<T>,
|
||||
name: Path<T>,
|
||||
label: string,
|
||||
dataColorMode: "dark"|"light",
|
||||
autocompleteSuggestions?: MdeAutocompleteSuggestion[],
|
||||
triggerConfigs?: AutocompleteTriggerConfig[],
|
||||
renderPreview?: (source: string) => ReactElement,
|
||||
}) {
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreen) return
|
||||
|
||||
const originalOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = "hidden"
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow
|
||||
}
|
||||
}, [fullscreen])
|
||||
|
||||
export default function MdeFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, dataColorMode: "dark"|"light" }) {
|
||||
return (
|
||||
<FormField
|
||||
control={params.control}
|
||||
name={params.name}
|
||||
render={({ field }) => {
|
||||
const editor = (
|
||||
<FormItem className={cn(fullscreen && "mde-form-field-fullscreen")}>
|
||||
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||
<FormLabel>
|
||||
{params.label}
|
||||
</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={fullscreen ? "Exit fullscreen editor" : "Open fullscreen editor"}
|
||||
onClick={() => setFullscreen((value) => !value)}
|
||||
>
|
||||
{fullscreen ? <Minimize2 /> : <Maximize2 />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl className={cn(fullscreen && "min-h-0 flex-1")}>
|
||||
<MDEditor
|
||||
className={cn(fullscreen && "mde-form-field-editor-fullscreen min-h-0 flex-1")}
|
||||
height={fullscreen ? "calc(100vh - 72px)" : undefined}
|
||||
visibleDragbar={!fullscreen}
|
||||
value={field.value ? field.value : ""}
|
||||
onChange={field.onChange}
|
||||
data-color-mode={params.dataColorMode}
|
||||
commandsFilter={(command) => command.name === "fullscreen" ? false : command}
|
||||
components={{
|
||||
textarea: (props) => (
|
||||
<InternalLinkTextarea
|
||||
{...(props as TextareaHTMLAttributes<HTMLTextAreaElement>)}
|
||||
suggestions={params.autocompleteSuggestions ?? []}
|
||||
triggerConfigs={params.triggerConfigs}
|
||||
/>
|
||||
),
|
||||
preview: params.renderPreview
|
||||
? (source) => params.renderPreview?.(source) ?? <></>
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)
|
||||
|
||||
if (fullscreen && mounted) {
|
||||
return createPortal(editor, document.body)
|
||||
}
|
||||
|
||||
return editor
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Description
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<MDEditor
|
||||
value={field.value ? field.value : ""}
|
||||
onChange={field.onChange}
|
||||
data-color-mode={params.dataColorMode}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,18 +4,10 @@ import { Moon, Sun } from "lucide-react"
|
||||
import { useEffect } from "react"
|
||||
type Props = {activeTheme:string|undefined}
|
||||
const ThemeIcon = (props:Props) => {
|
||||
return (
|
||||
<>
|
||||
{props.activeTheme && props.activeTheme == 'dark' &&
|
||||
<Sun/>
|
||||
}
|
||||
{props.activeTheme && props.activeTheme == 'light' &&
|
||||
<Moon/>
|
||||
}
|
||||
{!props.activeTheme &&
|
||||
<Sun/>
|
||||
}
|
||||
</>
|
||||
)
|
||||
if (props.activeTheme == "dark") {
|
||||
return (<Sun/>)
|
||||
} else {
|
||||
return (<Moon/>)
|
||||
}
|
||||
}
|
||||
export default ThemeIcon;
|
||||
|
||||
@@ -8,9 +8,6 @@ import ThemeIcon from "./ThemeIcon"
|
||||
|
||||
export function ThemeSwitch() {
|
||||
const { setTheme, theme } = useTheme()
|
||||
if (!theme) {
|
||||
setTheme('dark')
|
||||
}
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme == "dark" ? "light" : "dark")
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ThemeSwitch } from "./ThemeSwitch"
|
||||
|
||||
export default function TopNav() {
|
||||
return (
|
||||
<div className="fixed backdrop-blur-md lg:w-full right-0 z-50">
|
||||
<div className="fixed lg:w-full right-0 z-50 lg:bg-background">
|
||||
<nav className="flex flex-col-reverse lg:flex-row flex-wrap w-20 lg:w-full outline-1 lg:h-10 h-full">
|
||||
<div className="flex flex-wrap lg:h-full w-20 lg:w-fit lg:flex-row">
|
||||
<Button className="flex h-10 lg:h-full w-full lg:w-20" asChild variant="outline">
|
||||
@@ -19,12 +19,9 @@ export default function TopNav() {
|
||||
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
|
||||
<Link href={"/projects"}> Projects </Link>
|
||||
</Button>
|
||||
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
|
||||
<Link href={"/music"}> Music </Link>
|
||||
</Button>
|
||||
<Show when="signed-in">
|
||||
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
|
||||
<Link href="/chat"> Chat </Link>
|
||||
<Link href={"/chat"}> Chat </Link>
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -52,13 +49,7 @@ export default function TopNav() {
|
||||
<Show when="signed-in">
|
||||
<Button asChild className="flex h-10 lg:h-full cursor-pointer lg:w-20 content-center" variant={"outline"}>
|
||||
<div>
|
||||
<UserButton
|
||||
userProfileProps={{
|
||||
additionalOAuthScopes: {
|
||||
google: ['https://www.googleapis.com/auth/calendar'],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<UserButton />
|
||||
</div>
|
||||
</Button>
|
||||
</Show>
|
||||
|
||||
@@ -1,98 +1,18 @@
|
||||
'use client'
|
||||
import { useGSAP } from '@gsap/react'
|
||||
import gsap from 'gsap'
|
||||
import { SplitText } from 'gsap/SplitText'
|
||||
import { ScrollTrigger, GSDevTools } from 'gsap/all'
|
||||
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
|
||||
gsap.registerPlugin(useGSAP)
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
gsap.registerPlugin(SplitText)
|
||||
gsap.registerPlugin(GSDevTools)
|
||||
const GsapContext = createContext<{
|
||||
addAnimation: (
|
||||
animation: gsap.core.TimelineChild,
|
||||
position: gsap.Position
|
||||
) => void,
|
||||
resetTimeline: () => void,
|
||||
resumeTimeline: () => void,
|
||||
getScroller: () => Element | Window | null
|
||||
} | null>(null)
|
||||
const GsapContext = createContext<typeof globalThis.gsap | null>(null)
|
||||
|
||||
export function useGsapContext() {
|
||||
return useContext(GsapContext)
|
||||
}
|
||||
|
||||
export const useTimeLine = (dep:any,all?:boolean) => {
|
||||
const gsapContext = useGsapContext()
|
||||
useEffect(() => {
|
||||
if (dep instanceof Array && all) {
|
||||
let acc = true;
|
||||
let allDepsSatisfied = dep.reduce((p,c) => c !== undefined && p ,acc )
|
||||
if (allDepsSatisfied) {
|
||||
gsapContext?.resumeTimeline()
|
||||
}
|
||||
} else {
|
||||
if (dep) {
|
||||
gsapContext?.resumeTimeline()
|
||||
}
|
||||
}
|
||||
},[dep])
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
gsapContext?.resetTimeline()
|
||||
}
|
||||
},[])
|
||||
}
|
||||
|
||||
export default function GsapProvider({ children }: { children: ReactNode }) {
|
||||
const tl = useRef<gsap.core.Timeline | null>(null)
|
||||
const scrollerRef = useRef<Element | Window | null>(null)
|
||||
const getScroller = useCallback(() => {
|
||||
// const cached = scrollerRef.current
|
||||
// if (!cached || (cached instanceof Element && !document.contains(cached))) {
|
||||
let scrollers = document.querySelectorAll('[data-slot="scroll-area-viewport"]')
|
||||
if (scrollers.length < 1) {
|
||||
scrollerRef.current = window
|
||||
} else {
|
||||
let scrollerArray = Array.from(scrollers.values()).sort((a,b) => {
|
||||
const s1 = a as HTMLDivElement;
|
||||
const s2 = b as HTMLDivElement;
|
||||
// using bitwise not (~~) to coerce NaN values to 0
|
||||
const aPriority = ~~Number(s1.dataset?.scrollerPriority)
|
||||
const bPriority = ~~Number(s2.dataset?.scrollerPriority)
|
||||
return aPriority - bPriority;
|
||||
})
|
||||
let prioScroller = scrollerArray.pop();
|
||||
scrollerRef.current = prioScroller || window;
|
||||
}
|
||||
|
||||
// }
|
||||
return scrollerRef.current
|
||||
}, [])
|
||||
useGSAP(() => {
|
||||
if (!tl.current) {
|
||||
tl.current = gsap.timeline({ paused: true })
|
||||
}
|
||||
return () => { console.log("gsap cleanup") }
|
||||
})
|
||||
|
||||
const addAnimation = useCallback((animation: gsap.core.TimelineChild, position: gsap.Position) => {
|
||||
console.log("add animation to:", position, tl.current !== undefined)
|
||||
tl.current?.add(animation, position);
|
||||
},[])
|
||||
const resetTimeline = useCallback(() => {
|
||||
tl.current?.kill()
|
||||
tl.current?.revert()
|
||||
ScrollTrigger.getAll().forEach(st => st.kill())
|
||||
tl.current = gsap.timeline({paused:true})
|
||||
},[])
|
||||
const resumeTimeline = useCallback(() => {
|
||||
console.log("resuming timeline:",tl.current)
|
||||
tl.current?.resume()
|
||||
},[])
|
||||
export default function GsapProvider({children}:{children:ReactNode}) {
|
||||
return (
|
||||
<GsapContext.Provider value={{ addAnimation, resetTimeline, resumeTimeline, getScroller }}>
|
||||
<GsapContext.Provider value={gsap}>
|
||||
{children}
|
||||
</GsapContext.Provider>
|
||||
)
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
'use client'
|
||||
import type { inferRouterOutputs } from '@trpc/server';
|
||||
import { useUser } from '@clerk/nextjs'
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { trpc } from '~/app/_trpc/Client'
|
||||
import { type ChatRouter } from '~/server/routers/chat'
|
||||
const MessageContext = createContext<{
|
||||
session?: inferRouterOutputs<ChatRouter>['getSession']
|
||||
messages?: inferRouterOutputs<ChatRouter>['getMessages']
|
||||
refetchMessages: () => void
|
||||
clearChat: (callback?: () => void) => void
|
||||
error: string|null
|
||||
isLoading: boolean
|
||||
clearingChat: boolean
|
||||
clearedChat: boolean
|
||||
}>({
|
||||
session: undefined,
|
||||
messages: undefined,
|
||||
refetchMessages: () => undefined,
|
||||
clearChat: () => undefined,
|
||||
error: null,
|
||||
isLoading: true,
|
||||
clearingChat: false,
|
||||
clearedChat: false
|
||||
})
|
||||
export const useMessages = () => useContext(MessageContext)
|
||||
export const MessagesProvider = ({children}:{children:ReactNode}) => {
|
||||
const [error,setError] = useState<string|null>(null)
|
||||
const [isLoading,setIsLoading] = useState<boolean>(true)
|
||||
const { isLoaded, isSignedIn } = useUser()
|
||||
const { data: session,error:sessionError,isLoading:sessionLoading} = trpc.chat.getSession.useQuery(undefined, {
|
||||
enabled: isSignedIn === true,
|
||||
})
|
||||
const { data: messages, refetch, error:messageError, isLoading:messagesLoading } = trpc.chat.getMessages.useQuery(session?.id ? session.id : "", {
|
||||
enabled: isSignedIn === true && session?.id != undefined,
|
||||
})
|
||||
const { mutate ,isPending:clearingChat,isSuccess:clearedChat } = trpc.chat.clearChat.useMutation()
|
||||
const utils = trpc.useUtils()
|
||||
const refetchMessages = () => {
|
||||
if (!isSignedIn) {
|
||||
return;
|
||||
}
|
||||
utils.chat.getMessages.invalidate()
|
||||
refetch()
|
||||
}
|
||||
const clearChat = (callback?: () => void) => {
|
||||
if (!isSignedIn) {
|
||||
if (callback) {
|
||||
callback()
|
||||
}
|
||||
return;
|
||||
}
|
||||
mutate(undefined,{onSuccess: () => {
|
||||
if (callback) {
|
||||
callback()
|
||||
}
|
||||
utils.chat.getMessages.invalidate()
|
||||
}})
|
||||
}
|
||||
useEffect(() => {
|
||||
if (isSignedIn !== true) {
|
||||
setError(null)
|
||||
return;
|
||||
}
|
||||
messageError && setError(messageError.message)
|
||||
sessionError && setError(sessionError.message)
|
||||
},[messageError,sessionError,isSignedIn])
|
||||
useEffect(() => {
|
||||
if (!isLoaded) {
|
||||
setIsLoading(true)
|
||||
return;
|
||||
}
|
||||
if (isSignedIn !== true) {
|
||||
setIsLoading(false)
|
||||
return;
|
||||
}
|
||||
setIsLoading(sessionLoading || messagesLoading)
|
||||
},[isLoaded,isSignedIn,sessionLoading,messagesLoading])
|
||||
return (
|
||||
<MessageContext.Provider value={
|
||||
{
|
||||
session: isSignedIn === true ? session : undefined,
|
||||
messages: isSignedIn === true ? messages : undefined,
|
||||
refetchMessages,
|
||||
clearChat,
|
||||
error,
|
||||
isLoading,
|
||||
clearingChat,
|
||||
clearedChat
|
||||
}
|
||||
}>
|
||||
{children}
|
||||
</MessageContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
'use client'
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
export default function ThemeProvider({children}:{children: React.ReactNode}) {
|
||||
return (
|
||||
<NextThemesProvider disableTransitionOnChange attribute="class" defaultTheme="dark">
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
const [mounted,setMounted] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
})
|
||||
if (mounted) {
|
||||
return (
|
||||
<NextThemesProvider disableTransitionOnChange nonce="test" attribute="class" defaultTheme="dark">
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,5 @@ import { env } from "~/env"
|
||||
|
||||
export async function isAdmin() {
|
||||
const userid = (await auth()).userId
|
||||
console.log(userid)
|
||||
return (userid == env.ADMIN_USER_CLERK_ID)
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export default function currentTime() {
|
||||
let now = Date.now();
|
||||
console.log(now);
|
||||
return {
|
||||
success: true,
|
||||
time: now
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
'use server'
|
||||
import { clerkClient, auth } from '@clerk/nextjs/server'
|
||||
import { clerkClient } from '@clerk/nextjs/server'
|
||||
import { google } from 'googleapis'
|
||||
import { env } from '~/env'
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function scheduleMeeting({
|
||||
durationMinutes,
|
||||
attendeeEmail,
|
||||
attendeeName,
|
||||
userId,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
@@ -17,11 +18,11 @@ export async function scheduleMeeting({
|
||||
durationMinutes: number
|
||||
attendeeEmail?: string
|
||||
attendeeName?: string
|
||||
userId: string
|
||||
}) {
|
||||
try {
|
||||
const clerk = await clerkClient()
|
||||
const userAuth = await auth()
|
||||
const user = await clerk.users.getUser(userAuth.userId?userAuth.userId:"")
|
||||
|
||||
// Get admin's Google OAuth token to create the event on Gregor's calendar
|
||||
const adminTokenResponse = await clerk.users.getUserOauthAccessToken(
|
||||
env.ADMIN_USER_CLERK_ID,
|
||||
@@ -36,7 +37,16 @@ export async function scheduleMeeting({
|
||||
// Try to resolve visitor's Google email for the invite
|
||||
let visitorEmail: string | undefined = attendeeEmail
|
||||
if (!visitorEmail) {
|
||||
visitorEmail = user?.emailAddresses.at(0)?.emailAddress ?? undefined
|
||||
try {
|
||||
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()
|
||||
@@ -61,7 +71,6 @@ export async function scheduleMeeting({
|
||||
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
|
||||
attendees,
|
||||
},
|
||||
sendNotifications: true
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger } from "~/components/ui/sidebar";
|
||||
import SimpleSidebarGroup from "~/components/ui/simple-sidebar-group";
|
||||
|
||||
export default function AdminSideBar() {
|
||||
export default async function AdminSideBar() {
|
||||
return (
|
||||
<>
|
||||
<Sidebar variant="floating" className="h-[96%] mt-10 z-[51]">
|
||||
<SidebarProvider>
|
||||
<Sidebar className="z-[51]">
|
||||
<SidebarTrigger className="absolute z-[52] left-65 top-100" />
|
||||
<SidebarContent>
|
||||
<ScrollArea>
|
||||
<SimpleSidebarGroup lable="CV">
|
||||
<Link href={"/admin/cv/category/create"}> Create Category </Link>
|
||||
<Link href={"/admin/cv/entry/create"}> Create Entry </Link>
|
||||
@@ -21,19 +20,12 @@ export default function AdminSideBar() {
|
||||
<Link href={"/admin/project/techStack/create"}> Create Stack </Link>
|
||||
<Link href={"/admin/project/list"}> Project List </Link>
|
||||
</SimpleSidebarGroup>
|
||||
<SimpleSidebarGroup lable="Music">
|
||||
<Link href={"/admin/music"}> Manage Music </Link>
|
||||
</SimpleSidebarGroup>
|
||||
<SimpleSidebarGroup lable="Blog">
|
||||
<Link href={"/admin/blog/create"}> Create Post </Link>
|
||||
<Link href={"/admin/blog/list"}> Post List </Link>
|
||||
<Link href={"/"}> Some Blog Action </Link>
|
||||
</SimpleSidebarGroup>
|
||||
<SimpleSidebarGroup lable="Chat">
|
||||
<Link href={"/admin/chat"}> System Prompt </Link>
|
||||
</SimpleSidebarGroup>
|
||||
</ScrollArea>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
'use client'
|
||||
import { trpc } from '~/app/_trpc/Client'
|
||||
import { useParams } from 'next/navigation'
|
||||
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
|
||||
|
||||
export default function Page() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const { data } = trpc.blog.bySlug.useQuery(slug)
|
||||
if (data) return <CreateUpdateBlogForm entity={data} />
|
||||
return <></>
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { MDXRemote } from 'next-mdx-remote'
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
import type { MDXRemoteSerializeResult } from 'next-mdx-remote'
|
||||
import { mdxComponents } from '~/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>
|
||||
)
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
'use client'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { trpc } from '~/app/_trpc/Client'
|
||||
import { FormScaffold } from '~/app/_components/Form/Components'
|
||||
import { FormMutationContextProvider } from '~/app/_components/Form/Components/MutationProvider'
|
||||
import { useState } from 'react'
|
||||
import { TextInputFormField, MdeFormField } from '~/app/_components/Form/Fields'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useTheme } from 'next-themes'
|
||||
import type { RouterOutputs } from '~/server/routers/_app'
|
||||
import MdxComponentReference from './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: ``,
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
|
||||
|
||||
const examples = [
|
||||
{
|
||||
name: "Lead",
|
||||
description: "Intro paragraph with larger muted text.",
|
||||
code: `<Lead>
|
||||
Short opening summary for the post.
|
||||
</Lead>`,
|
||||
},
|
||||
{
|
||||
name: "Callout",
|
||||
description: "Highlighted note, tip, or warning block.",
|
||||
code: `<Callout title="Heads up" variant="note">
|
||||
Important context for readers.
|
||||
</Callout>
|
||||
|
||||
<Callout title="Tip" variant="tip">
|
||||
A practical recommendation.
|
||||
</Callout>
|
||||
|
||||
<Callout title="Careful" variant="warning">
|
||||
A caveat or tradeoff.
|
||||
</Callout>`,
|
||||
},
|
||||
{
|
||||
name: "ButtonLink",
|
||||
description: "Button-styled internal or external link.",
|
||||
code: `<ButtonLink href="/projects">
|
||||
View projects
|
||||
</ButtonLink>
|
||||
|
||||
<ButtonLink href="https://example.com" variant="outline">
|
||||
External resource
|
||||
</ButtonLink>`,
|
||||
},
|
||||
{
|
||||
name: "Figure",
|
||||
description: "Image with optional caption.",
|
||||
code: `<Figure
|
||||
src="https://example.com/image.jpg"
|
||||
alt="Describe the image"
|
||||
caption="Optional caption"
|
||||
/>`,
|
||||
},
|
||||
{
|
||||
name: "PullQuote",
|
||||
description: "Large emphasized quote or takeaway.",
|
||||
code: `<PullQuote>
|
||||
A highlighted quote or strong takeaway.
|
||||
</PullQuote>`,
|
||||
},
|
||||
{
|
||||
name: "TagList",
|
||||
description: "Inline list of tag badges inside the post body.",
|
||||
code: `<TagList tags={["nextjs", "mdx", "uploadthing"]} />`,
|
||||
},
|
||||
{
|
||||
name: "Badge",
|
||||
description: "Small inline label.",
|
||||
code: `<Badge variant="outline">Next.js</Badge>`,
|
||||
},
|
||||
];
|
||||
|
||||
export default function MdxComponentReference() {
|
||||
return (
|
||||
<section className="rounded-lg border p-4">
|
||||
<h2 className="text-base font-semibold">MDX Components</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Components available inside blog post content. Type <code className="rounded bg-muted px-1">[[</code> for internal links or <code className="rounded bg-muted px-1"><</code> for component snippets.
|
||||
</p>
|
||||
<Accordion type="single" collapsible className="mt-3">
|
||||
{examples.map((example) => (
|
||||
<AccordionItem key={example.name} value={example.name}>
|
||||
<AccordionTrigger>
|
||||
<span>
|
||||
<span className="block">{example.name}</span>
|
||||
<span className="text-muted-foreground block text-xs font-normal">{example.description}</span>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="bg-muted overflow-x-auto rounded-md p-3 text-xs">
|
||||
<code>{example.code}</code>
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
'use client'
|
||||
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
|
||||
|
||||
export default function Page() {
|
||||
return <CreateUpdateBlogForm />
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
'use client'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '~/app/_trpc/Client'
|
||||
import * as Card from '~/components/ui/card'
|
||||
import { CollapsibleForm } from '~/app/_components/Form/Components'
|
||||
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
|
||||
import { Badge } from '~/components/ui/badge'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
export default function BlogListPage() {
|
||||
const posts = trpc.blog.list.useQuery(undefined, { refetchInterval: 5000 })
|
||||
const syncMutation = trpc.blog.syncFromUploadThing.useMutation({
|
||||
onSuccess: () => posts.refetch(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='w-5/6 lg:w-1/2 flex flex-col gap-3'>
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => syncMutation.mutate(undefined)}
|
||||
disabled={syncMutation.status === 'pending'}
|
||||
>
|
||||
<RefreshCw />
|
||||
Sync
|
||||
</Button>
|
||||
</div>
|
||||
{syncMutation.data && (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Synced {syncMutation.data.created} created, {syncMutation.data.updated} updated, {syncMutation.data.skipped} skipped.
|
||||
</p>
|
||||
)}
|
||||
{posts.data == undefined ?
|
||||
<div className='gsapan' /> :
|
||||
<>
|
||||
{posts.data.map((post) => (
|
||||
<Card.Card className='gsapan' key={post.slug}>
|
||||
<Link href={`/admin/blog/${post.slug}`}>
|
||||
<Card.CardHeader>
|
||||
<Card.CardTitle>{post.title}</Card.CardTitle>
|
||||
{post.date && <p className='text-sm text-muted-foreground'>{post.date}</p>}
|
||||
{post.description && <p className='text-sm text-muted-foreground'>{post.description}</p>}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
{post.tags.map((tag) => (
|
||||
<Badge key={tag} variant='outline'>{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.CardHeader>
|
||||
</Link>
|
||||
</Card.Card>
|
||||
))}
|
||||
<CollapsibleForm entityName='Blog Post' form={CreateUpdateBlogForm} />
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { Textarea } from '~/components/ui/textarea'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { trpc } from '~/app/_trpc/Client'
|
||||
|
||||
export default function SystemPromptForm({ initialValue }: { initialValue: string }) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const mutation = trpc.chat.updateSystemPrompt.useMutation({
|
||||
onSuccess: () => setSaved(true),
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setSaved(false)
|
||||
mutation.mutate({ prompt: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 w-full">
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => { setValue(e.target.value); setSaved(false) }}
|
||||
rows={16}
|
||||
className="font-mono text-sm resize-y"
|
||||
placeholder="Enter the system prompt for the AI recruiter..."
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
{saved && <span className="text-sm text-muted-foreground">Saved</span>}
|
||||
{mutation.error && <span className="text-sm text-destructive">{mutation.error.message}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { servTrpc } from '~/app/_trpc/ServerClient'
|
||||
import SystemPromptForm from './_components/SystemPromptForm'
|
||||
|
||||
export default async function SystemPromptPage() {
|
||||
const prompt = await servTrpc.chat.getSystemPrompt()
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl p-6 flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">AI System Prompt</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This prompt is sent to the model on every chat request.
|
||||
</p>
|
||||
</div>
|
||||
<SystemPromptForm initialValue={prompt} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,13 +14,13 @@ import { SelectItem } from '~/components/ui/select';
|
||||
import {FormMutationContextProvider} from '~/app/_components/Form/Components/MutationProvider';
|
||||
export default function CreateUpdateCvCategoryForm(params: { className?: string, entity?: IterableElement<RouterOutputs['category']['select']> }) {
|
||||
const schemas = entitySchemas('cvCategory')
|
||||
const [id, setId] = useState<string | undefined>(params.entity?.id)
|
||||
const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
|
||||
const form = useForm<z.infer<typeof schemas.insert>>({
|
||||
resolver: zodResolver(schemas.insert),
|
||||
defaultValues: {
|
||||
id: params.entity?.id || crypto.randomUUID(),
|
||||
name: params.entity?.name || "",
|
||||
layoutPosition: params.entity?.layoutPosition || "col1"
|
||||
id: params.entity ? params.entity.id : crypto.randomUUID(),
|
||||
name: params.entity ? params.entity.name : "",
|
||||
layoutPosition: params.entity ? params.entity.layoutPosition : "col1"
|
||||
}
|
||||
})
|
||||
let path = usePathname()
|
||||
|
||||
@@ -11,8 +11,13 @@ import CreateUpdateCvCategoryForm from "../_components/CreateUpdateForm";
|
||||
export default function CvPage() {
|
||||
const categories = trpc.category.select.useQuery({}, { refetchInterval: 1000 });
|
||||
const entires = trpc.entry.select.useSuspenseQuery({}, { refetchInterval: 1000 })
|
||||
const gsap = useGsapContext()
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
useGSAP(() => {
|
||||
gsap?.from('.gsapan', { x: -100, opacity: 0, duration: 0.5, stagger: { each: 0.3 } });
|
||||
}, { scope: container, dependencies: [categories.status], revertOnUpdate: true });
|
||||
return (
|
||||
<>
|
||||
<div ref={container} className="w-5/6 lg:w-1/2 flex flex-col gap-3">
|
||||
{categories.data == undefined ?
|
||||
<div className="gsapan"></div>
|
||||
:
|
||||
@@ -59,6 +64,6 @@ export default function CvPage() {
|
||||
<CollapsibleForm entityName="Category" form={CreateUpdateCvCategoryForm} />
|
||||
</>
|
||||
}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,9 +8,13 @@ import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
||||
|
||||
export default function CvPage() {
|
||||
const entires = trpc.entry.select.useQuery({});
|
||||
const gsap = useGsapContext()
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
useGSAP(() => {
|
||||
gsap?.from('.gsapan', { x: -100, opacity: 0, duration: 0.5, stagger: { each: 0.3 } })
|
||||
}, { scope: container, dependencies: [entires.status], revertOnUpdate: true });
|
||||
return (
|
||||
<>
|
||||
<div ref={container} className="w-5/6 lg:w-1/2 flex flex-col gap-3">
|
||||
{entires.data == undefined ?
|
||||
<div className="gsapan"></div>
|
||||
:
|
||||
@@ -36,6 +40,6 @@ export default function CvPage() {
|
||||
})}
|
||||
</>
|
||||
}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { isAdmin } from "~/app/actions";
|
||||
import { SidebarProvider } from "~/components/ui/sidebar";
|
||||
import AdminSideBar from "./_components/AdminSideBar";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
'use server'
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
import AdminSideBar from "./_components/AdminSideBar";
|
||||
|
||||
export default async function Admin({children}: Readonly<{children: React.ReactNode}>) {
|
||||
if (!(await isAdmin())) redirect("/");
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarProvider>
|
||||
<AdminSideBar/>
|
||||
<ScrollArea className="px-10 lg:px-0 w-full h-screen pb-10 max-w-4xl mx-auto pt-10">
|
||||
<main className="absolute flex items-center content-center justify-center flex-wrap w-[100vw] left-0 top-15">
|
||||
{children}
|
||||
</ScrollArea>
|
||||
</SidebarProvider>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/app/_trpc/Client";
|
||||
import { UploadDropzone } from "~/lib/uploadthing";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { FormScaffold } from "~/app/_components/Form/Components";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { RouterOutputs } from "~/server/routers/_app";
|
||||
import type { IterableElement } from "type-fest";
|
||||
import { Toaster } from "~/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
import { FormMutationContextProvider } from "~/app/_components/Form/Components/MutationProvider";
|
||||
import { TextInputFormField } from "~/app/_components/Form/Fields";
|
||||
import { createMusicInputSchema } from "~/lib/trpc/music/schemas";
|
||||
export default function CreateUpdateMusicForm(props: {
|
||||
entity?: IterableElement<RouterOutputs['music']['list']>,
|
||||
className?: string
|
||||
}) {
|
||||
const entity = props.entity;
|
||||
const [id, setId] = useState<string | undefined>(entity?.id)
|
||||
const utils = trpc.useUtils();
|
||||
const form = useForm<z.infer<typeof createMusicInputSchema>>({
|
||||
resolver: zodResolver(createMusicInputSchema),
|
||||
defaultValues: {
|
||||
id: entity?.id || crypto.randomUUID(),
|
||||
title: entity?.title || "",
|
||||
description: entity?.description || "",
|
||||
fileUrl: entity?.fileUrl,
|
||||
fileKey: entity?.fileKey,
|
||||
fileName: entity?.fileName,
|
||||
}
|
||||
})
|
||||
|
||||
const createMutation = trpc.music.create.useMutation({
|
||||
onSuccess: (values) => {
|
||||
setId(values?.id);
|
||||
utils.music.list.invalidate();
|
||||
},
|
||||
onError: (e) => {
|
||||
toast(e.message)
|
||||
}
|
||||
})
|
||||
const updateMutation = trpc.music.update.useMutation({
|
||||
onSuccess: (_) => {
|
||||
utils.music.list.invalidate();
|
||||
},
|
||||
onError: (e) => {
|
||||
toast(e.message)
|
||||
}
|
||||
})
|
||||
const deleteMutation = trpc.music.delete.useMutation({
|
||||
onSuccess: (_) => {
|
||||
utils.music.list.invalidate();
|
||||
},
|
||||
onError: (e) => {
|
||||
toast(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
function onSubmit(values: z.infer<typeof createMusicInputSchema>) {
|
||||
id ?
|
||||
updateMutation.mutate(values) :
|
||||
createMutation.mutate(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster />
|
||||
<FormMutationContextProvider value={{
|
||||
createMutation: createMutation,
|
||||
updateMutation: updateMutation,
|
||||
deleteMutation: deleteMutation
|
||||
}}>
|
||||
<FormScaffold form={form} onSubmit={onSubmit} title='Music' id={id} className={props.className}>
|
||||
<TextInputFormField control={form.control} name='title' label='Title'/>
|
||||
<TextInputFormField control={form.control} name='description' label='Description'/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Audio File</Label>
|
||||
<UploadDropzone
|
||||
endpoint="musicUploader"
|
||||
config={{mode: 'auto'}}
|
||||
onUploadError={(e) => {
|
||||
toast(e.message)
|
||||
}}
|
||||
onClientUploadComplete={(res) => {
|
||||
console.log(res)
|
||||
if (res[0]) {
|
||||
form.setValue('fileKey',res[0].serverData.fileKey);
|
||||
form.setValue('fileName',res[0].serverData.fileName);
|
||||
form.setValue('title',res[0].serverData.fileName);
|
||||
form.setValue('description',res[0].serverData.fileName);
|
||||
form.setValue('fileUrl',res[0].serverData.fileUrl);
|
||||
}
|
||||
console.log(form.getValues());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormScaffold>
|
||||
</FormMutationContextProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from "~/app/_trpc/Client";
|
||||
import * as Card from "~/components/ui/card";
|
||||
import UploadMusicForm from "./_components/UploadMusicForm";
|
||||
import { CollapsibleForm } from "~/app/_components/Form/Components";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function AdminMusicPage() {
|
||||
const { data: tracks } = trpc.music.list.useQuery();
|
||||
useEffect(() => {console.log(tracks)}, [tracks])
|
||||
return (
|
||||
<div className="w-5/6 lg:w-1/2 flex flex-col gap-3">
|
||||
{tracks && <>
|
||||
{tracks.map((t) => (
|
||||
<Card.Card key={t.id}>
|
||||
<Card.CardContent>
|
||||
<UploadMusicForm entity={t} className="w-full"/>
|
||||
</Card.CardContent>
|
||||
</Card.Card>
|
||||
))}
|
||||
</>}
|
||||
<CollapsibleForm entityName="Track" form={UploadMusicForm}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
'use server'
|
||||
|
||||
import { Show } from "@clerk/nextjs";
|
||||
|
||||
export default async function AdminPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center">
|
||||
<div>
|
||||
hello admin
|
||||
</div>
|
||||
</main>
|
||||
<Show when="signed-in">
|
||||
<main className="flex min-h-screen flex-col items-center justify-center">
|
||||
<div>
|
||||
hello admin
|
||||
</div>
|
||||
</main>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function ProjectList() {
|
||||
const projects = trpc.project.select.useQuery({}, { refetchInterval: 1000 })
|
||||
const techStacks = trpc.techStack.select.useSuspenseQuery({}, { refetchInterval: 1000 })
|
||||
return (
|
||||
<>
|
||||
<div className="w-5/6 lg:w-1/2 flex flex-col gap-3">
|
||||
{
|
||||
projects.data == undefined ?
|
||||
<></> :
|
||||
@@ -55,6 +55,6 @@ export default function ProjectList() {
|
||||
<CollapsibleForm entityName="Project" form={CreateUpdateProjectForm} />
|
||||
</>
|
||||
}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function CreateUpdateStackForm(params: { className?: string, enti
|
||||
const deleteMutation = trpc.techStack.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
|
||||
function onSubmit(values: z.infer<typeof schemas.insert>) {
|
||||
setSubmitted(true)
|
||||
id ?
|
||||
params.entity ?
|
||||
updateMutation.mutate(values) :
|
||||
createMutation.mutate(values);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { createOpenAI } from '@ai-sdk/openai'
|
||||
import { streamText, tool, convertToModelMessages, stepCountIs, type UIMessage } from 'ai'
|
||||
import { success, z } from 'zod'
|
||||
import { z } from 'zod'
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { env } from '~/env'
|
||||
import { db } from '~/server/db'
|
||||
import { chatSession, chatMessage } from '~/server/dbschema/schema'
|
||||
import { servTrpc } from '~/app/_trpc/ServerClient'
|
||||
import { scheduleMeeting } from '~/app/actions/scheduleMeeting'
|
||||
import currentTime from '~/app/actions/currentTime';
|
||||
|
||||
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { userId } = await auth()
|
||||
if (userId == null) return new Response('Unauthorized', { status: 401 })
|
||||
if (!userId) return new Response('Unauthorized', { status: 401 })
|
||||
|
||||
const { messages, sessionId } = (await req.json()) as {
|
||||
messages: UIMessage[]
|
||||
@@ -31,8 +29,6 @@ export async function POST(req: Request) {
|
||||
|
||||
if (!session) return new Response('Session not found', { status: 404 })
|
||||
|
||||
const systemPrompt = await servTrpc.chat.getSystemPrompt() || 'You are an AI recruiter assistant.'
|
||||
|
||||
// Save the latest user message
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (lastMessage?.role === 'user') {
|
||||
@@ -46,15 +42,24 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const result = streamText({
|
||||
model: openai('gpt-5-mini'),
|
||||
system: systemPrompt,
|
||||
model: openai('gpt-4o'),
|
||||
system: `You are an AI recruiter assistant on Gregor Lohaus's personal portfolio website.
|
||||
Your role is to help visitors learn about Gregor's background, skills, and experience, and to schedule meetings.
|
||||
|
||||
About Gregor:
|
||||
- Fullstack developer specialising in TypeScript, React, Next.js, and modern web technologies
|
||||
- Experienced with Drizzle ORM, tRPC, PostgreSQL, server components, and the T3 stack
|
||||
- Also experienced with React Native, Expo, Java/Spring, gRPC, AWS, and Linux/Debian server administration
|
||||
- Open to new opportunities and collaboration
|
||||
|
||||
Be professional, friendly, and concise. When a visitor wants to schedule a meeting, collect the necessary details (preferred date/time, duration, and optionally their name/email) and use the scheduleMeeting tool.`,
|
||||
messages: await convertToModelMessages(messages),
|
||||
tools: {
|
||||
scheduleMeeting: tool({
|
||||
description: 'Schedule a meeting with Gregor Lohaus and add it to his Google Calendar',
|
||||
inputSchema: z.object({
|
||||
title: z.string().describe('Meeting title, make something up if not provided'),
|
||||
description: z.string().describe('Meeting description / agenda, make something up if not provided'),
|
||||
title: z.string().describe('Meeting title'),
|
||||
description: z.string().describe('Meeting description / agenda'),
|
||||
dateTime: z
|
||||
.string()
|
||||
.describe(
|
||||
@@ -65,23 +70,16 @@ export async function POST(req: Request) {
|
||||
.int()
|
||||
.min(15)
|
||||
.max(120)
|
||||
.describe('Duration of the meeting in minutes, if none provided ask if 20 minutes is ok'),
|
||||
.describe('Duration of the meeting in minutes'),
|
||||
attendeeEmail: z
|
||||
.string()
|
||||
.email()
|
||||
.optional()
|
||||
.describe('Optional Email of the visitor to invite (if provided)'),
|
||||
.describe('Email of the visitor to invite (if provided)'),
|
||||
attendeeName: z.string().optional().describe('Name of the visitor'),
|
||||
}),
|
||||
execute: async (input) => scheduleMeeting({ ...input }),
|
||||
execute: async (input) => scheduleMeeting({ ...input, userId }),
|
||||
}),
|
||||
getCurrentUnixTime: tool({
|
||||
description: 'Get the current unix time to reference for meeting dates',
|
||||
inputSchema: z.object({
|
||||
none: z.string().optional().describe("no inputs are needed")
|
||||
}),
|
||||
execute: async () => currentTime()
|
||||
})
|
||||
},
|
||||
stopWhen: stepCountIs(5),
|
||||
onFinish: async ({ text, finishReason }) => {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createRouteHandler } from "uploadthing/next";
|
||||
import { fileRouter } from "~/server/uploadthing";
|
||||
|
||||
export const { GET, POST } = createRouteHandler({
|
||||
router: fileRouter,
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function AssistantPage() {
|
||||
redirect('/chat')
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Children, isValidElement, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type CalloutVariant = "note" | "tip" | "warning";
|
||||
|
||||
const calloutStyles: Record<CalloutVariant, string> = {
|
||||
note: "border-sky-500/40 bg-sky-500/10 text-sky-950 dark:text-sky-100",
|
||||
tip: "border-emerald-500/40 bg-emerald-500/10 text-emerald-950 dark:text-emerald-100",
|
||||
warning: "border-amber-500/40 bg-amber-500/10 text-amber-950 dark:text-amber-100",
|
||||
};
|
||||
|
||||
function Callout({
|
||||
title,
|
||||
variant = "note",
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
variant?: CalloutVariant;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<aside className={cn("my-6 rounded-md border px-4 py-3", calloutStyles[variant])}>
|
||||
{title && <p className="mb-2 font-semibold">{title}</p>}
|
||||
<div className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{children}</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Lead({ children }: { children: ReactNode }) {
|
||||
return <span className="text-muted-foreground my-6 block text-lg leading-8">{children}</span>;
|
||||
}
|
||||
|
||||
function TagList({ tags }: { tags: string[] }) {
|
||||
return (
|
||||
<div className="my-4 flex flex-wrap gap-1.5">
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonLink({
|
||||
href,
|
||||
children,
|
||||
variant = "default",
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
variant?: ComponentPropsWithoutRef<typeof Button>["variant"];
|
||||
}) {
|
||||
const isExternal = /^https?:\/\//.test(href);
|
||||
|
||||
return (
|
||||
<Button asChild variant={variant}>
|
||||
{isExternal ? (
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
<Link href={href}>{children}</Link>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({
|
||||
src,
|
||||
alt,
|
||||
caption,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
caption?: string;
|
||||
}) {
|
||||
return (
|
||||
<figure className="my-8">
|
||||
<img src={src} alt={alt} className="w-full rounded-md border object-cover" />
|
||||
{caption && <figcaption className="text-muted-foreground mt-2 text-center text-sm">{caption}</figcaption>}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function PullQuote({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<blockquote className="border-primary my-8 border-l-4 pl-5 text-xl leading-8 font-medium">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalLink(props: ComponentPropsWithoutRef<"a">) {
|
||||
const href = props.href ?? "";
|
||||
const isExternal = /^https?:\/\//.test(href);
|
||||
|
||||
if (!isExternal) return <a {...props} />;
|
||||
|
||||
return <a {...props} target="_blank" rel="noreferrer" />;
|
||||
}
|
||||
|
||||
const blockComponents = new Set<unknown>([Callout, Figure, PullQuote, TagList]);
|
||||
|
||||
function Paragraph({ children }: { children: ReactNode }) {
|
||||
const containsBlockComponent = Children.toArray(children).some(
|
||||
(child) => isValidElement(child) && blockComponents.has(child.type),
|
||||
);
|
||||
|
||||
if (containsBlockComponent) return <>{children}</>;
|
||||
|
||||
return <p>{children}</p>;
|
||||
}
|
||||
|
||||
export const mdxComponents = {
|
||||
a: ExternalLink,
|
||||
p: Paragraph,
|
||||
Badge,
|
||||
ButtonLink,
|
||||
Callout,
|
||||
Figure,
|
||||
Lead,
|
||||
PullQuote,
|
||||
TagList,
|
||||
};
|
||||
@@ -1,3 +1,10 @@
|
||||
export default function BlogLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
'use client'
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode}>) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,45 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { servTrpc } from "~/app/_trpc/ServerClient";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
'use client'
|
||||
|
||||
export default async function BlogPage() {
|
||||
const posts = await servTrpc.blog.list();
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
export default function Page() {
|
||||
const pathName = usePathname()
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-12">
|
||||
<h1 className="mb-8 text-3xl font-bold">Blog</h1>
|
||||
{posts.length === 0 ? (
|
||||
<p className="text-muted-foreground">No posts yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-6">
|
||||
{posts.map((post) => (
|
||||
<li key={post.slug}>
|
||||
<Link href={`/blog/${post.slug}`} className="group block">
|
||||
<h2 className="text-xl font-semibold group-hover:underline">{post.title}</h2>
|
||||
{post.date && (
|
||||
<time className="text-muted-foreground text-sm">
|
||||
{new Date(post.date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</time>
|
||||
)}
|
||||
{post.description && (
|
||||
<p className="text-muted-foreground mt-1">{post.description}</p>
|
||||
)}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{post.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
<div>
|
||||
{pathName}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import Markdown from "react-markdown";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||
let message = props.message;
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className='flex justify-start'
|
||||
>
|
||||
<div
|
||||
className=
|
||||
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-muted'
|
||||
>
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<Markdown>
|
||||
{part.text}
|
||||
</Markdown>
|
||||
)
|
||||
}
|
||||
if (part.type === 'tool-scheduleMeeting') {
|
||||
const toolPart = part as unknown as {
|
||||
type: 'tool-scheduleMeeting'
|
||||
state: string
|
||||
input: unknown
|
||||
output?: { success: boolean; message?: string; htmlLink?: string; error?: string }
|
||||
}
|
||||
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
|
||||
return (
|
||||
<p key={i} className="text-xs opacity-70 italic">
|
||||
Scheduling meeting…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (toolPart.state === 'output-available' && toolPart.output) {
|
||||
const result = toolPart.output
|
||||
return (
|
||||
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
|
||||
{result.success ? (
|
||||
<span>
|
||||
✓ {result.message}{' '}
|
||||
{result.htmlLink && (
|
||||
<a
|
||||
href={result.htmlLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
View event
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>✗ {result.error}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +1,20 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport, type UIMessage } from 'ai'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Textarea } from '~/components/ui/textarea'
|
||||
import { SignInButton } from '@clerk/nextjs'
|
||||
import {
|
||||
useGsapContext,
|
||||
} from '~/app/_providers/GsapProvicer';
|
||||
import Messages from './Messages'
|
||||
import { DeleteIcon } from 'lucide-react';
|
||||
import { Spinner } from '~/components/ui/spinner';
|
||||
import { useMessages } from '~/app/_providers/MessagesProvider';
|
||||
interface DBMessage {
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
type DBMessage = {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
sessionId?: string,
|
||||
dbMessages: DBMessage[],
|
||||
}
|
||||
|
||||
function SignInChatPrompt() {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 text-center">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold">Sign in to use the chat</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You need to be signed in before you can talk to Gregor's AI assistant.
|
||||
</p>
|
||||
</div>
|
||||
<SignInButton mode="modal">
|
||||
<Button type="button">Sign in</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
)
|
||||
sessionId: string
|
||||
initialMessages: DBMessage[]
|
||||
}
|
||||
|
||||
function toUIMessages(dbMessages: DBMessage[]): UIMessage[] {
|
||||
@@ -47,76 +25,118 @@ function toUIMessages(dbMessages: DBMessage[]): UIMessage[] {
|
||||
}))
|
||||
}
|
||||
|
||||
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 }) {
|
||||
export default function ChatInterface({ sessionId, initialMessages }: ChatInterfaceProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const { clearingChat, clearChat, refetchMessages } = useMessages();
|
||||
const initialMessages = toUIMessages(dbMessages)
|
||||
addInitMessage(initialMessages)
|
||||
const { messages, sendMessage, status, error, clearError, setMessages } = useChat({
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { messages, sendMessage, status } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat', body: { sessionId },
|
||||
api: '/api/chat',
|
||||
body: { sessionId },
|
||||
}),
|
||||
messages: initialMessages,
|
||||
messages: toUIMessages(initialMessages),
|
||||
})
|
||||
|
||||
const isLoading = status === 'submitted' || status === 'streaming'
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
refetchMessages()
|
||||
}
|
||||
}, [])
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.trim()
|
||||
if (!text || status != 'ready' || clearingChat) return
|
||||
if (!text || isLoading) return
|
||||
setInput('')
|
||||
sendMessage({ text })
|
||||
}
|
||||
const gsapContext = useGsapContext()
|
||||
useEffect(() => {
|
||||
let scroller = gsapContext?.getScroller()
|
||||
if (scroller instanceof Window) {
|
||||
return;
|
||||
}
|
||||
console.log(scroller?.scrollHeight)
|
||||
scroller?.scrollTo({ behavior: 'smooth', top: scroller.scrollHeight })
|
||||
}, [messages])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{messages &&
|
||||
<Messages status={status} messages={messages} />
|
||||
}
|
||||
{error && (
|
||||
<div className="mx-4 mb-2 flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<span className="flex-1">
|
||||
{error.message.includes('quota') || error.message.includes('429')
|
||||
? 'OpenAI quota exceeded. Please try again later.'
|
||||
: `Error: ${error.message}`}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={clearError}
|
||||
className="shrink-0 opacity-60 hover:opacity-100"
|
||||
variant='destructive'
|
||||
>
|
||||
<DeleteIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="p-4 border-t flex flex-row gap-2">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn('flex', message.role === 'user' ? 'justify-end' : 'justify-start')}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[80%] rounded-lg px-4 py-2 text-sm space-y-2',
|
||||
message.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted',
|
||||
)}
|
||||
>
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<p key={i} className="whitespace-pre-wrap">
|
||||
{part.text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (part.type === 'tool-scheduleMeeting') {
|
||||
const toolPart = part as unknown as {
|
||||
type: 'tool-scheduleMeeting'
|
||||
state: string
|
||||
input: unknown
|
||||
output?: { success: boolean; message?: string; htmlLink?: string; error?: string }
|
||||
}
|
||||
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
|
||||
return (
|
||||
<p key={i} className="text-xs opacity-70 italic">
|
||||
Scheduling meeting…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (toolPart.state === 'output-available' && toolPart.output) {
|
||||
const result = toolPart.output
|
||||
return (
|
||||
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
|
||||
{result.success ? (
|
||||
<span>
|
||||
✓ {result.message}{' '}
|
||||
{result.htmlLink && (
|
||||
<a
|
||||
href={result.htmlLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
View event
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>✗ {result.error}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{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>
|
||||
|
||||
<div className="p-4 border-t flex gap-2">
|
||||
<Textarea
|
||||
name='message'
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Ask about Gregor's experience or schedule a meeting…"
|
||||
@@ -129,38 +149,14 @@ function AuthenticatedChatInterface({ dbMessages, sessionId }: ChatInterfaceProp
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={status != "ready" || !input.trim()}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={() => {
|
||||
clearChat(() => {
|
||||
let messages: UIMessage[] = [];
|
||||
addInitMessage(messages);
|
||||
setMessages(messages)
|
||||
})
|
||||
}}
|
||||
disabled={status != "ready" || clearingChat}
|
||||
>
|
||||
{clearingChat ?
|
||||
<Spinner /> :
|
||||
"Clear Chat"
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="self-end"
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChatInterface({ dbMessages, sessionId }: ChatInterfaceProps) {
|
||||
if (sessionId == undefined) {
|
||||
return <SignInChatPrompt />
|
||||
}
|
||||
return <AuthenticatedChatInterface sessionId={sessionId} dbMessages={dbMessages} />
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { type ChatStatus, type UIMessage } from 'ai'
|
||||
import * as Card from "~/components/ui/card"
|
||||
import { UserMessage } from './UserMessage';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
import { ScrollArea } from '~/components/ui/scroll-area';
|
||||
import { memo } from 'react';
|
||||
const Messages = memo(({messages,status}: { messages: UIMessage[],status:ChatStatus}) => {
|
||||
return (
|
||||
<ScrollArea data-scroller-priority='1' className="w-full h-[90%] max-w-4xl mx-auto">
|
||||
{messages.map((message, i) => (
|
||||
<Card.AnimatedCard scrollOnly={true} key={i}>
|
||||
<Card.CardContent>
|
||||
{message.role == 'assistant' && <AssistantMessage message={message} />}
|
||||
{message.role == 'user' && <UserMessage message={message} />}
|
||||
</Card.CardContent>
|
||||
</Card.AnimatedCard>
|
||||
))}
|
||||
{status == 'submitted' &&
|
||||
<Card.AnimatedCard scrollOnly={true}>
|
||||
<Card.CardContent>
|
||||
<AssistantMessage message={{
|
||||
id:"",
|
||||
role:"assistant",
|
||||
parts:[{
|
||||
type:'text',
|
||||
text:'Thinking ...'
|
||||
}]
|
||||
}}/>
|
||||
</Card.CardContent>
|
||||
</Card.AnimatedCard>
|
||||
|
||||
}
|
||||
</ScrollArea>)
|
||||
})
|
||||
|
||||
export default Messages;
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { UIMessage } from "ai"
|
||||
|
||||
export const UserMessage = (props:{message: UIMessage}) => {
|
||||
let message = props.message.parts.reduce((acc, part) => {
|
||||
if (part.type == 'text') {
|
||||
return acc + part.text
|
||||
}
|
||||
return acc
|
||||
},"");
|
||||
return (
|
||||
<div
|
||||
key={props.message.id}
|
||||
className='flex justify-end'
|
||||
>
|
||||
<div
|
||||
className=
|
||||
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-primary'
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +1,50 @@
|
||||
'use client'
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { asc, desc, eq } from 'drizzle-orm'
|
||||
import { db } from '~/server/db'
|
||||
import { chatMessage, chatSession } from '~/server/dbschema/schema'
|
||||
import ChatInterface from './_components/ChatInterface'
|
||||
import AnimatedPageTitle from '../_components/Animated/AnimatedPageTitle';
|
||||
import { useTimeLine } from '../_providers/GsapProvicer';
|
||||
import { useMessages } from '../_providers/MessagesProvider';
|
||||
import { Spinner } from '~/components/ui/spinner';
|
||||
|
||||
export default function ChatPage() {
|
||||
const {messages,session,isLoading,error} = useMessages()
|
||||
useTimeLine(messages)
|
||||
export default async function ChatPage() {
|
||||
const { userId } = await auth()
|
||||
if (!userId) redirect('/')
|
||||
|
||||
let session = await db
|
||||
.select()
|
||||
.from(chatSession)
|
||||
.where(eq(chatSession.userId, userId))
|
||||
.orderBy(desc(chatSession.createdAt))
|
||||
.limit(1)
|
||||
.then((r) => r[0])
|
||||
|
||||
if (!session) {
|
||||
const [created] = await db.insert(chatSession).values({ userId }).returning()
|
||||
session = created
|
||||
}
|
||||
|
||||
if (!session) redirect('/')
|
||||
|
||||
const messages = await db
|
||||
.select()
|
||||
.from(chatMessage)
|
||||
.where(eq(chatMessage.sessionId, session.id))
|
||||
.orderBy(asc(chatMessage.createdAt))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
|
||||
<AnimatedPageTitle position={0}>
|
||||
<span>Talk To My </span> <span> AI-Assistant</span>
|
||||
</AnimatedPageTitle>
|
||||
<div className='flex items-center h-[80%] w-full my-auto w-full'>
|
||||
{!isLoading &&
|
||||
<ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
|
||||
}
|
||||
{isLoading &&
|
||||
<><Spinner/> Loading Messages...</>
|
||||
}
|
||||
{error &&
|
||||
<div> {error} </div>
|
||||
}
|
||||
<div className="container max-w-2xl mx-auto h-screen pt-10 pb-4 flex flex-col">
|
||||
<div className="flex flex-col flex-1 bg-background border rounded-lg overflow-hidden">
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">AI Recruiter</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Chat with Gregor's AI assistant
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ChatInterface sessionId={session.id} initialMessages={messages} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
'use client'
|
||||
import { useGSAP } from "@gsap/react";
|
||||
import { useGsapContext,useTimeLine } from "../_providers/GsapProvicer";
|
||||
import { useGsapContext } from "../_providers/GsapProvicer";
|
||||
import { trpc } from "../_trpc/Client";
|
||||
import { useRef } from "react";
|
||||
import { SidebarContent, SidebarProvider, Sidebar } from "~/components/ui/sidebar";
|
||||
import SidebarTriggerDisappearsOnMobile from "./_components/SidebarTriggerDisappearsOnMobile";
|
||||
import CvCategory from "./_components/CvCategory";
|
||||
import gsap from 'gsap'
|
||||
export default function CvPage() {
|
||||
const sidebarCategories = trpc.categoryv2.listByLayoutPosition.useQuery("sidebar");
|
||||
const col1Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col1");
|
||||
const headerCategories = trpc.categoryv2.listByLayoutPosition.useQuery("header");
|
||||
const col2Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col2");
|
||||
const gsapContext = useGsapContext()
|
||||
const gsap = useGsapContext()
|
||||
const container = useRef<HTMLDivElement>(null)
|
||||
enum Direction {
|
||||
Left = 1,
|
||||
@@ -32,12 +31,12 @@ export default function CvPage() {
|
||||
return { y: 100, opacity: 0, duration: 0.5 }
|
||||
}
|
||||
}
|
||||
useTimeLine(col2Categories)
|
||||
useGSAP(() => {
|
||||
const items = gsap?.utils.toArray<GSAPTweenTarget>('.gsapan');
|
||||
const tl = gsap?.timeline();
|
||||
let dir = Direction.Left;
|
||||
items?.forEach(item => {
|
||||
gsapContext?.addAnimation(gsap.from(item, nextGsapConf(dir)),0)
|
||||
tl?.from(item, nextGsapConf(dir))
|
||||
if (dir == Direction.Down) {
|
||||
dir = Direction.Left
|
||||
} else {
|
||||
@@ -48,7 +47,7 @@ export default function CvPage() {
|
||||
return (
|
||||
<>
|
||||
<SidebarProvider ref={container}>
|
||||
{sidebarCategories.data &&
|
||||
{(sidebarCategories.data?.length ? sidebarCategories.data?.length : 0) > 0 ?
|
||||
<>
|
||||
<SidebarTriggerDisappearsOnMobile />
|
||||
<Sidebar className="gsapan ">
|
||||
@@ -62,7 +61,8 @@ export default function CvPage() {
|
||||
})}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</>
|
||||
</> :
|
||||
<></>
|
||||
}
|
||||
<div className="h-full w-full flex flex-wrap flex-row p-4 pt-8 ">
|
||||
<div id="mainwrap" className="flex w-full flex-col gap-4 lg:px-[15vw]">
|
||||
|
||||
@@ -5,17 +5,14 @@ import { ClerkProvider } from "@clerk/nextjs";
|
||||
import { config } from "@fortawesome/fontawesome-svg-core"
|
||||
import "@fortawesome/fontawesome-svg-core/styles.css"
|
||||
import TopNav from "./_components/TopNav";
|
||||
import ChatFAB from "./_components/ChatFAB";
|
||||
import TrpcProvider from "./_trpc/TrpcProvider";
|
||||
// import dynamic from "next/dynamic";
|
||||
// const ThemeProvider = dynamic(() => import("./_providers/ThemeProvider"),{ssr:true})
|
||||
import ThemeProvider from './_providers/ThemeProvider'
|
||||
import GsapProvider from "./_providers/GsapProvicer";
|
||||
import {MessagesProvider} from "./_providers/MessagesProvider";
|
||||
import { CodeHighlightStyle } from "./_components/CodeHighlightSyle";
|
||||
import { cn } from "~/lib/utils";
|
||||
import AnimatedBackGroundContainer from "./_components/Animated/AnimatedBackGroundContainer";
|
||||
import {SpeedInsights} from "@vercel/speed-insights/next"
|
||||
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||
|
||||
|
||||
@@ -31,14 +28,13 @@ const geist = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
});
|
||||
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
modal
|
||||
}: Readonly<{ children: React.ReactNode, modal: React.ReactNode }>) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SpeedInsights/>
|
||||
<ClerkProvider>
|
||||
<TrpcProvider>
|
||||
<GsapProvider>
|
||||
@@ -48,22 +44,16 @@ export default async function RootLayout({
|
||||
</head>
|
||||
<body className="flex flex-col bg-background text-foreground">
|
||||
<ThemeProvider>
|
||||
<MessagesProvider>
|
||||
<AnimatedBackGroundContainer followSpeed={0.003} particleCount={100} orbitRadius={2000}>
|
||||
<TopNav />
|
||||
<main className="absolute lg:top-10 h-screen lg:h-[calc(100vh-var(--spacing)*10)] w-screen">
|
||||
{children}
|
||||
</main>
|
||||
{modal}
|
||||
</AnimatedBackGroundContainer>
|
||||
<ChatFAB />
|
||||
</MessagesProvider>
|
||||
<TopNav />
|
||||
<main className="absolute lg:top-10 h-screen w-screen">
|
||||
{children}
|
||||
</main>
|
||||
{modal}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
</GsapProvider>
|
||||
</TrpcProvider>
|
||||
</ClerkProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
'use client'
|
||||
import { trpc } from "~/app/_trpc/Client";
|
||||
import * as Card from "~/components/ui/card";
|
||||
import { useTimeLine } from "../_providers/GsapProvicer";
|
||||
import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle";
|
||||
import { Spinner } from "~/components/ui/spinner";
|
||||
import AnimateTextIn from "../_components/Animated/AnimateIn";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
|
||||
export default function MusicPage() {
|
||||
const { data: tracks, isLoading } = trpc.music.list.useQuery();
|
||||
useTimeLine(tracks)
|
||||
return (
|
||||
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
|
||||
<AnimatedPageTitle position={0}><span>Just Some </span> <span>Music I Made</span> </AnimatedPageTitle>
|
||||
<div className="flex flex-wrap h-fit content-center">
|
||||
<AnimateTextIn className="flex flex-wrap mr-[1em]" position={0.5}>
|
||||
<div><p className="break-after-avoid mr-[1em]">All works on this page are licensed under:</p></div>
|
||||
<div><a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a></div>
|
||||
</AnimateTextIn>
|
||||
<AnimatePopUp position={2} className="items-center content-center">
|
||||
<div className="flex flex-row">
|
||||
<img className="max-w-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" />
|
||||
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="" />
|
||||
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/nc.svg" alt="" />
|
||||
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="" />
|
||||
</div>
|
||||
</AnimatePopUp>
|
||||
</div>
|
||||
<div className="pt-10" />
|
||||
{tracks && tracks.map((track, i) => (
|
||||
<div key={track.id}>
|
||||
<Card.AnimatedCard position={i + 1}>
|
||||
<Card.CardHeader>
|
||||
<AnimateTextIn position={i + 1.2} animation="slide">
|
||||
<Card.CardTitle>{track.title}</Card.CardTitle>
|
||||
</AnimateTextIn>
|
||||
</Card.CardHeader>
|
||||
<Card.CardContent className="flex flex-col gap-3">
|
||||
{track.description && (
|
||||
<p className="text-sm text-muted-foreground gsapant">{track.description}</p>
|
||||
)}
|
||||
<AnimatePopUp position={i + 1.3}>
|
||||
<audio controls className="w-full player" src={track.fileUrl}>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</AnimatePopUp>
|
||||
</Card.CardContent>
|
||||
</Card.AnimatedCard>
|
||||
<div className="pt-5" />
|
||||
</div>
|
||||
))}
|
||||
{!isLoading && !tracks?.length &&
|
||||
<div className="flex justify-center items-center text-muted-foreground">
|
||||
No music yet.
|
||||
</div>
|
||||
}
|
||||
{isLoading && <div className="w-full h-full items-center flex flex-row content-center gap-4 justify-center">
|
||||
<Spinner /> Loading Tracks
|
||||
</div>}
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -5,17 +5,10 @@ import * as Card from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { StackBadge } from "~/components/StackBadge";
|
||||
import Markdown from "react-markdown";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle";
|
||||
import AnimateTextIn from "../_components/Animated/AnimateIn";
|
||||
import { useTimeLine } from "../_providers/GsapProvicer";
|
||||
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import remarkGfm from "remark-gfm"
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery();
|
||||
useTimeLine(projects)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground">
|
||||
@@ -33,84 +26,68 @@ export default function ProjectsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
|
||||
<AnimatedPageTitle position={0}><span>Projects I've Been</span><span> Working on</span> </AnimatedPageTitle>
|
||||
<div className="pt-10" />
|
||||
{projects.map((project, i) => (
|
||||
<div id={project.id} key={i} className="scroll-mt-10">
|
||||
<Card.AnimatedCard position={i + 1.2} key={project.id}>
|
||||
<Card.CardHeader>
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<AnimateTextIn position={i + 1.4} animation="slide"><Card.CardTitle>{project.title}</Card.CardTitle></AnimateTextIn>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{project.sourceType && (
|
||||
<AnimatePopUp position={i + 2} duration={2}>
|
||||
<Badge variant={project.sourceType === "open" ? "secondary" : "outline"}>
|
||||
{project.sourceType === "open" ? "Open Source" : "Closed Source"}
|
||||
</Badge>
|
||||
</AnimatePopUp>
|
||||
)}
|
||||
{project.releaseStatus && (
|
||||
<Badge variant={project.releaseStatus === "released" ? "default" : "outline"}>
|
||||
{project.releaseStatus === "released" ? "Released" : "Unreleased"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card.CardHeader>
|
||||
{(project.description || project.sourceLink || project.releaseLink || project.techStack?.stackItems?.length) && (
|
||||
<Card.CardContent className="flex flex-col gap-3">
|
||||
{project.description && (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
|
||||
<AnimatePopUp position={i + 1.4} duration={10}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{project.description}</Markdown>
|
||||
</AnimatePopUp>
|
||||
</div>
|
||||
<div className="w-full max-w-4xl mx-auto px-4 py-8 flex flex-col gap-4">
|
||||
{projects.map((project) => (
|
||||
<Card.Card key={project.id}>
|
||||
<Card.CardHeader>
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<Card.CardTitle>{project.title}</Card.CardTitle>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{project.sourceType && (
|
||||
<Badge variant={project.sourceType === "open" ? "secondary" : "outline"}>
|
||||
{project.sourceType === "open" ? "Open Source" : "Closed Source"}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex flex-row">
|
||||
{project.techStack?.stackItems && project.techStack.stackItems.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{project.techStack.stackItems.map((item, k) => (
|
||||
<AnimatePopUp key={k} position={(i + 2) + k * 0.5}> <StackBadge key={item} item={item} /> </AnimatePopUp>
|
||||
))}
|
||||
</div>
|
||||
{project.releaseStatus && (
|
||||
<Badge variant={project.releaseStatus === "released" ? "default" : "outline"}>
|
||||
{project.releaseStatus === "released" ? "Released" : "Unreleased"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card.CardHeader>
|
||||
{(project.description || project.sourceLink || project.releaseLink || project.techStack?.stackItems?.length) && (
|
||||
<Card.CardContent className="flex flex-col gap-3">
|
||||
{project.description && (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
|
||||
<Markdown>{project.description}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{(project.sourceLink || project.releaseLink) && (
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{project.sourceLink && (
|
||||
<a
|
||||
href={project.sourceLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4 transition-colors"
|
||||
>
|
||||
Source
|
||||
</a>
|
||||
)}
|
||||
{(project.sourceLink || project.releaseLink) && (
|
||||
<div className="ml-auto flex-col lg:flex-row justify-center gap-5">
|
||||
{project.sourceLink &&
|
||||
<Button variant='outline' className="cursor-pointer mb-3 lg:mb-0 lg:mr-3 min-w-18">
|
||||
<a
|
||||
href={project.sourceLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className='items-center'
|
||||
>
|
||||
Source
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
{project.releaseLink &&
|
||||
<Button variant='default' className="cursor-pointer min-w-18 items-center">
|
||||
<a
|
||||
href={project.releaseLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className='items-center'
|
||||
>
|
||||
Live
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
}
|
||||
</div>
|
||||
{project.releaseLink && (
|
||||
<a
|
||||
href={project.releaseLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4 transition-colors"
|
||||
>
|
||||
Live
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Card.CardContent>
|
||||
)}
|
||||
</Card.AnimatedCard>
|
||||
<div className="pt-5" />
|
||||
</div>
|
||||
)}
|
||||
{project.techStack?.stackItems && project.techStack.stackItems.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{project.techStack.stackItems.map((item) => (
|
||||
<StackBadge key={item} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.CardContent>
|
||||
)}
|
||||
</Card.Card>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useGSAP } from "@gsap/react"; import * as React from "react"
|
||||
import { useRef } from "react";
|
||||
import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
||||
import gsap from 'gsap'
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function Card({
|
||||
@@ -14,60 +12,7 @@ function Card({
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl bg-opacity-60 backdrop-blur-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AnimatedCard({
|
||||
className,
|
||||
position = 0,
|
||||
size = "default",
|
||||
scrollOnly = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm", position?: gsap.Position, scrollOnly?: boolean }) {
|
||||
const gsapContext = useGsapContext()
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
useGSAP(() => {
|
||||
const rect = ref.current?.getBoundingClientRect()
|
||||
const scroller = gsapContext?.getScroller()
|
||||
console.log(scroller)
|
||||
let viewportTop = 0
|
||||
let viewportBottom = window.innerHeight
|
||||
if (scroller && scroller instanceof Element) {
|
||||
const scrollerRect = scroller.getBoundingClientRect()
|
||||
viewportTop = scrollerRect.top
|
||||
viewportBottom = scrollerRect.top + scrollerRect.height
|
||||
}
|
||||
const isInView = rect && rect.bottom > viewportTop && rect.top < viewportBottom
|
||||
console.log(isInView)
|
||||
const fromVars = { x: -100, opacity: 0, duration: 0.5 }
|
||||
if (isInView && !scrollOnly) {
|
||||
gsapContext?.addAnimation(gsap.from(ref.current, fromVars), position)
|
||||
} else {
|
||||
gsap.from(ref.current,
|
||||
{
|
||||
...fromVars,
|
||||
scrollTrigger: {
|
||||
trigger: ref.current,
|
||||
start: 'top bottom',
|
||||
end: 'bottom top',
|
||||
toggleActions: "play reverse play reverse",
|
||||
scroller
|
||||
}
|
||||
})
|
||||
}
|
||||
}, { dependencies: [] })
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl bg-opacity-60 backdrop-blur-sm",
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -155,5 +100,4 @@ export {
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
AnimatedCard
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { cn } from "~/lib/utils"
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
@@ -7,9 +7,6 @@ export const env = createEnv({
|
||||
* isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
UPLOADTHING_TOKEN: z.string(),
|
||||
BLOG_MDX_PREFIX: z.string().default("blog"),
|
||||
|
||||
DATABASE_URL: z.string().url(),
|
||||
DATABASE_URL_UNPOOLED: z.string().url(),
|
||||
|
||||
@@ -52,9 +49,6 @@ export const env = createEnv({
|
||||
* middlewares) or client-side so we need to destruct manually.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
UPLOADTHING_TOKEN: process.env.UPLOADTHING_TOKEN,
|
||||
BLOG_MDX_PREFIX: process.env.BLOG_MDX_PREFIX,
|
||||
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DATABASE_URL_UNPOOLED: process.env.DATABASE_URL,
|
||||
PGHOST: process.env.PGHOST,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { UseTRPCQueryResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
function useRelationShipSuccess<T extends Record<string,any> & {id:string},K extends keyof T>(
|
||||
relationShipData: T[] | undefined,
|
||||
@@ -56,10 +56,3 @@ export function makeUseRelationShipWithNameIndex<K extends string>(key:K) {
|
||||
}
|
||||
}
|
||||
|
||||
export function usePrevious<T>(value:T,initialValue:T) {
|
||||
const ref = useRef(initialValue)
|
||||
useEffect(() => {
|
||||
ref.current = value
|
||||
})
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import z from "zod"
|
||||
|
||||
export const createMusicInputSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
title: z.string().min(1).max(100),
|
||||
description: z.string().optional(),
|
||||
fileUrl: z.string(),
|
||||
fileKey: z.string(),
|
||||
fileName: z.string(),
|
||||
})
|
||||
export const updateMusicInputSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
title: z.string().min(1).max(100).optional(),
|
||||
description: z.string().optional(),
|
||||
fileUrl: z.string().optional(),
|
||||
fileKey: z.string().optional(),
|
||||
fileName: z.string().optional(),
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import { generateUploadButton, generateUploadDropzone } from "@uploadthing/react";
|
||||
import type { FileRouter } from "~/server/uploadthing";
|
||||
|
||||
export const UploadButton = generateUploadButton<FileRouter>();
|
||||
export const UploadDropzone = generateUploadDropzone<FileRouter>();
|
||||
17
src/proxy.ts
17
src/proxy.ts
@@ -1,16 +1,13 @@
|
||||
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { clerkMiddleware, createRouteMatcher, currentUser } from "@clerk/nextjs/server";
|
||||
import { env } from "~/env";
|
||||
|
||||
const isTenantAdminRoute = createRouteMatcher(["/admin(.*)"]);
|
||||
|
||||
export default clerkMiddleware(async (auth, req) => {
|
||||
const isTenantAdminRoute = createRouteMatcher(['/admin(.*)'])
|
||||
export default clerkMiddleware(async (auth,req) => {
|
||||
if (isTenantAdminRoute(req)) {
|
||||
await auth.protect();
|
||||
|
||||
const { userId } = await auth();
|
||||
if (userId !== env.ADMIN_USER_CLERK_ID) {
|
||||
return NextResponse.redirect(new URL("/", req.url));
|
||||
console.log("running clerk middleware");
|
||||
let userid = (await auth()).userId
|
||||
if (userid != env.ADMIN_USER_CLERK_ID) {
|
||||
await auth.protect()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// https://orm.drizzle.team/docs/sql-schema-declaration
|
||||
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { index, pgEnum, pgSchema, pgTableCreator, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { index, pgEnum, pgSchema, pgTableCreator } from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||
@@ -86,51 +86,6 @@ export const techStack = createTable(
|
||||
})
|
||||
)
|
||||
|
||||
export const music = createTable(
|
||||
"music",
|
||||
(d) => ({
|
||||
id: d.uuid().primaryKey().notNull(),
|
||||
title: d.varchar({ length: 100 }).notNull(),
|
||||
description: d.text(),
|
||||
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
|
||||
fileKey: d.varchar("file_key", { length: 200 }).notNull(),
|
||||
fileName: d.varchar("file_name", { length: 200 }).notNull(),
|
||||
createdAt: d
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull()
|
||||
.$type<Date>(),
|
||||
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
|
||||
})
|
||||
)
|
||||
|
||||
export const blogPost = createTable(
|
||||
"blog_post",
|
||||
(d) => ({
|
||||
id: d.uuid().primaryKey().defaultRandom(),
|
||||
slug: d.varchar({ length: 200 }).notNull(),
|
||||
title: d.varchar({ length: 200 }).notNull(),
|
||||
date: d.varchar({ length: 20 }),
|
||||
description: d.text(),
|
||||
tags: d.text().array(),
|
||||
fileKey: d.varchar("file_key", { length: 200 }).notNull(),
|
||||
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
|
||||
fileName: d.varchar("file_name", { length: 255 }).notNull(),
|
||||
customId: d.varchar("custom_id", { length: 255 }).notNull(),
|
||||
createdAt: d
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull()
|
||||
.$type<Date>(),
|
||||
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
|
||||
}),
|
||||
(t) => [
|
||||
uniqueIndex("blog_post_slug_idx").on(t.slug),
|
||||
uniqueIndex("blog_post_file_key_idx").on(t.fileKey),
|
||||
uniqueIndex("blog_post_custom_id_idx").on(t.customId),
|
||||
],
|
||||
)
|
||||
|
||||
export const messageRoleEnum = pgEnum('message_role', ['user', 'assistant'])
|
||||
|
||||
export const chatSession = createTable(
|
||||
@@ -164,10 +119,3 @@ export const chatMessageRelations = relations(chatMessage, ({ one }) => ({
|
||||
references: [chatSession.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export const systemSettings = createTable(
|
||||
"systemSetting",
|
||||
(d) => ({
|
||||
systemPropmt: d.text()
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import { router } from "../trpc";
|
||||
import type { inferReactQueryProcedureOptions } from "@trpc/react-query";
|
||||
import { blogRouter } from "./blog";
|
||||
import { projectRouter } from "./project";
|
||||
import { techStackRouter } from "./techStack";
|
||||
import { cvCategoryRouter } from "./cvCategory";
|
||||
import { cvEntryRouter } from "./cvEntry";
|
||||
import { musicRouter } from "./music";
|
||||
import { trpcCrudRouterFromDrizzleEntity } from "../lib";
|
||||
import { cvCategory } from "../dbschema/schema";
|
||||
import { chatRouter } from "./chat";
|
||||
|
||||
export const trpcRouter = router({
|
||||
blog: blogRouter,
|
||||
project: trpcCrudRouterFromDrizzleEntity('project').router,
|
||||
projectv2: projectRouter,
|
||||
techStack: trpcCrudRouterFromDrizzleEntity('techStack').router,
|
||||
@@ -21,8 +17,6 @@ export const trpcRouter = router({
|
||||
categoryv2: cvCategoryRouter,
|
||||
entry: trpcCrudRouterFromDrizzleEntity('cvEntry').router,
|
||||
entryv2: cvEntryRouter,
|
||||
music: musicRouter,
|
||||
chat: chatRouter
|
||||
});
|
||||
|
||||
export type TrpcRouter = typeof trpcRouter;
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { desc, eq, or } from "drizzle-orm";
|
||||
import matter from "gray-matter";
|
||||
import { UTApi, UTFile } from "uploadthing/server";
|
||||
import z from "zod";
|
||||
import { isAdmin } from "~/app/actions";
|
||||
import { env } from "~/env.js";
|
||||
import { db } from "~/server/db";
|
||||
import { blogPost } from "~/server/dbschema/schema";
|
||||
import { publicProcedure, router } from "~/server/trpc";
|
||||
|
||||
const utapi = new UTApi({ token: env.UPLOADTHING_TOKEN });
|
||||
|
||||
const blogPostInput = z.object({
|
||||
slug: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
date: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
type BlogPostInput = z.infer<typeof blogPostInput>;
|
||||
|
||||
type UploadThingFile = Awaited<ReturnType<typeof utapi.listFiles>>["files"][number];
|
||||
|
||||
function cleanPrefix(): string {
|
||||
return env.BLOG_MDX_PREFIX.trim().replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function blogFileName(slug: string): string {
|
||||
const prefix = cleanPrefix();
|
||||
return prefix ? `${prefix}-${slug}.mdx` : `${slug}.mdx`;
|
||||
}
|
||||
|
||||
function blogCustomId(slug: string): string {
|
||||
const prefix = cleanPrefix();
|
||||
const id = crypto.randomUUID();
|
||||
return prefix ? `${prefix}:${slug}:${id}` : `${slug}:${id}`;
|
||||
}
|
||||
|
||||
function optionalText(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function frontmatterText(value: unknown): string | null {
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||
return optionalText(typeof value === "string" ? value : undefined);
|
||||
}
|
||||
|
||||
function normalizeTags(tags: unknown): string[] {
|
||||
const values = Array.isArray(tags)
|
||||
? tags
|
||||
: typeof tags === "string"
|
||||
? tags.split(",")
|
||||
: [];
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
values
|
||||
.map((tag) => String(tag).trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function createMdxContent(input: BlogPostInput): string {
|
||||
const date = optionalText(input.date);
|
||||
const description = optionalText(input.description);
|
||||
const tags = normalizeTags(input.tags);
|
||||
const frontmatter: Record<string, unknown> = { slug: input.slug, title: input.title };
|
||||
if (date) frontmatter.date = date;
|
||||
if (description) frontmatter.description = description;
|
||||
if (tags.length > 0) frontmatter.tags = tags;
|
||||
return matter.stringify(input.content, frontmatter);
|
||||
}
|
||||
|
||||
function summaryFromInput(input: BlogPostInput) {
|
||||
return {
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
date: optionalText(input.date),
|
||||
description: optionalText(input.description),
|
||||
tags: normalizeTags(input.tags),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertAdmin() {
|
||||
const admin = await isAdmin();
|
||||
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
|
||||
}
|
||||
|
||||
async function uploadMdx(input: BlogPostInput) {
|
||||
const mdxContent = createMdxContent(input);
|
||||
const customId = blogCustomId(input.slug);
|
||||
const file = new UTFile([mdxContent], blogFileName(input.slug), {
|
||||
customId,
|
||||
type: "text/plain",
|
||||
});
|
||||
const result = await utapi.uploadFiles(file);
|
||||
|
||||
if (result.error) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error.message });
|
||||
}
|
||||
|
||||
if (!result.data.ufsUrl) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "UploadThing did not return a file URL" });
|
||||
}
|
||||
|
||||
return {
|
||||
fileKey: result.data.key,
|
||||
fileUrl: result.data.ufsUrl,
|
||||
fileName: result.data.name,
|
||||
customId: result.data.customId ?? customId,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMdx(fileUrl: string): Promise<string> {
|
||||
const res = await fetch(fileUrl, { next: { revalidate: 3600 } });
|
||||
if (!res.ok) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to fetch MDX file" });
|
||||
return res.text();
|
||||
}
|
||||
|
||||
function fileUrl(file: Pick<UploadThingFile, "key">): string {
|
||||
return `https://utfs.io/f/${file.key}`;
|
||||
}
|
||||
|
||||
function slugFromFileName(name: string): string {
|
||||
const prefix = cleanPrefix();
|
||||
const withoutExtension = name.replace(/\.mdx?$/, "");
|
||||
if (prefix && withoutExtension.startsWith(`${prefix}-`)) return withoutExtension.slice(prefix.length + 1);
|
||||
if (prefix && withoutExtension.startsWith(`${prefix}/`)) return withoutExtension.slice(prefix.length + 1);
|
||||
return withoutExtension;
|
||||
}
|
||||
|
||||
function slugFromCustomId(customId: string): string {
|
||||
const prefix = cleanPrefix();
|
||||
const value = prefix && customId.startsWith(`${prefix}:`)
|
||||
? customId.slice(prefix.length + 1)
|
||||
: customId;
|
||||
return value.split(":")[0] ?? value;
|
||||
}
|
||||
|
||||
function fileMatchesPrefix(file: Pick<UploadThingFile, "name">): boolean {
|
||||
if (!/\.mdx?$/.test(file.name)) return false;
|
||||
|
||||
const prefix = cleanPrefix();
|
||||
if (!prefix) return true;
|
||||
|
||||
return file.name.startsWith(`${prefix}-`) || file.name.startsWith(`${prefix}/`);
|
||||
}
|
||||
|
||||
function metadataFromFile(file: UploadThingFile, raw: string) {
|
||||
const parsed = matter(raw);
|
||||
const fallbackSlug = file.customId ? slugFromCustomId(file.customId) : slugFromFileName(file.name);
|
||||
|
||||
return {
|
||||
slug: String(parsed.data.slug ?? fallbackSlug),
|
||||
title: String(parsed.data.title ?? fallbackSlug),
|
||||
date: frontmatterText(parsed.data.date),
|
||||
description: frontmatterText(parsed.data.description),
|
||||
tags: normalizeTags(parsed.data.tags),
|
||||
fileKey: file.key,
|
||||
fileUrl: fileUrl(file),
|
||||
fileName: file.name,
|
||||
customId: file.customId ?? blogCustomId(fallbackSlug),
|
||||
};
|
||||
}
|
||||
|
||||
async function listAllFiles(): Promise<readonly UploadThingFile[]> {
|
||||
const files: UploadThingFile[] = [];
|
||||
let offset = 0;
|
||||
const limit = 500;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const page = await utapi.listFiles({ limit, offset });
|
||||
files.push(...page.files);
|
||||
hasMore = page.hasMore;
|
||||
offset += limit;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export const blogRouter = router({
|
||||
insert: publicProcedure
|
||||
.input(blogPostInput)
|
||||
.mutation(async ({ input }) => {
|
||||
await assertAdmin();
|
||||
|
||||
const existing = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.slug, input.slug);
|
||||
},
|
||||
});
|
||||
if (existing) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` });
|
||||
|
||||
const uploaded = await uploadMdx(input);
|
||||
|
||||
try {
|
||||
await db.insert(blogPost).values({
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
date: optionalText(input.date),
|
||||
description: optionalText(input.description),
|
||||
tags: normalizeTags(input.tags),
|
||||
...uploaded,
|
||||
});
|
||||
return [summaryFromInput(input)];
|
||||
} catch (error) {
|
||||
await utapi.deleteFiles(uploaded.fileKey);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
update: publicProcedure
|
||||
.input(blogPostInput.extend({ originalSlug: z.string().min(1) }))
|
||||
.mutation(async ({ input }) => {
|
||||
await assertAdmin();
|
||||
|
||||
const existing = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.slug, input.originalSlug);
|
||||
},
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.originalSlug}" not found` });
|
||||
|
||||
if (input.slug !== input.originalSlug) {
|
||||
const slugConflict = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.slug, input.slug);
|
||||
},
|
||||
});
|
||||
if (slugConflict) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` });
|
||||
}
|
||||
|
||||
const uploaded = await uploadMdx(input);
|
||||
|
||||
try {
|
||||
await db.update(blogPost).set({
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
date: optionalText(input.date),
|
||||
description: optionalText(input.description),
|
||||
tags: normalizeTags(input.tags),
|
||||
...uploaded,
|
||||
}).where(eq(blogPost.id, existing.id));
|
||||
|
||||
await utapi.deleteFiles(existing.fileKey);
|
||||
return [summaryFromInput(input)];
|
||||
} catch (error) {
|
||||
await utapi.deleteFiles(uploaded.fileKey);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
await assertAdmin();
|
||||
|
||||
const post = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.slug, input.id);
|
||||
},
|
||||
});
|
||||
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.id}" not found` });
|
||||
|
||||
await db.delete(blogPost).where(eq(blogPost.id, post.id));
|
||||
await utapi.deleteFiles(post.fileKey);
|
||||
return [];
|
||||
}),
|
||||
|
||||
list: publicProcedure.query(async () => {
|
||||
return db.select({
|
||||
slug: blogPost.slug,
|
||||
title: blogPost.title,
|
||||
date: blogPost.date,
|
||||
description: blogPost.description,
|
||||
tags: blogPost.tags,
|
||||
}).from(blogPost).orderBy(desc(blogPost.date), desc(blogPost.createdAt));
|
||||
}),
|
||||
|
||||
bySlug: publicProcedure.input(z.string()).query(async ({ input: slug }) => {
|
||||
const post = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.slug, slug);
|
||||
},
|
||||
});
|
||||
|
||||
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${slug}" not found` });
|
||||
|
||||
const raw = await fetchMdx(post.fileUrl);
|
||||
const { content, data } = matter(raw);
|
||||
|
||||
return {
|
||||
slug: post.slug,
|
||||
content,
|
||||
title: (data.title as string | undefined) ?? post.title,
|
||||
date: frontmatterText(data.date) ?? post.date,
|
||||
description: frontmatterText(data.description) ?? post.description,
|
||||
tags: normalizeTags(data.tags).length > 0 ? normalizeTags(data.tags) : (post.tags ?? []),
|
||||
};
|
||||
}),
|
||||
|
||||
syncFromUploadThing: publicProcedure.mutation(async () => {
|
||||
await assertAdmin();
|
||||
|
||||
const files = (await listAllFiles()).filter(fileMatchesPrefix);
|
||||
const seenFileKeys = new Set<string>();
|
||||
const seenSlugs = new Set<string>();
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let deleted = 0;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const raw = await fetchMdx(fileUrl(file));
|
||||
const metadata = metadataFromFile(file, raw);
|
||||
seenFileKeys.add(file.key);
|
||||
seenSlugs.add(metadata.slug);
|
||||
const existing = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return or(operators.eq(fields.fileKey, file.key), operators.eq(fields.slug, metadata.slug));
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await db.update(blogPost).set(metadata).where(eq(blogPost.id, existing.id));
|
||||
updated += 1;
|
||||
} else {
|
||||
await db.insert(blogPost).values(metadata);
|
||||
created += 1;
|
||||
}
|
||||
} catch {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const posts = await db.select({
|
||||
id: blogPost.id,
|
||||
fileKey: blogPost.fileKey,
|
||||
slug: blogPost.slug,
|
||||
}).from(blogPost);
|
||||
|
||||
const stalePostIds = posts
|
||||
.filter((post) => !seenFileKeys.has(post.fileKey) && !seenSlugs.has(post.slug))
|
||||
.map((post) => post.id);
|
||||
|
||||
for (const id of stalePostIds) {
|
||||
await db.delete(blogPost).where(eq(blogPost.id, id));
|
||||
deleted += 1;
|
||||
}
|
||||
|
||||
return { created, updated, skipped, deleted };
|
||||
}),
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { publicProcedure, router } from "../trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { db } from '~/server/db'
|
||||
import { chatMessage,
|
||||
chatSession, systemSettings } from "../dbschema/schema";
|
||||
import { isAdmin } from '~/app/actions';
|
||||
import { z } from 'zod';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { clerkClient, auth } from '@clerk/nextjs/server'
|
||||
export const chatRouter = router({
|
||||
getSession: publicProcedure.query(async () => {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
throw new TRPCError({ message: "chat is only available to signed in users", code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const clerk = await clerkClient()
|
||||
const user = await clerk.users.getUser(userId)
|
||||
let session = await db.query.chatSession.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.userId, user.id)
|
||||
},
|
||||
})
|
||||
if (session !== undefined) {
|
||||
return session;
|
||||
}
|
||||
let newSession = await db.insert(chatSession).values({ userId: user.id}).returning().execute().then((r) => r.at(0)); if (newSession == undefined) {
|
||||
throw new TRPCError({ message: "failed to create session", code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
session = await db.query.chatSession.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.userId, user.id)
|
||||
},
|
||||
})
|
||||
if (session == undefined) {
|
||||
throw new TRPCError({ message: "session not found", code: "NOT_FOUND" });
|
||||
}
|
||||
if (session !== undefined) {
|
||||
return session;
|
||||
}
|
||||
}),
|
||||
getMessages: publicProcedure.input(z.string()).query(async ({input}) => {
|
||||
let res = await db.query.chatMessage.findMany({
|
||||
where(fields,operators) {
|
||||
return operators.eq(fields.sessionId,input)
|
||||
}
|
||||
})
|
||||
return res;
|
||||
}),
|
||||
clearChat: publicProcedure.mutation(async () => {
|
||||
console.log("deleting session")
|
||||
const { userId } = await auth();
|
||||
if (userId == null) {
|
||||
throw new TRPCError({ message: "chat is only available to signed in users", code: 'UNAUTHORIZED' });
|
||||
}
|
||||
let session = await db.query.chatSession.findFirst({
|
||||
with: {
|
||||
messages: true
|
||||
},
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.userId, userId)
|
||||
},
|
||||
})
|
||||
if (session != undefined) {
|
||||
db.delete(chatMessage).where(eq(chatMessage.sessionId,session.id)).execute()
|
||||
}
|
||||
|
||||
}),
|
||||
getSystemPrompt: publicProcedure.query(async () => {
|
||||
const row = await db.select().from(systemSettings).limit(1).then((r) => r[0])
|
||||
return row?.systemPropmt ?? ''
|
||||
}),
|
||||
updateSystemPrompt: publicProcedure.input(z.object({ prompt: z.string() })).mutation(async ({ input }) => {
|
||||
if (!(await isAdmin())) throw new TRPCError({ code: 'FORBIDDEN' })
|
||||
await db.delete(systemSettings)
|
||||
await db.insert(systemSettings).values({ systemPropmt: input.prompt })
|
||||
}),
|
||||
})
|
||||
|
||||
export type ChatRouter = typeof chatRouter;
|
||||
@@ -1,48 +0,0 @@
|
||||
import { publicProcedure, router } from "~/server/trpc";
|
||||
import { db } from "~/server/db";
|
||||
import { music } from "~/server/dbschema/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { isAdmin } from "~/app/actions";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { createMusicInputSchema, updateMusicInputSchema } from "~/lib/trpc/music/schemas";
|
||||
import { utapi } from "../uploadthing";
|
||||
export const musicRouter = router({
|
||||
list: publicProcedure.query(async () => {
|
||||
let res = await db.select().from(music).orderBy(music.createdAt);
|
||||
console.log(res);
|
||||
return res;
|
||||
}),
|
||||
create: publicProcedure
|
||||
.input(
|
||||
createMusicInputSchema
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const admin = await isAdmin();
|
||||
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
|
||||
let res = await db.insert(music).values(input).returning();
|
||||
return res.at(0);
|
||||
}),
|
||||
update: publicProcedure
|
||||
.input(
|
||||
updateMusicInputSchema
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const admin = await isAdmin();
|
||||
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
|
||||
const { id, ...data } = input;
|
||||
return db.update(music).set(data).where(eq(music.id, id)).returning();
|
||||
}),
|
||||
delete: publicProcedure
|
||||
.input(z.object({id:z.string().uuid()}))
|
||||
.mutation(async ({ input }) => {
|
||||
const admin = await isAdmin();
|
||||
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
|
||||
let res = await db.delete(music).where(eq(music.id, input.id)).returning();
|
||||
let ret = res.at(0)
|
||||
if (ret) {
|
||||
utapi.deleteFiles(ret.fileKey)
|
||||
}
|
||||
return ret;
|
||||
}),
|
||||
});
|
||||
@@ -1,92 +1,12 @@
|
||||
import { publicProcedure, router } from "~/server/trpc";
|
||||
import { db } from "~/server/db";
|
||||
|
||||
type ReadmeRequest = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
function getReadmeRequest(sourceLink: string): ReadmeRequest | null {
|
||||
let url: URL;
|
||||
|
||||
try {
|
||||
url = new URL(sourceLink);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathParts = url.pathname.split("/").filter(Boolean);
|
||||
const [owner, repo] = pathParts;
|
||||
|
||||
if (!owner || !repo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const repoName = repo.replace(/\.git$/, "");
|
||||
|
||||
if (url.hostname === "github.com" || url.hostname === "www.github.com") {
|
||||
return {
|
||||
url: `https://raw.githubusercontent.com/${owner}/${repoName}/main/README.md`,
|
||||
};
|
||||
}
|
||||
|
||||
if (url.hostname.includes("gitea.")) {
|
||||
return {
|
||||
url: `${url.origin}/${owner}/${repoName}/raw/branch/main/README.md`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchReadme(sourceLink: string) {
|
||||
const readmeRequest = getReadmeRequest(sourceLink);
|
||||
|
||||
if (!readmeRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const response = await fetch(readmeRequest.url, {
|
||||
headers: {
|
||||
Accept: "text/plain",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export const projectRouter = router({
|
||||
listWithStack: publicProcedure.query(async () => {
|
||||
const projects = await db.query.project.findMany({
|
||||
return db.query.project.findMany({
|
||||
with: {
|
||||
techStack: true,
|
||||
},
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
projects.map(async (project) => {
|
||||
if (project.description?.length !== 0 || !project.sourceLink) {
|
||||
return project;
|
||||
}
|
||||
|
||||
return {
|
||||
...project,
|
||||
description: await fetchReadme(project.sourceLink),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { createUploadthing, type FileRouter as UploadThingFileRouter } from "uploadthing/next";
|
||||
import { UTApi } from 'uploadthing/server'
|
||||
import { isAdmin } from "~/app/actions";
|
||||
|
||||
const f = createUploadthing();
|
||||
|
||||
export const fileRouter = {
|
||||
musicUploader: f({ audio: { maxFileSize: "64MB", maxFileCount: 1 } })
|
||||
.middleware(async () => {
|
||||
const admin = await isAdmin();
|
||||
if (!admin) throw new Error("Unauthorized");
|
||||
return {};
|
||||
})
|
||||
.onUploadComplete(async ({ file }) => {
|
||||
console.log(file)
|
||||
return { fileUrl: file.ufsUrl, fileKey: file.key, fileName: file.name };
|
||||
}),
|
||||
} satisfies UploadThingFileRouter ;
|
||||
|
||||
export type FileRouter = typeof fileRouter;
|
||||
|
||||
export const utapi = new UTApi();
|
||||
@@ -139,48 +139,4 @@
|
||||
* {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.cl-button__google {
|
||||
display: none
|
||||
}
|
||||
|
||||
.mde-form-field-fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000;
|
||||
display: flex !important;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mde-form-field-editor-fullscreen.w-md-editor {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 72px) !important;
|
||||
}
|
||||
|
||||
.mde-form-field-editor-fullscreen .w-md-editor-toolbar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mde-form-field-editor-fullscreen .w-md-editor-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: auto !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mde-form-field-editor-fullscreen .w-md-editor-input,
|
||||
.mde-form-field-editor-fullscreen .w-md-editor-area,
|
||||
.mde-form-field-editor-fullscreen .w-md-editor-text,
|
||||
.mde-form-field-editor-fullscreen .w-md-editor-text-pre,
|
||||
.mde-form-field-editor-fullscreen .w-md-editor-text-input,
|
||||
.mde-form-field-editor-fullscreen .w-md-editor-preview {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user