16 Commits

33 changed files with 1648 additions and 584 deletions

View File

@@ -15,7 +15,7 @@ export default function FormScaffold<T extends FieldValues,>(params: {
}) { }) {
const { form, onSubmit, title, id, className, children } = params const { form, onSubmit, title, id, className, children } = params
return ( return (
<Card.Card className={className ? className : "w-5/6 lg:w-1/2"}> <Card.Card className={className ? className : "w-full"}>
<Card.CardHeader> <Card.CardHeader>
<Card.CardTitle> <Card.CardTitle>
<DependentText bool={id ? true : false} true={`Update ${title}`} false={`Create ${title}`} /> <DependentText bool={id ? true : false} true={`Update ${title}`} false={`Create ${title}`} />

View File

@@ -0,0 +1,165 @@
"use client";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import Link from "next/link";
import { useRef } from "react";
// The centerpiece is the site's own "G" mark (public/GLIcon.svg). GSAP draws the
// outline on with a stroke-dash trick, fades the fill in, then keeps everything
// gently alive (floating logo, counter-rotating rings). A curved arrow in the
// lower-right corner, with its label above the tail, points at the chat FAB to
// nudge people toward the assistant.
export default function HomeHero() {
const root = useRef<HTMLDivElement>(null);
const gPath = useRef<SVGPathElement>(null);
useGSAP(
() => {
const logo = gPath.current;
if (logo) {
const len = logo.getTotalLength();
gsap.set(logo, { strokeDasharray: len, strokeDashoffset: len });
}
gsap.set(".hero-fill", { fillOpacity: 0 });
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
tl.from(
".hero-ring",
{ scale: 0, opacity: 0, duration: 0.9, stagger: 0.12, svgOrigin: "100 100" },
0,
);
if (logo) tl.to(logo, { strokeDashoffset: 0, duration: 1.3 }, 0.25);
tl.to(".hero-fill", { fillOpacity: 1, duration: 0.6 }, "-=0.35");
tl.from(".hero-line", { yPercent: 120, opacity: 0, duration: 0.7, stagger: 0.12 }, "-=0.25");
tl.from(".hero-arrow-label", { yPercent: 120, opacity: 0, duration: 0.6 }, "-=0.1");
tl.from(
".hero-arrow-svg",
{ opacity: 0, scale: 0.7, transformOrigin: "top left", duration: 0.6 },
"<+=0.1",
);
// Idle life — runs forever once the entrance has settled.
gsap.to(".hero-logo", { y: -12, duration: 3.2, ease: "sine.inOut", yoyo: true, repeat: -1 });
gsap.to(".hero-ring-spin", { rotation: 360, duration: 44, ease: "none", repeat: -1, svgOrigin: "100 100" });
gsap.to(".hero-ring-spin-rev", { rotation: -360, duration: 32, ease: "none", repeat: -1, svgOrigin: "100 100" });
gsap.to(".hero-arrow-wrap", { y: 10, duration: 1.5, ease: "sine.inOut", yoyo: true, repeat: -1 });
},
{ scope: root },
);
return (
<div
ref={root}
className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden px-6 text-center"
>
{/* Logo + orbiting rings */}
<div className="hero-logo relative h-56 w-56 sm:h-64 sm:w-64">
<svg
className="absolute inset-0 h-full w-full text-primary/40"
viewBox="0 0 200 200"
fill="none"
aria-hidden="true"
>
<circle
className="hero-ring hero-ring-spin"
cx="100"
cy="100"
r="92"
stroke="currentColor"
strokeWidth="1"
strokeDasharray="2 10"
/>
<circle
className="hero-ring hero-ring-spin-rev"
cx="100"
cy="100"
r="78"
stroke="currentColor"
strokeWidth="1.5"
strokeDasharray="14 8"
opacity="0.6"
/>
<circle
className="hero-ring"
cx="100"
cy="100"
r="64"
stroke="currentColor"
strokeWidth="1"
opacity="0.35"
/>
</svg>
<svg
className="absolute inset-0 h-full w-full p-10 text-foreground"
viewBox="0 0 74.193405 74.232162"
aria-label="Gregor Lohaus logo"
>
<g transform="translate(-24.550957,-64.437925)">
<path
ref={gPath}
className="hero-fill"
d="m 61.66652,64.437927 c -20.498425,1.81e-4 -37.115669,16.617653 -37.115564,37.116083 -1.05e-4,20.49842 16.617139,37.1159 37.115564,37.11608 16.081184,-0.0265 30.316081,-10.4061 35.258313,-25.70903 1.144195,-3.51294 1.757471,-7.1771 1.819527,-10.87117 H 87.864404 67.217603 v 10.87117 h 17.977714 c -4.361366,9.03731 -13.494221,14.79672 -23.528797,14.83786 -14.494622,0 -26.244916,-11.75029 -26.244909,-26.24491 -7e-6,-14.494627 11.750287,-26.244918 26.244909,-26.244912 z"
fill="currentColor"
stroke="currentColor"
strokeWidth="0.9"
/>
<rect
className="hero-fill"
width="31.802109"
height="11.397169"
x="-96.2453"
y="67.460899"
transform="rotate(-90)"
fill="currentColor"
/>
</g>
</svg>
</div>
{/* Headline */}
<div className="mt-10 max-w-xl">
<h1 className="overflow-hidden pb-2">
<span className="hero-line block text-4xl font-semibold leading-tight tracking-tight sm:text-6xl">
Gregor Lohaus
</span>
</h1>
<div className="mt-4 overflow-hidden">
<p className="hero-line text-lg text-muted-foreground sm:text-xl">
Full Stack Developer
</p>
</div>
</div>
{/* Lower-right arrow pointing at the chat FAB */}
<Link
href="/assistant"
aria-label="Chat with my AI assistant"
className="hero-arrow-wrap absolute bottom-10 right-8 z-40 flex flex-col items-start text-foreground transition-opacity hover:opacity-80 sm:right-16"
>
<span className="mb-1 max-w-[11rem] -translate-x-20 -translate-y-2 overflow-hidden sm:-translate-x-24">
<span className="hero-arrow-label block font-semibold leading-snug text-foreground drop-shadow-[0_2px_10px_rgba(0,0,0,0.7)] sm:text-lg">
Chat with my AI&nbsp;assistant
</span>
</span>
<svg
className="hero-arrow-svg h-28 w-32 drop-shadow-[0_2px_10px_rgba(0,0,0,0.4)] sm:h-32 sm:w-36"
viewBox="0 0 776.09175 693.66538"
fill="currentColor"
aria-hidden="true"
>
<g transform="matrix(2.7190747,0,0,3.1037754,-326.9763,-1172.9045)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="m 130.838,381.118 c 1.125,28.749 5.277,54.82 12.695,78.018 7.205,22.53 18.847,40.222 36.812,53.747 52.018,39.16 153.369,16.572 153.369,16.572 l -4.632,-32.843 72.918,42.778 -58.597,58.775 -3.85,-27.303 c 0,0 -100.347,18.529 -163.905,-34.881 -37.659,-31.646 -53.293,-84.021 -51.593,-153.962 0.266,-0.247 4.728,-0.908 6.783,-0.901 z"
/>
</g>
</svg>
</Link>
</div>
);
}

View File

@@ -0,0 +1,25 @@
'use server'
import { getGoogleCalendarClient, getGoogleCalendarId } from '~/server/googleCalendar'
export async function cancelMeeting({ eventId }: { eventId: string }) {
try {
const calendar = getGoogleCalendarClient()
await calendar.events.delete({
calendarId: getGoogleCalendarId(),
eventId,
})
return {
success: true,
eventId,
message: 'Meeting removed from Gregor availability calendar.',
}
} catch (error) {
console.error('Failed to cancel meeting:', error)
return {
success: false,
error: 'Failed to remove the meeting from Gregor availability calendar.',
}
}
}

View File

@@ -1,7 +1,34 @@
'use server' 'use server'
import { clerkClient, auth } from '@clerk/nextjs/server'
import { google } from 'googleapis'
import { env } from '~/env' import { env } from '~/env'
import { getGoogleCalendarClient, getGoogleCalendarId } from '~/server/googleCalendar'
function googleCalendarDate(date: Date) {
return date.toISOString().replace(/[-:]|\.\d{3}/g, '')
}
function createGoogleCalendarTemplateLink({
title,
description,
startTime,
endTime,
gregorEmail,
}: {
title: string
description: string
startTime: Date
endTime: Date
gregorEmail: string
}) {
const params = new URLSearchParams({
action: 'TEMPLATE',
text: title,
dates: `${googleCalendarDate(startTime)}/${googleCalendarDate(endTime)}`,
details: description,
add: gregorEmail,
})
return `https://calendar.google.com/calendar/render?${params.toString()}`
}
export async function scheduleMeeting({ export async function scheduleMeeting({
title, title,
@@ -19,56 +46,39 @@ export async function scheduleMeeting({
attendeeName?: string attendeeName?: string
}) { }) {
try { try {
const clerk = await clerkClient() const calendar = getGoogleCalendarClient()
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 startTime = new Date(dateTime)
const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000) const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000)
const attendeeNote = attendeeEmail
? `\n\nVisitor: ${attendeeName ?? 'Unknown'} <${attendeeEmail}>`
: ''
const eventDescription = `${description}${attendeeNote}`
const attendees: { email: string; displayName?: string }[] = [] const eventRequest = {
if (visitorEmail) { summary: title,
attendees.push({ email: visitorEmail, displayName: attendeeName }) description: eventDescription,
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' },
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
} }
const event = await calendar.events.insert({ const event = await calendar.events.insert({
calendarId: 'primary', calendarId: getGoogleCalendarId(),
sendUpdates: 'all', requestBody: eventRequest,
requestBody: { })
summary: title,
description, const addToCalendarLink = createGoogleCalendarTemplateLink({
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' }, title,
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' }, description,
attendees, startTime,
}, endTime,
sendNotifications: true gregorEmail: env.GREGOR_MEETING_EMAIL,
}) })
return { return {
success: true, success: true,
eventId: event.data.id, eventId: event.data.id,
htmlLink: event.data.htmlLink, addToCalendarLink,
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}${visitorEmail ? `. Invite sent to ${visitorEmail}.` : '.'}`, message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}.${attendeeEmail ? ` Visitor email noted: ${attendeeEmail}.` : ''} The add-to-calendar link invites ${env.GREGOR_MEETING_EMAIL}.`,
} }
} catch (error) { } catch (error) {
console.error('Failed to schedule meeting:', error) console.error('Failed to schedule meeting:', error)

View File

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

View File

@@ -6,7 +6,7 @@ import { serialize } from 'next-mdx-remote/serialize'
import type { MDXRemoteSerializeResult } from 'next-mdx-remote' import type { MDXRemoteSerializeResult } from 'next-mdx-remote'
import { mdxComponents } from '~/components/mdx-components' import { mdxComponents } from '~/components/mdx-components'
export default function BlogMdxEditorPreview(params: { source: string }) { export default function MdxEditorPreview(params: { source: string }) {
const [compiled, setCompiled] = useState<MDXRemoteSerializeResult | null>(null) const [compiled, setCompiled] = useState<MDXRemoteSerializeResult | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)

View File

@@ -0,0 +1,143 @@
'use client'
import { trpc } from '~/app/_trpc/Client'
import type { RouterOutputs } from '~/server/routers/_app'
import {
AUTOCOMPLETE_CURSOR_MARKER,
linkSuggestionsToAutocomplete,
type AutocompleteTriggerConfig,
type InternalLinkSuggestion,
type MdeAutocompleteSuggestion,
} from '~/app/_components/Form/Fields/InternalLinkTextarea'
import MdxEditorPreview from './MdxEditorPreview'
function internalLinkSuggestions(params: {
posts?: RouterOutputs['blog']['list'],
projects?: RouterOutputs['projectv2']['listWithStack'],
}): InternalLinkSuggestion[] {
const postLinks = params.posts?.map((post) => ({
label: post.title,
href: `/blog/${post.slug}`,
group: 'Blog',
})) ?? []
const projectLinks = params.projects?.map((project) => ({
label: project.title,
href: `/projects#${project.id}`,
group: 'Project',
})) ?? []
return [...postLinks, ...projectLinks]
}
const mdxAutocompleteSuggestions: MdeAutocompleteSuggestion[] = [
{
label: 'Lead',
value: `<Lead>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Lead>`,
detail: 'Intro paragraph with larger muted text.',
group: 'Component',
trigger: '<',
},
{
label: 'Callout note',
value: `<Callout title="Heads up" variant="note">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
detail: 'Highlighted note block.',
group: 'Component',
trigger: '<',
},
{
label: 'Callout tip',
value: `<Callout title="Tip" variant="tip">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
detail: 'Highlighted tip block.',
group: 'Component',
trigger: '<',
},
{
label: 'Callout warning',
value: `<Callout title="Careful" variant="warning">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
detail: 'Highlighted warning block.',
group: 'Component',
trigger: '<',
},
{
label: 'ButtonLink',
value: `<ButtonLink href="${AUTOCOMPLETE_CURSOR_MARKER}">\nView projects\n</ButtonLink>`,
detail: 'Button-styled internal or external link.',
group: 'Component',
trigger: '<',
},
{
label: 'Figure',
value: `<Figure\n src="${AUTOCOMPLETE_CURSOR_MARKER}"\n alt="Describe the image"\n caption="Optional caption"\n/>`,
detail: 'Image with optional caption.',
group: 'Component',
trigger: '<',
},
{
label: 'PullQuote',
value: `<PullQuote>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</PullQuote>`,
detail: 'Large emphasized quote.',
group: 'Component',
trigger: '<',
},
{
label: 'TagList',
value: `<TagList tags={[${AUTOCOMPLETE_CURSOR_MARKER}]} />`,
detail: 'Inline list of tag badges.',
group: 'Component',
trigger: '<',
},
{
label: 'Badge',
value: `<Badge variant="outline">${AUTOCOMPLETE_CURSOR_MARKER}</Badge>`,
detail: 'Small inline label.',
group: 'Component',
trigger: '<',
},
{
label: 'Image',
value: `![Image](${AUTOCOMPLETE_CURSOR_MARKER})`,
detail: 'Markdown image',
group: 'Markdown',
trigger: '!',
},
]
const mdxTriggerConfigs: AutocompleteTriggerConfig[] = [
{
trigger: '[[',
label: 'Internal links',
isQueryValid: (query) => !query.includes(']'),
},
{
trigger: '<',
label: 'MDX components',
isQueryValid: (query) => !/[\s>]/.test(query),
},
{
trigger: '!',
label: 'Markdown',
isQueryValid: (query) => !/[\s\)]/.test(query),
},
]
/**
* Shared props for an MDX-aware `MdeFormField`: internal-link + component
* autocomplete, trigger configs, and a live MDX preview. Used by every admin
* form that edits MDX content (blog, project, cv entry).
*/
export function useMdxEditorFieldProps() {
const posts = trpc.blog.list.useQuery(undefined, { refetchInterval: 5000 })
const projects = trpc.projectv2.listWithStack.useQuery()
const autocompleteSuggestions = [
...linkSuggestionsToAutocomplete(internalLinkSuggestions({ posts: posts.data, projects: projects.data })),
...mdxAutocompleteSuggestions,
]
return {
autocompleteSuggestions,
triggerConfigs: mdxTriggerConfigs,
renderPreview: (source: string) => <MdxEditorPreview source={source} />,
}
}

View File

@@ -10,15 +10,8 @@ import { TextInputFormField, MdeFormField } from '~/app/_components/Form/Fields'
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import type { RouterOutputs } from '~/server/routers/_app' import type { RouterOutputs } from '~/server/routers/_app'
import MdxComponentReference from './MdxComponentReference' import MdxComponentReference from '~/app/admin/_components/MdxComponentReference'
import BlogMdxEditorPreview from './BlogMdxEditorPreview' import { useMdxEditorFieldProps } from '~/app/admin/_components/useMdxEditorFieldProps'
import {
AUTOCOMPLETE_CURSOR_MARKER,
linkSuggestionsToAutocomplete,
type AutocompleteTriggerConfig,
type InternalLinkSuggestion,
type MdeAutocompleteSuggestion,
} from '~/app/_components/Form/Fields/InternalLinkTextarea'
type BlogPost = RouterOutputs['blog']['bySlug'] type BlogPost = RouterOutputs['blog']['bySlug']
@@ -35,116 +28,6 @@ function parseTags(value: string | undefined): string[] {
return value?.split(',').map((tag) => tag.trim()).filter(Boolean) ?? [] return value?.split(',').map((tag) => tag.trim()).filter(Boolean) ?? []
} }
function internalLinkSuggestions(params: {
posts?: RouterOutputs['blog']['list'],
projects?: RouterOutputs['projectv2']['listWithStack'],
}): InternalLinkSuggestion[] {
const postLinks = params.posts?.map((post) => ({
label: post.title,
href: `/blog/${post.slug}`,
group: 'Blog',
})) ?? []
const projectLinks = params.projects?.map((project) => ({
label: project.title,
href: `/projects#${project.id}`,
group: 'Project',
})) ?? []
return [...postLinks, ...projectLinks]
}
const blogAutocompleteSuggestions: MdeAutocompleteSuggestion[] = [
{
label: 'Lead',
value: `<Lead>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Lead>`,
detail: 'Intro paragraph with larger muted text.',
group: 'Component',
trigger: '<',
},
{
label: 'Callout note',
value: `<Callout title="Heads up" variant="note">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
detail: 'Highlighted note block.',
group: 'Component',
trigger: '<',
},
{
label: 'Callout tip',
value: `<Callout title="Tip" variant="tip">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
detail: 'Highlighted tip block.',
group: 'Component',
trigger: '<',
},
{
label: 'Callout warning',
value: `<Callout title="Careful" variant="warning">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
detail: 'Highlighted warning block.',
group: 'Component',
trigger: '<',
},
{
label: 'ButtonLink',
value: `<ButtonLink href="${AUTOCOMPLETE_CURSOR_MARKER}">\nView projects\n</ButtonLink>`,
detail: 'Button-styled internal or external link.',
group: 'Component',
trigger: '<',
},
{
label: 'Figure',
value: `<Figure\n src="${AUTOCOMPLETE_CURSOR_MARKER}"\n alt="Describe the image"\n caption="Optional caption"\n/>`,
detail: 'Image with optional caption.',
group: 'Component',
trigger: '<',
},
{
label: 'PullQuote',
value: `<PullQuote>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</PullQuote>`,
detail: 'Large emphasized quote.',
group: 'Component',
trigger: '<',
},
{
label: 'TagList',
value: `<TagList tags={[${AUTOCOMPLETE_CURSOR_MARKER}]} />`,
detail: 'Inline list of tag badges.',
group: 'Component',
trigger: '<',
},
{
label: 'Badge',
value: `<Badge variant="outline">${AUTOCOMPLETE_CURSOR_MARKER}</Badge>`,
detail: 'Small inline label.',
group: 'Component',
trigger: '<',
},
{
label: 'Image',
value: `![Image](${AUTOCOMPLETE_CURSOR_MARKER})`,
detail: 'Markdown image',
group: 'Markdown',
trigger: '!',
},
]
const blogTriggerConfigs: AutocompleteTriggerConfig[] = [
{
trigger: '[[',
label: 'Internal links',
isQueryValid: (query) => !query.includes(']'),
},
{
trigger: '<',
label: 'MDX components',
isQueryValid: (query) => !/[\s>]/.test(query),
},
{
trigger: '!',
label: 'Markdown',
isQueryValid: (query) => !/[\s\)]/.test(query),
},
]
export default function CreateUpdateBlogForm(params: { className?: string, entity?: BlogPost }) { export default function CreateUpdateBlogForm(params: { className?: string, entity?: BlogPost }) {
const [slug, setSlug] = useState<string | undefined>(params.entity?.slug) const [slug, setSlug] = useState<string | undefined>(params.entity?.slug)
const [originalSlug, setOriginalSlug] = useState<string | undefined>(params.entity?.slug) const [originalSlug, setOriginalSlug] = useState<string | undefined>(params.entity?.slug)
@@ -162,12 +45,7 @@ export default function CreateUpdateBlogForm(params: { className?: string, entit
}) })
const path = usePathname() const path = usePathname()
const router = useRouter() const router = useRouter()
const posts = trpc.blog.list.useQuery(undefined, { refetchInterval: 5000 }) const mdxEditorProps = useMdxEditorFieldProps()
const projects = trpc.projectv2.listWithStack.useQuery()
const autocompleteSuggestions = [
...linkSuggestionsToAutocomplete(internalLinkSuggestions({ posts: posts.data, projects: projects.data })),
...blogAutocompleteSuggestions,
]
const createMutation = trpc.blog.insert.useMutation({ const createMutation = trpc.blog.insert.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
@@ -226,9 +104,7 @@ export default function CreateUpdateBlogForm(params: { className?: string, entit
name='content' name='content'
label='Content' label='Content'
dataColorMode={(theme as 'dark' | 'light') ?? 'dark'} dataColorMode={(theme as 'dark' | 'light') ?? 'dark'}
autocompleteSuggestions={autocompleteSuggestions} {...mdxEditorProps}
triggerConfigs={blogTriggerConfigs}
renderPreview={(source) => <BlogMdxEditorPreview source={source} />}
/> />
</FormScaffold> </FormScaffold>
</div> </div>

View File

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

View File

@@ -14,6 +14,8 @@ import { useState } from 'react';
import { SelectFormField, TextInputFormField, MdeFormField, CalenderFormField, BooleanFormField } from '~/app/_components/Form/Fields' import { SelectFormField, TextInputFormField, MdeFormField, CalenderFormField, BooleanFormField } from '~/app/_components/Form/Fields'
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import {FormMutationContextProvider, type FormCreateMutationInterface} from '~/app/_components/Form/Components/MutationProvider'; import {FormMutationContextProvider, type FormCreateMutationInterface} from '~/app/_components/Form/Components/MutationProvider';
import MdxComponentReference from '~/app/admin/_components/MdxComponentReference';
import { useMdxEditorFieldProps } from '~/app/admin/_components/useMdxEditorFieldProps';
export default function CreateUpdateCvEntryForm(params: { className?: string, entity?: IterableElement<RouterOutputs['entry']['select']>, isUpdate?: boolean }) { export default function CreateUpdateCvEntryForm(params: { className?: string, entity?: IterableElement<RouterOutputs['entry']['select']>, isUpdate?: boolean }) {
const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined) const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
const { theme } = useTheme() const { theme } = useTheme()
@@ -34,6 +36,7 @@ export default function CreateUpdateCvEntryForm(params: { className?: string, en
}) })
let path = usePathname() let path = usePathname()
let router = useRouter() let router = useRouter()
const mdxEditorProps = useMdxEditorFieldProps()
const createMutation = trpc.entry.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) }) const createMutation = trpc.entry.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) })
const updateMutation = trpc.entry.update.useMutation({ onSuccess: makeOnSuccess('update', form) }) const updateMutation = trpc.entry.update.useMutation({ onSuccess: makeOnSuccess('update', form) })
const deleteMutation = trpc.entry.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) }) const deleteMutation = trpc.entry.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
@@ -51,6 +54,8 @@ export default function CreateUpdateCvEntryForm(params: { className?: string, en
updateMutation:updateMutation, updateMutation:updateMutation,
deleteMutation:deleteMutation deleteMutation:deleteMutation
}}> }}>
<div className='flex flex-col gap-4'>
<MdxComponentReference />
<FormScaffold <FormScaffold
form={form} form={form}
onSubmit={onSubmit} onSubmit={onSubmit}
@@ -70,11 +75,12 @@ export default function CreateUpdateCvEntryForm(params: { className?: string, en
} }
</SelectFormField> </SelectFormField>
<TextInputFormField control={form.control} name='title' label='Title' /> <TextInputFormField control={form.control} name='title' label='Title' />
<MdeFormField control={form.control} name='description' label='Description' dataColorMode={(theme as "dark" | "light") ? (theme as "dark" | "light") : "dark"} /> <MdeFormField control={form.control} name='description' label='Description' dataColorMode={(theme as "dark" | "light") ? (theme as "dark" | "light") : "dark"} {...mdxEditorProps} />
<CalenderFormField control={form.control} name='fromTime' label='From Date' /> <CalenderFormField control={form.control} name='fromTime' label='From Date' />
<CalenderFormField control={form.control} name='toTime' label='To Date' /> <CalenderFormField control={form.control} name='toTime' label='To Date' />
<BooleanFormField control={form.control} name='hideDates' label='Hide Dates' /> <BooleanFormField control={form.control} name='hideDates' label='Hide Dates' />
</FormScaffold> </FormScaffold>
</div>
</FormMutationContextProvider> </FormMutationContextProvider>
) )
} }

View File

@@ -14,6 +14,8 @@ import { usePathname, useRouter } from 'next/navigation';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { makeUseRelationShipWithNameIndex } from '~/lib/hooks'; import { makeUseRelationShipWithNameIndex } from '~/lib/hooks';
import { FormMutationContextProvider } from '~/app/_components/Form/Components/MutationProvider'; import { FormMutationContextProvider } from '~/app/_components/Form/Components/MutationProvider';
import MdxComponentReference from '~/app/admin/_components/MdxComponentReference';
import { useMdxEditorFieldProps } from '~/app/admin/_components/useMdxEditorFieldProps';
export default function CreateUpdateProjectForm(params: { className?: string, entity?: IterableElement<RouterOutputs['project']['select']> }) { export default function CreateUpdateProjectForm(params: { className?: string, entity?: IterableElement<RouterOutputs['project']['select']> }) {
const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined) const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
const { theme } = useTheme() const { theme } = useTheme()
@@ -35,6 +37,7 @@ export default function CreateUpdateProjectForm(params: { className?: string, en
}) })
let path = usePathname() let path = usePathname()
let router = useRouter() let router = useRouter()
const mdxEditorProps = useMdxEditorFieldProps()
const createMutation = trpc.project.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) }) const createMutation = trpc.project.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) })
const updateMutation = trpc.project.update.useMutation({ onSuccess: makeOnSuccess('update', form) }) const updateMutation = trpc.project.update.useMutation({ onSuccess: makeOnSuccess('update', form) })
const deleteMutation = trpc.project.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) }) const deleteMutation = trpc.project.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
@@ -50,6 +53,8 @@ export default function CreateUpdateProjectForm(params: { className?: string, en
updateMutation: updateMutation, updateMutation: updateMutation,
deleteMutation: deleteMutation deleteMutation: deleteMutation
}}> }}>
<div className='flex flex-col gap-4'>
<MdxComponentReference />
<FormScaffold <FormScaffold
form={form} form={form}
onSubmit={onSubmit} onSubmit={onSubmit}
@@ -69,7 +74,7 @@ export default function CreateUpdateProjectForm(params: { className?: string, en
} }
</SelectFormField> </SelectFormField>
<TextInputFormField control={form.control} name='title' label='Title' /> <TextInputFormField control={form.control} name='title' label='Title' />
<MdeFormField control={form.control} name='description' label='Description' dataColorMode={(theme as "dark" | "light") ?? "dark"} /> <MdeFormField control={form.control} name='description' label='Description' dataColorMode={(theme as "dark" | "light") ?? "dark"} {...mdxEditorProps} />
<SelectFormField control={form.control} name='sourceType' label='Source Type' defaultValue={'open'} placeholder='open' > <SelectFormField control={form.control} name='sourceType' label='Source Type' defaultValue={'open'} placeholder='open' >
<SelectItem value="open"> open </SelectItem> <SelectItem value="open"> open </SelectItem>
<SelectItem value="closed"> closed </SelectItem> <SelectItem value="closed"> closed </SelectItem>
@@ -82,6 +87,7 @@ export default function CreateUpdateProjectForm(params: { className?: string, en
<TextInputFormField control={form.control} label='Release Link' name='releaseLink' /> <TextInputFormField control={form.control} label='Release Link' name='releaseLink' />
<IntInputFormField control={form.control} label='Order Position' name='orderPos'/> <IntInputFormField control={form.control} label='Order Position' name='orderPos'/>
</FormScaffold> </FormScaffold>
</div>
</FormMutationContextProvider> </FormMutationContextProvider>
) )
} }

View File

@@ -1,14 +1,12 @@
import { auth } from '@clerk/nextjs/server' import { auth } from '@clerk/nextjs/server'
import { createOpenAI } from '@ai-sdk/openai' import { createOpenAI } from '@ai-sdk/openai'
import { streamText, tool, convertToModelMessages, stepCountIs, type UIMessage } from 'ai' import { streamText, convertToModelMessages, stepCountIs, type UIMessage } from 'ai'
import { success, z } from 'zod'
import { eq, and } from 'drizzle-orm' import { eq, and } from 'drizzle-orm'
import { env } from '~/env' import { env } from '~/env'
import { db } from '~/server/db' import { db } from '~/server/db'
import { chatSession, chatMessage } from '~/server/dbschema/schema' import { chatSession, chatMessage } from '~/server/dbschema/schema'
import { servTrpc } from '~/app/_trpc/ServerClient' import { servTrpc } from '~/app/_trpc/ServerClient'
import { scheduleMeeting } from '~/app/actions/scheduleMeeting' import { createChatTools } from '~/server/ai/tools'
import currentTime from '~/app/actions/currentTime';
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY }) const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
@@ -31,7 +29,17 @@ export async function POST(req: Request) {
if (!session) return new Response('Session not found', { status: 404 }) if (!session) return new Response('Session not found', { status: 404 })
const systemPrompt = await servTrpc.chat.getSystemPrompt() || 'You are an AI recruiter assistant.' const configuredSystemPrompt = await servTrpc.chat.getSystemPrompt() || 'You are an AI recruiter assistant.'
const systemPrompt = `${configuredSystemPrompt}
Runtime context:
- Current server time: ${new Date().toISOString()}.
- Default meeting timezone: Europe/Berlin.
- For availability questions like "next open spot", call getAvailability once. It defaults to checking from now. Use nextAvailableSlot for the next opening, or the first item in availableSlots if needed. Do not call getAvailability again just to get more slots.
- After scheduleMeeting succeeds, include only the returned addToCalendarLink for the visitor. Format it as a Markdown link like [Add this meeting to your Google Calendar](URL); do not paste the raw URL. Explain briefly that this link lets them add the meeting to their own calendar and invite Gregor. Do not mention internal Google Calendar event links.
- You can remove meetings from Gregor's availability calendar with cancelMeeting only when you have the exact eventId from a previous scheduleMeeting result. If a visitor asks to reschedule and you have both the old eventId and a confirmed new slot, call cancelMeeting once for the old event and scheduleMeeting once for the new event. If you do not have the old eventId, ask for clarification instead of guessing.
- When rescheduling, make clear that cancelMeeting only removes the old slot from Gregor's availability calendar. If the visitor already added the old link to their own calendar, they may need to remove that copy themselves.
- Do not calculate or invent calendar availability yourself.`
const model = await servTrpc.chat.getModel() const model = await servTrpc.chat.getModel()
// Save the latest user message // Save the latest user message
@@ -50,42 +58,14 @@ export async function POST(req: Request) {
model: openai(model), model: openai(model),
system: systemPrompt, system: systemPrompt,
messages: await convertToModelMessages(messages), messages: await convertToModelMessages(messages),
tools: { tools: createChatTools(),
scheduleMeeting: tool({ stopWhen: stepCountIs(3),
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 }) => { onFinish: async ({ text, finishReason }) => {
console.log('[ai:chat:onFinish]', {
finishReason,
hasText: Boolean(text),
textLength: text.length,
})
if (text && finishReason === 'stop') { if (text && finishReason === 'stop') {
await db.insert(chatMessage).values({ await db.insert(chatMessage).values({
sessionId, sessionId,

View File

@@ -32,7 +32,7 @@ export default async function BlogPostPage({ params }: Props) {
const date = typeof parsed.data.date === "string" ? parsed.data.date : post.date; const date = typeof parsed.data.date === "string" ? parsed.data.date : post.date;
return ( return (
<main className="mx-auto max-w-2xl px-4 py-12"> <main className="mx-auto h-full max-w-2xl overflow-y-auto px-4 py-12">
<header className="mb-8"> <header className="mb-8">
<h1 className="text-3xl font-bold">{title}</h1> <h1 className="text-3xl font-bold">{title}</h1>
{date && ( {date && (

View File

@@ -6,7 +6,7 @@ export default async function BlogPage() {
const posts = await servTrpc.blog.list(); const posts = await servTrpc.blog.list();
return ( return (
<main className="mx-auto max-w-2xl px-4 py-12"> <main className="mx-auto h-full max-w-2xl overflow-y-auto px-4 py-12">
<h1 className="mb-8 text-3xl font-bold">Blog</h1> <h1 className="mb-8 text-3xl font-bold">Blog</h1>
{posts.length === 0 ? ( {posts.length === 0 ? (
<p className="text-muted-foreground">No posts yet.</p> <p className="text-muted-foreground">No posts yet.</p>

View File

@@ -1,6 +1,25 @@
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import { ClientMdx } from "~/components/ClientMdx"; import { ClientMdx } from "~/components/ClientMdx";
function toolLabel(type: string) {
switch (type) {
case "tool-searchSiteContent":
return "Searching site content";
case "tool-getRelevantExperience":
return "Finding relevant experience";
case "tool-getProjectDetails":
return "Loading project details";
case "tool-getAvailability":
return "Checking availability";
case "tool-cancelMeeting":
return "Removing meeting";
case "tool-getCurrentUnixTime":
return "Checking current time";
default:
return "Using tool";
}
}
export const AssistantMessage = (props: { message: UIMessage }) => { export const AssistantMessage = (props: { message: UIMessage }) => {
let message = props.message; let message = props.message;
return ( return (
@@ -10,7 +29,7 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
> >
<div <div
className= className=
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-muted' 'max-w-[80%] min-w-0 px-4 py-2 text-sm space-y-2 bg-muted break-words [overflow-wrap:anywhere] [&_a]:break-all [&_pre]:max-w-full [&_pre]:overflow-x-auto'
> >
{message.parts.map((part, i) => { {message.parts.map((part, i) => {
if (part.type === 'text') { if (part.type === 'text') {
@@ -18,47 +37,82 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
<ClientMdx key={i} source={part.text} fallback={part.text} /> <ClientMdx key={i} source={part.text} fallback={part.text} />
) )
} }
if (part.type === 'tool-scheduleMeeting') { if (part.type === 'tool-scheduleMeeting') {
const toolPart = part as unknown as { const toolPart = part as unknown as {
type: 'tool-scheduleMeeting' type: 'tool-scheduleMeeting'
state: string state: string
input: unknown input: unknown
output?: { success: boolean; message?: string; htmlLink?: string; error?: string } output?: { success: boolean; error?: string }
} }
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') { if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
return ( return (
<p key={i} className="text-xs opacity-70 italic"> <p key={i} className="text-xs opacity-70 italic">
Scheduling meeting Scheduling meeting
</p> </p>
) )
} }
if (toolPart.state === 'output-available' && toolPart.output) { if (toolPart.state === 'output-available' && toolPart.output) {
const result = toolPart.output const result = toolPart.output
return ( if (result.success) return null
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded"> return (
{result.success ? ( <div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
<span> <span> {result.error}</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> </div>
) )
} }
} }
return null if (part.type === 'tool-cancelMeeting') {
})} const toolPart = part as unknown as {
type: 'tool-cancelMeeting'
state: string
output?: { success: boolean; error?: string }
}
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
return (
<p key={i} className="text-xs opacity-70 italic">
Removing meeting
</p>
)
}
if (toolPart.state === 'output-available' && toolPart.output) {
if (toolPart.output.success) return null
return (
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
<span> {toolPart.output.error}</span>
</div>
)
}
}
if (part.type.startsWith('tool-')) {
const toolPart = part as unknown as {
type: string
state: string
output?: { success?: boolean; results?: unknown[]; matches?: unknown[]; availableSlots?: unknown[]; project?: unknown; error?: string }
}
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
return (
<p key={i} className="text-xs opacity-70 italic">
{toolLabel(toolPart.type)}...
</p>
)
}
if (toolPart.state === 'output-available') {
const count = toolPart.output?.results?.length
?? toolPart.output?.matches?.length
?? toolPart.output?.availableSlots?.length
return (
<p key={i} className="text-xs opacity-70 italic">
{toolPart.output?.success === false
? (toolPart.output.error ?? `${toolLabel(toolPart.type)} failed`)
: count != null
? `${toolLabel(toolPart.type)} complete (${count})`
: `${toolLabel(toolPart.type)} complete`}
</p>
)
}
}
return null
})}
</div> </div>
</div> </div>
) )

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { useChat } from '@ai-sdk/react' import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai' import { DefaultChatTransport, type UIMessage } from 'ai'
import { Button } from '~/components/ui/button' import { Button } from '~/components/ui/button'
@@ -54,7 +54,7 @@ function addInitMessage(messageArray: UIMessage[]) {
role: 'assistant', role: 'assistant',
parts: [{ parts: [{
type: 'text', type: 'text',
text: "Hi im gregors ai assistant,you can ask me to provide general information or to schedule a meeting." text: "Hi, I'm Gregor's AI assistant. Ask me about his experience, projects, blog posts, or availability for a meeting."
}], }],
}) })
} }
@@ -83,14 +83,21 @@ function AuthenticatedChatInterface({ dbMessages, sessionId }: ChatInterfaceProp
sendMessage({ text }) sendMessage({ text })
} }
const gsapContext = useGsapContext() const gsapContext = useGsapContext()
const didInitialScroll = useRef(false)
useEffect(() => { useEffect(() => {
let scroller = gsapContext?.getScroller() const scroller = gsapContext?.getScroller()
if (scroller instanceof Window) { if (!scroller || scroller instanceof Window) {
return; return
} }
console.log(scroller?.scrollHeight) // Jump instantly on first open so the chat starts pinned to the bottom;
scroller?.scrollTo({ behavior: 'smooth', top: scroller.scrollHeight }) // animate subsequent updates. Defer a frame so the messages have laid out
}, [messages]) // (and any streaming content has grown) before we measure scrollHeight.
const behavior: ScrollBehavior = didInitialScroll.current ? 'smooth' : 'auto'
didInitialScroll.current = true
requestAnimationFrame(() => {
scroller.scrollTo({ behavior, top: scroller.scrollHeight })
})
}, [messages, status])
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{messages && {messages &&
@@ -114,7 +121,7 @@ function AuthenticatedChatInterface({ dbMessages, sessionId }: ChatInterfaceProp
</div> </div>
)} )}
<div className="p-4 border-t flex flex-row gap-2"> <div className="p-4 border-t flex flex-row gap-2 shrink-0">
<Textarea <Textarea
name='message' name='message'
value={input} value={input}

View File

@@ -6,7 +6,7 @@ import { ScrollArea } from '~/components/ui/scroll-area';
import { memo } from 'react'; import { memo } from 'react';
const Messages = memo(({messages,status}: { messages: UIMessage[],status:ChatStatus}) => { const Messages = memo(({messages,status}: { messages: UIMessage[],status:ChatStatus}) => {
return ( return (
<ScrollArea data-scroller-priority='1' className="w-full h-[90%] max-w-4xl mx-auto"> <ScrollArea data-scroller-priority='1' className="w-full flex-1 min-h-0 max-w-4xl mx-auto">
{messages.map((message, i) => ( {messages.map((message, i) => (
<Card.AnimatedCard scrollOnly={true} key={i}> <Card.AnimatedCard scrollOnly={true} key={i}>
<Card.CardContent> <Card.CardContent>

View File

@@ -14,7 +14,7 @@ export const UserMessage = (props:{message: UIMessage}) => {
> >
<div <div
className= className=
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-primary' 'max-w-[80%] min-w-0 px-4 py-2 text-sm space-y-2 bg-primary break-words whitespace-pre-wrap [overflow-wrap:anywhere]'
> >
{message} {message}
</div> </div>

View File

@@ -9,11 +9,11 @@ export default function ChatPage() {
const {messages,session,isLoading,error} = useMessages() const {messages,session,isLoading,error} = useMessages()
useTimeLine(messages) useTimeLine(messages)
return ( return (
<div className="flex flex-col px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10"> <div className="flex flex-col px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10 pb-4">
<AnimatedPageTitle position={0}> <AnimatedPageTitle position={0}>
<span>Talk To My </span> <span> AI-Assistant</span> <span>Talk To My </span> <span> AI-Assistant</span>
</AnimatedPageTitle> </AnimatedPageTitle>
<div className='flex items-center h-[80%] w-full my-auto w-full'> <div className='flex flex-1 min-h-0 w-full'>
{!isLoading && {!isLoading &&
<ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/> <ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
} }

View File

@@ -5,7 +5,7 @@ import { cn } from "~/lib/utils"
import { AnimatedCard, CardContent, CardHeader, CardTitle } from "~/components/ui/card" import { AnimatedCard, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
import type { ArrayElement } from "type-fest" import type { ArrayElement } from "type-fest"
import AnimateTextIn from "~/app/_components/Animated/AnimateIn" import AnimateTextIn from "~/app/_components/Animated/AnimateIn"
import AnimatePopUp from "~/app/_components/Animated/AnimatePopUp" import AnimatedDiv from "~/app/_components/Animated/AnimatedDiv"
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
export type CvCategoryData = ArrayElement<RouterOutputs['categoryv2']['listAllWithEntries']> export type CvCategoryData = ArrayElement<RouterOutputs['categoryv2']['listAllWithEntries']>
@@ -18,22 +18,46 @@ type CvCategoryProps = {
} }
export default function CvCategory({ category, layout, position = 0, descriptions }: CvCategoryProps) { export default function CvCategory({ category, layout, position = 0, descriptions }: CvCategoryProps) {
const entries = category.cvEntry const entries = category.cvEntry
const isRowLayout = layout === "row"
const entryStart = position + 1
const entryStagger = 1.1
const entryItems = entries.map((entry, i) => {
const entryPosition = entryStart + i * entryStagger
return (
<AnimatedDiv
className={cn(isRowLayout ? "min-w-[min(100%,18rem)] flex-1" : "w-full", "opacity-0 -translate-x-6")}
position={entryPosition}
debugId={`cv-entry-wrapper:${category.name}:${entry.title}:${entryPosition}`}
opacity={1}
x={0}
duration={0.4}
ease="power2.out"
key={entry.id}
>
<CvEntry position={entryPosition} entry={entry} description={descriptions[entry.id]} row={isRowLayout} className="w-full" />
</AnimatedDiv>
)
})
return ( return (
<AnimatedCard position={position} className={cn(layout == "row" ? "w-full" : "", "h-screen")}> <AnimatedCard position={position} className={cn(isRowLayout ? "h-fit min-w-[min(100%,18rem)] flex-1" : "lg:h-full lg:min-h-0")}>
<CardHeader> <CardHeader>
<AnimateTextIn once position={position + 0.2} animation="slide" debugId={`cv-category-title:${category.name}:${position + 0.2}`}> <AnimateTextIn once position={position + 0.35} animation="slide" debugId={`cv-category-title:${category.name}:${position + 0.35}`}>
<CardTitle>{category.name}</CardTitle> <CardTitle>{category.name}</CardTitle>
</AnimateTextIn> </AnimateTextIn>
</CardHeader> </CardHeader>
{entries.length > 0 ? {entries.length > 0 ?
<CardContent className={cn(layout == "row" ? "flex flex-row flex-wrap justify-center lg:justify-between" : "flex flex-col", "gap-4", "overflow-scroll")}> <CardContent className={cn(isRowLayout ? "flex flex-row flex-wrap items-stretch justify-center lg:justify-between" : "flex flex-col flex-1 min-h-0", "gap-4")}>
<ScrollArea> {isRowLayout ? (
{entries.map((entry, i) => ( entryItems
<AnimatePopUp position={position + 0.4 + i * 0.2} debugId={`cv-entry-wrapper:${category.name}:${entry.title}:${position + 0.4 + i * 0.2}`} key={entry.id}> ) : (
<CvEntry position={position + 0.4 + i * 0.2} entry={entry} description={descriptions[entry.id]} className={layout == "row" ? "w-full lg:w-fit" : undefined} /> <ScrollArea className="min-h-0 w-full flex-1">
</AnimatePopUp> <div className="flex flex-col gap-4 pr-2">
))} {entryItems}
</ScrollArea> </div>
</ScrollArea>
)}
</CardContent> </CardContent>
: :
<></> <></>

View File

@@ -9,30 +9,34 @@ import type { CvCategoryData } from "./CvCategory"
export type CvEntryData = ArrayElement<CvCategoryData['cvEntry']> export type CvEntryData = ArrayElement<CvCategoryData['cvEntry']>
export default function CvEntry({ entry, description, className, position = 0 }: { export default function CvEntry({ entry, description, className, position = 0, row = false }: {
entry: CvEntryData, entry: CvEntryData,
description?: ReactNode, description?: ReactNode,
className?: string, className?: string,
position?: number position?: number,
row?: boolean,
}) { }) {
const from = format(new Date(entry.fromTime), 'MMMM yyyy')
const to = format(new Date(entry.toTime), 'MMMM yyyy')
return ( return (
<Card className={className ? cn("w-fit", className) : "w-fit"}> <Card className={cn("w-full ring-0", row && "h-full", className)}>
{entry.title ? {entry.title ?
<CardHeader> <CardHeader>
<AnimateTextIn position={position} animation="slide" debugId={`cv-entry-title:${entry.title}:${position}`}> <AnimateTextIn once position={position + 0.25} animation="slide" debugId={`cv-entry-title:${entry.title}:${position + 0.25}`}>
<CardTitle> {entry.title} </CardTitle> <CardTitle> {entry.title} </CardTitle>
</AnimateTextIn> </AnimateTextIn>
</CardHeader> : </CardHeader> :
<></> <></>
} }
{entry.description ? {entry.description ?
<CardContent className="text-sm lg:text-base"> <CardContent className={cn("text-sm lg:text-base", row && "flex flex-1 items-center justify-center")}>
{/* Fade the description in place instead of collapsing its height: {/* Fade the description in place instead of collapsing its height:
the outer entry pop-up (CvCategory) measures height:auto when it the outer entry pop-up (CvCategory) measures height:auto when it
plays, so the description must stay laid out at full height or the plays, so the description must stay laid out at full height or the
entry reveals too short. */} entry reveals too short. */}
<AnimatedDiv once position={position + 0.2} className="opacity-0" opacity={1} duration={0.5} debugId={`cv-entry-description:${entry.title}:${position + 0.2}`}> <AnimatedDiv once position={position + 0.75} className={cn("opacity-0", row && "w-full")} opacity={1} duration={0.5} debugId={`cv-entry-description:${entry.title}:${position + 0.75}`}>
<article className="prose prose-zinc dark:prose-invert max-w-none"> <article className={cn("prose prose-zinc dark:prose-invert max-w-none", row && "text-center")}>
{description ?? entry.description} {description ?? entry.description}
</article> </article>
</AnimatedDiv> </AnimatedDiv>
@@ -40,9 +44,9 @@ export default function CvEntry({ entry, description, className, position = 0 }:
<></> <></>
} }
{!entry.hideDates ? {!entry.hideDates ?
<CardFooter className="text-sm"> <CardFooter className="border-t-0 text-sm">
<AnimateTextIn position={position + 0.4} debugId={`cv-entry-dates:${entry.title}:${position + 0.4}`}> <AnimateTextIn once position={position + 1.15} debugId={`cv-entry-dates:${entry.title}:${position + 1.15}`}>
{`von ${format(new Date(entry.fromTime), 'M. yyyy')} bis zum ${format(new Date(entry.toTime), 'M. yyyy')}`} {`${from} to ${to}`}
</AnimateTextIn> </AnimateTextIn>
</CardFooter> : </CardFooter> :
<></> <></>

View File

@@ -1,10 +1,12 @@
'use client' 'use client'
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Sidebar, SidebarContent, SidebarProvider } from "~/components/ui/sidebar"; import { Sidebar, SidebarContent, SidebarProvider, useSidebar } from "~/components/ui/sidebar";
import { ScrollArea } from "~/components/ui/scroll-area";
import type { RouterOutputs } from "~/server/routers/_app" import type { RouterOutputs } from "~/server/routers/_app"
import SidebarTriggerDisappearsOnMobile from "./SidebarTriggerDisappearsOnMobile"; import SidebarTriggerDisappearsOnMobile from "./SidebarTriggerDisappearsOnMobile";
import CvCategory from "./CvCategory"; import CvCategory from "./CvCategory";
import { useTimeLine } from "~/app/_providers/GsapProvicer"; import { useTimeLine } from "~/app/_providers/GsapProvicer";
import { cn } from "~/lib/utils";
export default function CvPage(props: { export default function CvPage(props: {
cv: RouterOutputs['categoryv2']['listAllWithEntries'], cv: RouterOutputs['categoryv2']['listAllWithEntries'],
descriptions: Record<string, ReactNode>, descriptions: Record<string, ReactNode>,
@@ -17,43 +19,104 @@ export default function CvPage(props: {
const headerCategories = byPosition("header") const headerCategories = byPosition("header")
const col1Categories = byPosition("col1") const col1Categories = byPosition("col1")
const col2Categories = byPosition("col2") const col2Categories = byPosition("col2")
const hasSidebar = sidebarCategories.length > 0
const hasTwoMainColumns = col1Categories.length > 0 && col2Categories.length > 0
const mainColumnWidthClass = hasTwoMainColumns ? "lg:w-1/2 lg:h-full" : ""
const sequencePositions = <T extends { cvEntry: unknown[] }>(categories: T[], start = 0) => {
let cursor = start
const positions = categories.map((category) => {
const position = cursor
cursor += 1.8 + category.cvEntry.length * 1.2
return position
})
return { end: cursor, positions }
}
const headerSequence = sequencePositions(headerCategories)
const sidebarSequence = sequencePositions(sidebarCategories)
const contentStart = Math.max(headerSequence.end, sidebarSequence.end)
const col1Sequence = sequencePositions(col1Categories, contentStart)
const col2Sequence = sequencePositions(col2Categories, contentStart)
return ( return (
<> <>
<SidebarProvider> <SidebarProvider className="h-full min-h-0 overflow-hidden">
{sidebarCategories.length > 0 && {sidebarCategories.length > 0 &&
<> <>
<SidebarTriggerDisappearsOnMobile /> <SidebarTriggerDisappearsOnMobile />
<Sidebar> <Sidebar>
<SidebarContent className="p-2 lg:pt-[3.2rem]"> <SidebarContent className="p-2 lg:pt-[3.2rem]">
{sidebarCategories.map((cat, i) => ( {sidebarCategories.map((cat, i) => (
<CvCategory layout="col" position={i} category={cat} descriptions={descriptions} key={cat.id} /> <CvCategory layout="col" position={sidebarSequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))} ))}
</SidebarContent> </SidebarContent>
</Sidebar> </Sidebar>
</> </>
} }
<div className="h-full w-full flex flex-wrap flex-row p-4 pt-8 "> <MainContent
<div id="mainwrap" className="flex w-full flex-col gap-4 lg:px-[15vw]"> hasSidebar={hasSidebar}
<div id="header" className="flex w-full h-fit flex-row gap-4 flex-wrap"> mainColumnWidthClass={mainColumnWidthClass}
{headerCategories.map((cat, i) => ( headerCategories={headerCategories}
<CvCategory layout="row" position={i} category={cat} descriptions={descriptions} key={cat.id} /> headerSequence={headerSequence}
))} col1Categories={col1Categories}
</div> col1Sequence={col1Sequence}
<div id="colwrapper" className="flex flex-col lg:flex-row w-full h-3/4 gap-4"> col2Categories={col2Categories}
<div id="col1" className={`flex flex-col w-full ${col1Categories.length > 0 ? "lg:w-1/2" : ""} h-full gap-4`}> col2Sequence={col2Sequence}
{col1Categories.map((cat, i) => ( descriptions={descriptions}
<CvCategory layout="col" position={i} category={cat} descriptions={descriptions} key={cat.id} /> />
))}
</div>
<div id="col2" className={`flex flex-col w-full ${col2Categories.length > 0 ? "lg:w-1/2" : ""} h-full gap-4`}>
{col2Categories.map((cat, i) => (
<CvCategory layout="col" position={i} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
</div>
</div>
</div>
</SidebarProvider> </SidebarProvider>
</> </>
) )
} }
type Sequence = { positions: number[] }
type Category = NonNullable<RouterOutputs['categoryv2']['listAllWithEntries']>[number]
function MainContent(props: {
hasSidebar: boolean,
mainColumnWidthClass: string,
headerCategories: Category[],
headerSequence: Sequence,
col1Categories: Category[],
col1Sequence: Sequence,
col2Categories: Category[],
col2Sequence: Sequence,
descriptions: Record<string, ReactNode>,
}) {
const {
hasSidebar, mainColumnWidthClass, headerCategories, headerSequence,
col1Categories, col1Sequence, col2Categories, col2Sequence, descriptions,
} = props
const { open } = useSidebar()
return (
<ScrollArea className="h-full min-h-0 w-full flex-1">
<div
id="mainwrap"
className={cn(
"flex w-full flex-col gap-4 p-4 pt-8",
!hasSidebar && "lg:px-[15vw]",
hasSidebar && !open && "lg:px-[8vw]",
)}
>
<div id="header" className="flex w-full flex-row flex-wrap items-stretch gap-4">
{headerCategories.map((cat, i) => (
<CvCategory layout="row" position={headerSequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
<div id="colwrapper" className="flex w-full flex-col gap-4 lg:h-[80vh] lg:flex-row">
<div id="col1" className={cn("flex min-h-0 w-full flex-col gap-4", mainColumnWidthClass)}>
{col1Categories.map((cat, i) => (
<CvCategory layout="col" position={col1Sequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
<div id="col2" className={cn("flex min-h-0 w-full flex-col gap-4", mainColumnWidthClass)}>
{col2Categories.map((cat, i) => (
<CvCategory layout="col" position={col2Sequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
</div>
</div>
</ScrollArea>
)
}

View File

@@ -53,7 +53,7 @@ export default async function RootLayout({
<MusicPlayerProvider> <MusicPlayerProvider>
<AnimatedBackGroundContainer followSpeed={0.003} particleCount={100} orbitRadius={2000}> <AnimatedBackGroundContainer followSpeed={0.003} particleCount={100} orbitRadius={2000}>
<TopNav /> <TopNav />
<main className="absolute lg:top-10 h-screen lg:h-[calc(100vh-var(--spacing)*10)] w-screen"> <main className="absolute lg:top-10 h-[100dvh] lg:h-[calc(100vh-var(--spacing)*10)] w-screen">
{children} {children}
</main> </main>
{modal} {modal}

View File

@@ -0,0 +1,68 @@
'use client'
import * as Card from "~/components/ui/card";
import { useTimeLine } from "../../_providers/GsapProvicer";
import AnimatedPageTitle from "../../_components/Animated/AnimatedPageTitle";
import AnimateTextIn from "../../_components/Animated/AnimateIn";
import { ScrollArea } from "~/components/ui/scroll-area";
import AnimatePopUp from "../../_components/Animated/AnimatePopUp";
import AudioPlayer from "./AudioPlayer";
import type { RouterOutputs } from "~/server/routers/_app";
export default function MusicPage(props: {
tracks: RouterOutputs['music']['list'],
}) {
const { tracks } = props;
useTimeLine(tracks)
return (
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
<AnimatedPageTitle position={0}><span>Just Some </span> <span>Music I Made</span> </AnimatedPageTitle>
<div className="flex flex-wrap h-fit content-center">
<AnimateTextIn once className="flex flex-wrap mr-[1em]" position={0.5}>
<div><p className="break-after-avoid mr-[1em]">All works on this page are licensed under:</p></div>
<div><a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a></div>
</AnimateTextIn>
<AnimatePopUp duration={1} ease='elastic.inOut' position={2} once className="items-center content-center">
<div className="flex flex-row">
<img className="max-w-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" />
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="" />
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/nc.svg" alt="" />
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="" />
</div>
</AnimatePopUp>
</div>
<div className="pt-10" />
{tracks && tracks.map((track, i) => (
<div key={track.id}>
<Card.AnimatedCard position={i + 1}>
<Card.CardHeader>
<AnimateTextIn once position={i + 1.2} animation="slide">
<Card.CardTitle>{track.title}</Card.CardTitle>
</AnimateTextIn>
</Card.CardHeader>
<Card.CardContent className="flex flex-col gap-3">
{track.description && (
<AnimatePopUp once position={i + 1.25} duration={2}>
<p className="text-sm text-muted-foreground">{track.description}</p>
</AnimatePopUp>
)}
<AnimatePopUp duration={2} ease='elastic.inOut' position={i + 1.3} once>
<AudioPlayer
id={track.id}
src={track.streamUrl ?? track.fileUrl}
downloadUrl={track.fileUrl}
downloadName={track.fileName}
/>
</AnimatePopUp>
</Card.CardContent>
</Card.AnimatedCard>
<div className="pt-5" />
</div>
))}
{!tracks?.length &&
<div className="flex justify-center items-center text-muted-foreground">
No music yet.
</div>}
</ScrollArea>
);
}

View File

@@ -1,67 +1,13 @@
'use client' import { Suspense } from "react";
import { trpc } from "~/app/_trpc/Client"; import { servTrpc as trpc } from "../_trpc/ServerClient";
import * as Card from "~/components/ui/card"; import Page from "./_components/Page";
import { useTimeLine } from "../_providers/GsapProvicer";
import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle"; export default async function MusicPage() {
import { Spinner } from "~/components/ui/spinner"; const tracks = await trpc.music.list();
import AnimateTextIn from "../_components/Animated/AnimateIn";
import { ScrollArea } from "~/components/ui/scroll-area"; return (
import AnimatePopUp from "../_components/Animated/AnimatePopUp"; <Suspense>
import AudioPlayer from "./_components/AudioPlayer"; <Page tracks={tracks} />
export default function MusicPage() { </Suspense>
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 once className="flex flex-wrap mr-[1em]" position={0.5}>
<div><p className="break-after-avoid mr-[1em]">All works on this page are licensed under:</p></div>
<div><a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a></div>
</AnimateTextIn>
<AnimatePopUp duration={1} ease='elastic.inOut' position={2} once className="items-center content-center">
<div className="flex flex-row">
<img className="max-w-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" />
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="" />
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/nc.svg" alt="" />
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="" />
</div>
</AnimatePopUp>
</div>
<div className="pt-10" />
{tracks && tracks.map((track, i) => (
<div key={track.id}>
<Card.AnimatedCard position={i + 1}>
<Card.CardHeader>
<AnimateTextIn once position={i + 1.2} animation="slide">
<Card.CardTitle>{track.title}</Card.CardTitle>
</AnimateTextIn>
</Card.CardHeader>
<Card.CardContent className="flex flex-col gap-3">
{track.description && (
<p className="text-sm text-muted-foreground gsapant">{track.description}</p>
)}
<AnimatePopUp duration={2} ease='elastic.inOut' position={i + 1.3} once>
<AudioPlayer
id={track.id}
src={track.streamUrl ?? track.fileUrl}
downloadUrl={track.fileUrl}
downloadName={track.fileName}
/>
</AnimatePopUp>
</Card.CardContent>
</Card.AnimatedCard>
<div className="pt-5" />
</div>
))}
{!isLoading && !tracks?.length &&
<div className="flex justify-center items-center text-muted-foreground">
No music yet.
</div>
}
{isLoading && <div className="w-full h-full items-center flex flex-row content-center gap-4 justify-center">
<Spinner /> Loading Tracks
</div>}
</ScrollArea>
);
} }

View File

@@ -1,9 +1,9 @@
import HomeHero from "./_components/Home/HomeHero";
export default function HomePage() { export default function HomePage() {
return ( return (
<main> <main className="h-full w-full">
<div> <HomeHero />
hello world
</div>
</main> </main>
); );
} }

View File

@@ -0,0 +1,111 @@
'use client'
import type { ReactNode } from "react";
import * as Card from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { StackBadge } from "~/components/StackBadge";
import { ScrollArea } from "~/components/ui/scroll-area";
import AnimatedPageTitle from "../../_components/Animated/AnimatedPageTitle";
import AnimateTextIn from "../../_components/Animated/AnimateIn";
import { useTimeLine } from "../../_providers/GsapProvicer";
import AnimatePopUp from "../../_components/Animated/AnimatePopUp";
import { Button } from "~/components/ui/button";
import type { RouterOutputs } from "~/server/routers/_app";
export default function ProjectsPage(props: {
projects: RouterOutputs['projectv2']['listWithStack'],
descriptions: Record<string, ReactNode>,
}) {
const { projects, descriptions } = props;
useTimeLine(projects)
if (!projects?.length) {
return (
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground">
No projects yet.
</div>
);
}
return (
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
<AnimatedPageTitle position={0}><span>Projects I've Been</span><span> Working on</span> </AnimatedPageTitle>
<div className="pt-10" />
{projects.map((project, i) => (
<div id={project.id} key={i} className="scroll-mt-10">
<Card.AnimatedCard position={i + 1.2} key={project.id}>
<Card.CardHeader>
<div className="flex items-start justify-between gap-2 flex-wrap">
<AnimateTextIn once position={i + 1.4} animation="slide"><Card.CardTitle>{project.title}</Card.CardTitle></AnimateTextIn>
<div className="flex gap-2 flex-wrap">
{project.sourceType && (
<AnimatePopUp position={i + 2} duration={2} once>
<Badge variant={project.sourceType === "open" ? "secondary" : "outline"}>
{project.sourceType === "open" ? "Open Source" : "Closed Source"}
</Badge>
</AnimatePopUp>
)}
{project.releaseStatus && (
<Badge variant={project.releaseStatus === "released" ? "default" : "outline"}>
{project.releaseStatus === "released" ? "Released" : "Unreleased"}
</Badge>
)}
</div>
</div>
</Card.CardHeader>
{(project.description || project.sourceLink || project.releaseLink || project.techStack?.stackItems?.length) && (
<Card.CardContent className="flex flex-col gap-3">
{project.description && (
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
<AnimatePopUp once position={i + 1.4} duration={10}>
{descriptions[project.id] ?? project.description}
</AnimatePopUp>
</div>
)}
<div className="flex flex-row">
{project.techStack?.stackItems && project.techStack.stackItems.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{project.techStack.stackItems.map((item, k) => (
<AnimatePopUp key={k} position={(i + 2) + k * 0.5} once> <StackBadge key={item} item={item} /> </AnimatePopUp>
))}
</div>
)}
{(project.sourceLink || project.releaseLink) && (
<div className="ml-auto flex-col lg:flex-row justify-center gap-5">
{project.sourceLink &&
<Button variant='outline' className="cursor-pointer mb-3 lg:mb-0 lg:mr-3 min-w-18">
<a
href={project.sourceLink}
target="_blank"
rel="noopener noreferrer"
className='items-center'
>
Source
</a>
</Button>
}
{project.releaseLink &&
<Button variant='default' className="cursor-pointer min-w-18 items-center">
<a
href={project.releaseLink}
target="_blank"
rel="noopener noreferrer"
className='items-center'
>
Live
</a>
</Button>
}
</div>
)}
</div>
</Card.CardContent>
)}
</Card.AnimatedCard>
<div className="pt-5" />
</div>
))}
</ScrollArea>
);
}

View File

@@ -1,115 +1,40 @@
'use client' import { Suspense, type ReactNode } from "react";
import { MDXRemote } from "next-mdx-remote/rsc";
import rehypeHighlight from "rehype-highlight";
import remarkGfm from "remark-gfm";
import { servTrpc as trpc } from "../_trpc/ServerClient";
import { mdxComponents } from "~/components/mdx-components";
import Page from "./_components/Page";
import { trpc } from "~/app/_trpc/Client"; export default async function ProjectsPage() {
import * as Card from "~/components/ui/card"; const projects = await trpc.projectv2.listWithStack();
import { Badge } from "~/components/ui/badge";
import { StackBadge } from "~/components/StackBadge";
import { ScrollArea } from "~/components/ui/scroll-area";
import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle";
import AnimateTextIn from "../_components/Animated/AnimateIn";
import { useTimeLine } from "../_providers/GsapProvicer";
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
import { Button } from "~/components/ui/button";
import { ClientMdx } from "~/components/ClientMdx";
export default function ProjectsPage() { // Render the MDX descriptions on the server so they exist at first paint.
const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery(); // The client tree (which runs the GSAP entrance via useTimeLine) only places
useTimeLine(projects) // these already-rendered nodes — it never invokes the MDX renderer itself, so
if (isLoading) { // the 'use client' boundary stays intact and the animations no longer play
return ( // against an un-rendered fallback.
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground"> const descriptions: Record<string, ReactNode> = {};
Loading... for (const project of projects ?? []) {
</div> if (!project.description?.trim()) continue;
); descriptions[project.id] = (
} <MDXRemote
source={project.description}
if (!projects?.length) { components={mdxComponents}
return ( options={{
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground"> mdxOptions: {
No projects yet. format: "md",
</div> remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeHighlight],
},
}}
/>
); );
} }
return ( return (
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10"> <Suspense>
<AnimatedPageTitle position={0}><span>Projects I've Been</span><span> Working on</span> </AnimatedPageTitle> <Page projects={projects} descriptions={descriptions} />
<div className="pt-10" /> </Suspense>
{projects.map((project, i) => (
<div id={project.id} key={i} className="scroll-mt-10">
<Card.AnimatedCard position={i + 1.2} key={project.id}>
<Card.CardHeader>
<div className="flex items-start justify-between gap-2 flex-wrap">
<AnimateTextIn once position={i + 1.4} animation="slide"><Card.CardTitle>{project.title}</Card.CardTitle></AnimateTextIn>
<div className="flex gap-2 flex-wrap">
{project.sourceType && (
<AnimatePopUp position={i + 2} duration={2} once>
<Badge variant={project.sourceType === "open" ? "secondary" : "outline"}>
{project.sourceType === "open" ? "Open Source" : "Closed Source"}
</Badge>
</AnimatePopUp>
)}
{project.releaseStatus && (
<Badge variant={project.releaseStatus === "released" ? "default" : "outline"}>
{project.releaseStatus === "released" ? "Released" : "Unreleased"}
</Badge>
)}
</div>
</div>
</Card.CardHeader>
{(project.description || project.sourceLink || project.releaseLink || project.techStack?.stackItems?.length) && (
<Card.CardContent className="flex flex-col gap-3">
{project.description && (
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
<AnimatePopUp once position={i + 1.4} duration={10}>
<ClientMdx source={project.description} fallback={project.description} />
</AnimatePopUp>
</div>
)}
<div className="flex flex-row">
{project.techStack?.stackItems && project.techStack.stackItems.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{project.techStack.stackItems.map((item, k) => (
<AnimatePopUp key={k} position={(i + 2) + k * 0.5} once> <StackBadge key={item} item={item} /> </AnimatePopUp>
))}
</div>
)}
{(project.sourceLink || project.releaseLink) && (
<div className="ml-auto flex-col lg:flex-row justify-center gap-5">
{project.sourceLink &&
<Button variant='outline' className="cursor-pointer mb-3 lg:mb-0 lg:mr-3 min-w-18">
<a
href={project.sourceLink}
target="_blank"
rel="noopener noreferrer"
className='items-center'
>
Source
</a>
</Button>
}
{project.releaseLink &&
<Button variant='default' className="cursor-pointer min-w-18 items-center">
<a
href={project.releaseLink}
target="_blank"
rel="noopener noreferrer"
className='items-center'
>
Live
</a>
</Button>
}
</div>
)}
</div>
</Card.CardContent>
)}
</Card.AnimatedCard>
<div className="pt-5" />
</div>
))}
</ScrollArea>
); );
} }

View File

@@ -91,6 +91,26 @@ function Figure({
); );
} }
// A bare markdown image (![alt](src)) fills the prose width. Pass a numeric
// markdown title — ![alt](src "160") — to render it as a fixed-size, circular
// avatar instead (used by the CV header). Untitled images keep the old look.
function Img({ src, alt, title }: { src: string; alt?: string; title?: string }) {
const size = title && /^\d+$/.test(title) ? Number(title) : undefined;
if (size) {
return (
<img
src={src}
alt={alt ?? ""}
width={size}
height={size}
style={{ width: size, height: size }}
className="mx-auto !my-0 shrink-0 rounded-full object-cover ring-2 ring-foreground/10"
/>
);
}
return <img src={src} alt={alt ?? ""} className="w-full rounded-md border object-cover" />;
}
function PullQuote({ children }: { children: ReactNode }) { function PullQuote({ children }: { children: ReactNode }) {
return ( return (
<blockquote className="border-primary my-8 border-l-4 pl-5 text-xl leading-8 font-medium"> <blockquote className="border-primary my-8 border-l-4 pl-5 text-xl leading-8 font-medium">
@@ -100,12 +120,19 @@ function PullQuote({ children }: { children: ReactNode }) {
} }
function ExternalLink(props: ComponentPropsWithoutRef<"a">) { function ExternalLink(props: ComponentPropsWithoutRef<"a">) {
const href = props.href ?? ""; const { className, ...rest } = props;
const isExternal = /^https?:\/\//.test(href);
if (!isExternal) return <a {...props} />; return (
<a
return <a {...props} target="_blank" rel="noreferrer" />; {...rest}
target="_blank"
rel="noreferrer"
className={cn(
"text-sky-600 underline underline-offset-4 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300",
className,
)}
/>
);
} }
const blockComponents = new Set<unknown>([Callout, Figure, PullQuote, TagList]); const blockComponents = new Set<unknown>([Callout, Figure, PullQuote, TagList]);
@@ -127,6 +154,7 @@ export const mdxComponents = {
ButtonLink, ButtonLink,
Callout, Callout,
Figure, Figure,
img: Img,
Lead, Lead,
PullQuote, PullQuote,
TagList, TagList,

View File

@@ -37,7 +37,9 @@ function AnimatedCard({
scrollOnly, scrollOnly,
once, once,
debugId: `card-${position}`, debugId: `card-${position}`,
makeReveal: (el) => gsap.from(el, { x: -100, opacity: 0, duration: 0.5, paused: true }), // Start hidden via CSS (see className) so the server-rendered card never
// flashes visible before GSAP runs; reveal animates *to* the shown state.
makeReveal: (el) => gsap.to(el, { x: 0, opacity: 1, duration: 0.5, paused: true }),
}) })
return ( return (
<div <div
@@ -45,7 +47,7 @@ function AnimatedCard({
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 bg-opacity-60 backdrop-blur-sm", "opacity-0 -translate-x-[100px] group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl bg-opacity-60 backdrop-blur-sm",
className className
)} )}
{...props} {...props}

View File

@@ -31,6 +31,10 @@ 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(), OPENAI_API_KEY: z.string(),
GOOGLE_SERVICE_ACCOUNT_EMAIL: z.string().email(),
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: z.string(),
GOOGLE_CALENDAR_ID: z.string(),
GREGOR_MEETING_EMAIL: z.string().email(),
NODE_ENV: z NODE_ENV: z
.enum(["development", "test", "production"]) .enum(["development", "test", "production"])
.default("development"), .default("development"),
@@ -72,6 +76,10 @@ export const env = createEnv({
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, OPENAI_API_KEY: process.env.OPENAI_API_KEY,
GOOGLE_SERVICE_ACCOUNT_EMAIL: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: process.env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY,
GOOGLE_CALENDAR_ID: process.env.GOOGLE_CALENDAR_ID,
GREGOR_MEETING_EMAIL: process.env.GREGOR_MEETING_EMAIL,
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,

586
src/server/ai/tools.ts Normal file
View File

@@ -0,0 +1,586 @@
import "server-only";
import { tool } from "ai";
import { desc } from "drizzle-orm";
import { z } from "zod";
import { cancelMeeting } from "~/app/actions/cancelMeeting";
import { scheduleMeeting } from "~/app/actions/scheduleMeeting";
import { db } from "~/server/db";
import { blogPost, music } from "~/server/dbschema/schema";
import { getGoogleCalendarClient, getGoogleCalendarId } from "~/server/googleCalendar";
const contentTypeSchema = z.enum(["cv", "project", "blog", "music"]);
type ContentType = z.infer<typeof contentTypeSchema>;
type SearchResult = {
type: ContentType;
title: string;
snippet: string;
url: string;
score: number;
metadata?: Record<string, unknown>;
};
type ProjectWithStack = Awaited<ReturnType<typeof loadProjects>>[number];
function stripMarkup(value: string | null | undefined) {
return (value ?? "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/`([^`]+)`/g, "$1")
.replace(/<[^>]+>/g, " ")
.replace(/[#*_~[\]()>-]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function tokenize(value: string) {
return Array.from(new Set(value.toLowerCase().match(/[a-z0-9+#.-]+/g) ?? []));
}
function scoreText(query: string, title: string, body: string, extraTerms: string[] = []) {
const normalizedQuery = query.trim().toLowerCase();
const titleLower = title.toLowerCase();
const bodyLower = body.toLowerCase();
const tokens = tokenize(query);
let score = 0;
if (normalizedQuery && titleLower.includes(normalizedQuery)) score += 40;
if (normalizedQuery && bodyLower.includes(normalizedQuery)) score += 20;
for (const token of tokens) {
if (titleLower.includes(token)) score += 12;
if (bodyLower.includes(token)) score += 6;
if (extraTerms.some((term) => term.toLowerCase() === token)) score += 10;
}
return score;
}
const projectCatalogTerms = new Set(["project", "projects", "portfolio", "work"]);
const genericQuestionTerms = new Set([
"a",
"about",
"all",
"any",
"are",
"can",
"current",
"do",
"give",
"have",
"list",
"me",
"of",
"on",
"show",
"site",
"tell",
"the",
"there",
"these",
"this",
"what",
"which",
"you",
]);
function isProjectCatalogQuery(query: string) {
const tokens = tokenize(query);
if (!tokens.some((token) => projectCatalogTerms.has(token))) return false;
return tokens.every((token) => projectCatalogTerms.has(token) || genericQuestionTerms.has(token));
}
function snippet(value: string, query: string, maxLength = 240) {
const text = stripMarkup(value);
if (text.length <= maxLength) return text;
const tokens = tokenize(query);
const lower = text.toLowerCase();
const firstMatch = tokens
.map((token) => lower.indexOf(token))
.filter((index) => index >= 0)
.sort((a, b) => a - b)[0] ?? 0;
const start = Math.max(0, firstMatch - 60);
const excerpt = text.slice(start, start + maxLength).trim();
return `${start > 0 ? "..." : ""}${excerpt}${start + maxLength < text.length ? "..." : ""}`;
}
function uniqueByUrl(results: SearchResult[]) {
const seen = new Set<string>();
return results.filter((result) => {
const key = `${result.type}:${result.url}:${result.title}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
async function loadCvEntries() {
const categories = await db.query.cvCategory.findMany({
orderBy: (fields, { asc }) => [asc(fields.layoutPosition), asc(fields.name)],
with: {
cvEntry: {
orderBy: (fields, { desc }) => [desc(fields.toTime), desc(fields.fromTime)],
},
},
});
return categories.flatMap((category) =>
category.cvEntry.map((entry) => ({
...entry,
categoryName: category.name ?? "CV",
})),
);
}
async function loadProjects() {
return db.query.project.findMany({
orderBy: (fields, { asc }) => [asc(fields.orderPos), asc(fields.title), asc(fields.id)],
with: {
techStack: true,
},
});
}
async function buildSearchResults(query: string, types: ContentType[]) {
const selected = new Set(types);
const results: SearchResult[] = [];
if (selected.has("cv")) {
const entries = await loadCvEntries();
for (const entry of entries) {
const body = stripMarkup(`${entry.categoryName} ${entry.description ?? ""}`);
const score = scoreText(query, entry.title, body);
if (score > 0 || !query.trim()) {
results.push({
type: "cv",
title: entry.title,
snippet: snippet(body, query),
url: "/cv",
score,
metadata: {
category: entry.categoryName,
fromTime: entry.fromTime,
toTime: entry.toTime,
},
});
}
}
}
if (selected.has("project")) {
const projects = await loadProjects();
const catalogQuery = isProjectCatalogQuery(query);
for (const [index, item] of projects.entries()) {
const stackItems = item.techStack?.stackItems ?? [];
const body = stripMarkup(`${item.description ?? ""} ${stackItems.join(" ")}`);
const score = catalogQuery ? 1000 - index : scoreText(query, item.title, body, stackItems);
if (score > 0 || !query.trim() || catalogQuery) {
results.push({
type: "project",
title: item.title,
snippet: snippet(body || item.title, query),
url: `/projects#${item.id}`,
score,
metadata: {
id: item.id,
stackItems,
sourceType: item.sourceType,
releaseStatus: item.releaseStatus,
sourceLink: item.sourceLink,
releaseLink: item.releaseLink,
},
});
}
}
}
if (selected.has("blog")) {
const posts = await 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));
for (const post of posts) {
const tags = post.tags ?? [];
const body = stripMarkup(`${post.description ?? ""} ${tags.join(" ")}`);
const score = scoreText(query, post.title, body, tags);
if (score > 0 || !query.trim()) {
results.push({
type: "blog",
title: post.title,
snippet: snippet(body || post.title, query),
url: `/blog/${post.slug}`,
score,
metadata: {
slug: post.slug,
date: post.date,
tags,
},
});
}
}
}
if (selected.has("music")) {
const tracks = await db.select().from(music).orderBy(desc(music.createdAt));
for (const track of tracks) {
const body = stripMarkup(track.description);
const score = scoreText(query, track.title, body);
if (score > 0 || !query.trim()) {
results.push({
type: "music",
title: track.title,
snippet: snippet(body || track.fileName, query),
url: "/music",
score,
metadata: {
id: track.id,
fileName: track.fileName,
hasStream: Boolean(track.streamUrl),
},
});
}
}
}
return uniqueByUrl(results).sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
}
function projectMatches(projectItem: ProjectWithStack, idOrTitle: string) {
const normalized = idOrTitle.trim().toLowerCase();
const title = projectItem.title.toLowerCase();
return projectItem.id === idOrTitle || title === normalized || title.includes(normalized);
}
function matchedTerms(text: string, terms: string[]) {
const lower = text.toLowerCase();
return terms.filter((term) => lower.includes(term.toLowerCase()));
}
function parseDate(value: string | undefined, fallback: Date) {
if (!value?.trim()) return fallback;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? fallback : date;
}
function safeTimeZone(value: string | undefined) {
const timeZone = value?.trim() || "Europe/Berlin";
try {
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date());
return timeZone;
} catch {
return "Europe/Berlin";
}
}
function isLikelyEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
function overlaps(
start: Date,
end: Date,
busy: Array<{ start: Date; end: Date }>,
) {
return busy.some((item) => start < item.end && end > item.start);
}
function getZonedParts(date: Date, timeZone: string) {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
weekday: "short",
hourCycle: "h23",
hour: "2-digit",
minute: "2-digit",
});
const parts = Object.fromEntries(formatter.formatToParts(date).map((part) => [part.type, part.value]));
return {
weekday: parts.weekday ?? "",
hour: Number(parts.hour ?? 0),
minute: Number(parts.minute ?? 0),
};
}
function isInsideWorkingHours(start: Date, end: Date, timeZone: string, workdayStartHour: number, workdayEndHour: number) {
const startParts = getZonedParts(start, timeZone);
const endParts = getZonedParts(end, timeZone);
const weekend = startParts.weekday === "Sat" || startParts.weekday === "Sun";
const startMinutes = startParts.hour * 60 + startParts.minute;
const endMinutes = endParts.hour * 60 + endParts.minute;
return !weekend && startMinutes >= workdayStartHour * 60 && endMinutes <= workdayEndHour * 60;
}
function availabilitySlots({
rangeStart,
rangeEnd,
busy,
durationMinutes,
timeZone,
workdayStartHour,
workdayEndHour,
}: {
rangeStart: Date;
rangeEnd: Date;
busy: Array<{ start: Date; end: Date }>;
durationMinutes: number;
timeZone: string;
workdayStartHour: number;
workdayEndHour: number;
}) {
const slots: Array<{ start: string; end: string }> = [];
const stepMinutes = 30;
const durationMs = durationMinutes * 60 * 1000;
const cursor = new Date(Math.ceil(rangeStart.getTime() / (stepMinutes * 60 * 1000)) * stepMinutes * 60 * 1000);
while (cursor.getTime() + durationMs <= rangeEnd.getTime() && slots.length < 10) {
const end = new Date(cursor.getTime() + durationMs);
if (
isInsideWorkingHours(cursor, end, timeZone, workdayStartHour, workdayEndHour)
&& !overlaps(cursor, end, busy)
) {
slots.push({ start: cursor.toISOString(), end: end.toISOString() });
}
cursor.setMinutes(cursor.getMinutes() + stepMinutes);
}
return slots;
}
function logAvailability(requestId: string, message: string, data?: Record<string, unknown>) {
console.log(`[ai:getAvailability:${requestId}] ${message}`, data ?? "");
}
export function createChatTools() {
return {
scheduleMeeting: tool({
description: "Schedule a meeting with Gregor Lohaus and add it to his Google Calendar. Use getAvailability first when the user asks for a meeting at a flexible or uncertain time.",
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. 2026-06-18T10:00:00+02: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()
.optional()
.describe("Optional email of the visitor to invite, if provided"),
attendeeName: z.string().optional().describe("Name of the visitor"),
}),
execute: async (input) => {
if (input.attendeeEmail && !isLikelyEmail(input.attendeeEmail)) {
return {
success: false,
error: "The attendee email does not look valid. Ask the visitor to provide a valid email address before scheduling.",
};
}
return scheduleMeeting({ ...input });
},
}),
cancelMeeting: tool({
description: "Remove a previously scheduled meeting from Gregor's dedicated availability calendar. Use only when you have the exact eventId returned by scheduleMeeting.",
inputSchema: z.object({
eventId: z.string().min(1).describe("Google Calendar event id returned by a previous scheduleMeeting tool call."),
}),
execute: async ({ eventId }) => cancelMeeting({ eventId }),
}),
searchSiteContent: tool({
description: "Search Gregor Lohaus's own website content across CV entries, projects, blog posts, and music. Use this for questions about Gregor's work, skills, writing, projects, or site content. For broad questions about Gregor's projects, use types ['project'] so the tool returns the project catalog.",
inputSchema: z.object({
query: z.string().describe("Search query, skill, technology, topic, or phrase."),
types: z.array(contentTypeSchema).optional().describe("Optional content types to search. Omit to search all site content."),
limit: z.number().int().min(1).max(12).optional().describe("Maximum number of results to return."),
}),
execute: async ({ query, types, limit }) => {
const results = await buildSearchResults(query, types?.length ? types : ["cv", "project", "blog", "music"]);
return {
success: true,
query,
results: results.slice(0, limit ?? 8).map(({ score, ...result }) => result),
};
},
}),
getRelevantExperience: tool({
description: "Find Gregor's most relevant CV entries and projects for a role, skill set, seniority, or domain. Use this for recruiter-style qualification questions.",
inputSchema: z.object({
role: z.string().optional().describe("Role or job title, such as full-stack engineer or React Native developer."),
skills: z.array(z.string()).optional().describe("Technologies, tools, or skills to match."),
domain: z.string().optional().describe("Product or business domain to match, if any."),
seniority: z.string().optional().describe("Seniority or responsibility level to match, if any."),
limit: z.number().int().min(1).max(10).optional().describe("Maximum matching entries to return."),
}),
execute: async ({ role, skills, domain, seniority, limit }) => {
const terms = [role, domain, seniority, ...(skills ?? [])].filter((value): value is string => Boolean(value?.trim()));
const query = terms.join(" ");
const results = await buildSearchResults(query, ["cv", "project"]);
const selected = results.slice(0, limit ?? 6);
return {
success: true,
query,
matches: selected.map(({ score, ...result }) => ({
...result,
matchedTerms: matchedTerms(`${result.title} ${result.snippet} ${(result.metadata?.stackItems as string[] | undefined)?.join(" ") ?? ""}`, terms),
whyRelevant: result.type === "project"
? "Project match based on title, description, and technology stack."
: "CV match based on experience title, category, and description.",
})),
};
},
}),
getProjectDetails: tool({
description: "Get detailed information for one of Gregor's projects, including description, stack, source link, release link, and project page URL.",
inputSchema: z.object({
idOrTitle: z.string().min(1).describe("Project id, exact title, or partial project title."),
}),
execute: async ({ idOrTitle }) => {
const projects = await loadProjects();
const found = projects.find((item) => projectMatches(item, idOrTitle));
if (!found) {
return {
success: false,
error: `No project matched "${idOrTitle}".`,
suggestions: projects.slice(0, 5).map((item) => ({
id: item.id,
title: item.title,
url: `/projects#${item.id}`,
})),
};
}
return {
success: true,
project: {
id: found.id,
title: found.title,
description: stripMarkup(found.description),
sourceType: found.sourceType,
sourceLink: found.sourceLink,
releaseStatus: found.releaseStatus,
releaseLink: found.releaseLink,
stackItems: found.techStack?.stackItems ?? [],
url: `/projects#${found.id}`,
},
};
},
}),
getAvailability: tool({
description: "Check Gregor's Google Calendar availability and suggest open meeting slots. Use this directly for questions like 'when is the next open spot?' or 'what times are available?'. If no date range is provided, it checks from now. Use before scheduling when the requested time is flexible.",
inputSchema: z.object({
fromDateTime: z.string().optional().describe("ISO 8601 range start. Defaults to now."),
toDateTime: z.string().optional().describe("ISO 8601 range end. Defaults to 14 days after the range start."),
durationMinutes: z.number().int().min(15).max(120).optional().describe("Desired meeting duration. Defaults to 30 minutes."),
timeZone: z.string().optional().describe("IANA time zone for working-hours filtering. Defaults to Europe/Berlin."),
workdayStartHour: z.number().int().min(0).max(23).optional().describe("Earliest local start hour. Defaults to 9."),
workdayEndHour: z.number().int().min(1).max(24).optional().describe("Latest local end hour. Defaults to 17."),
}),
execute: async (input) => {
const requestId = crypto.randomUUID();
logAvailability(requestId, "start", {
input,
});
const durationMinutes = input.durationMinutes ?? 30;
const timeZone = safeTimeZone(input.timeZone);
const workdayStartHour = input.workdayStartHour ?? 9;
const workdayEndHour = Math.max(input.workdayEndHour ?? 17, workdayStartHour + 1);
const rangeStart = parseDate(input.fromDateTime, new Date());
const defaultEnd = new Date(rangeStart.getTime() + 14 * 24 * 60 * 60 * 1000);
const requestedEnd = parseDate(input.toDateTime, defaultEnd);
const maxEnd = new Date(rangeStart.getTime() + 31 * 24 * 60 * 60 * 1000);
const rangeEnd = requestedEnd <= rangeStart ? defaultEnd : requestedEnd > maxEnd ? maxEnd : requestedEnd;
logAvailability(requestId, "resolved range", {
durationMinutes,
timeZone,
workdayStartHour,
workdayEndHour,
rangeStart: rangeStart.toISOString(),
rangeEnd: rangeEnd.toISOString(),
});
const calendar = getGoogleCalendarClient();
const calendarId = getGoogleCalendarId();
logAvailability(requestId, "service account calendar ready", {
calendarId,
});
let busy: Array<{ start: Date; end: Date }>;
try {
logAvailability(requestId, "freebusy request");
const response = await calendar.freebusy.query({
requestBody: {
timeMin: rangeStart.toISOString(),
timeMax: rangeEnd.toISOString(),
timeZone,
items: [{ id: calendarId }],
},
});
busy = (response.data.calendars?.[calendarId]?.busy ?? [])
.map((item) => ({
start: parseDate(item.start ?? undefined, rangeStart),
end: parseDate(item.end ?? undefined, rangeStart),
}))
.filter((item) => item.end > item.start);
logAvailability(requestId, "freebusy response", {
busyCount: busy.length,
});
} catch (error) {
console.error(`[ai:getAvailability:${requestId}] freebusy failed`, error);
return {
success: false,
error: "Failed to read Gregor's Google Calendar availability.",
};
}
const availableSlots = availabilitySlots({
rangeStart,
rangeEnd,
busy,
durationMinutes,
timeZone,
workdayStartHour,
workdayEndHour,
});
logAvailability(requestId, "complete", {
busyCount: busy.length,
availableSlotCount: availableSlots.length,
firstAvailableSlot: availableSlots[0],
});
const nextAvailableSlot = availableSlots[0] ?? null;
return {
success: true,
range: {
start: rangeStart.toISOString(),
end: rangeEnd.toISOString(),
timeZone,
},
durationMinutes,
busy: busy.map((item) => ({
start: item.start.toISOString(),
end: item.end.toISOString(),
})),
nextAvailableSlot,
availableSlots,
};
},
}),
};
}

View File

@@ -0,0 +1,20 @@
import "server-only";
import { google } from "googleapis";
import { env } from "~/env";
const calendarScope = "https://www.googleapis.com/auth/calendar";
export function getGoogleCalendarClient() {
const auth = new google.auth.JWT({
email: env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
key: env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY.replace(/\\n/g, "\n"),
scopes: [calendarScope],
});
return google.calendar({ version: "v3", auth });
}
export function getGoogleCalendarId() {
return env.GOOGLE_CALENDAR_ID;
}