Compare commits
3 Commits
fb379b912a
...
1100e35091
| Author | SHA1 | Date | |
|---|---|---|---|
| 1100e35091 | |||
| 246d7339fb | |||
| 85af4aec77 |
165
src/app/_components/Home/HomeHero.tsx
Normal file
165
src/app/_components/Home/HomeHero.tsx
Normal 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 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>
|
||||
);
|
||||
}
|
||||
25
src/app/actions/cancelMeeting.ts
Normal file
25
src/app/actions/cancelMeeting.ts
Normal 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.',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,34 @@
|
||||
'use server'
|
||||
import { clerkClient, auth } from '@clerk/nextjs/server'
|
||||
import { google } from 'googleapis'
|
||||
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({
|
||||
title,
|
||||
@@ -19,59 +46,39 @@ export async function scheduleMeeting({
|
||||
attendeeName?: string
|
||||
}) {
|
||||
try {
|
||||
const clerk = await clerkClient()
|
||||
const userAuth = await auth()
|
||||
const user = await clerk.users.getUser(userAuth.userId?userAuth.userId:"")
|
||||
// Get admin's Google OAuth token to create the event on Gregor's calendar
|
||||
const adminTokenResponse = await clerk.users.getUserOauthAccessToken(
|
||||
env.ADMIN_USER_CLERK_ID,
|
||||
'oauth_google',
|
||||
)
|
||||
const adminToken = adminTokenResponse.data[0]
|
||||
|
||||
if (!adminToken?.token) {
|
||||
return { success: false, error: 'Admin Google Calendar not connected. Ensure the admin account is linked with Google and has calendar scope enabled.' }
|
||||
}
|
||||
|
||||
// Try to resolve visitor's Google email for the invite
|
||||
let visitorEmail: string | undefined = attendeeEmail
|
||||
if (!visitorEmail) {
|
||||
visitorEmail = user?.emailAddresses.at(0)?.emailAddress ?? undefined
|
||||
}
|
||||
|
||||
const oAuth2Client = new google.auth.OAuth2()
|
||||
oAuth2Client.setCredentials({ access_token: adminToken.token })
|
||||
const calendar = google.calendar({ version: 'v3', auth: oAuth2Client })
|
||||
const calendar = getGoogleCalendarClient()
|
||||
|
||||
const startTime = new Date(dateTime)
|
||||
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 }[] = []
|
||||
if (visitorEmail) {
|
||||
attendees.push({ email: visitorEmail, displayName: attendeeName })
|
||||
const eventRequest = {
|
||||
summary: title,
|
||||
description: eventDescription,
|
||||
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' },
|
||||
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
|
||||
}
|
||||
|
||||
const event = await calendar.events.insert({
|
||||
calendarId: 'primary',
|
||||
sendUpdates: 'all',
|
||||
requestBody: {
|
||||
summary: title,
|
||||
description,
|
||||
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' },
|
||||
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
|
||||
attendees,
|
||||
},
|
||||
sendNotifications: true
|
||||
calendarId: getGoogleCalendarId(),
|
||||
requestBody: eventRequest,
|
||||
})
|
||||
|
||||
const inviteLink = event.data.htmlLink ?? undefined
|
||||
const addToCalendarLink = createGoogleCalendarTemplateLink({
|
||||
title,
|
||||
description,
|
||||
startTime,
|
||||
endTime,
|
||||
gregorEmail: env.GREGOR_MEETING_EMAIL,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
eventId: event.data.id,
|
||||
htmlLink: inviteLink,
|
||||
inviteLink,
|
||||
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}${visitorEmail ? `. Invite sent to ${visitorEmail}.` : '.'}${inviteLink ? ` Calendar invite: ${inviteLink}` : ''}`,
|
||||
addToCalendarLink,
|
||||
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}.${attendeeEmail ? ` Visitor email noted: ${attendeeEmail}.` : ''} The add-to-calendar link invites ${env.GREGOR_MEETING_EMAIL}.`,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to schedule meeting:', error)
|
||||
|
||||
@@ -36,7 +36,9 @@ 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 the returned inviteLink or htmlLink in your response.
|
||||
- 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()
|
||||
|
||||
@@ -57,7 +59,7 @@ Runtime context:
|
||||
system: systemPrompt,
|
||||
messages: await convertToModelMessages(messages),
|
||||
tools: createChatTools(),
|
||||
stopWhen: stepCountIs(2),
|
||||
stopWhen: stepCountIs(3),
|
||||
onFinish: async ({ text, finishReason }) => {
|
||||
console.log('[ai:chat:onFinish]', {
|
||||
finishReason,
|
||||
|
||||
@@ -11,6 +11,8 @@ function toolLabel(type: string) {
|
||||
return "Loading project details";
|
||||
case "tool-getAvailability":
|
||||
return "Checking availability";
|
||||
case "tool-cancelMeeting":
|
||||
return "Removing meeting";
|
||||
case "tool-getCurrentUnixTime":
|
||||
return "Checking current time";
|
||||
default:
|
||||
@@ -27,7 +29,7 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||
>
|
||||
<div
|
||||
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) => {
|
||||
if (part.type === 'text') {
|
||||
@@ -35,12 +37,12 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||
<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 {
|
||||
type: 'tool-scheduleMeeting'
|
||||
state: string
|
||||
input: unknown
|
||||
output?: { success: boolean; message?: string; htmlLink?: string; inviteLink?: string; error?: string }
|
||||
output?: { success: boolean; error?: string }
|
||||
}
|
||||
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
|
||||
return (
|
||||
@@ -48,33 +50,39 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||
Scheduling meeting…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
}
|
||||
if (toolPart.state === 'output-available' && toolPart.output) {
|
||||
const result = toolPart.output
|
||||
const inviteLink = result.inviteLink ?? result.htmlLink
|
||||
if (result.success) return null
|
||||
return (
|
||||
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
|
||||
{result.success ? (
|
||||
<span>
|
||||
✓ {result.message}{' '}
|
||||
{inviteLink && (
|
||||
<a
|
||||
href={inviteLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Open calendar invite
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>✗ {result.error}</span>
|
||||
)}
|
||||
<span>✗ {result.error}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -14,7 +14,7 @@ export const UserMessage = (props:{message: UIMessage}) => {
|
||||
>
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from "~/lib/utils"
|
||||
import { AnimatedCard, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||
import type { ArrayElement } from "type-fest"
|
||||
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";
|
||||
|
||||
export type CvCategoryData = ArrayElement<RouterOutputs['categoryv2']['listAllWithEntries']>
|
||||
@@ -18,22 +18,46 @@ type CvCategoryProps = {
|
||||
}
|
||||
export default function CvCategory({ category, layout, position = 0, descriptions }: CvCategoryProps) {
|
||||
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 (
|
||||
<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>
|
||||
<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>
|
||||
</AnimateTextIn>
|
||||
</CardHeader>
|
||||
{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")}>
|
||||
<ScrollArea>
|
||||
{entries.map((entry, i) => (
|
||||
<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} />
|
||||
</AnimatePopUp>
|
||||
))}
|
||||
</ScrollArea>
|
||||
<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")}>
|
||||
{isRowLayout ? (
|
||||
entryItems
|
||||
) : (
|
||||
<ScrollArea className="min-h-0 w-full flex-1">
|
||||
<div className="flex flex-col gap-4 pr-2">
|
||||
{entryItems}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
:
|
||||
<></>
|
||||
|
||||
@@ -9,30 +9,34 @@ import type { CvCategoryData } from "./CvCategory"
|
||||
|
||||
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,
|
||||
description?: ReactNode,
|
||||
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 (
|
||||
<Card className={className ? cn("w-fit", className) : "w-fit"}>
|
||||
<Card className={cn("w-full ring-0", row && "h-full", className)}>
|
||||
{entry.title ?
|
||||
<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>
|
||||
</AnimateTextIn>
|
||||
</CardHeader> :
|
||||
<></>
|
||||
}
|
||||
{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:
|
||||
the outer entry pop-up (CvCategory) measures height:auto when it
|
||||
plays, so the description must stay laid out at full height or the
|
||||
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}`}>
|
||||
<article className="prose prose-zinc dark:prose-invert max-w-none">
|
||||
<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={cn("prose prose-zinc dark:prose-invert max-w-none", row && "text-center")}>
|
||||
{description ?? entry.description}
|
||||
</article>
|
||||
</AnimatedDiv>
|
||||
@@ -40,9 +44,9 @@ export default function CvEntry({ entry, description, className, position = 0 }:
|
||||
<></>
|
||||
}
|
||||
{!entry.hideDates ?
|
||||
<CardFooter className="text-sm">
|
||||
<AnimateTextIn position={position + 0.4} debugId={`cv-entry-dates:${entry.title}:${position + 0.4}`}>
|
||||
{`von ${format(new Date(entry.fromTime), 'M. yyyy')} bis zum ${format(new Date(entry.toTime), 'M. yyyy')}`}
|
||||
<CardFooter className="border-t-0 text-sm">
|
||||
<AnimateTextIn once position={position + 1.15} debugId={`cv-entry-dates:${entry.title}:${position + 1.15}`}>
|
||||
{`${from} to ${to}`}
|
||||
</AnimateTextIn>
|
||||
</CardFooter> :
|
||||
<></>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client'
|
||||
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 SidebarTriggerDisappearsOnMobile from "./SidebarTriggerDisappearsOnMobile";
|
||||
import CvCategory from "./CvCategory";
|
||||
import { useTimeLine } from "~/app/_providers/GsapProvicer";
|
||||
import { cn } from "~/lib/utils";
|
||||
export default function CvPage(props: {
|
||||
cv: RouterOutputs['categoryv2']['listAllWithEntries'],
|
||||
descriptions: Record<string, ReactNode>,
|
||||
@@ -17,43 +19,104 @@ export default function CvPage(props: {
|
||||
const headerCategories = byPosition("header")
|
||||
const col1Categories = byPosition("col1")
|
||||
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 (
|
||||
<>
|
||||
<SidebarProvider>
|
||||
<SidebarProvider className="h-full min-h-0 overflow-hidden">
|
||||
{sidebarCategories.length > 0 &&
|
||||
<>
|
||||
<SidebarTriggerDisappearsOnMobile />
|
||||
<Sidebar>
|
||||
<SidebarContent className="p-2 lg:pt-[3.2rem]">
|
||||
{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>
|
||||
</Sidebar>
|
||||
</>
|
||||
}
|
||||
<div className="h-full w-full flex flex-wrap flex-row p-4 pt-8 ">
|
||||
<div id="mainwrap" className="flex w-full flex-col gap-4 lg:px-[15vw]">
|
||||
<div id="header" className="flex w-full h-fit flex-row gap-4 flex-wrap">
|
||||
{headerCategories.map((cat, i) => (
|
||||
<CvCategory layout="row" position={i} category={cat} descriptions={descriptions} key={cat.id} />
|
||||
))}
|
||||
</div>
|
||||
<div id="colwrapper" className="flex flex-col lg:flex-row w-full h-3/4 gap-4">
|
||||
<div id="col1" className={`flex flex-col w-full ${col1Categories.length > 0 ? "lg:w-1/2" : ""} h-full gap-4`}>
|
||||
{col1Categories.map((cat, i) => (
|
||||
<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>
|
||||
<MainContent
|
||||
hasSidebar={hasSidebar}
|
||||
mainColumnWidthClass={mainColumnWidthClass}
|
||||
headerCategories={headerCategories}
|
||||
headerSequence={headerSequence}
|
||||
col1Categories={col1Categories}
|
||||
col1Sequence={col1Sequence}
|
||||
col2Categories={col2Categories}
|
||||
col2Sequence={col2Sequence}
|
||||
descriptions={descriptions}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import HomeHero from "./_components/Home/HomeHero";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main>
|
||||
<div>
|
||||
hello world
|
||||
</div>
|
||||
<main className="h-full w-full">
|
||||
<HomeHero />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,26 @@ function Figure({
|
||||
);
|
||||
}
|
||||
|
||||
// A bare markdown image () fills the prose width. Pass a numeric
|
||||
// markdown title —  — 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 }) {
|
||||
return (
|
||||
<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">) {
|
||||
const href = props.href ?? "";
|
||||
const isExternal = /^https?:\/\//.test(href);
|
||||
const { className, ...rest } = props;
|
||||
|
||||
if (!isExternal) return <a {...props} />;
|
||||
|
||||
return <a {...props} target="_blank" rel="noreferrer" />;
|
||||
return (
|
||||
<a
|
||||
{...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]);
|
||||
@@ -127,6 +154,7 @@ export const mdxComponents = {
|
||||
ButtonLink,
|
||||
Callout,
|
||||
Figure,
|
||||
img: Img,
|
||||
Lead,
|
||||
PullQuote,
|
||||
TagList,
|
||||
|
||||
@@ -31,6 +31,10 @@ export const env = createEnv({
|
||||
CLERK_SECRET_KEY: z.string(),
|
||||
ADMIN_USER_CLERK_ID: 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
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
@@ -72,6 +76,10 @@ export const env = createEnv({
|
||||
POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL,
|
||||
ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
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,
|
||||
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import "server-only";
|
||||
|
||||
import { clerkClient } from "@clerk/nextjs/server";
|
||||
import { tool } from "ai";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { google } from "googleapis";
|
||||
import { z } from "zod";
|
||||
import { cancelMeeting } from "~/app/actions/cancelMeeting";
|
||||
import { scheduleMeeting } from "~/app/actions/scheduleMeeting";
|
||||
import { env } from "~/env";
|
||||
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"]);
|
||||
|
||||
@@ -25,9 +24,6 @@ type SearchResult = {
|
||||
|
||||
type ProjectWithStack = Awaited<ReturnType<typeof loadProjects>>[number];
|
||||
|
||||
let cachedAdminGoogleToken: { token: string; expiresAt: number } | undefined;
|
||||
let adminGoogleTokenRequest: Promise<string | undefined> | undefined;
|
||||
|
||||
function stripMarkup(value: string | null | undefined) {
|
||||
return (value ?? "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
@@ -364,83 +360,6 @@ function logAvailability(requestId: string, message: string, data?: Record<strin
|
||||
console.log(`[ai:getAvailability:${requestId}] ${message}`, data ?? "");
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async function getAdminGoogleToken(requestId: string) {
|
||||
if (cachedAdminGoogleToken && cachedAdminGoogleToken.expiresAt > Date.now()) {
|
||||
logAvailability(requestId, "admin oauth token cache hit");
|
||||
return cachedAdminGoogleToken.token;
|
||||
}
|
||||
|
||||
if (!adminGoogleTokenRequest) {
|
||||
adminGoogleTokenRequest = (async () => {
|
||||
logAvailability(requestId, "admin oauth token request start");
|
||||
const clerk = await clerkClient();
|
||||
const adminTokenResponse = await clerk.users.getUserOauthAccessToken(
|
||||
env.ADMIN_USER_CLERK_ID,
|
||||
"oauth_google",
|
||||
);
|
||||
const token = adminTokenResponse.data[0]?.token;
|
||||
if (token) {
|
||||
cachedAdminGoogleToken = {
|
||||
token,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
return token;
|
||||
})().finally(() => {
|
||||
adminGoogleTokenRequest = undefined;
|
||||
});
|
||||
} else {
|
||||
logAvailability(requestId, "admin oauth token request joined");
|
||||
}
|
||||
|
||||
return withTimeout(
|
||||
adminGoogleTokenRequest,
|
||||
5000,
|
||||
"Timed out while loading the admin Google OAuth token.",
|
||||
);
|
||||
}
|
||||
|
||||
async function getAdminCalendar(requestId: string) {
|
||||
let adminToken: string | undefined;
|
||||
try {
|
||||
adminToken = await getAdminGoogleToken(requestId);
|
||||
} catch (error) {
|
||||
console.error(`[ai:getAvailability:${requestId}] admin oauth token failed`, error);
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Timed out while loading Gregor's Google Calendar connection.",
|
||||
};
|
||||
}
|
||||
|
||||
logAvailability(requestId, "admin oauth token resolved", {
|
||||
hasToken: Boolean(adminToken),
|
||||
});
|
||||
|
||||
if (!adminToken) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Admin Google Calendar is not connected or does not expose a Google OAuth token.",
|
||||
};
|
||||
}
|
||||
|
||||
const oAuth2Client = new google.auth.OAuth2();
|
||||
oAuth2Client.setCredentials({ access_token: adminToken });
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
calendar: google.calendar({ version: "v3", auth: oAuth2Client }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createChatTools() {
|
||||
return {
|
||||
scheduleMeeting: tool({
|
||||
@@ -474,6 +393,13 @@ export function createChatTools() {
|
||||
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({
|
||||
@@ -589,27 +515,24 @@ export function createChatTools() {
|
||||
rangeEnd: rangeEnd.toISOString(),
|
||||
});
|
||||
|
||||
const adminCalendar = await getAdminCalendar(requestId);
|
||||
|
||||
if (!adminCalendar.success) {
|
||||
logAvailability(requestId, "admin calendar unavailable", {
|
||||
error: adminCalendar.error,
|
||||
});
|
||||
return adminCalendar;
|
||||
}
|
||||
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 adminCalendar.calendar.freebusy.query({
|
||||
const response = await calendar.freebusy.query({
|
||||
requestBody: {
|
||||
timeMin: rangeStart.toISOString(),
|
||||
timeMax: rangeEnd.toISOString(),
|
||||
timeZone,
|
||||
items: [{ id: "primary" }],
|
||||
items: [{ id: calendarId }],
|
||||
},
|
||||
});
|
||||
busy = (response.data.calendars?.["primary"]?.busy ?? [])
|
||||
busy = (response.data.calendars?.[calendarId]?.busy ?? [])
|
||||
.map((item) => ({
|
||||
start: parseDate(item.start ?? undefined, rangeStart),
|
||||
end: parseDate(item.end ?? undefined, rangeStart),
|
||||
|
||||
20
src/server/googleCalendar.ts
Normal file
20
src/server/googleCalendar.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user