Compare commits
44 Commits
9b48661a6a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 65b9184a22 | |||
| ea7ddb8e51 | |||
| bcefe397ca | |||
| 8ce95f2b5c | |||
| da43b31aa3 | |||
| 538d896b0e | |||
| be6df0c8ad | |||
| daab745c13 | |||
| c527391259 | |||
| 404062904f | |||
| 4e8538552e | |||
| 64bd5c429e | |||
| 52e0a65113 | |||
| 30e3dbb42b | |||
| caa9604704 | |||
| c62ee37538 | |||
| ead9548744 | |||
| c5b3ee3875 | |||
| 2b5c105abb | |||
| e25fc39bac | |||
| e481fa66cd | |||
| 009d2b8d60 | |||
| d567fa3e02 | |||
| 399d78e508 | |||
| bfc2bb1501 | |||
| d7a9e53d9a | |||
| 18abc4b3f7 | |||
| 34dc53a8e9 | |||
| 10b3f989c8 | |||
| 348ed790e2 | |||
| c7de58a4b8 | |||
| a51b313aba | |||
| 363a91dd7d | |||
| dfaba3a24e | |||
| 9c5aec01e0 | |||
| 03399de14f | |||
| 095acc216a | |||
| 288616548a | |||
| 57978d81e1 | |||
| b5291caa6e | |||
| 4293eef824 | |||
| d71bb5d26e | |||
| e0e32d16e2 | |||
| 166ae50c49 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -47,3 +47,4 @@ yarn-error.log*
|
|||||||
# clerk configuration (can include secrets)
|
# clerk configuration (can include secrets)
|
||||||
/.clerk/
|
/.clerk/
|
||||||
.worktrees
|
.worktrees
|
||||||
|
.claudesession
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -19,6 +19,8 @@
|
|||||||
"test": "vitest --typecheck"
|
"test": "vitest --typecheck"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai": "^3.0.41",
|
||||||
|
"@ai-sdk/react": "^3.0.118",
|
||||||
"@clerk/nextjs": "^7.0.2",
|
"@clerk/nextjs": "^7.0.2",
|
||||||
"@electric-sql/pglite": "^0.3.16",
|
"@electric-sql/pglite": "^0.3.16",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
@@ -63,6 +65,9 @@
|
|||||||
"@trpc/react-query": "^11.12.0",
|
"@trpc/react-query": "^11.12.0",
|
||||||
"@trpc/server": "^11.12.0",
|
"@trpc/server": "^11.12.0",
|
||||||
"@uiw/react-md-editor": "^4.0.11",
|
"@uiw/react-md-editor": "^4.0.11",
|
||||||
|
"@uploadthing/react": "^7.3.3",
|
||||||
|
"@vercel/speed-insights": "^2.0.0",
|
||||||
|
"ai": "^6.0.116",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -72,10 +77,13 @@
|
|||||||
"drizzle-zod": "^0.8.3",
|
"drizzle-zod": "^0.8.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"glazejs": "^2.0.1",
|
"glazejs": "^2.0.1",
|
||||||
|
"googleapis": "^171.4.0",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
"gsap": "^3.14.2",
|
"gsap": "^3.14.2",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"next-mdx-remote": "^6.0.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
@@ -88,12 +96,14 @@
|
|||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"shadcn": "^4.0.2",
|
"shadcn": "^4.0.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-motion": "^1.1.1",
|
"tailwindcss-motion": "^1.1.1",
|
||||||
"type-fest": "^5.4.4",
|
"type-fest": "^5.4.4",
|
||||||
|
"uploadthing": "^7.7.4",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
31
src/app/@modal/(.)assistant/_components/ChatModal.tsx
Normal file
31
src/app/@modal/(.)assistant/_components/ChatModal.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog'
|
||||||
|
import ChatInterface from '~/app/chat/_components/ChatInterface'
|
||||||
|
import { useMessages } from '~/app/_providers/MessagesProvider';
|
||||||
|
import { Spinner } from '~/components/ui/spinner';
|
||||||
|
|
||||||
|
export default function ChatModal() {
|
||||||
|
const router = useRouter()
|
||||||
|
const {messages,session,isLoading,error} = useMessages()
|
||||||
|
return (
|
||||||
|
<Dialog modal={true} open onOpenChange={() => router.back()}>
|
||||||
|
<DialogContent className="w-full max-w-full rounded-none sm:max-w-full h-[100svh] lg:max-w-3xl lg:rounded-xl lg:h-[80vh] flex flex-col p-0 gap-0">
|
||||||
|
<DialogHeader className="p-4 border-b shrink-0">
|
||||||
|
<DialogTitle>Talk To My AI-Assistant</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 overflow-hidden min-h-0">
|
||||||
|
{!isLoading &&
|
||||||
|
<ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
|
||||||
|
}
|
||||||
|
{isLoading &&
|
||||||
|
<><Spinner/> Loading Messages...</>
|
||||||
|
}
|
||||||
|
{error &&
|
||||||
|
<div> {error} </div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
src/app/@modal/(.)assistant/page.tsx
Normal file
8
src/app/@modal/(.)assistant/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
'use client'
|
||||||
|
import ChatModal from './_components/ChatModal'
|
||||||
|
|
||||||
|
export default function AssistantModalPage() {
|
||||||
|
return (
|
||||||
|
<ChatModal/>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
src/app/_components/Animated/AnimateIn.tsx
Normal file
67
src/app/_components/Animated/AnimateIn.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { useGSAP } from "@gsap/react";
|
||||||
|
import { useRef, type HTMLAttributes, type ReactNode } from "react";
|
||||||
|
import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
||||||
|
import { SplitText } from "gsap/SplitText";
|
||||||
|
import gsap from 'gsap'
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
const AnimateTextIn = ({
|
||||||
|
children,
|
||||||
|
animation = "type",
|
||||||
|
position = 0,
|
||||||
|
tlId = undefined,
|
||||||
|
speed = 1,
|
||||||
|
scrollOnly = false,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
children: ReactNode,
|
||||||
|
animation?: "type" | "slide",
|
||||||
|
position?: gsap.Position,
|
||||||
|
tlId?: string,
|
||||||
|
scrollOnly?: boolean,
|
||||||
|
speed?: number,
|
||||||
|
className?: HTMLAttributes<HTMLDivElement>['className']
|
||||||
|
}) => {
|
||||||
|
const el = useRef<HTMLDivElement>(null)
|
||||||
|
const gsapContext = useGsapContext();
|
||||||
|
useGSAP(() => {
|
||||||
|
const rect = el.current?.getBoundingClientRect()
|
||||||
|
const scroller = gsapContext?.getScroller()
|
||||||
|
console.log(scroller)
|
||||||
|
let viewportTop = 0
|
||||||
|
let viewportBottom = window.innerHeight
|
||||||
|
if (scroller && scroller instanceof Element) {
|
||||||
|
const scrollerRect = scroller.getBoundingClientRect()
|
||||||
|
viewportTop = scrollerRect.top
|
||||||
|
viewportBottom = scrollerRect.top + scrollerRect.height
|
||||||
|
}
|
||||||
|
const isInView = rect && rect.bottom > viewportTop && rect.top < viewportBottom
|
||||||
|
console.log(isInView)
|
||||||
|
const chars = new SplitText(el.current, { type: 'chars' })
|
||||||
|
gsapContext?.addAnimation(gsap.to(el.current, { opacity: 100, duration: 0 }), 0, tlId)
|
||||||
|
const fromVars = animation === "slide"
|
||||||
|
? { opacity: 0, x: -10, duration: 0.2 * speed, stagger: { each: 0.08 * speed }, ease: 'bounce.inOut', onComplete: () => chars.revert() }
|
||||||
|
: { opacity: 0, duration: 0.01 * speed, stagger: { each: 0.04 * speed }, ease: 'bounce.inOut', onComplete: () => chars.revert() }
|
||||||
|
if (isInView && !scrollOnly) {
|
||||||
|
gsapContext?.addAnimation(gsap.from(chars.chars, fromVars), position, tlId)
|
||||||
|
} else {
|
||||||
|
gsap.from(chars.chars,
|
||||||
|
{
|
||||||
|
...fromVars,
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: el.current,
|
||||||
|
start: 'top bottom',
|
||||||
|
end: 'bottom top',
|
||||||
|
toggleActions: "play reverse play reverse",
|
||||||
|
scroller
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, { dependencies: [] })
|
||||||
|
return (
|
||||||
|
<div ref={el} className={cn(className, "opacity-0")}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnimateTextIn;
|
||||||
22
src/app/_components/Animated/AnimatePopUp.tsx
Normal file
22
src/app/_components/Animated/AnimatePopUp.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { type HTMLAttributes, type ReactNode } from "react";
|
||||||
|
import AnimatedDiv from "./AnimatedDiv";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
const AnimatePopUp = ({
|
||||||
|
children,
|
||||||
|
position,
|
||||||
|
className,
|
||||||
|
duration=1,
|
||||||
|
ease='elastic'
|
||||||
|
}:{
|
||||||
|
children:ReactNode
|
||||||
|
position:gsap.Position,
|
||||||
|
className?:HTMLAttributes<HTMLDivElement>['className']
|
||||||
|
duration?:number,
|
||||||
|
ease?:gsap.EaseString|gsap.EaseFunction
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<AnimatedDiv children={children} position={position} className={cn(className,'h-0 translate-y-[50] overflow-hidden')} height='auto' y={0} overflow='' ease={ease} duration={duration} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnimatePopUp;
|
||||||
346
src/app/_components/Animated/AnimatedBackGroundContainer.tsx
Normal file
346
src/app/_components/Animated/AnimatedBackGroundContainer.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/app/_components/Animated/AnimatedDiv.tsx
Normal file
41
src/app/_components/Animated/AnimatedDiv.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import gsap from "gsap";
|
||||||
|
import { type HTMLAttributes,
|
||||||
|
type ReactNode, useLayoutEffect, useRef } from "react";
|
||||||
|
import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
||||||
|
const AnimatedDiv = (
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
position,
|
||||||
|
className,
|
||||||
|
animationMode='to',
|
||||||
|
...tweenVars
|
||||||
|
}:
|
||||||
|
gsap.TweenVars & {
|
||||||
|
children:ReactNode,
|
||||||
|
position:gsap.Position,
|
||||||
|
animationMode?: 'from'|'to',
|
||||||
|
className?:HTMLAttributes<HTMLDivElement>['className']
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const div = useRef<HTMLDivElement>(null);
|
||||||
|
const gsapContext = useGsapContext()
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
let tween:gsap.core.Tween;
|
||||||
|
switch(animationMode) {
|
||||||
|
case 'from':
|
||||||
|
tween = gsap.from(div.current,tweenVars);
|
||||||
|
break;
|
||||||
|
case 'to':
|
||||||
|
tween = gsap.to(div.current,tweenVars);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
gsapContext?.addAnimation(tween,position)
|
||||||
|
},[])
|
||||||
|
return (
|
||||||
|
<div ref={div} className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnimatedDiv;
|
||||||
22
src/app/_components/Animated/AnimatedPageTitle.tsx
Normal file
22
src/app/_components/Animated/AnimatedPageTitle.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useGSAP } from "@gsap/react"; import { useEffect, useLayoutEffect, useRef,type ReactNode } from "react";
|
||||||
|
import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
||||||
|
import { SplitText } from "gsap/SplitText";
|
||||||
|
import gsap from 'gsap'
|
||||||
|
const AnimatedPageTitle = (
|
||||||
|
{ children, position }: { children: ReactNode, position:gsap.Position }
|
||||||
|
) => {
|
||||||
|
const el = useRef<HTMLHeadingElement>(null)
|
||||||
|
const gsapContext = useGsapContext();
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const split = new SplitText(el.current, { type: "lines,chars", autoSplit:true })
|
||||||
|
gsapContext?.addAnimation(gsap.to(el.current, { opacity: 100 }),position)
|
||||||
|
gsapContext?.addAnimation(gsap.from(split.chars, { id: 'titlesplit',
|
||||||
|
stagger: 0.05, rotate: -90, opacity: 0, x: -10, onComplete: () => {split.revert()}
|
||||||
|
}),'>')
|
||||||
|
},[])
|
||||||
|
return (
|
||||||
|
<h1 className="text-4xl break-keep opacity-0 font-bold text-balance w-full" ref={el}> {children} </h1>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnimatedPageTitle;
|
||||||
22
src/app/_components/ChatFAB.tsx
Normal file
22
src/app/_components/ChatFAB.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
'use client'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { MessageCircle } from 'lucide-react'
|
||||||
|
import { Button } from '~/components/ui/button'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
export default function ChatFAB() {
|
||||||
|
const pathName = usePathname()
|
||||||
|
const isChat = pathName.indexOf('\/chat') > -1
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!isChat &&
|
||||||
|
<div className="fixed bottom-6 right-6 z-50">
|
||||||
|
<Button asChild size="icon" className="h-14 w-14 rounded-full shadow-lg">
|
||||||
|
<Link href="/assistant">
|
||||||
|
<MessageCircle className="h-6 w-6" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { UseTRPCMutationResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs";
|
|
||||||
import { createContext, useContext, type ReactNode } from "react";
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
|
||||||
interface ToString {
|
interface ToString {
|
||||||
@@ -8,7 +7,7 @@ interface ToString {
|
|||||||
|
|
||||||
|
|
||||||
export interface MutationInterface {
|
export interface MutationInterface {
|
||||||
mutate: (params:{id:string}) => void
|
mutate: (params: any) => void
|
||||||
error: ToString | null
|
error: ToString | null
|
||||||
status: "error" | "idle" | "pending" | "success"
|
status: "error" | "idle" | "pending" | "success"
|
||||||
}
|
}
|
||||||
|
|||||||
224
src/app/_components/Form/Fields/InternalLinkTextarea.tsx
Normal file
224
src/app/_components/Form/Fields/InternalLinkTextarea.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { forwardRef, useMemo, useRef, useState, type KeyboardEvent, type TextareaHTMLAttributes } from 'react'
|
||||||
|
|
||||||
|
export type InternalLinkSuggestion = {
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
group: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MdeAutocompleteSuggestion = {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
detail: string
|
||||||
|
group: string
|
||||||
|
trigger: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTOCOMPLETE_CURSOR_MARKER = '{{cursor}}'
|
||||||
|
|
||||||
|
export type AutocompleteTriggerConfig = {
|
||||||
|
trigger: string
|
||||||
|
label: string
|
||||||
|
isQueryValid?: (query: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActiveToken = {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
query: string
|
||||||
|
trigger: MdeAutocompleteSuggestion['trigger']
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTriggerConfigs: AutocompleteTriggerConfig[] = [
|
||||||
|
{
|
||||||
|
trigger: '[[',
|
||||||
|
label: 'Internal links',
|
||||||
|
isQueryValid: (query) => !query.includes(']'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: '<',
|
||||||
|
label: 'MDX components',
|
||||||
|
isQueryValid: (query) => !/[\s>]/.test(query),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function findActiveToken(
|
||||||
|
value: string,
|
||||||
|
cursor: number,
|
||||||
|
triggerConfigs: AutocompleteTriggerConfig[],
|
||||||
|
): ActiveToken | null {
|
||||||
|
const beforeCursor = value.slice(0, cursor)
|
||||||
|
const activeTrigger = triggerConfigs
|
||||||
|
.map((config) => ({ config, start: beforeCursor.lastIndexOf(config.trigger) }))
|
||||||
|
.filter((candidate) => candidate.start !== -1)
|
||||||
|
.sort((a, b) => b.start - a.start)[0]
|
||||||
|
|
||||||
|
if (!activeTrigger) return null
|
||||||
|
|
||||||
|
const query = beforeCursor.slice(activeTrigger.start + activeTrigger.config.trigger.length)
|
||||||
|
if (query.includes('\n')) return null
|
||||||
|
if (activeTrigger.config.isQueryValid && !activeTrigger.config.isQueryValid(query)) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: activeTrigger.start,
|
||||||
|
end: cursor,
|
||||||
|
query,
|
||||||
|
trigger: activeTrigger.config.trigger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linkSuggestionsToAutocomplete(suggestions: InternalLinkSuggestion[]): MdeAutocompleteSuggestion[] {
|
||||||
|
return suggestions.map((suggestion) => ({
|
||||||
|
label: suggestion.label,
|
||||||
|
value: `[${suggestion.label}](${suggestion.href})`,
|
||||||
|
detail: suggestion.href,
|
||||||
|
group: suggestion.group,
|
||||||
|
trigger: '[[',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InternalLinkTextarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||||
|
suggestions: MdeAutocompleteSuggestion[]
|
||||||
|
triggerConfigs?: AutocompleteTriggerConfig[]
|
||||||
|
}>(({ suggestions, triggerConfigs, value, onChange, onKeyDown, ...props }, ref) => {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||||
|
const [token, setToken] = useState<ActiveToken | null>(null)
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
|
||||||
|
function setRefs(element: HTMLTextAreaElement | null) {
|
||||||
|
textareaRef.current = element
|
||||||
|
if (typeof ref === 'function') ref(element)
|
||||||
|
else if (ref) ref.current = element
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedTriggerConfigs = useMemo(() => {
|
||||||
|
const configured = triggerConfigs?.length ? triggerConfigs : defaultTriggerConfigs
|
||||||
|
const merged = new Map(configured.map((config) => [config.trigger, config]))
|
||||||
|
|
||||||
|
for (const suggestion of suggestions) {
|
||||||
|
if (!merged.has(suggestion.trigger)) {
|
||||||
|
merged.set(suggestion.trigger, {
|
||||||
|
trigger: suggestion.trigger,
|
||||||
|
label: suggestion.trigger,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(merged.values()).sort((a, b) => b.trigger.length - a.trigger.length)
|
||||||
|
}, [suggestions, triggerConfigs])
|
||||||
|
|
||||||
|
const triggerLabels = useMemo(
|
||||||
|
() => new Map(resolvedTriggerConfigs.map((config) => [config.trigger, config.label])),
|
||||||
|
[resolvedTriggerConfigs],
|
||||||
|
)
|
||||||
|
|
||||||
|
const matches = useMemo(() => {
|
||||||
|
if (!token) return []
|
||||||
|
const query = token.query.toLowerCase()
|
||||||
|
return suggestions
|
||||||
|
.filter((suggestion) => suggestion.trigger === token.trigger)
|
||||||
|
.filter((suggestion) => {
|
||||||
|
const haystack = `${suggestion.group} ${suggestion.label} ${suggestion.detail}`.toLowerCase()
|
||||||
|
return haystack.includes(query)
|
||||||
|
})
|
||||||
|
.slice(0, 8)
|
||||||
|
}, [suggestions, token])
|
||||||
|
|
||||||
|
function updateToken(textarea: HTMLTextAreaElement) {
|
||||||
|
const nextToken = findActiveToken(textarea.value, textarea.selectionStart, resolvedTriggerConfigs)
|
||||||
|
setToken(nextToken)
|
||||||
|
setSelectedIndex(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertSuggestion(textarea: HTMLTextAreaElement, suggestion: MdeAutocompleteSuggestion) {
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
const markerIndex = suggestion.value.indexOf(AUTOCOMPLETE_CURSOR_MARKER)
|
||||||
|
const insertedValue = markerIndex === -1
|
||||||
|
? suggestion.value
|
||||||
|
: suggestion.value.replace(AUTOCOMPLETE_CURSOR_MARKER, '')
|
||||||
|
const cursor = token.start + (markerIndex === -1 ? insertedValue.length : markerIndex)
|
||||||
|
const nextValue = `${textarea.value.slice(0, token.start)}${insertedValue}${textarea.value.slice(token.end)}`
|
||||||
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
|
||||||
|
nativeInputValueSetter?.call(textarea, nextValue)
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
textarea.setSelectionRange(cursor, cursor)
|
||||||
|
setToken(null)
|
||||||
|
setSelectedIndex(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if (token && matches.length > 0) {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
setSelectedIndex((index) => (index + 1) % matches.length)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
setSelectedIndex((index) => (index - 1 + matches.length) % matches.length)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||||
|
event.preventDefault()
|
||||||
|
const suggestion = matches[selectedIndex]
|
||||||
|
if (suggestion) insertSuggestion(event.currentTarget, suggestion)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
setToken(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown?.(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
{...props}
|
||||||
|
ref={setRefs}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
onChange?.(event)
|
||||||
|
updateToken(event.currentTarget)
|
||||||
|
}}
|
||||||
|
onClick={(event) => updateToken(event.currentTarget)}
|
||||||
|
onKeyUp={(event) => {
|
||||||
|
if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(event.key)) return
|
||||||
|
updateToken(event.currentTarget)
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
{token && matches.length > 0 && (
|
||||||
|
<div className='bg-popover text-popover-foreground absolute left-3 top-12 z-50 w-80 overflow-hidden rounded-md border shadow-md'>
|
||||||
|
<div className='border-b px-3 py-2 text-xs text-muted-foreground'>
|
||||||
|
{triggerLabels.get(token.trigger) ?? token.trigger} for {token.trigger}{token.query}
|
||||||
|
</div>
|
||||||
|
<div className='max-h-64 overflow-y-auto py-1'>
|
||||||
|
{matches.map((suggestion, index) => (
|
||||||
|
<button
|
||||||
|
key={`${suggestion.trigger}:${suggestion.group}:${suggestion.label}`}
|
||||||
|
type='button'
|
||||||
|
className={`flex w-full flex-col px-3 py-2 text-left text-sm ${index === selectedIndex ? 'bg-muted' : ''}`}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (textareaRef.current) insertSuggestion(textareaRef.current, suggestion)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className='font-medium'>{suggestion.label}</span>
|
||||||
|
<span className='text-xs text-muted-foreground'>{suggestion.group} - {suggestion.detail}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
InternalLinkTextarea.displayName = 'InternalLinkTextarea'
|
||||||
@@ -1,25 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import MDEditor from "@uiw/react-md-editor";
|
import MDEditor from "@uiw/react-md-editor";
|
||||||
|
import { Maximize2, Minimize2 } from "lucide-react";
|
||||||
|
import { useEffect, useState, type ReactElement, type TextareaHTMLAttributes } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||||
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
|
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
|
||||||
export default function MdeFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, dataColorMode: "dark"|"light" }) {
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import {
|
||||||
|
InternalLinkTextarea,
|
||||||
|
type AutocompleteTriggerConfig,
|
||||||
|
type MdeAutocompleteSuggestion,
|
||||||
|
} from "./InternalLinkTextarea";
|
||||||
|
|
||||||
|
export default function MdeFormField<T extends FieldValues>(params: {
|
||||||
|
control: Control<T>,
|
||||||
|
name: Path<T>,
|
||||||
|
label: string,
|
||||||
|
dataColorMode: "dark"|"light",
|
||||||
|
autocompleteSuggestions?: MdeAutocompleteSuggestion[],
|
||||||
|
triggerConfigs?: AutocompleteTriggerConfig[],
|
||||||
|
renderPreview?: (source: string) => ReactElement,
|
||||||
|
}) {
|
||||||
|
const [fullscreen, setFullscreen] = useState(false)
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fullscreen) return
|
||||||
|
|
||||||
|
const originalOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = "hidden"
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = originalOverflow
|
||||||
|
}
|
||||||
|
}, [fullscreen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField
|
<FormField
|
||||||
control={params.control}
|
control={params.control}
|
||||||
name={params.name}
|
name={params.name}
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem>
|
const editor = (
|
||||||
<FormLabel>
|
<FormItem className={cn(fullscreen && "mde-form-field-fullscreen")}>
|
||||||
Description
|
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||||
</FormLabel>
|
<FormLabel>
|
||||||
<FormControl>
|
{params.label}
|
||||||
<MDEditor
|
</FormLabel>
|
||||||
value={field.value ? field.value : ""}
|
<Button
|
||||||
onChange={field.onChange}
|
type="button"
|
||||||
data-color-mode={params.dataColorMode}
|
variant="outline"
|
||||||
/>
|
size="icon-sm"
|
||||||
</FormControl>
|
aria-label={fullscreen ? "Exit fullscreen editor" : "Open fullscreen editor"}
|
||||||
</FormItem>
|
onClick={() => setFullscreen((value) => !value)}
|
||||||
)}
|
>
|
||||||
|
{fullscreen ? <Minimize2 /> : <Maximize2 />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormControl className={cn(fullscreen && "min-h-0 flex-1")}>
|
||||||
|
<MDEditor
|
||||||
|
className={cn(fullscreen && "mde-form-field-editor-fullscreen min-h-0 flex-1")}
|
||||||
|
height={fullscreen ? "calc(100vh - 72px)" : undefined}
|
||||||
|
visibleDragbar={!fullscreen}
|
||||||
|
value={field.value ? field.value : ""}
|
||||||
|
onChange={field.onChange}
|
||||||
|
data-color-mode={params.dataColorMode}
|
||||||
|
commandsFilter={(command) => command.name === "fullscreen" ? false : command}
|
||||||
|
components={{
|
||||||
|
textarea: (props) => (
|
||||||
|
<InternalLinkTextarea
|
||||||
|
{...(props as TextareaHTMLAttributes<HTMLTextAreaElement>)}
|
||||||
|
suggestions={params.autocompleteSuggestions ?? []}
|
||||||
|
triggerConfigs={params.triggerConfigs}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
preview: params.renderPreview
|
||||||
|
? (source) => params.renderPreview?.(source) ?? <></>
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (fullscreen && mounted) {
|
||||||
|
return createPortal(editor, document.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,18 @@ import { Moon, Sun } from "lucide-react"
|
|||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
type Props = {activeTheme:string|undefined}
|
type Props = {activeTheme:string|undefined}
|
||||||
const ThemeIcon = (props:Props) => {
|
const ThemeIcon = (props:Props) => {
|
||||||
if (props.activeTheme == "dark") {
|
return (
|
||||||
return (<Sun/>)
|
<>
|
||||||
} else {
|
{props.activeTheme && props.activeTheme == 'dark' &&
|
||||||
return (<Moon/>)
|
<Sun/>
|
||||||
}
|
}
|
||||||
|
{props.activeTheme && props.activeTheme == 'light' &&
|
||||||
|
<Moon/>
|
||||||
|
}
|
||||||
|
{!props.activeTheme &&
|
||||||
|
<Sun/>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
export default ThemeIcon;
|
export default ThemeIcon;
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import ThemeIcon from "./ThemeIcon"
|
|||||||
|
|
||||||
export function ThemeSwitch() {
|
export function ThemeSwitch() {
|
||||||
const { setTheme, theme } = useTheme()
|
const { setTheme, theme } = useTheme()
|
||||||
|
if (!theme) {
|
||||||
|
setTheme('dark')
|
||||||
|
}
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setTheme(theme == "dark" ? "light" : "dark")
|
setTheme(theme == "dark" ? "light" : "dark")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ThemeSwitch } from "./ThemeSwitch"
|
|||||||
|
|
||||||
export default function TopNav() {
|
export default function TopNav() {
|
||||||
return (
|
return (
|
||||||
<div className="fixed lg:w-full right-0 z-50 lg:bg-background">
|
<div className="fixed backdrop-blur-md lg:w-full right-0 z-50">
|
||||||
<nav className="flex flex-col-reverse lg:flex-row flex-wrap w-20 lg:w-full outline-1 lg:h-10 h-full">
|
<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">
|
<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">
|
<Button className="flex h-10 lg:h-full w-full lg:w-20" asChild variant="outline">
|
||||||
@@ -19,6 +19,14 @@ export default function TopNav() {
|
|||||||
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
|
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
|
||||||
<Link href={"/projects"}> Projects </Link>
|
<Link href={"/projects"}> Projects </Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
|
||||||
|
<Link href={"/music"}> Music </Link>
|
||||||
|
</Button>
|
||||||
|
<Show when="signed-in">
|
||||||
|
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
|
||||||
|
<Link href="/chat"> Chat </Link>
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col-reverse flex-wrap lg:h-full w-20 lg:w-fit lg:flex-row lg:ml-auto">
|
<div className="flex flex-col-reverse flex-wrap lg:h-full w-20 lg:w-fit lg:flex-row lg:ml-auto">
|
||||||
<AdminWrap>
|
<AdminWrap>
|
||||||
@@ -44,7 +52,13 @@ export default function TopNav() {
|
|||||||
<Show when="signed-in">
|
<Show when="signed-in">
|
||||||
<Button asChild className="flex h-10 lg:h-full cursor-pointer lg:w-20 content-center" variant={"outline"}>
|
<Button asChild className="flex h-10 lg:h-full cursor-pointer lg:w-20 content-center" variant={"outline"}>
|
||||||
<div>
|
<div>
|
||||||
<UserButton />
|
<UserButton
|
||||||
|
userProfileProps={{
|
||||||
|
additionalOAuthScopes: {
|
||||||
|
google: ['https://www.googleapis.com/auth/calendar'],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,18 +1,98 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useGSAP } from '@gsap/react'
|
import { useGSAP } from '@gsap/react'
|
||||||
import gsap from 'gsap'
|
import gsap from 'gsap'
|
||||||
import { createContext, useContext, type ReactNode } from 'react'
|
import { SplitText } from 'gsap/SplitText'
|
||||||
|
import { ScrollTrigger, GSDevTools } from 'gsap/all'
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'
|
||||||
|
|
||||||
gsap.registerPlugin(useGSAP)
|
gsap.registerPlugin(useGSAP)
|
||||||
const GsapContext = createContext<typeof globalThis.gsap | null>(null)
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
gsap.registerPlugin(SplitText)
|
||||||
|
gsap.registerPlugin(GSDevTools)
|
||||||
|
const GsapContext = createContext<{
|
||||||
|
addAnimation: (
|
||||||
|
animation: gsap.core.TimelineChild,
|
||||||
|
position: gsap.Position
|
||||||
|
) => void,
|
||||||
|
resetTimeline: () => void,
|
||||||
|
resumeTimeline: () => void,
|
||||||
|
getScroller: () => Element | Window | null
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
export function useGsapContext() {
|
export function useGsapContext() {
|
||||||
return useContext(GsapContext)
|
return useContext(GsapContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GsapProvider({children}:{children:ReactNode}) {
|
export const useTimeLine = (dep:any,all?:boolean) => {
|
||||||
|
const gsapContext = useGsapContext()
|
||||||
|
useEffect(() => {
|
||||||
|
if (dep instanceof Array && all) {
|
||||||
|
let acc = true;
|
||||||
|
let allDepsSatisfied = dep.reduce((p,c) => c !== undefined && p ,acc )
|
||||||
|
if (allDepsSatisfied) {
|
||||||
|
gsapContext?.resumeTimeline()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (dep) {
|
||||||
|
gsapContext?.resumeTimeline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},[dep])
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
return () => {
|
||||||
|
gsapContext?.resetTimeline()
|
||||||
|
}
|
||||||
|
},[])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GsapProvider({ children }: { children: ReactNode }) {
|
||||||
|
const tl = useRef<gsap.core.Timeline | null>(null)
|
||||||
|
const scrollerRef = useRef<Element | Window | null>(null)
|
||||||
|
const getScroller = useCallback(() => {
|
||||||
|
// const cached = scrollerRef.current
|
||||||
|
// if (!cached || (cached instanceof Element && !document.contains(cached))) {
|
||||||
|
let scrollers = document.querySelectorAll('[data-slot="scroll-area-viewport"]')
|
||||||
|
if (scrollers.length < 1) {
|
||||||
|
scrollerRef.current = window
|
||||||
|
} else {
|
||||||
|
let scrollerArray = Array.from(scrollers.values()).sort((a,b) => {
|
||||||
|
const s1 = a as HTMLDivElement;
|
||||||
|
const s2 = b as HTMLDivElement;
|
||||||
|
// using bitwise not (~~) to coerce NaN values to 0
|
||||||
|
const aPriority = ~~Number(s1.dataset?.scrollerPriority)
|
||||||
|
const bPriority = ~~Number(s2.dataset?.scrollerPriority)
|
||||||
|
return aPriority - bPriority;
|
||||||
|
})
|
||||||
|
let prioScroller = scrollerArray.pop();
|
||||||
|
scrollerRef.current = prioScroller || window;
|
||||||
|
}
|
||||||
|
|
||||||
|
// }
|
||||||
|
return scrollerRef.current
|
||||||
|
}, [])
|
||||||
|
useGSAP(() => {
|
||||||
|
if (!tl.current) {
|
||||||
|
tl.current = gsap.timeline({ paused: true })
|
||||||
|
}
|
||||||
|
return () => { console.log("gsap cleanup") }
|
||||||
|
})
|
||||||
|
|
||||||
|
const addAnimation = useCallback((animation: gsap.core.TimelineChild, position: gsap.Position) => {
|
||||||
|
console.log("add animation to:", position, tl.current !== undefined)
|
||||||
|
tl.current?.add(animation, position);
|
||||||
|
},[])
|
||||||
|
const resetTimeline = useCallback(() => {
|
||||||
|
tl.current?.kill()
|
||||||
|
tl.current?.revert()
|
||||||
|
ScrollTrigger.getAll().forEach(st => st.kill())
|
||||||
|
tl.current = gsap.timeline({paused:true})
|
||||||
|
},[])
|
||||||
|
const resumeTimeline = useCallback(() => {
|
||||||
|
console.log("resuming timeline:",tl.current)
|
||||||
|
tl.current?.resume()
|
||||||
|
},[])
|
||||||
return (
|
return (
|
||||||
<GsapContext.Provider value={gsap}>
|
<GsapContext.Provider value={{ addAnimation, resetTimeline, resumeTimeline, getScroller }}>
|
||||||
{children}
|
{children}
|
||||||
</GsapContext.Provider>
|
</GsapContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
95
src/app/_providers/MessagesProvider.tsx
Normal file
95
src/app/_providers/MessagesProvider.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
'use client'
|
||||||
|
import type { inferRouterOutputs } from '@trpc/server';
|
||||||
|
import { useUser } from '@clerk/nextjs'
|
||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { trpc } from '~/app/_trpc/Client'
|
||||||
|
import { type ChatRouter } from '~/server/routers/chat'
|
||||||
|
const MessageContext = createContext<{
|
||||||
|
session?: inferRouterOutputs<ChatRouter>['getSession']
|
||||||
|
messages?: inferRouterOutputs<ChatRouter>['getMessages']
|
||||||
|
refetchMessages: () => void
|
||||||
|
clearChat: (callback?: () => void) => void
|
||||||
|
error: string|null
|
||||||
|
isLoading: boolean
|
||||||
|
clearingChat: boolean
|
||||||
|
clearedChat: boolean
|
||||||
|
}>({
|
||||||
|
session: undefined,
|
||||||
|
messages: undefined,
|
||||||
|
refetchMessages: () => undefined,
|
||||||
|
clearChat: () => undefined,
|
||||||
|
error: null,
|
||||||
|
isLoading: true,
|
||||||
|
clearingChat: false,
|
||||||
|
clearedChat: false
|
||||||
|
})
|
||||||
|
export const useMessages = () => useContext(MessageContext)
|
||||||
|
export const MessagesProvider = ({children}:{children:ReactNode}) => {
|
||||||
|
const [error,setError] = useState<string|null>(null)
|
||||||
|
const [isLoading,setIsLoading] = useState<boolean>(true)
|
||||||
|
const { isLoaded, isSignedIn } = useUser()
|
||||||
|
const { data: session,error:sessionError,isLoading:sessionLoading} = trpc.chat.getSession.useQuery(undefined, {
|
||||||
|
enabled: isSignedIn === true,
|
||||||
|
})
|
||||||
|
const { data: messages, refetch, error:messageError, isLoading:messagesLoading } = trpc.chat.getMessages.useQuery(session?.id ? session.id : "", {
|
||||||
|
enabled: isSignedIn === true && session?.id != undefined,
|
||||||
|
})
|
||||||
|
const { mutate ,isPending:clearingChat,isSuccess:clearedChat } = trpc.chat.clearChat.useMutation()
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const refetchMessages = () => {
|
||||||
|
if (!isSignedIn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
utils.chat.getMessages.invalidate()
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
const clearChat = (callback?: () => void) => {
|
||||||
|
if (!isSignedIn) {
|
||||||
|
if (callback) {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mutate(undefined,{onSuccess: () => {
|
||||||
|
if (callback) {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
utils.chat.getMessages.invalidate()
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSignedIn !== true) {
|
||||||
|
setError(null)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messageError && setError(messageError.message)
|
||||||
|
sessionError && setError(sessionError.message)
|
||||||
|
},[messageError,sessionError,isSignedIn])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoaded) {
|
||||||
|
setIsLoading(true)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSignedIn !== true) {
|
||||||
|
setIsLoading(false)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(sessionLoading || messagesLoading)
|
||||||
|
},[isLoaded,isSignedIn,sessionLoading,messagesLoading])
|
||||||
|
return (
|
||||||
|
<MessageContext.Provider value={
|
||||||
|
{
|
||||||
|
session: isSignedIn === true ? session : undefined,
|
||||||
|
messages: isSignedIn === true ? messages : undefined,
|
||||||
|
refetchMessages,
|
||||||
|
clearChat,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
clearingChat,
|
||||||
|
clearedChat
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
{children}
|
||||||
|
</MessageContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,23 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import * as React from "react"
|
|
||||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||||
|
|
||||||
export default function ThemeProvider({children}:{children: React.ReactNode}) {
|
export default function ThemeProvider({children}:{children: React.ReactNode}) {
|
||||||
const [mounted,setMounted] = React.useState(false)
|
return (
|
||||||
React.useEffect(() => {
|
<NextThemesProvider disableTransitionOnChange attribute="class" defaultTheme="dark">
|
||||||
setMounted(true)
|
{children}
|
||||||
})
|
</NextThemesProvider>
|
||||||
if (mounted) {
|
)
|
||||||
return (
|
|
||||||
<NextThemesProvider disableTransitionOnChange nonce="test" attribute="class" defaultTheme="dark">
|
|
||||||
{children}
|
|
||||||
</NextThemesProvider>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{children}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ import { env } from "~/env"
|
|||||||
|
|
||||||
export async function isAdmin() {
|
export async function isAdmin() {
|
||||||
const userid = (await auth()).userId
|
const userid = (await auth()).userId
|
||||||
|
console.log(userid)
|
||||||
return (userid == env.ADMIN_USER_CLERK_ID)
|
return (userid == env.ADMIN_USER_CLERK_ID)
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/app/actions/currentTime.ts
Normal file
8
src/app/actions/currentTime.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export default function currentTime() {
|
||||||
|
let now = Date.now();
|
||||||
|
console.log(now);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
time: now
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/app/actions/scheduleMeeting.ts
Normal file
77
src/app/actions/scheduleMeeting.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use server'
|
||||||
|
import { clerkClient, auth } from '@clerk/nextjs/server'
|
||||||
|
import { google } from 'googleapis'
|
||||||
|
import { env } from '~/env'
|
||||||
|
|
||||||
|
export async function scheduleMeeting({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
dateTime,
|
||||||
|
durationMinutes,
|
||||||
|
attendeeEmail,
|
||||||
|
attendeeName,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
dateTime: string
|
||||||
|
durationMinutes: number
|
||||||
|
attendeeEmail?: string
|
||||||
|
attendeeName?: string
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const clerk = await clerkClient()
|
||||||
|
const userAuth = await auth()
|
||||||
|
const user = await clerk.users.getUser(userAuth.userId?userAuth.userId:"")
|
||||||
|
// Get admin's Google OAuth token to create the event on Gregor's calendar
|
||||||
|
const adminTokenResponse = await clerk.users.getUserOauthAccessToken(
|
||||||
|
env.ADMIN_USER_CLERK_ID,
|
||||||
|
'oauth_google',
|
||||||
|
)
|
||||||
|
const adminToken = adminTokenResponse.data[0]
|
||||||
|
|
||||||
|
if (!adminToken?.token) {
|
||||||
|
return { success: false, error: 'Admin Google Calendar not connected. Ensure the admin account is linked with Google and has calendar scope enabled.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve visitor's Google email for the invite
|
||||||
|
let visitorEmail: string | undefined = attendeeEmail
|
||||||
|
if (!visitorEmail) {
|
||||||
|
visitorEmail = user?.emailAddresses.at(0)?.emailAddress ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const oAuth2Client = new google.auth.OAuth2()
|
||||||
|
oAuth2Client.setCredentials({ access_token: adminToken.token })
|
||||||
|
const calendar = google.calendar({ version: 'v3', auth: oAuth2Client })
|
||||||
|
|
||||||
|
const startTime = new Date(dateTime)
|
||||||
|
const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000)
|
||||||
|
|
||||||
|
const attendees: { email: string; displayName?: string }[] = []
|
||||||
|
if (visitorEmail) {
|
||||||
|
attendees.push({ email: visitorEmail, displayName: attendeeName })
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await calendar.events.insert({
|
||||||
|
calendarId: 'primary',
|
||||||
|
sendUpdates: 'all',
|
||||||
|
requestBody: {
|
||||||
|
summary: title,
|
||||||
|
description,
|
||||||
|
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' },
|
||||||
|
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
|
||||||
|
attendees,
|
||||||
|
},
|
||||||
|
sendNotifications: true
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
eventId: event.data.id,
|
||||||
|
htmlLink: event.data.htmlLink,
|
||||||
|
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}${visitorEmail ? `. Invite sent to ${visitorEmail}.` : '.'}`,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to schedule meeting:', error)
|
||||||
|
return { success: false, error: 'Failed to schedule meeting. Please try again.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger } from "~/components/ui/sidebar";
|
import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger } from "~/components/ui/sidebar";
|
||||||
import SimpleSidebarGroup from "~/components/ui/simple-sidebar-group";
|
import SimpleSidebarGroup from "~/components/ui/simple-sidebar-group";
|
||||||
|
|
||||||
export default async function AdminSideBar() {
|
export default function AdminSideBar() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarProvider>
|
<Sidebar variant="floating" className="h-[96%] mt-10 z-[51]">
|
||||||
<Sidebar className="z-[51]">
|
|
||||||
<SidebarTrigger className="absolute z-[52] left-65 top-100" />
|
<SidebarTrigger className="absolute z-[52] left-65 top-100" />
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
|
<ScrollArea>
|
||||||
<SimpleSidebarGroup lable="CV">
|
<SimpleSidebarGroup lable="CV">
|
||||||
<Link href={"/admin/cv/category/create"}> Create Category </Link>
|
<Link href={"/admin/cv/category/create"}> Create Category </Link>
|
||||||
<Link href={"/admin/cv/entry/create"}> Create Entry </Link>
|
<Link href={"/admin/cv/entry/create"}> Create Entry </Link>
|
||||||
@@ -20,12 +21,19 @@ export default async function AdminSideBar() {
|
|||||||
<Link href={"/admin/project/techStack/create"}> Create Stack </Link>
|
<Link href={"/admin/project/techStack/create"}> Create Stack </Link>
|
||||||
<Link href={"/admin/project/list"}> Project List </Link>
|
<Link href={"/admin/project/list"}> Project List </Link>
|
||||||
</SimpleSidebarGroup>
|
</SimpleSidebarGroup>
|
||||||
<SimpleSidebarGroup lable="Blog">
|
<SimpleSidebarGroup lable="Music">
|
||||||
<Link href={"/"}> Some Blog Action </Link>
|
<Link href={"/admin/music"}> Manage Music </Link>
|
||||||
</SimpleSidebarGroup>
|
</SimpleSidebarGroup>
|
||||||
|
<SimpleSidebarGroup lable="Blog">
|
||||||
|
<Link href={"/admin/blog/create"}> Create Post </Link>
|
||||||
|
<Link href={"/admin/blog/list"}> Post List </Link>
|
||||||
|
</SimpleSidebarGroup>
|
||||||
|
<SimpleSidebarGroup lable="Chat">
|
||||||
|
<Link href={"/admin/chat"}> System Prompt </Link>
|
||||||
|
</SimpleSidebarGroup>
|
||||||
|
</ScrollArea>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</SidebarProvider>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/app/admin/blog/[slug]/page.tsx
Normal file
11
src/app/admin/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
'use client'
|
||||||
|
import { trpc } from '~/app/_trpc/Client'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const { slug } = useParams<{ slug: string }>()
|
||||||
|
const { data } = trpc.blog.bySlug.useQuery(slug)
|
||||||
|
if (data) return <CreateUpdateBlogForm entity={data} />
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
58
src/app/admin/blog/_components/BlogMdxEditorPreview.tsx
Normal file
58
src/app/admin/blog/_components/BlogMdxEditorPreview.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { MDXRemote } from 'next-mdx-remote'
|
||||||
|
import { serialize } from 'next-mdx-remote/serialize'
|
||||||
|
import type { MDXRemoteSerializeResult } from 'next-mdx-remote'
|
||||||
|
import { mdxComponents } from '~/app/blog/_components/mdx-components'
|
||||||
|
|
||||||
|
export default function BlogMdxEditorPreview(params: { source: string }) {
|
||||||
|
const [compiled, setCompiled] = useState<MDXRemoteSerializeResult | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
void serialize(params.source, {
|
||||||
|
parseFrontmatter: false,
|
||||||
|
mdxOptions: {
|
||||||
|
remarkPlugins: [],
|
||||||
|
rehypePlugins: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setCompiled(result)
|
||||||
|
setError(null)
|
||||||
|
})
|
||||||
|
.catch((nextError: unknown) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setCompiled(null)
|
||||||
|
setError(nextError instanceof Error ? nextError.message : 'Failed to compile MDX preview')
|
||||||
|
})
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [params.source])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className='rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive'>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!compiled) {
|
||||||
|
return <div className='text-muted-foreground p-4 text-sm'>Rendering preview...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className='prose dark:prose-invert max-w-none'>
|
||||||
|
<MDXRemote {...compiled} components={mdxComponents} />
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
237
src/app/admin/blog/_components/CreateUpdateForm.tsx
Normal file
237
src/app/admin/blog/_components/CreateUpdateForm.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
'use client'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { trpc } from '~/app/_trpc/Client'
|
||||||
|
import { FormScaffold } from '~/app/_components/Form/Components'
|
||||||
|
import { FormMutationContextProvider } from '~/app/_components/Form/Components/MutationProvider'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { TextInputFormField, MdeFormField } from '~/app/_components/Form/Fields'
|
||||||
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import type { RouterOutputs } from '~/server/routers/_app'
|
||||||
|
import MdxComponentReference from './MdxComponentReference'
|
||||||
|
import BlogMdxEditorPreview from './BlogMdxEditorPreview'
|
||||||
|
import {
|
||||||
|
AUTOCOMPLETE_CURSOR_MARKER,
|
||||||
|
linkSuggestionsToAutocomplete,
|
||||||
|
type AutocompleteTriggerConfig,
|
||||||
|
type InternalLinkSuggestion,
|
||||||
|
type MdeAutocompleteSuggestion,
|
||||||
|
} from '~/app/_components/Form/Fields/InternalLinkTextarea'
|
||||||
|
|
||||||
|
type BlogPost = RouterOutputs['blog']['bySlug']
|
||||||
|
|
||||||
|
const blogPostSchema = z.object({
|
||||||
|
slug: z.string().min(1),
|
||||||
|
title: z.string().min(1),
|
||||||
|
date: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
tags: z.string().optional(),
|
||||||
|
content: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function parseTags(value: string | undefined): string[] {
|
||||||
|
return value?.split(',').map((tag) => tag.trim()).filter(Boolean) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function internalLinkSuggestions(params: {
|
||||||
|
posts?: RouterOutputs['blog']['list'],
|
||||||
|
projects?: RouterOutputs['projectv2']['listWithStack'],
|
||||||
|
}): InternalLinkSuggestion[] {
|
||||||
|
const postLinks = params.posts?.map((post) => ({
|
||||||
|
label: post.title,
|
||||||
|
href: `/blog/${post.slug}`,
|
||||||
|
group: 'Blog',
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
|
const projectLinks = params.projects?.map((project) => ({
|
||||||
|
label: project.title,
|
||||||
|
href: `/projects#${project.id}`,
|
||||||
|
group: 'Project',
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
|
return [...postLinks, ...projectLinks]
|
||||||
|
}
|
||||||
|
|
||||||
|
const blogAutocompleteSuggestions: MdeAutocompleteSuggestion[] = [
|
||||||
|
{
|
||||||
|
label: 'Lead',
|
||||||
|
value: `<Lead>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Lead>`,
|
||||||
|
detail: 'Intro paragraph with larger muted text.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Callout note',
|
||||||
|
value: `<Callout title="Heads up" variant="note">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
|
||||||
|
detail: 'Highlighted note block.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Callout tip',
|
||||||
|
value: `<Callout title="Tip" variant="tip">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
|
||||||
|
detail: 'Highlighted tip block.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Callout warning',
|
||||||
|
value: `<Callout title="Careful" variant="warning">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
|
||||||
|
detail: 'Highlighted warning block.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'ButtonLink',
|
||||||
|
value: `<ButtonLink href="${AUTOCOMPLETE_CURSOR_MARKER}">\nView projects\n</ButtonLink>`,
|
||||||
|
detail: 'Button-styled internal or external link.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Figure',
|
||||||
|
value: `<Figure\n src="${AUTOCOMPLETE_CURSOR_MARKER}"\n alt="Describe the image"\n caption="Optional caption"\n/>`,
|
||||||
|
detail: 'Image with optional caption.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'PullQuote',
|
||||||
|
value: `<PullQuote>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</PullQuote>`,
|
||||||
|
detail: 'Large emphasized quote.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'TagList',
|
||||||
|
value: `<TagList tags={[${AUTOCOMPLETE_CURSOR_MARKER}]} />`,
|
||||||
|
detail: 'Inline list of tag badges.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Badge',
|
||||||
|
value: `<Badge variant="outline">${AUTOCOMPLETE_CURSOR_MARKER}</Badge>`,
|
||||||
|
detail: 'Small inline label.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Image',
|
||||||
|
value: ``,
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
src/app/admin/blog/_components/MdxComponentReference.tsx
Normal file
91
src/app/admin/blog/_components/MdxComponentReference.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
|
||||||
|
|
||||||
|
const examples = [
|
||||||
|
{
|
||||||
|
name: "Lead",
|
||||||
|
description: "Intro paragraph with larger muted text.",
|
||||||
|
code: `<Lead>
|
||||||
|
Short opening summary for the post.
|
||||||
|
</Lead>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Callout",
|
||||||
|
description: "Highlighted note, tip, or warning block.",
|
||||||
|
code: `<Callout title="Heads up" variant="note">
|
||||||
|
Important context for readers.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<Callout title="Tip" variant="tip">
|
||||||
|
A practical recommendation.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<Callout title="Careful" variant="warning">
|
||||||
|
A caveat or tradeoff.
|
||||||
|
</Callout>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ButtonLink",
|
||||||
|
description: "Button-styled internal or external link.",
|
||||||
|
code: `<ButtonLink href="/projects">
|
||||||
|
View projects
|
||||||
|
</ButtonLink>
|
||||||
|
|
||||||
|
<ButtonLink href="https://example.com" variant="outline">
|
||||||
|
External resource
|
||||||
|
</ButtonLink>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Figure",
|
||||||
|
description: "Image with optional caption.",
|
||||||
|
code: `<Figure
|
||||||
|
src="https://example.com/image.jpg"
|
||||||
|
alt="Describe the image"
|
||||||
|
caption="Optional caption"
|
||||||
|
/>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PullQuote",
|
||||||
|
description: "Large emphasized quote or takeaway.",
|
||||||
|
code: `<PullQuote>
|
||||||
|
A highlighted quote or strong takeaway.
|
||||||
|
</PullQuote>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TagList",
|
||||||
|
description: "Inline list of tag badges inside the post body.",
|
||||||
|
code: `<TagList tags={["nextjs", "mdx", "uploadthing"]} />`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Badge",
|
||||||
|
description: "Small inline label.",
|
||||||
|
code: `<Badge variant="outline">Next.js</Badge>`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MdxComponentReference() {
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border p-4">
|
||||||
|
<h2 className="text-base font-semibold">MDX Components</h2>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
Components available inside blog post content. Type <code className="rounded bg-muted px-1">[[</code> for internal links or <code className="rounded bg-muted px-1"><</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/app/admin/blog/create/page.tsx
Normal file
6
src/app/admin/blog/create/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
'use client'
|
||||||
|
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <CreateUpdateBlogForm />
|
||||||
|
}
|
||||||
61
src/app/admin/blog/list/page.tsx
Normal file
61
src/app/admin/blog/list/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '~/app/_trpc/Client'
|
||||||
|
import * as Card from '~/components/ui/card'
|
||||||
|
import { CollapsibleForm } from '~/app/_components/Form/Components'
|
||||||
|
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
|
||||||
|
import { Badge } from '~/components/ui/badge'
|
||||||
|
import { Button } from '~/components/ui/button'
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function BlogListPage() {
|
||||||
|
const posts = trpc.blog.list.useQuery(undefined, { refetchInterval: 5000 })
|
||||||
|
const syncMutation = trpc.blog.syncFromUploadThing.useMutation({
|
||||||
|
onSuccess: () => posts.refetch(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-5/6 lg:w-1/2 flex flex-col gap-3'>
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
onClick={() => syncMutation.mutate(undefined)}
|
||||||
|
disabled={syncMutation.status === 'pending'}
|
||||||
|
>
|
||||||
|
<RefreshCw />
|
||||||
|
Sync
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{syncMutation.data && (
|
||||||
|
<p className='text-sm text-muted-foreground'>
|
||||||
|
Synced {syncMutation.data.created} created, {syncMutation.data.updated} updated, {syncMutation.data.skipped} skipped.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{posts.data == undefined ?
|
||||||
|
<div className='gsapan' /> :
|
||||||
|
<>
|
||||||
|
{posts.data.map((post) => (
|
||||||
|
<Card.Card className='gsapan' key={post.slug}>
|
||||||
|
<Link href={`/admin/blog/${post.slug}`}>
|
||||||
|
<Card.CardHeader>
|
||||||
|
<Card.CardTitle>{post.title}</Card.CardTitle>
|
||||||
|
{post.date && <p className='text-sm text-muted-foreground'>{post.date}</p>}
|
||||||
|
{post.description && <p className='text-sm text-muted-foreground'>{post.description}</p>}
|
||||||
|
{post.tags && post.tags.length > 0 && (
|
||||||
|
<div className='flex flex-wrap gap-1.5'>
|
||||||
|
{post.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant='outline'>{tag}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.CardHeader>
|
||||||
|
</Link>
|
||||||
|
</Card.Card>
|
||||||
|
))}
|
||||||
|
<CollapsibleForm entityName='Blog Post' form={CreateUpdateBlogForm} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
src/app/admin/chat/_components/SystemPromptForm.tsx
Normal file
39
src/app/admin/chat/_components/SystemPromptForm.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Textarea } from '~/components/ui/textarea'
|
||||||
|
import { Button } from '~/components/ui/button'
|
||||||
|
import { trpc } from '~/app/_trpc/Client'
|
||||||
|
|
||||||
|
export default function SystemPromptForm({ initialValue }: { initialValue: string }) {
|
||||||
|
const [value, setValue] = useState(initialValue)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
|
||||||
|
const mutation = trpc.chat.updateSystemPrompt.useMutation({
|
||||||
|
onSuccess: () => setSaved(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaved(false)
|
||||||
|
mutation.mutate({ prompt: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 w-full">
|
||||||
|
<Textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => { setValue(e.target.value); setSaved(false) }}
|
||||||
|
rows={16}
|
||||||
|
className="font-mono text-sm resize-y"
|
||||||
|
placeholder="Enter the system prompt for the AI recruiter..."
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
{saved && <span className="text-sm text-muted-foreground">Saved</span>}
|
||||||
|
{mutation.error && <span className="text-sm text-destructive">{mutation.error.message}</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/app/admin/chat/page.tsx
Normal file
18
src/app/admin/chat/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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';
|
import {FormMutationContextProvider} from '~/app/_components/Form/Components/MutationProvider';
|
||||||
export default function CreateUpdateCvCategoryForm(params: { className?: string, entity?: IterableElement<RouterOutputs['category']['select']> }) {
|
export default function CreateUpdateCvCategoryForm(params: { className?: string, entity?: IterableElement<RouterOutputs['category']['select']> }) {
|
||||||
const schemas = entitySchemas('cvCategory')
|
const schemas = entitySchemas('cvCategory')
|
||||||
const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
|
const [id, setId] = useState<string | undefined>(params.entity?.id)
|
||||||
const form = useForm<z.infer<typeof schemas.insert>>({
|
const form = useForm<z.infer<typeof schemas.insert>>({
|
||||||
resolver: zodResolver(schemas.insert),
|
resolver: zodResolver(schemas.insert),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
id: params.entity ? params.entity.id : crypto.randomUUID(),
|
id: params.entity?.id || crypto.randomUUID(),
|
||||||
name: params.entity ? params.entity.name : "",
|
name: params.entity?.name || "",
|
||||||
layoutPosition: params.entity ? params.entity.layoutPosition : "col1"
|
layoutPosition: params.entity?.layoutPosition || "col1"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
let path = usePathname()
|
let path = usePathname()
|
||||||
|
|||||||
@@ -11,13 +11,8 @@ import CreateUpdateCvCategoryForm from "../_components/CreateUpdateForm";
|
|||||||
export default function CvPage() {
|
export default function CvPage() {
|
||||||
const categories = trpc.category.select.useQuery({}, { refetchInterval: 1000 });
|
const categories = trpc.category.select.useQuery({}, { refetchInterval: 1000 });
|
||||||
const entires = trpc.entry.select.useSuspenseQuery({}, { refetchInterval: 1000 })
|
const entires = trpc.entry.select.useSuspenseQuery({}, { refetchInterval: 1000 })
|
||||||
const gsap = useGsapContext()
|
|
||||||
const container = useRef<HTMLDivElement>(null);
|
|
||||||
useGSAP(() => {
|
|
||||||
gsap?.from('.gsapan', { x: -100, opacity: 0, duration: 0.5, stagger: { each: 0.3 } });
|
|
||||||
}, { scope: container, dependencies: [categories.status], revertOnUpdate: true });
|
|
||||||
return (
|
return (
|
||||||
<div ref={container} className="w-5/6 lg:w-1/2 flex flex-col gap-3">
|
<>
|
||||||
{categories.data == undefined ?
|
{categories.data == undefined ?
|
||||||
<div className="gsapan"></div>
|
<div className="gsapan"></div>
|
||||||
:
|
:
|
||||||
@@ -64,6 +59,6 @@ export default function CvPage() {
|
|||||||
<CollapsibleForm entityName="Category" form={CreateUpdateCvCategoryForm} />
|
<CollapsibleForm entityName="Category" form={CreateUpdateCvCategoryForm} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,9 @@ import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
|||||||
|
|
||||||
export default function CvPage() {
|
export default function CvPage() {
|
||||||
const entires = trpc.entry.select.useQuery({});
|
const entires = trpc.entry.select.useQuery({});
|
||||||
const gsap = useGsapContext()
|
|
||||||
const container = useRef<HTMLDivElement>(null);
|
const container = useRef<HTMLDivElement>(null);
|
||||||
useGSAP(() => {
|
|
||||||
gsap?.from('.gsapan', { x: -100, opacity: 0, duration: 0.5, stagger: { each: 0.3 } })
|
|
||||||
}, { scope: container, dependencies: [entires.status], revertOnUpdate: true });
|
|
||||||
return (
|
return (
|
||||||
<div ref={container} className="w-5/6 lg:w-1/2 flex flex-col gap-3">
|
<>
|
||||||
{entires.data == undefined ?
|
{entires.data == undefined ?
|
||||||
<div className="gsapan"></div>
|
<div className="gsapan"></div>
|
||||||
:
|
:
|
||||||
@@ -40,6 +36,6 @@ export default function CvPage() {
|
|||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
'use server'
|
import { redirect } from "next/navigation";
|
||||||
|
import { isAdmin } from "~/app/actions";
|
||||||
|
import { SidebarProvider } from "~/components/ui/sidebar";
|
||||||
import AdminSideBar from "./_components/AdminSideBar";
|
import AdminSideBar from "./_components/AdminSideBar";
|
||||||
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function Admin({children}: Readonly<{children: React.ReactNode}>) {
|
export default async function Admin({children}: Readonly<{children: React.ReactNode}>) {
|
||||||
|
if (!(await isAdmin())) redirect("/");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<SidebarProvider>
|
||||||
<AdminSideBar/>
|
<AdminSideBar/>
|
||||||
<main className="absolute flex items-center content-center justify-center flex-wrap w-[100vw] left-0 top-15">
|
<ScrollArea className="px-10 lg:px-0 w-full h-screen pb-10 max-w-4xl mx-auto pt-10">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</ScrollArea>
|
||||||
|
</SidebarProvider>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
105
src/app/admin/music/_components/UploadMusicForm.tsx
Normal file
105
src/app/admin/music/_components/UploadMusicForm.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { trpc } from "~/app/_trpc/Client";
|
||||||
|
import { UploadDropzone } from "~/lib/uploadthing";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { FormScaffold } from "~/app/_components/Form/Components";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import type { RouterOutputs } from "~/server/routers/_app";
|
||||||
|
import type { IterableElement } from "type-fest";
|
||||||
|
import { Toaster } from "~/components/ui/sonner";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { FormMutationContextProvider } from "~/app/_components/Form/Components/MutationProvider";
|
||||||
|
import { TextInputFormField } from "~/app/_components/Form/Fields";
|
||||||
|
import { createMusicInputSchema } from "~/lib/trpc/music/schemas";
|
||||||
|
export default function CreateUpdateMusicForm(props: {
|
||||||
|
entity?: IterableElement<RouterOutputs['music']['list']>,
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const entity = props.entity;
|
||||||
|
const [id, setId] = useState<string | undefined>(entity?.id)
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const form = useForm<z.infer<typeof createMusicInputSchema>>({
|
||||||
|
resolver: zodResolver(createMusicInputSchema),
|
||||||
|
defaultValues: {
|
||||||
|
id: entity?.id || crypto.randomUUID(),
|
||||||
|
title: entity?.title || "",
|
||||||
|
description: entity?.description || "",
|
||||||
|
fileUrl: entity?.fileUrl,
|
||||||
|
fileKey: entity?.fileKey,
|
||||||
|
fileName: entity?.fileName,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = trpc.music.create.useMutation({
|
||||||
|
onSuccess: (values) => {
|
||||||
|
setId(values?.id);
|
||||||
|
utils.music.list.invalidate();
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
toast(e.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const updateMutation = trpc.music.update.useMutation({
|
||||||
|
onSuccess: (_) => {
|
||||||
|
utils.music.list.invalidate();
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
toast(e.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const deleteMutation = trpc.music.delete.useMutation({
|
||||||
|
onSuccess: (_) => {
|
||||||
|
utils.music.list.invalidate();
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
toast(e.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof createMusicInputSchema>) {
|
||||||
|
id ?
|
||||||
|
updateMutation.mutate(values) :
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toaster />
|
||||||
|
<FormMutationContextProvider value={{
|
||||||
|
createMutation: createMutation,
|
||||||
|
updateMutation: updateMutation,
|
||||||
|
deleteMutation: deleteMutation
|
||||||
|
}}>
|
||||||
|
<FormScaffold form={form} onSubmit={onSubmit} title='Music' id={id} className={props.className}>
|
||||||
|
<TextInputFormField control={form.control} name='title' label='Title'/>
|
||||||
|
<TextInputFormField control={form.control} name='description' label='Description'/>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Audio File</Label>
|
||||||
|
<UploadDropzone
|
||||||
|
endpoint="musicUploader"
|
||||||
|
config={{mode: 'auto'}}
|
||||||
|
onUploadError={(e) => {
|
||||||
|
toast(e.message)
|
||||||
|
}}
|
||||||
|
onClientUploadComplete={(res) => {
|
||||||
|
console.log(res)
|
||||||
|
if (res[0]) {
|
||||||
|
form.setValue('fileKey',res[0].serverData.fileKey);
|
||||||
|
form.setValue('fileName',res[0].serverData.fileName);
|
||||||
|
form.setValue('title',res[0].serverData.fileName);
|
||||||
|
form.setValue('description',res[0].serverData.fileName);
|
||||||
|
form.setValue('fileUrl',res[0].serverData.fileUrl);
|
||||||
|
}
|
||||||
|
console.log(form.getValues());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormScaffold>
|
||||||
|
</FormMutationContextProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/app/admin/music/page.tsx
Normal file
26
src/app/admin/music/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from "~/app/_trpc/Client";
|
||||||
|
import * as Card from "~/components/ui/card";
|
||||||
|
import UploadMusicForm from "./_components/UploadMusicForm";
|
||||||
|
import { CollapsibleForm } from "~/app/_components/Form/Components";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export default function AdminMusicPage() {
|
||||||
|
const { data: tracks } = trpc.music.list.useQuery();
|
||||||
|
useEffect(() => {console.log(tracks)}, [tracks])
|
||||||
|
return (
|
||||||
|
<div className="w-5/6 lg:w-1/2 flex flex-col gap-3">
|
||||||
|
{tracks && <>
|
||||||
|
{tracks.map((t) => (
|
||||||
|
<Card.Card key={t.id}>
|
||||||
|
<Card.CardContent>
|
||||||
|
<UploadMusicForm entity={t} className="w-full"/>
|
||||||
|
</Card.CardContent>
|
||||||
|
</Card.Card>
|
||||||
|
))}
|
||||||
|
</>}
|
||||||
|
<CollapsibleForm entityName="Track" form={UploadMusicForm}/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
'use server'
|
|
||||||
|
|
||||||
import { Show } from "@clerk/nextjs";
|
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
return (
|
return (
|
||||||
<Show when="signed-in">
|
<main className="flex min-h-screen flex-col items-center justify-center">
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center">
|
<div>
|
||||||
<div>
|
hello admin
|
||||||
hello admin
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</main>
|
|
||||||
</Show>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function ProjectList() {
|
|||||||
const projects = trpc.project.select.useQuery({}, { refetchInterval: 1000 })
|
const projects = trpc.project.select.useQuery({}, { refetchInterval: 1000 })
|
||||||
const techStacks = trpc.techStack.select.useSuspenseQuery({}, { refetchInterval: 1000 })
|
const techStacks = trpc.techStack.select.useSuspenseQuery({}, { refetchInterval: 1000 })
|
||||||
return (
|
return (
|
||||||
<div className="w-5/6 lg:w-1/2 flex flex-col gap-3">
|
<>
|
||||||
{
|
{
|
||||||
projects.data == undefined ?
|
projects.data == undefined ?
|
||||||
<></> :
|
<></> :
|
||||||
@@ -55,6 +55,6 @@ export default function ProjectList() {
|
|||||||
<CollapsibleForm entityName="Project" form={CreateUpdateProjectForm} />
|
<CollapsibleForm entityName="Project" form={CreateUpdateProjectForm} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) })
|
const deleteMutation = trpc.techStack.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
|
||||||
function onSubmit(values: z.infer<typeof schemas.insert>) {
|
function onSubmit(values: z.infer<typeof schemas.insert>) {
|
||||||
setSubmitted(true)
|
setSubmitted(true)
|
||||||
params.entity ?
|
id ?
|
||||||
updateMutation.mutate(values) :
|
updateMutation.mutate(values) :
|
||||||
createMutation.mutate(values);
|
createMutation.mutate(values);
|
||||||
}
|
}
|
||||||
|
|||||||
99
src/app/api/chat/route.ts
Normal file
99
src/app/api/chat/route.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { auth } from '@clerk/nextjs/server'
|
||||||
|
import { createOpenAI } from '@ai-sdk/openai'
|
||||||
|
import { streamText, tool, convertToModelMessages, stepCountIs, type UIMessage } from 'ai'
|
||||||
|
import { success, z } from 'zod'
|
||||||
|
import { eq, and } from 'drizzle-orm'
|
||||||
|
import { env } from '~/env'
|
||||||
|
import { db } from '~/server/db'
|
||||||
|
import { chatSession, chatMessage } from '~/server/dbschema/schema'
|
||||||
|
import { servTrpc } from '~/app/_trpc/ServerClient'
|
||||||
|
import { scheduleMeeting } from '~/app/actions/scheduleMeeting'
|
||||||
|
import currentTime from '~/app/actions/currentTime';
|
||||||
|
|
||||||
|
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { userId } = await auth()
|
||||||
|
if (userId == null) return new Response('Unauthorized', { status: 401 })
|
||||||
|
|
||||||
|
const { messages, sessionId } = (await req.json()) as {
|
||||||
|
messages: UIMessage[]
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify this session belongs to the authenticated user
|
||||||
|
const session = await db
|
||||||
|
.select()
|
||||||
|
.from(chatSession)
|
||||||
|
.where(and(eq(chatSession.id, sessionId), eq(chatSession.userId, userId)))
|
||||||
|
.limit(1)
|
||||||
|
.then((r) => r[0])
|
||||||
|
|
||||||
|
if (!session) return new Response('Session not found', { status: 404 })
|
||||||
|
|
||||||
|
const systemPrompt = await servTrpc.chat.getSystemPrompt() || 'You are an AI recruiter assistant.'
|
||||||
|
|
||||||
|
// Save the latest user message
|
||||||
|
const lastMessage = messages[messages.length - 1]
|
||||||
|
if (lastMessage?.role === 'user') {
|
||||||
|
const content = lastMessage.parts
|
||||||
|
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join('')
|
||||||
|
if (content) {
|
||||||
|
await db.insert(chatMessage).values({ sessionId, role: 'user', content })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model: openai('gpt-5-mini'),
|
||||||
|
system: systemPrompt,
|
||||||
|
messages: await convertToModelMessages(messages),
|
||||||
|
tools: {
|
||||||
|
scheduleMeeting: tool({
|
||||||
|
description: 'Schedule a meeting with Gregor Lohaus and add it to his Google Calendar',
|
||||||
|
inputSchema: z.object({
|
||||||
|
title: z.string().describe('Meeting title, make something up if not provided'),
|
||||||
|
description: z.string().describe('Meeting description / agenda, make something up if not provided'),
|
||||||
|
dateTime: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'ISO 8601 datetime for the meeting start, e.g. 2025-04-01T10:00:00',
|
||||||
|
),
|
||||||
|
durationMinutes: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(15)
|
||||||
|
.max(120)
|
||||||
|
.describe('Duration of the meeting in minutes, if none provided ask if 20 minutes is ok'),
|
||||||
|
attendeeEmail: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.optional()
|
||||||
|
.describe('Optional Email of the visitor to invite (if provided)'),
|
||||||
|
attendeeName: z.string().optional().describe('Name of the visitor'),
|
||||||
|
}),
|
||||||
|
execute: async (input) => scheduleMeeting({ ...input }),
|
||||||
|
}),
|
||||||
|
getCurrentUnixTime: tool({
|
||||||
|
description: 'Get the current unix time to reference for meeting dates',
|
||||||
|
inputSchema: z.object({
|
||||||
|
none: z.string().optional().describe("no inputs are needed")
|
||||||
|
}),
|
||||||
|
execute: async () => currentTime()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
stopWhen: stepCountIs(5),
|
||||||
|
onFinish: async ({ text, finishReason }) => {
|
||||||
|
if (text && finishReason === 'stop') {
|
||||||
|
await db.insert(chatMessage).values({
|
||||||
|
sessionId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return result.toUIMessageStreamResponse()
|
||||||
|
}
|
||||||
6
src/app/api/uploadthing/route.ts
Normal file
6
src/app/api/uploadthing/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createRouteHandler } from "uploadthing/next";
|
||||||
|
import { fileRouter } from "~/server/uploadthing";
|
||||||
|
|
||||||
|
export const { GET, POST } = createRouteHandler({
|
||||||
|
router: fileRouter,
|
||||||
|
});
|
||||||
5
src/app/assistant/page.tsx
Normal file
5
src/app/assistant/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function AssistantPage() {
|
||||||
|
redirect('/chat')
|
||||||
|
}
|
||||||
49
src/app/blog/[slug]/page.tsx
Normal file
49
src/app/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { MDXRemote } from "next-mdx-remote/rsc";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { servTrpc } from "~/app/_trpc/ServerClient";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { mdxComponents } from "../_components/mdx-components";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function BlogPostPage({ params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
let post: Awaited<ReturnType<typeof servTrpc.blog.bySlug>>;
|
||||||
|
try {
|
||||||
|
post = await servTrpc.blog.bySlug(slug);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof TRPCError && e.code === "NOT_FOUND") notFound();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-2xl px-4 py-12">
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">{post.title}</h1>
|
||||||
|
{post.date && (
|
||||||
|
<time className="text-muted-foreground text-sm">
|
||||||
|
{new Date(post.date).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
)}
|
||||||
|
{post.tags.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{post.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline">{tag}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<article className="prose dark:prose-invert max-w-none">
|
||||||
|
<MDXRemote source={post.content} components={mdxComponents} />
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/app/blog/_components/mdx-components.tsx
Normal file
128
src/app/blog/_components/mdx-components.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Children, isValidElement, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
type CalloutVariant = "note" | "tip" | "warning";
|
||||||
|
|
||||||
|
const calloutStyles: Record<CalloutVariant, string> = {
|
||||||
|
note: "border-sky-500/40 bg-sky-500/10 text-sky-950 dark:text-sky-100",
|
||||||
|
tip: "border-emerald-500/40 bg-emerald-500/10 text-emerald-950 dark:text-emerald-100",
|
||||||
|
warning: "border-amber-500/40 bg-amber-500/10 text-amber-950 dark:text-amber-100",
|
||||||
|
};
|
||||||
|
|
||||||
|
function Callout({
|
||||||
|
title,
|
||||||
|
variant = "note",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
variant?: CalloutVariant;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<aside className={cn("my-6 rounded-md border px-4 py-3", calloutStyles[variant])}>
|
||||||
|
{title && <p className="mb-2 font-semibold">{title}</p>}
|
||||||
|
<div className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{children}</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Lead({ children }: { children: ReactNode }) {
|
||||||
|
return <span className="text-muted-foreground my-6 block text-lg leading-8">{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagList({ tags }: { tags: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 flex flex-wrap gap-1.5">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonLink({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
variant = "default",
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: ReactNode;
|
||||||
|
variant?: ComponentPropsWithoutRef<typeof Button>["variant"];
|
||||||
|
}) {
|
||||||
|
const isExternal = /^https?:\/\//.test(href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button asChild variant={variant}>
|
||||||
|
{isExternal ? (
|
||||||
|
<a href={href} target="_blank" rel="noreferrer">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link href={href}>{children}</Link>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Figure({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
caption,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
caption?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<figure className="my-8">
|
||||||
|
<img src={src} alt={alt} className="w-full rounded-md border object-cover" />
|
||||||
|
{caption && <figcaption className="text-muted-foreground mt-2 text-center text-sm">{caption}</figcaption>}
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PullQuote({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<blockquote className="border-primary my-8 border-l-4 pl-5 text-xl leading-8 font-medium">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExternalLink(props: ComponentPropsWithoutRef<"a">) {
|
||||||
|
const href = props.href ?? "";
|
||||||
|
const isExternal = /^https?:\/\//.test(href);
|
||||||
|
|
||||||
|
if (!isExternal) return <a {...props} />;
|
||||||
|
|
||||||
|
return <a {...props} target="_blank" rel="noreferrer" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockComponents = new Set<unknown>([Callout, Figure, PullQuote, TagList]);
|
||||||
|
|
||||||
|
function Paragraph({ children }: { children: ReactNode }) {
|
||||||
|
const containsBlockComponent = Children.toArray(children).some(
|
||||||
|
(child) => isValidElement(child) && blockComponents.has(child.type),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (containsBlockComponent) return <>{children}</>;
|
||||||
|
|
||||||
|
return <p>{children}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mdxComponents = {
|
||||||
|
a: ExternalLink,
|
||||||
|
p: Paragraph,
|
||||||
|
Badge,
|
||||||
|
ButtonLink,
|
||||||
|
Callout,
|
||||||
|
Figure,
|
||||||
|
Lead,
|
||||||
|
PullQuote,
|
||||||
|
TagList,
|
||||||
|
};
|
||||||
@@ -1,10 +1,3 @@
|
|||||||
'use client'
|
export default function BlogLayout({ children }: { children: React.ReactNode }) {
|
||||||
export default function RootLayout({
|
return <>{children}</>;
|
||||||
children,
|
|
||||||
}: Readonly<{ children: React.ReactNode}>) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{children}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,45 @@
|
|||||||
'use client'
|
import Link from "next/link";
|
||||||
|
import { servTrpc } from "~/app/_trpc/ServerClient";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
|
||||||
import { usePathname } from "next/navigation"
|
export default async function BlogPage() {
|
||||||
|
const posts = await servTrpc.blog.list();
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const pathName = usePathname()
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<main className="mx-auto max-w-2xl px-4 py-12">
|
||||||
{pathName}
|
<h1 className="mb-8 text-3xl font-bold">Blog</h1>
|
||||||
</div>
|
{posts.length === 0 ? (
|
||||||
)
|
<p className="text-muted-foreground">No posts yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-6">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<li key={post.slug}>
|
||||||
|
<Link href={`/blog/${post.slug}`} className="group block">
|
||||||
|
<h2 className="text-xl font-semibold group-hover:underline">{post.title}</h2>
|
||||||
|
{post.date && (
|
||||||
|
<time className="text-muted-foreground text-sm">
|
||||||
|
{new Date(post.date).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
)}
|
||||||
|
{post.description && (
|
||||||
|
<p className="text-muted-foreground mt-1">{post.description}</p>
|
||||||
|
)}
|
||||||
|
{post.tags && post.tags.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{post.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline">{tag}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
68
src/app/chat/_components/AssistantMessage.tsx
Normal file
68
src/app/chat/_components/AssistantMessage.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { UIMessage } from "ai";
|
||||||
|
import Markdown from "react-markdown";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||||
|
let message = props.message;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className='flex justify-start'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className=
|
||||||
|
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-muted'
|
||||||
|
>
|
||||||
|
{message.parts.map((part, i) => {
|
||||||
|
if (part.type === 'text') {
|
||||||
|
return (
|
||||||
|
<Markdown>
|
||||||
|
{part.text}
|
||||||
|
</Markdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (part.type === 'tool-scheduleMeeting') {
|
||||||
|
const toolPart = part as unknown as {
|
||||||
|
type: 'tool-scheduleMeeting'
|
||||||
|
state: string
|
||||||
|
input: unknown
|
||||||
|
output?: { success: boolean; message?: string; htmlLink?: string; error?: string }
|
||||||
|
}
|
||||||
|
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
|
||||||
|
return (
|
||||||
|
<p key={i} className="text-xs opacity-70 italic">
|
||||||
|
Scheduling meeting…
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (toolPart.state === 'output-available' && toolPart.output) {
|
||||||
|
const result = toolPart.output
|
||||||
|
return (
|
||||||
|
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
|
||||||
|
{result.success ? (
|
||||||
|
<span>
|
||||||
|
✓ {result.message}{' '}
|
||||||
|
{result.htmlLink && (
|
||||||
|
<a
|
||||||
|
href={result.htmlLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
View event
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>✗ {result.error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
src/app/chat/_components/ChatInterface.tsx
Normal file
166
src/app/chat/_components/ChatInterface.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useChat } from '@ai-sdk/react'
|
||||||
|
import { DefaultChatTransport, type UIMessage } from 'ai'
|
||||||
|
import { Button } from '~/components/ui/button'
|
||||||
|
import { Textarea } from '~/components/ui/textarea'
|
||||||
|
import { SignInButton } from '@clerk/nextjs'
|
||||||
|
import {
|
||||||
|
useGsapContext,
|
||||||
|
} from '~/app/_providers/GsapProvicer';
|
||||||
|
import Messages from './Messages'
|
||||||
|
import { DeleteIcon } from 'lucide-react';
|
||||||
|
import { Spinner } from '~/components/ui/spinner';
|
||||||
|
import { useMessages } from '~/app/_providers/MessagesProvider';
|
||||||
|
interface DBMessage {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatInterfaceProps {
|
||||||
|
sessionId?: string,
|
||||||
|
dbMessages: DBMessage[],
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignInChatPrompt() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-4 text-center">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-xl font-semibold">Sign in to use the chat</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You need to be signed in before you can talk to Gregor's AI assistant.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SignInButton mode="modal">
|
||||||
|
<Button type="button">Sign in</Button>
|
||||||
|
</SignInButton>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUIMessages(dbMessages: DBMessage[]): UIMessage[] {
|
||||||
|
return dbMessages.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
role: m.role,
|
||||||
|
parts: [{ type: 'text' as const, text: m.content }],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInitMessage(messageArray: UIMessage[]) {
|
||||||
|
if (messageArray.at(0)?.id != 'init') {
|
||||||
|
messageArray.unshift({
|
||||||
|
id: "init",
|
||||||
|
role: 'assistant',
|
||||||
|
parts: [{
|
||||||
|
type: 'text',
|
||||||
|
text: "Hi im gregors ai assistant,you can ask me to provide general information or to schedule a meeting."
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthenticatedChatInterface({ dbMessages, sessionId }: ChatInterfaceProps & { sessionId: string }) {
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const { clearingChat, clearChat, refetchMessages } = useMessages();
|
||||||
|
const initialMessages = toUIMessages(dbMessages)
|
||||||
|
addInitMessage(initialMessages)
|
||||||
|
const { messages, sendMessage, status, error, clearError, setMessages } = useChat({
|
||||||
|
transport: new DefaultChatTransport({
|
||||||
|
api: '/api/chat', body: { sessionId },
|
||||||
|
}),
|
||||||
|
messages: initialMessages,
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
refetchMessages()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
const handleSend = () => {
|
||||||
|
const text = input.trim()
|
||||||
|
if (!text || status != 'ready' || clearingChat) return
|
||||||
|
setInput('')
|
||||||
|
sendMessage({ text })
|
||||||
|
}
|
||||||
|
const gsapContext = useGsapContext()
|
||||||
|
useEffect(() => {
|
||||||
|
let scroller = gsapContext?.getScroller()
|
||||||
|
if (scroller instanceof Window) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(scroller?.scrollHeight)
|
||||||
|
scroller?.scrollTo({ behavior: 'smooth', top: scroller.scrollHeight })
|
||||||
|
}, [messages])
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{messages &&
|
||||||
|
<Messages status={status} messages={messages} />
|
||||||
|
}
|
||||||
|
{error && (
|
||||||
|
<div className="mx-4 mb-2 flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
<span className="flex-1">
|
||||||
|
{error.message.includes('quota') || error.message.includes('429')
|
||||||
|
? 'OpenAI quota exceeded. Please try again later.'
|
||||||
|
: `Error: ${error.message}`}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={clearError}
|
||||||
|
className="shrink-0 opacity-60 hover:opacity-100"
|
||||||
|
variant='destructive'
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4 border-t flex flex-row gap-2">
|
||||||
|
<Textarea
|
||||||
|
name='message'
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="Ask about Gregor's experience or schedule a meeting…"
|
||||||
|
className="resize-none"
|
||||||
|
rows={2}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<Button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={status != "ready" || !input.trim()}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='destructive'
|
||||||
|
onClick={() => {
|
||||||
|
clearChat(() => {
|
||||||
|
let messages: UIMessage[] = [];
|
||||||
|
addInitMessage(messages);
|
||||||
|
setMessages(messages)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={status != "ready" || clearingChat}
|
||||||
|
>
|
||||||
|
{clearingChat ?
|
||||||
|
<Spinner /> :
|
||||||
|
"Clear Chat"
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatInterface({ dbMessages, sessionId }: ChatInterfaceProps) {
|
||||||
|
if (sessionId == undefined) {
|
||||||
|
return <SignInChatPrompt />
|
||||||
|
}
|
||||||
|
return <AuthenticatedChatInterface sessionId={sessionId} dbMessages={dbMessages} />
|
||||||
|
}
|
||||||
36
src/app/chat/_components/Messages.tsx
Normal file
36
src/app/chat/_components/Messages.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { type ChatStatus, type UIMessage } from 'ai'
|
||||||
|
import * as Card from "~/components/ui/card"
|
||||||
|
import { UserMessage } from './UserMessage';
|
||||||
|
import { AssistantMessage } from './AssistantMessage';
|
||||||
|
import { ScrollArea } from '~/components/ui/scroll-area';
|
||||||
|
import { memo } from 'react';
|
||||||
|
const Messages = memo(({messages,status}: { messages: UIMessage[],status:ChatStatus}) => {
|
||||||
|
return (
|
||||||
|
<ScrollArea data-scroller-priority='1' className="w-full h-[90%] max-w-4xl mx-auto">
|
||||||
|
{messages.map((message, i) => (
|
||||||
|
<Card.AnimatedCard scrollOnly={true} key={i}>
|
||||||
|
<Card.CardContent>
|
||||||
|
{message.role == 'assistant' && <AssistantMessage message={message} />}
|
||||||
|
{message.role == 'user' && <UserMessage message={message} />}
|
||||||
|
</Card.CardContent>
|
||||||
|
</Card.AnimatedCard>
|
||||||
|
))}
|
||||||
|
{status == 'submitted' &&
|
||||||
|
<Card.AnimatedCard scrollOnly={true}>
|
||||||
|
<Card.CardContent>
|
||||||
|
<AssistantMessage message={{
|
||||||
|
id:"",
|
||||||
|
role:"assistant",
|
||||||
|
parts:[{
|
||||||
|
type:'text',
|
||||||
|
text:'Thinking ...'
|
||||||
|
}]
|
||||||
|
}}/>
|
||||||
|
</Card.CardContent>
|
||||||
|
</Card.AnimatedCard>
|
||||||
|
|
||||||
|
}
|
||||||
|
</ScrollArea>)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default Messages;
|
||||||
23
src/app/chat/_components/UserMessage.tsx
Normal file
23
src/app/chat/_components/UserMessage.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { UIMessage } from "ai"
|
||||||
|
|
||||||
|
export const UserMessage = (props:{message: UIMessage}) => {
|
||||||
|
let message = props.message.parts.reduce((acc, part) => {
|
||||||
|
if (part.type == 'text') {
|
||||||
|
return acc + part.text
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
},"");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={props.message.id}
|
||||||
|
className='flex justify-end'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className=
|
||||||
|
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-primary'
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
src/app/chat/page.tsx
Normal file
29
src/app/chat/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
'use client'
|
||||||
|
import ChatInterface from './_components/ChatInterface'
|
||||||
|
import AnimatedPageTitle from '../_components/Animated/AnimatedPageTitle';
|
||||||
|
import { useTimeLine } from '../_providers/GsapProvicer';
|
||||||
|
import { useMessages } from '../_providers/MessagesProvider';
|
||||||
|
import { Spinner } from '~/components/ui/spinner';
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const {messages,session,isLoading,error} = useMessages()
|
||||||
|
useTimeLine(messages)
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
|
||||||
|
<AnimatedPageTitle position={0}>
|
||||||
|
<span>Talk To My </span> <span> AI-Assistant</span>
|
||||||
|
</AnimatedPageTitle>
|
||||||
|
<div className='flex items-center h-[80%] w-full my-auto w-full'>
|
||||||
|
{!isLoading &&
|
||||||
|
<ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
|
||||||
|
}
|
||||||
|
{isLoading &&
|
||||||
|
<><Spinner/> Loading Messages...</>
|
||||||
|
}
|
||||||
|
{error &&
|
||||||
|
<div> {error} </div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useGSAP } from "@gsap/react";
|
import { useGSAP } from "@gsap/react";
|
||||||
import { useGsapContext } from "../_providers/GsapProvicer";
|
import { useGsapContext,useTimeLine } from "../_providers/GsapProvicer";
|
||||||
import { trpc } from "../_trpc/Client";
|
import { trpc } from "../_trpc/Client";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { SidebarContent, SidebarProvider, Sidebar } from "~/components/ui/sidebar";
|
import { SidebarContent, SidebarProvider, Sidebar } from "~/components/ui/sidebar";
|
||||||
import SidebarTriggerDisappearsOnMobile from "./_components/SidebarTriggerDisappearsOnMobile";
|
import SidebarTriggerDisappearsOnMobile from "./_components/SidebarTriggerDisappearsOnMobile";
|
||||||
import CvCategory from "./_components/CvCategory";
|
import CvCategory from "./_components/CvCategory";
|
||||||
|
import gsap from 'gsap'
|
||||||
export default function CvPage() {
|
export default function CvPage() {
|
||||||
const sidebarCategories = trpc.categoryv2.listByLayoutPosition.useQuery("sidebar");
|
const sidebarCategories = trpc.categoryv2.listByLayoutPosition.useQuery("sidebar");
|
||||||
const col1Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col1");
|
const col1Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col1");
|
||||||
const headerCategories = trpc.categoryv2.listByLayoutPosition.useQuery("header");
|
const headerCategories = trpc.categoryv2.listByLayoutPosition.useQuery("header");
|
||||||
const col2Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col2");
|
const col2Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col2");
|
||||||
const gsap = useGsapContext()
|
const gsapContext = useGsapContext()
|
||||||
const container = useRef<HTMLDivElement>(null)
|
const container = useRef<HTMLDivElement>(null)
|
||||||
enum Direction {
|
enum Direction {
|
||||||
Left = 1,
|
Left = 1,
|
||||||
@@ -31,12 +32,12 @@ export default function CvPage() {
|
|||||||
return { y: 100, opacity: 0, duration: 0.5 }
|
return { y: 100, opacity: 0, duration: 0.5 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
useTimeLine(col2Categories)
|
||||||
useGSAP(() => {
|
useGSAP(() => {
|
||||||
const items = gsap?.utils.toArray<GSAPTweenTarget>('.gsapan');
|
const items = gsap?.utils.toArray<GSAPTweenTarget>('.gsapan');
|
||||||
const tl = gsap?.timeline();
|
|
||||||
let dir = Direction.Left;
|
let dir = Direction.Left;
|
||||||
items?.forEach(item => {
|
items?.forEach(item => {
|
||||||
tl?.from(item, nextGsapConf(dir))
|
gsapContext?.addAnimation(gsap.from(item, nextGsapConf(dir)),0)
|
||||||
if (dir == Direction.Down) {
|
if (dir == Direction.Down) {
|
||||||
dir = Direction.Left
|
dir = Direction.Left
|
||||||
} else {
|
} else {
|
||||||
@@ -47,7 +48,7 @@ export default function CvPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarProvider ref={container}>
|
<SidebarProvider ref={container}>
|
||||||
{(sidebarCategories.data?.length ? sidebarCategories.data?.length : 0) > 0 ?
|
{sidebarCategories.data &&
|
||||||
<>
|
<>
|
||||||
<SidebarTriggerDisappearsOnMobile />
|
<SidebarTriggerDisappearsOnMobile />
|
||||||
<Sidebar className="gsapan ">
|
<Sidebar className="gsapan ">
|
||||||
@@ -61,8 +62,7 @@ export default function CvPage() {
|
|||||||
})}
|
})}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</> :
|
</>
|
||||||
<></>
|
|
||||||
}
|
}
|
||||||
<div className="h-full w-full flex flex-wrap flex-row p-4 pt-8 ">
|
<div className="h-full w-full flex flex-wrap flex-row p-4 pt-8 ">
|
||||||
<div id="mainwrap" className="flex w-full flex-col gap-4 lg:px-[15vw]">
|
<div id="mainwrap" className="flex w-full flex-col gap-4 lg:px-[15vw]">
|
||||||
|
|||||||
@@ -5,14 +5,17 @@ import { ClerkProvider } from "@clerk/nextjs";
|
|||||||
import { config } from "@fortawesome/fontawesome-svg-core"
|
import { config } from "@fortawesome/fontawesome-svg-core"
|
||||||
import "@fortawesome/fontawesome-svg-core/styles.css"
|
import "@fortawesome/fontawesome-svg-core/styles.css"
|
||||||
import TopNav from "./_components/TopNav";
|
import TopNav from "./_components/TopNav";
|
||||||
|
import ChatFAB from "./_components/ChatFAB";
|
||||||
import TrpcProvider from "./_trpc/TrpcProvider";
|
import TrpcProvider from "./_trpc/TrpcProvider";
|
||||||
// import dynamic from "next/dynamic";
|
// import dynamic from "next/dynamic";
|
||||||
// const ThemeProvider = dynamic(() => import("./_providers/ThemeProvider"),{ssr:true})
|
// const ThemeProvider = dynamic(() => import("./_providers/ThemeProvider"),{ssr:true})
|
||||||
import ThemeProvider from './_providers/ThemeProvider'
|
import ThemeProvider from './_providers/ThemeProvider'
|
||||||
import GsapProvider from "./_providers/GsapProvicer";
|
import GsapProvider from "./_providers/GsapProvicer";
|
||||||
|
import {MessagesProvider} from "./_providers/MessagesProvider";
|
||||||
import { CodeHighlightStyle } from "./_components/CodeHighlightSyle";
|
import { CodeHighlightStyle } from "./_components/CodeHighlightSyle";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
import AnimatedBackGroundContainer from "./_components/Animated/AnimatedBackGroundContainer";
|
||||||
|
import {SpeedInsights} from "@vercel/speed-insights/next"
|
||||||
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||||
|
|
||||||
|
|
||||||
@@ -28,13 +31,14 @@ const geist = Geist({
|
|||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
modal
|
modal
|
||||||
}: Readonly<{ children: React.ReactNode, modal: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode, modal: React.ReactNode }>) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<SpeedInsights/>
|
||||||
<ClerkProvider>
|
<ClerkProvider>
|
||||||
<TrpcProvider>
|
<TrpcProvider>
|
||||||
<GsapProvider>
|
<GsapProvider>
|
||||||
@@ -44,16 +48,22 @@ export default async function RootLayout({
|
|||||||
</head>
|
</head>
|
||||||
<body className="flex flex-col bg-background text-foreground">
|
<body className="flex flex-col bg-background text-foreground">
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<TopNav />
|
<MessagesProvider>
|
||||||
<main className="absolute lg:top-10 h-screen w-screen">
|
<AnimatedBackGroundContainer followSpeed={0.003} particleCount={100} orbitRadius={2000}>
|
||||||
{children}
|
<TopNav />
|
||||||
</main>
|
<main className="absolute lg:top-10 h-screen lg:h-[calc(100vh-var(--spacing)*10)] w-screen">
|
||||||
{modal}
|
{children}
|
||||||
|
</main>
|
||||||
|
{modal}
|
||||||
|
</AnimatedBackGroundContainer>
|
||||||
|
<ChatFAB />
|
||||||
|
</MessagesProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
</GsapProvider>
|
</GsapProvider>
|
||||||
</TrpcProvider>
|
</TrpcProvider>
|
||||||
</ClerkProvider>
|
</ClerkProvider>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
63
src/app/music/page.tsx
Normal file
63
src/app/music/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use client'
|
||||||
|
import { trpc } from "~/app/_trpc/Client";
|
||||||
|
import * as Card from "~/components/ui/card";
|
||||||
|
import { useTimeLine } from "../_providers/GsapProvicer";
|
||||||
|
import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle";
|
||||||
|
import { Spinner } from "~/components/ui/spinner";
|
||||||
|
import AnimateTextIn from "../_components/Animated/AnimateIn";
|
||||||
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
|
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
|
||||||
|
export default function MusicPage() {
|
||||||
|
const { data: tracks, isLoading } = trpc.music.list.useQuery();
|
||||||
|
useTimeLine(tracks)
|
||||||
|
return (
|
||||||
|
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
|
||||||
|
<AnimatedPageTitle position={0}><span>Just Some </span> <span>Music I Made</span> </AnimatedPageTitle>
|
||||||
|
<div className="flex flex-wrap h-fit content-center">
|
||||||
|
<AnimateTextIn className="flex flex-wrap mr-[1em]" position={0.5}>
|
||||||
|
<div><p className="break-after-avoid mr-[1em]">All works on this page are licensed under:</p></div>
|
||||||
|
<div><a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a></div>
|
||||||
|
</AnimateTextIn>
|
||||||
|
<AnimatePopUp position={2} className="items-center content-center">
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<img className="max-w-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" />
|
||||||
|
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="" />
|
||||||
|
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/nc.svg" alt="" />
|
||||||
|
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="" />
|
||||||
|
</div>
|
||||||
|
</AnimatePopUp>
|
||||||
|
</div>
|
||||||
|
<div className="pt-10" />
|
||||||
|
{tracks && tracks.map((track, i) => (
|
||||||
|
<div key={track.id}>
|
||||||
|
<Card.AnimatedCard position={i + 1}>
|
||||||
|
<Card.CardHeader>
|
||||||
|
<AnimateTextIn position={i + 1.2} animation="slide">
|
||||||
|
<Card.CardTitle>{track.title}</Card.CardTitle>
|
||||||
|
</AnimateTextIn>
|
||||||
|
</Card.CardHeader>
|
||||||
|
<Card.CardContent className="flex flex-col gap-3">
|
||||||
|
{track.description && (
|
||||||
|
<p className="text-sm text-muted-foreground gsapant">{track.description}</p>
|
||||||
|
)}
|
||||||
|
<AnimatePopUp position={i + 1.3}>
|
||||||
|
<audio controls className="w-full player" src={track.fileUrl}>
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
</AnimatePopUp>
|
||||||
|
</Card.CardContent>
|
||||||
|
</Card.AnimatedCard>
|
||||||
|
<div className="pt-5" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!isLoading && !tracks?.length &&
|
||||||
|
<div className="flex justify-center items-center text-muted-foreground">
|
||||||
|
No music yet.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{isLoading && <div className="w-full h-full items-center flex flex-row content-center gap-4 justify-center">
|
||||||
|
<Spinner /> Loading Tracks
|
||||||
|
</div>}
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,17 @@ import * as Card from "~/components/ui/card";
|
|||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { StackBadge } from "~/components/StackBadge";
|
import { StackBadge } from "~/components/StackBadge";
|
||||||
import Markdown from "react-markdown";
|
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() {
|
export default function ProjectsPage() {
|
||||||
const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery();
|
const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery();
|
||||||
|
useTimeLine(projects)
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground">
|
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground">
|
||||||
@@ -26,68 +33,84 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-4xl mx-auto px-4 py-8 flex flex-col gap-4">
|
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
|
||||||
{projects.map((project) => (
|
<AnimatedPageTitle position={0}><span>Projects I've Been</span><span> Working on</span> </AnimatedPageTitle>
|
||||||
<Card.Card key={project.id}>
|
<div className="pt-10" />
|
||||||
<Card.CardHeader>
|
{projects.map((project, i) => (
|
||||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
<div id={project.id} key={i} className="scroll-mt-10">
|
||||||
<Card.CardTitle>{project.title}</Card.CardTitle>
|
<Card.AnimatedCard position={i + 1.2} key={project.id}>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<Card.CardHeader>
|
||||||
{project.sourceType && (
|
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||||
<Badge variant={project.sourceType === "open" ? "secondary" : "outline"}>
|
<AnimateTextIn position={i + 1.4} animation="slide"><Card.CardTitle>{project.title}</Card.CardTitle></AnimateTextIn>
|
||||||
{project.sourceType === "open" ? "Open Source" : "Closed Source"}
|
<div className="flex gap-2 flex-wrap">
|
||||||
</Badge>
|
{project.sourceType && (
|
||||||
)}
|
<AnimatePopUp position={i + 2} duration={2}>
|
||||||
{project.releaseStatus && (
|
<Badge variant={project.sourceType === "open" ? "secondary" : "outline"}>
|
||||||
<Badge variant={project.releaseStatus === "released" ? "default" : "outline"}>
|
{project.sourceType === "open" ? "Open Source" : "Closed Source"}
|
||||||
{project.releaseStatus === "released" ? "Released" : "Unreleased"}
|
</Badge>
|
||||||
</Badge>
|
</AnimatePopUp>
|
||||||
)}
|
)}
|
||||||
|
{project.releaseStatus && (
|
||||||
|
<Badge variant={project.releaseStatus === "released" ? "default" : "outline"}>
|
||||||
|
{project.releaseStatus === "released" ? "Released" : "Unreleased"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card.CardHeader>
|
||||||
</Card.CardHeader>
|
{(project.description || project.sourceLink || project.releaseLink || project.techStack?.stackItems?.length) && (
|
||||||
{(project.description || project.sourceLink || project.releaseLink || project.techStack?.stackItems?.length) && (
|
<Card.CardContent className="flex flex-col gap-3">
|
||||||
<Card.CardContent className="flex flex-col gap-3">
|
{project.description && (
|
||||||
{project.description && (
|
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
|
<AnimatePopUp position={i + 1.4} duration={10}>
|
||||||
<Markdown>{project.description}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm]}>{project.description}</Markdown>
|
||||||
</div>
|
</AnimatePopUp>
|
||||||
)}
|
</div>
|
||||||
{(project.sourceLink || project.releaseLink) && (
|
)}
|
||||||
<div className="flex gap-3 flex-wrap">
|
<div className="flex flex-row">
|
||||||
{project.sourceLink && (
|
{project.techStack?.stackItems && project.techStack.stackItems.length > 0 && (
|
||||||
<a
|
<div className="flex flex-wrap gap-1.5">
|
||||||
href={project.sourceLink}
|
{project.techStack.stackItems.map((item, k) => (
|
||||||
target="_blank"
|
<AnimatePopUp key={k} position={(i + 2) + k * 0.5}> <StackBadge key={item} item={item} /> </AnimatePopUp>
|
||||||
rel="noopener noreferrer"
|
))}
|
||||||
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4 transition-colors"
|
</div>
|
||||||
>
|
|
||||||
Source
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
{project.releaseLink && (
|
{(project.sourceLink || project.releaseLink) && (
|
||||||
<a
|
<div className="ml-auto flex-col lg:flex-row justify-center gap-5">
|
||||||
href={project.releaseLink}
|
{project.sourceLink &&
|
||||||
target="_blank"
|
<Button variant='outline' className="cursor-pointer mb-3 lg:mb-0 lg:mr-3 min-w-18">
|
||||||
rel="noopener noreferrer"
|
<a
|
||||||
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4 transition-colors"
|
href={project.sourceLink}
|
||||||
>
|
target="_blank"
|
||||||
Live
|
rel="noopener noreferrer"
|
||||||
</a>
|
className='items-center'
|
||||||
|
>
|
||||||
|
Source
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
{project.releaseLink &&
|
||||||
|
<Button variant='default' className="cursor-pointer min-w-18 items-center">
|
||||||
|
<a
|
||||||
|
href={project.releaseLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className='items-center'
|
||||||
|
>
|
||||||
|
Live
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Card.CardContent>
|
||||||
{project.techStack?.stackItems && project.techStack.stackItems.length > 0 && (
|
)}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
</Card.AnimatedCard>
|
||||||
{project.techStack.stackItems.map((item) => (
|
<div className="pt-5" />
|
||||||
<StackBadge key={item} item={item} />
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card.CardContent>
|
|
||||||
)}
|
|
||||||
</Card.Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import * as React from "react"
|
import { useGSAP } from "@gsap/react"; import * as React from "react"
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
||||||
|
import gsap from 'gsap'
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
function Card({
|
function Card({
|
||||||
@@ -12,7 +14,60 @@ function Card({
|
|||||||
data-slot="card"
|
data-slot="card"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl bg-opacity-60 backdrop-blur-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedCard({
|
||||||
|
className,
|
||||||
|
position = 0,
|
||||||
|
size = "default",
|
||||||
|
scrollOnly = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm", position?: gsap.Position, scrollOnly?: boolean }) {
|
||||||
|
const gsapContext = useGsapContext()
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null)
|
||||||
|
useGSAP(() => {
|
||||||
|
const rect = ref.current?.getBoundingClientRect()
|
||||||
|
const scroller = gsapContext?.getScroller()
|
||||||
|
console.log(scroller)
|
||||||
|
let viewportTop = 0
|
||||||
|
let viewportBottom = window.innerHeight
|
||||||
|
if (scroller && scroller instanceof Element) {
|
||||||
|
const scrollerRect = scroller.getBoundingClientRect()
|
||||||
|
viewportTop = scrollerRect.top
|
||||||
|
viewportBottom = scrollerRect.top + scrollerRect.height
|
||||||
|
}
|
||||||
|
const isInView = rect && rect.bottom > viewportTop && rect.top < viewportBottom
|
||||||
|
console.log(isInView)
|
||||||
|
const fromVars = { x: -100, opacity: 0, duration: 0.5 }
|
||||||
|
if (isInView && !scrollOnly) {
|
||||||
|
gsapContext?.addAnimation(gsap.from(ref.current, fromVars), position)
|
||||||
|
} else {
|
||||||
|
gsap.from(ref.current,
|
||||||
|
{
|
||||||
|
...fromVars,
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: ref.current,
|
||||||
|
start: 'top bottom',
|
||||||
|
end: 'bottom top',
|
||||||
|
toggleActions: "play reverse play reverse",
|
||||||
|
scroller
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, { dependencies: [] })
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl bg-opacity-60 backdrop-blur-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -100,4 +155,5 @@ export {
|
|||||||
CardAction,
|
CardAction,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
AnimatedCard
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/components/ui/spinner.tsx
Normal file
10
src/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import { Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Spinner }
|
||||||
@@ -7,6 +7,9 @@ export const env = createEnv({
|
|||||||
* isn't built with invalid env vars.
|
* isn't built with invalid env vars.
|
||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
|
UPLOADTHING_TOKEN: z.string(),
|
||||||
|
BLOG_MDX_PREFIX: z.string().default("blog"),
|
||||||
|
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
DATABASE_URL_UNPOOLED: z.string().url(),
|
DATABASE_URL_UNPOOLED: z.string().url(),
|
||||||
|
|
||||||
@@ -27,6 +30,7 @@ export const env = createEnv({
|
|||||||
|
|
||||||
CLERK_SECRET_KEY: z.string(),
|
CLERK_SECRET_KEY: z.string(),
|
||||||
ADMIN_USER_CLERK_ID: z.string(),
|
ADMIN_USER_CLERK_ID: z.string(),
|
||||||
|
OPENAI_API_KEY: z.string(),
|
||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(["development", "test", "production"])
|
.enum(["development", "test", "production"])
|
||||||
.default("development"),
|
.default("development"),
|
||||||
@@ -48,6 +52,9 @@ export const env = createEnv({
|
|||||||
* middlewares) or client-side so we need to destruct manually.
|
* middlewares) or client-side so we need to destruct manually.
|
||||||
*/
|
*/
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
|
UPLOADTHING_TOKEN: process.env.UPLOADTHING_TOKEN,
|
||||||
|
BLOG_MDX_PREFIX: process.env.BLOG_MDX_PREFIX,
|
||||||
|
|
||||||
DATABASE_URL: process.env.DATABASE_URL,
|
DATABASE_URL: process.env.DATABASE_URL,
|
||||||
DATABASE_URL_UNPOOLED: process.env.DATABASE_URL,
|
DATABASE_URL_UNPOOLED: process.env.DATABASE_URL,
|
||||||
PGHOST: process.env.PGHOST,
|
PGHOST: process.env.PGHOST,
|
||||||
@@ -64,6 +71,7 @@ export const env = createEnv({
|
|||||||
POSTGRES_URL_NO_SSL: process.env.POSTGRES_URL_NO_SSL,
|
POSTGRES_URL_NO_SSL: process.env.POSTGRES_URL_NO_SSL,
|
||||||
POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL,
|
POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL,
|
||||||
ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID,
|
ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID,
|
||||||
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||||
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: process.env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID,
|
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: process.env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID,
|
||||||
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
|
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
|
||||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { UseTRPCQueryResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs"
|
import type { UseTRPCQueryResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
function useRelationShipSuccess<T extends Record<string,any> & {id:string},K extends keyof T>(
|
function useRelationShipSuccess<T extends Record<string,any> & {id:string},K extends keyof T>(
|
||||||
relationShipData: T[] | undefined,
|
relationShipData: T[] | undefined,
|
||||||
@@ -56,3 +56,10 @@ export function makeUseRelationShipWithNameIndex<K extends string>(key:K) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usePrevious<T>(value:T,initialValue:T) {
|
||||||
|
const ref = useRef(initialValue)
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current = value
|
||||||
|
})
|
||||||
|
return ref.current;
|
||||||
|
}
|
||||||
|
|||||||
18
src/lib/trpc/music/schemas.ts
Normal file
18
src/lib/trpc/music/schemas.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import z from "zod"
|
||||||
|
|
||||||
|
export const createMusicInputSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
title: z.string().min(1).max(100),
|
||||||
|
description: z.string().optional(),
|
||||||
|
fileUrl: z.string(),
|
||||||
|
fileKey: z.string(),
|
||||||
|
fileName: z.string(),
|
||||||
|
})
|
||||||
|
export const updateMusicInputSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
title: z.string().min(1).max(100).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
fileUrl: z.string().optional(),
|
||||||
|
fileKey: z.string().optional(),
|
||||||
|
fileName: z.string().optional(),
|
||||||
|
})
|
||||||
5
src/lib/uploadthing.ts
Normal file
5
src/lib/uploadthing.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { generateUploadButton, generateUploadDropzone } from "@uploadthing/react";
|
||||||
|
import type { FileRouter } from "~/server/uploadthing";
|
||||||
|
|
||||||
|
export const UploadButton = generateUploadButton<FileRouter>();
|
||||||
|
export const UploadDropzone = generateUploadDropzone<FileRouter>();
|
||||||
17
src/proxy.ts
17
src/proxy.ts
@@ -1,13 +1,16 @@
|
|||||||
import { clerkMiddleware, createRouteMatcher, currentUser } from "@clerk/nextjs/server";
|
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
|
|
||||||
const isTenantAdminRoute = createRouteMatcher(['/admin(.*)'])
|
const isTenantAdminRoute = createRouteMatcher(["/admin(.*)"]);
|
||||||
export default clerkMiddleware(async (auth,req) => {
|
|
||||||
|
export default clerkMiddleware(async (auth, req) => {
|
||||||
if (isTenantAdminRoute(req)) {
|
if (isTenantAdminRoute(req)) {
|
||||||
console.log("running clerk middleware");
|
await auth.protect();
|
||||||
let userid = (await auth()).userId
|
|
||||||
if (userid != env.ADMIN_USER_CLERK_ID) {
|
const { userId } = await auth();
|
||||||
await auth.protect()
|
if (userId !== env.ADMIN_USER_CLERK_ID) {
|
||||||
|
return NextResponse.redirect(new URL("/", req.url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// https://orm.drizzle.team/docs/sql-schema-declaration
|
// https://orm.drizzle.team/docs/sql-schema-declaration
|
||||||
|
|
||||||
import { relations, sql } from "drizzle-orm";
|
import { relations, sql } from "drizzle-orm";
|
||||||
import { index, pgEnum, pgSchema, pgTableCreator } from "drizzle-orm/pg-core";
|
import { index, pgEnum, pgSchema, pgTableCreator, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||||
@@ -85,3 +85,89 @@ export const techStack = createTable(
|
|||||||
stackItems: stackItemEnum().array()
|
stackItems: stackItemEnum().array()
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const music = createTable(
|
||||||
|
"music",
|
||||||
|
(d) => ({
|
||||||
|
id: d.uuid().primaryKey().notNull(),
|
||||||
|
title: d.varchar({ length: 100 }).notNull(),
|
||||||
|
description: d.text(),
|
||||||
|
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
|
||||||
|
fileKey: d.varchar("file_key", { length: 200 }).notNull(),
|
||||||
|
fileName: d.varchar("file_name", { length: 200 }).notNull(),
|
||||||
|
createdAt: d
|
||||||
|
.timestamp({ withTimezone: true })
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
.notNull()
|
||||||
|
.$type<Date>(),
|
||||||
|
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const blogPost = createTable(
|
||||||
|
"blog_post",
|
||||||
|
(d) => ({
|
||||||
|
id: d.uuid().primaryKey().defaultRandom(),
|
||||||
|
slug: d.varchar({ length: 200 }).notNull(),
|
||||||
|
title: d.varchar({ length: 200 }).notNull(),
|
||||||
|
date: d.varchar({ length: 20 }),
|
||||||
|
description: d.text(),
|
||||||
|
tags: d.text().array(),
|
||||||
|
fileKey: d.varchar("file_key", { length: 200 }).notNull(),
|
||||||
|
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
|
||||||
|
fileName: d.varchar("file_name", { length: 255 }).notNull(),
|
||||||
|
customId: d.varchar("custom_id", { length: 255 }).notNull(),
|
||||||
|
createdAt: d
|
||||||
|
.timestamp({ withTimezone: true })
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
.notNull()
|
||||||
|
.$type<Date>(),
|
||||||
|
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
|
||||||
|
}),
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("blog_post_slug_idx").on(t.slug),
|
||||||
|
uniqueIndex("blog_post_file_key_idx").on(t.fileKey),
|
||||||
|
uniqueIndex("blog_post_custom_id_idx").on(t.customId),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
export const messageRoleEnum = pgEnum('message_role', ['user', 'assistant'])
|
||||||
|
|
||||||
|
export const chatSession = createTable(
|
||||||
|
"chat_session",
|
||||||
|
(d) => ({
|
||||||
|
id: d.uuid().primaryKey().defaultRandom(),
|
||||||
|
userId: d.varchar({ length: 255 }).notNull(),
|
||||||
|
createdAt: d.timestamp({ withTimezone: true }).default(sql`CURRENT_TIMESTAMP`).notNull().$type<Date>(),
|
||||||
|
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const chatSessionRelations = relations(chatSession, ({ many }) => ({
|
||||||
|
messages: many(chatMessage),
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const chatMessage = createTable(
|
||||||
|
"chat_message",
|
||||||
|
(d) => ({
|
||||||
|
id: d.uuid().primaryKey().defaultRandom(),
|
||||||
|
sessionId: d.uuid('session_id').notNull(),
|
||||||
|
role: messageRoleEnum().notNull(),
|
||||||
|
content: d.text().notNull(),
|
||||||
|
createdAt: d.timestamp({ withTimezone: true }).default(sql`CURRENT_TIMESTAMP`).notNull().$type<Date>(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const chatMessageRelations = relations(chatMessage, ({ one }) => ({
|
||||||
|
session: one(chatSession, {
|
||||||
|
fields: [chatMessage.sessionId],
|
||||||
|
references: [chatSession.id],
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const systemSettings = createTable(
|
||||||
|
"systemSetting",
|
||||||
|
(d) => ({
|
||||||
|
systemPropmt: d.text()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import type { inferRouterOutputs } from "@trpc/server";
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
import { router } from "../trpc";
|
import { router } from "../trpc";
|
||||||
import type { inferReactQueryProcedureOptions } from "@trpc/react-query";
|
import type { inferReactQueryProcedureOptions } from "@trpc/react-query";
|
||||||
|
import { blogRouter } from "./blog";
|
||||||
import { projectRouter } from "./project";
|
import { projectRouter } from "./project";
|
||||||
import { techStackRouter } from "./techStack";
|
import { techStackRouter } from "./techStack";
|
||||||
import { cvCategoryRouter } from "./cvCategory";
|
import { cvCategoryRouter } from "./cvCategory";
|
||||||
import { cvEntryRouter } from "./cvEntry";
|
import { cvEntryRouter } from "./cvEntry";
|
||||||
|
import { musicRouter } from "./music";
|
||||||
import { trpcCrudRouterFromDrizzleEntity } from "../lib";
|
import { trpcCrudRouterFromDrizzleEntity } from "../lib";
|
||||||
import { cvCategory } from "../dbschema/schema";
|
import { cvCategory } from "../dbschema/schema";
|
||||||
|
import { chatRouter } from "./chat";
|
||||||
|
|
||||||
export const trpcRouter = router({
|
export const trpcRouter = router({
|
||||||
|
blog: blogRouter,
|
||||||
project: trpcCrudRouterFromDrizzleEntity('project').router,
|
project: trpcCrudRouterFromDrizzleEntity('project').router,
|
||||||
projectv2: projectRouter,
|
projectv2: projectRouter,
|
||||||
techStack: trpcCrudRouterFromDrizzleEntity('techStack').router,
|
techStack: trpcCrudRouterFromDrizzleEntity('techStack').router,
|
||||||
@@ -17,6 +21,8 @@ export const trpcRouter = router({
|
|||||||
categoryv2: cvCategoryRouter,
|
categoryv2: cvCategoryRouter,
|
||||||
entry: trpcCrudRouterFromDrizzleEntity('cvEntry').router,
|
entry: trpcCrudRouterFromDrizzleEntity('cvEntry').router,
|
||||||
entryv2: cvEntryRouter,
|
entryv2: cvEntryRouter,
|
||||||
|
music: musicRouter,
|
||||||
|
chat: chatRouter
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TrpcRouter = typeof trpcRouter;
|
export type TrpcRouter = typeof trpcRouter;
|
||||||
|
|||||||
360
src/server/routers/blog.ts
Normal file
360
src/server/routers/blog.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { desc, eq, or } from "drizzle-orm";
|
||||||
|
import matter from "gray-matter";
|
||||||
|
import { UTApi, UTFile } from "uploadthing/server";
|
||||||
|
import z from "zod";
|
||||||
|
import { isAdmin } from "~/app/actions";
|
||||||
|
import { env } from "~/env.js";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { blogPost } from "~/server/dbschema/schema";
|
||||||
|
import { publicProcedure, router } from "~/server/trpc";
|
||||||
|
|
||||||
|
const utapi = new UTApi({ token: env.UPLOADTHING_TOKEN });
|
||||||
|
|
||||||
|
const blogPostInput = z.object({
|
||||||
|
slug: z.string().min(1),
|
||||||
|
title: z.string().min(1),
|
||||||
|
date: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type BlogPostInput = z.infer<typeof blogPostInput>;
|
||||||
|
|
||||||
|
type UploadThingFile = Awaited<ReturnType<typeof utapi.listFiles>>["files"][number];
|
||||||
|
|
||||||
|
function cleanPrefix(): string {
|
||||||
|
return env.BLOG_MDX_PREFIX.trim().replace(/^\/+|\/+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function blogFileName(slug: string): string {
|
||||||
|
const prefix = cleanPrefix();
|
||||||
|
return prefix ? `${prefix}-${slug}.mdx` : `${slug}.mdx`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blogCustomId(slug: string): string {
|
||||||
|
const prefix = cleanPrefix();
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
return prefix ? `${prefix}:${slug}:${id}` : `${slug}:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalText(value: string | undefined): string | null {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frontmatterText(value: unknown): string | null {
|
||||||
|
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||||
|
return optionalText(typeof value === "string" ? value : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTags(tags: unknown): string[] {
|
||||||
|
const values = Array.isArray(tags)
|
||||||
|
? tags
|
||||||
|
: typeof tags === "string"
|
||||||
|
? tags.split(",")
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
values
|
||||||
|
.map((tag) => String(tag).trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMdxContent(input: BlogPostInput): string {
|
||||||
|
const date = optionalText(input.date);
|
||||||
|
const description = optionalText(input.description);
|
||||||
|
const tags = normalizeTags(input.tags);
|
||||||
|
const frontmatter: Record<string, unknown> = { slug: input.slug, title: input.title };
|
||||||
|
if (date) frontmatter.date = date;
|
||||||
|
if (description) frontmatter.description = description;
|
||||||
|
if (tags.length > 0) frontmatter.tags = tags;
|
||||||
|
return matter.stringify(input.content, frontmatter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryFromInput(input: BlogPostInput) {
|
||||||
|
return {
|
||||||
|
slug: input.slug,
|
||||||
|
title: input.title,
|
||||||
|
date: optionalText(input.date),
|
||||||
|
description: optionalText(input.description),
|
||||||
|
tags: normalizeTags(input.tags),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertAdmin() {
|
||||||
|
const admin = await isAdmin();
|
||||||
|
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadMdx(input: BlogPostInput) {
|
||||||
|
const mdxContent = createMdxContent(input);
|
||||||
|
const customId = blogCustomId(input.slug);
|
||||||
|
const file = new UTFile([mdxContent], blogFileName(input.slug), {
|
||||||
|
customId,
|
||||||
|
type: "text/plain",
|
||||||
|
});
|
||||||
|
const result = await utapi.uploadFiles(file);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.data.ufsUrl) {
|
||||||
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "UploadThing did not return a file URL" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileKey: result.data.key,
|
||||||
|
fileUrl: result.data.ufsUrl,
|
||||||
|
fileName: result.data.name,
|
||||||
|
customId: result.data.customId ?? customId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMdx(fileUrl: string): Promise<string> {
|
||||||
|
const res = await fetch(fileUrl, { next: { revalidate: 3600 } });
|
||||||
|
if (!res.ok) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to fetch MDX file" });
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileUrl(file: Pick<UploadThingFile, "key">): string {
|
||||||
|
return `https://utfs.io/f/${file.key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugFromFileName(name: string): string {
|
||||||
|
const prefix = cleanPrefix();
|
||||||
|
const withoutExtension = name.replace(/\.mdx?$/, "");
|
||||||
|
if (prefix && withoutExtension.startsWith(`${prefix}-`)) return withoutExtension.slice(prefix.length + 1);
|
||||||
|
if (prefix && withoutExtension.startsWith(`${prefix}/`)) return withoutExtension.slice(prefix.length + 1);
|
||||||
|
return withoutExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugFromCustomId(customId: string): string {
|
||||||
|
const prefix = cleanPrefix();
|
||||||
|
const value = prefix && customId.startsWith(`${prefix}:`)
|
||||||
|
? customId.slice(prefix.length + 1)
|
||||||
|
: customId;
|
||||||
|
return value.split(":")[0] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileMatchesPrefix(file: Pick<UploadThingFile, "name">): boolean {
|
||||||
|
if (!/\.mdx?$/.test(file.name)) return false;
|
||||||
|
|
||||||
|
const prefix = cleanPrefix();
|
||||||
|
if (!prefix) return true;
|
||||||
|
|
||||||
|
return file.name.startsWith(`${prefix}-`) || file.name.startsWith(`${prefix}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataFromFile(file: UploadThingFile, raw: string) {
|
||||||
|
const parsed = matter(raw);
|
||||||
|
const fallbackSlug = file.customId ? slugFromCustomId(file.customId) : slugFromFileName(file.name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: String(parsed.data.slug ?? fallbackSlug),
|
||||||
|
title: String(parsed.data.title ?? fallbackSlug),
|
||||||
|
date: frontmatterText(parsed.data.date),
|
||||||
|
description: frontmatterText(parsed.data.description),
|
||||||
|
tags: normalizeTags(parsed.data.tags),
|
||||||
|
fileKey: file.key,
|
||||||
|
fileUrl: fileUrl(file),
|
||||||
|
fileName: file.name,
|
||||||
|
customId: file.customId ?? blogCustomId(fallbackSlug),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAllFiles(): Promise<readonly UploadThingFile[]> {
|
||||||
|
const files: UploadThingFile[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
const limit = 500;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const page = await utapi.listFiles({ limit, offset });
|
||||||
|
files.push(...page.files);
|
||||||
|
hasMore = page.hasMore;
|
||||||
|
offset += limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const blogRouter = router({
|
||||||
|
insert: publicProcedure
|
||||||
|
.input(blogPostInput)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await assertAdmin();
|
||||||
|
|
||||||
|
const existing = await db.query.blogPost.findFirst({
|
||||||
|
where(fields, operators) {
|
||||||
|
return operators.eq(fields.slug, input.slug);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` });
|
||||||
|
|
||||||
|
const uploaded = await uploadMdx(input);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(blogPost).values({
|
||||||
|
slug: input.slug,
|
||||||
|
title: input.title,
|
||||||
|
date: optionalText(input.date),
|
||||||
|
description: optionalText(input.description),
|
||||||
|
tags: normalizeTags(input.tags),
|
||||||
|
...uploaded,
|
||||||
|
});
|
||||||
|
return [summaryFromInput(input)];
|
||||||
|
} catch (error) {
|
||||||
|
await utapi.deleteFiles(uploaded.fileKey);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: publicProcedure
|
||||||
|
.input(blogPostInput.extend({ originalSlug: z.string().min(1) }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await assertAdmin();
|
||||||
|
|
||||||
|
const existing = await db.query.blogPost.findFirst({
|
||||||
|
where(fields, operators) {
|
||||||
|
return operators.eq(fields.slug, input.originalSlug);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.originalSlug}" not found` });
|
||||||
|
|
||||||
|
if (input.slug !== input.originalSlug) {
|
||||||
|
const slugConflict = await db.query.blogPost.findFirst({
|
||||||
|
where(fields, operators) {
|
||||||
|
return operators.eq(fields.slug, input.slug);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (slugConflict) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploaded = await uploadMdx(input);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.update(blogPost).set({
|
||||||
|
slug: input.slug,
|
||||||
|
title: input.title,
|
||||||
|
date: optionalText(input.date),
|
||||||
|
description: optionalText(input.description),
|
||||||
|
tags: normalizeTags(input.tags),
|
||||||
|
...uploaded,
|
||||||
|
}).where(eq(blogPost.id, existing.id));
|
||||||
|
|
||||||
|
await utapi.deleteFiles(existing.fileKey);
|
||||||
|
return [summaryFromInput(input)];
|
||||||
|
} catch (error) {
|
||||||
|
await utapi.deleteFiles(uploaded.fileKey);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: publicProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await assertAdmin();
|
||||||
|
|
||||||
|
const post = await db.query.blogPost.findFirst({
|
||||||
|
where(fields, operators) {
|
||||||
|
return operators.eq(fields.slug, input.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.id}" not found` });
|
||||||
|
|
||||||
|
await db.delete(blogPost).where(eq(blogPost.id, post.id));
|
||||||
|
await utapi.deleteFiles(post.fileKey);
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
|
||||||
|
list: publicProcedure.query(async () => {
|
||||||
|
return db.select({
|
||||||
|
slug: blogPost.slug,
|
||||||
|
title: blogPost.title,
|
||||||
|
date: blogPost.date,
|
||||||
|
description: blogPost.description,
|
||||||
|
tags: blogPost.tags,
|
||||||
|
}).from(blogPost).orderBy(desc(blogPost.date), desc(blogPost.createdAt));
|
||||||
|
}),
|
||||||
|
|
||||||
|
bySlug: publicProcedure.input(z.string()).query(async ({ input: slug }) => {
|
||||||
|
const post = await db.query.blogPost.findFirst({
|
||||||
|
where(fields, operators) {
|
||||||
|
return operators.eq(fields.slug, slug);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${slug}" not found` });
|
||||||
|
|
||||||
|
const raw = await fetchMdx(post.fileUrl);
|
||||||
|
const { content, data } = matter(raw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: post.slug,
|
||||||
|
content,
|
||||||
|
title: (data.title as string | undefined) ?? post.title,
|
||||||
|
date: frontmatterText(data.date) ?? post.date,
|
||||||
|
description: frontmatterText(data.description) ?? post.description,
|
||||||
|
tags: normalizeTags(data.tags).length > 0 ? normalizeTags(data.tags) : (post.tags ?? []),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
syncFromUploadThing: publicProcedure.mutation(async () => {
|
||||||
|
await assertAdmin();
|
||||||
|
|
||||||
|
const files = (await listAllFiles()).filter(fileMatchesPrefix);
|
||||||
|
const seenFileKeys = new Set<string>();
|
||||||
|
const seenSlugs = new Set<string>();
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let deleted = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const raw = await fetchMdx(fileUrl(file));
|
||||||
|
const metadata = metadataFromFile(file, raw);
|
||||||
|
seenFileKeys.add(file.key);
|
||||||
|
seenSlugs.add(metadata.slug);
|
||||||
|
const existing = await db.query.blogPost.findFirst({
|
||||||
|
where(fields, operators) {
|
||||||
|
return or(operators.eq(fields.fileKey, file.key), operators.eq(fields.slug, metadata.slug));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db.update(blogPost).set(metadata).where(eq(blogPost.id, existing.id));
|
||||||
|
updated += 1;
|
||||||
|
} else {
|
||||||
|
await db.insert(blogPost).values(metadata);
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = await db.select({
|
||||||
|
id: blogPost.id,
|
||||||
|
fileKey: blogPost.fileKey,
|
||||||
|
slug: blogPost.slug,
|
||||||
|
}).from(blogPost);
|
||||||
|
|
||||||
|
const stalePostIds = posts
|
||||||
|
.filter((post) => !seenFileKeys.has(post.fileKey) && !seenSlugs.has(post.slug))
|
||||||
|
.map((post) => post.id);
|
||||||
|
|
||||||
|
for (const id of stalePostIds) {
|
||||||
|
await db.delete(blogPost).where(eq(blogPost.id, id));
|
||||||
|
deleted += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, updated, skipped, deleted };
|
||||||
|
}),
|
||||||
|
});
|
||||||
79
src/server/routers/chat.ts
Normal file
79
src/server/routers/chat.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { publicProcedure, router } from "../trpc";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { db } from '~/server/db'
|
||||||
|
import { chatMessage,
|
||||||
|
chatSession, systemSettings } from "../dbschema/schema";
|
||||||
|
import { isAdmin } from '~/app/actions';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { clerkClient, auth } from '@clerk/nextjs/server'
|
||||||
|
export const chatRouter = router({
|
||||||
|
getSession: publicProcedure.query(async () => {
|
||||||
|
const { 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;
|
||||||
48
src/server/routers/music.ts
Normal file
48
src/server/routers/music.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { publicProcedure, router } from "~/server/trpc";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { music } from "~/server/dbschema/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { isAdmin } from "~/app/actions";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createMusicInputSchema, updateMusicInputSchema } from "~/lib/trpc/music/schemas";
|
||||||
|
import { utapi } from "../uploadthing";
|
||||||
|
export const musicRouter = router({
|
||||||
|
list: publicProcedure.query(async () => {
|
||||||
|
let res = await db.select().from(music).orderBy(music.createdAt);
|
||||||
|
console.log(res);
|
||||||
|
return res;
|
||||||
|
}),
|
||||||
|
create: publicProcedure
|
||||||
|
.input(
|
||||||
|
createMusicInputSchema
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const admin = await isAdmin();
|
||||||
|
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
|
||||||
|
let res = await db.insert(music).values(input).returning();
|
||||||
|
return res.at(0);
|
||||||
|
}),
|
||||||
|
update: publicProcedure
|
||||||
|
.input(
|
||||||
|
updateMusicInputSchema
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const admin = await isAdmin();
|
||||||
|
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
|
||||||
|
const { id, ...data } = input;
|
||||||
|
return db.update(music).set(data).where(eq(music.id, id)).returning();
|
||||||
|
}),
|
||||||
|
delete: publicProcedure
|
||||||
|
.input(z.object({id:z.string().uuid()}))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const admin = await isAdmin();
|
||||||
|
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
|
||||||
|
let res = await db.delete(music).where(eq(music.id, input.id)).returning();
|
||||||
|
let ret = res.at(0)
|
||||||
|
if (ret) {
|
||||||
|
utapi.deleteFiles(ret.fileKey)
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,12 +1,92 @@
|
|||||||
import { publicProcedure, router } from "~/server/trpc";
|
import { publicProcedure, router } from "~/server/trpc";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
|
|
||||||
|
type ReadmeRequest = {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getReadmeRequest(sourceLink: string): ReadmeRequest | null {
|
||||||
|
let url: URL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
url = new URL(sourceLink);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathParts = url.pathname.split("/").filter(Boolean);
|
||||||
|
const [owner, repo] = pathParts;
|
||||||
|
|
||||||
|
if (!owner || !repo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoName = repo.replace(/\.git$/, "");
|
||||||
|
|
||||||
|
if (url.hostname === "github.com" || url.hostname === "www.github.com") {
|
||||||
|
return {
|
||||||
|
url: `https://raw.githubusercontent.com/${owner}/${repoName}/main/README.md`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.hostname.includes("gitea.")) {
|
||||||
|
return {
|
||||||
|
url: `${url.origin}/${owner}/${repoName}/raw/branch/main/README.md`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchReadme(sourceLink: string) {
|
||||||
|
const readmeRequest = getReadmeRequest(sourceLink);
|
||||||
|
|
||||||
|
if (!readmeRequest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(readmeRequest.url, {
|
||||||
|
headers: {
|
||||||
|
Accept: "text/plain",
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.text();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const projectRouter = router({
|
export const projectRouter = router({
|
||||||
listWithStack: publicProcedure.query(async () => {
|
listWithStack: publicProcedure.query(async () => {
|
||||||
return db.query.project.findMany({
|
const projects = await db.query.project.findMany({
|
||||||
with: {
|
with: {
|
||||||
techStack: true,
|
techStack: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
projects.map(async (project) => {
|
||||||
|
if (project.description?.length !== 0 || !project.sourceLink) {
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
description: await fetchReadme(project.sourceLink),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
22
src/server/uploadthing.ts
Normal file
22
src/server/uploadthing.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { createUploadthing, type FileRouter as UploadThingFileRouter } from "uploadthing/next";
|
||||||
|
import { UTApi } from 'uploadthing/server'
|
||||||
|
import { isAdmin } from "~/app/actions";
|
||||||
|
|
||||||
|
const f = createUploadthing();
|
||||||
|
|
||||||
|
export const fileRouter = {
|
||||||
|
musicUploader: f({ audio: { maxFileSize: "64MB", maxFileCount: 1 } })
|
||||||
|
.middleware(async () => {
|
||||||
|
const admin = await isAdmin();
|
||||||
|
if (!admin) throw new Error("Unauthorized");
|
||||||
|
return {};
|
||||||
|
})
|
||||||
|
.onUploadComplete(async ({ file }) => {
|
||||||
|
console.log(file)
|
||||||
|
return { fileUrl: file.ufsUrl, fileKey: file.key, fileName: file.name };
|
||||||
|
}),
|
||||||
|
} satisfies UploadThingFileRouter ;
|
||||||
|
|
||||||
|
export type FileRouter = typeof fileRouter;
|
||||||
|
|
||||||
|
export const utapi = new UTApi();
|
||||||
@@ -139,4 +139,48 @@
|
|||||||
* {
|
* {
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cl-button__google {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
||||||
|
.mde-form-field-fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100000;
|
||||||
|
display: flex !important;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--background);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mde-form-field-editor-fullscreen.w-md-editor {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: calc(100vh - 72px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-toolbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: auto !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-input,
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-area,
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-text,
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-text-pre,
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-text-input,
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-preview {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user