Skip to content
Merged

pr #7

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,8 @@ systemprompt-example.txt
# Ehnhance Prompt example

enhance-prompt.txt

.env.local

# clerk configuration (can include secrets)
/.clerk/
128 changes: 128 additions & 0 deletions app/api/webhooks/clerk/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { Webhook } from 'svix';
import { headers } from 'next/headers';
import { WebhookEvent } from '@clerk/nextjs/server';
import { createClient } from '@supabase/supabase-js';
import { NextResponse } from 'next/server';

// Initialize Supabase client
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;

// Helper function to create Supabase admin client with service role key
const getSupabaseAdmin = () => {
if (!supabaseUrl || !supabaseServiceKey) {
console.error('Missing Supabase environment variables for admin operations');
return null;
}
return createClient(supabaseUrl, supabaseServiceKey);
};

export async function POST(req: Request) {
// Get the headers
const headerPayload = headers();
const svix_id = headerPayload.get('svix-id');
const svix_timestamp = headerPayload.get('svix-timestamp');
const svix_signature = headerPayload.get('svix-signature');

// If there are no svix headers, error out
if (!svix_id || !svix_timestamp || !svix_signature) {
return new Response('Error: Missing svix headers', {
status: 400,
});
}

// Get the body
const payload = await req.json();
const body = JSON.stringify(payload);

// Create a new Svix instance with your webhook secret
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET || '');

let evt: WebhookEvent;

// Verify the payload with the headers
try {
evt = wh.verify(body, {
'svix-id': svix_id,
'svix-timestamp': svix_timestamp,
'svix-signature': svix_signature,
}) as WebhookEvent;
} catch (err) {
console.error('Error verifying webhook:', err);
return new Response('Error verifying webhook', {
status: 400,
});
}

// Get the ID and type
const eventType = evt.type;
const supabase = getSupabaseAdmin();

if (!supabase) {
return NextResponse.json({ error: 'Could not initialize Supabase client' }, { status: 500 });
}

// Handle the event
try {
switch (eventType) {
case 'user.created': {
const { id, email_addresses, first_name, last_name, image_url } = evt.data;
const primaryEmail = email_addresses?.[0]?.email_address;

// Create user in Supabase
const { error } = await supabase.from('users').insert({
clerk_user_id: id,
email: primaryEmail,
first_name: first_name || null,
last_name: last_name || null,
avatar_url: image_url || null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});

if (error) throw error;
break;
}
case 'user.updated': {
const { id, email_addresses, first_name, last_name, image_url } = evt.data;
const primaryEmail = email_addresses?.[0]?.email_address;

// Update user in Supabase
const { error } = await supabase
.from('users')
.update({
email: primaryEmail,
first_name: first_name || null,
last_name: last_name || null,
avatar_url: image_url || null,
updated_at: new Date().toISOString(),
})
.eq('clerk_user_id', id);

if (error) throw error;
break;
}
case 'user.deleted': {
const { id } = evt.data;

// Delete user in Supabase
const { error } = await supabase
.from('users')
.delete()
.eq('clerk_user_id', id);

if (error) throw error;
break;
}
}

return NextResponse.json({ success: true });
} catch (error) {
console.error('Error processing webhook:', error);
return NextResponse.json(
{ error: 'Error processing webhook' },
{ status: 500 }
);
}
}
130 changes: 64 additions & 66 deletions app/chat/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { motion } from "framer-motion"
import { useRouter, useParams } from "next/navigation"
import { useUser, UserButton, SignedIn } from "@clerk/nextjs"
import { useEffect, useState } from "react"
import { cn } from "@/lib/utils"

export default function ChatSessionPage() {
const router = useRouter()
const params = useParams()
const chatId = params.id as string
const { user, isLoaded } = useUser()
const [isValidSession, setIsValidSession] = useState(true)
const [isChatStarted, setIsChatStarted] = useState(false)

useEffect(() => {
// Ensure user and chatId are available
Expand All @@ -31,77 +33,73 @@ export default function ChatSessionPage() {
}, [chatId, user, isLoaded, router])

return (
<div className="min-h-screen flex flex-col w-full items-center justify-center bg-[#0D0D10] text-white relative overflow-hidden">
{/* Auth button */}
<motion.div
className="absolute top-4 right-4 flex gap-4 z-50"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<SignedIn>
<UserButton afterSignOutUrl="/" />
</SignedIn>
</motion.div>

{/* ZapDev branding */}
<motion.div
className="absolute top-6 left-6 flex items-center gap-2 z-50"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
<span className="text-xl font-bold">
<span className="text-gradient">ZapDev</span> Studio
</span>
</motion.div>
<div className="min-h-screen flex flex-col bg-[#0D0D10] text-white relative overflow-hidden">
{/* Header elements */}
<header className="absolute top-0 left-0 right-0 z-50 p-6 flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Back button */}
<motion.button
onClick={() => router.push("/")}
className="p-2 rounded-full bg-white/5 hover:bg-white/10 transition-colors flex items-center justify-center"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-label="Back to Home"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 12H5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12 19L5 12L12 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</motion.button>

{/* ZapDev branding */}
<motion.div
className="flex items-center gap-2"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
<span className="text-xl font-bold">
<span className="text-gradient">ZapDev</span> Studio
</span>
</motion.div>
</div>

{/* Back button */}
<motion.div
className="absolute top-6 left-40 flex items-center gap-2 z-50"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
<motion.button
onClick={() => router.push("/")}
className="px-3 py-1 rounded-lg bg-white/5 hover:bg-white/10 transition-colors text-xs flex items-center gap-1"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
{/* Auth button */}
<motion.div
className="flex gap-4"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.6666 8H3.33329" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M7.33329 4L3.33329 8L7.33329 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>Back to Home</span>
</motion.button>
</motion.div>
<SignedIn>
<UserButton afterSignOutUrl="/" />
</SignedIn>
</motion.div>
</header>

{/* Session ID indicator */}
<motion.div
className="absolute top-6 right-20 z-50"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
<div className="px-3 py-1 rounded-lg bg-white/5 text-xs flex items-center gap-1">
<span>Chat ID: {chatId}</span>
<button
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/chat/${chatId}`);
alert("Chat link copied to clipboard!");
}}
className="ml-2 text-white/60 hover:text-white/100 transition-colors"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 16H6C4.89543 16 4 15.1046 4 14V6C4 4.89543 4.89543 4 6 4H14C15.1046 4 16 4.89543 16 6V8M10 20H18C19.1046 20 20 19.1046 20 18V10C20 8.89543 19.1046 8 18 8H10C8.89543 8 8 8.89543 8 10V18C8 19.1046 8.89543 20 10 20Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{/* Main content with conditional layout */}
<div className="flex-1 flex flex-col md:flex-row gap-6 w-full max-w-screen-2xl mx-auto pt-24 pb-8 px-6">
{/* Left Card / Full Width Card: Chat Interface */}
<div className={cn(
"h-full flex flex-col bg-slate-900/50 rounded-lg border border-slate-800",
isChatStarted ? "md:w-1/2" : "md:w-full"
)}>
<AnimatedAIChat chatId={chatId} onFirstMessageSent={() => setIsChatStarted(true)} />
</div>
</motion.div>

{/* Chat interface */}
<AnimatedAIChat chatId={chatId} />
{/* Right Card: Desktop Preview (conditionally rendered) */}
{isChatStarted && (
<div className="md:w-1/2 h-full flex flex-col bg-slate-900/50 rounded-lg border border-slate-800 items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-bold mb-2">Desktop Preview</h2>
<p className="text-slate-400">The UI preview will appear here.</p>
</div>
</div>
)}
</div>
</div>
)
}
2 changes: 1 addition & 1 deletion app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default function ChatPage() {

useEffect(() => {
// Generate a new unique chat ID and redirect
const chatId = uuidv4().substring(0, 8)
const chatId = uuidv4()
router.push(`/chat/${chatId}`)
}, [router])

Expand Down
66 changes: 66 additions & 0 deletions app/example/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import { Authenticated, Unauthenticated, AuthLoading } from "convex/react";
import { SignInButton, UserButton } from "@clerk/nextjs";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";

export default function ExamplePage() {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4">
<h1 className="text-3xl font-bold mb-8">Convex + Clerk Example</h1>

<AuthLoading>
<div className="text-lg">Loading authentication state...</div>
</AuthLoading>

<Authenticated>
<div className="flex flex-col items-center gap-4 p-6 bg-slate-800 rounded-lg">
<div className="flex items-center gap-2">
<span>You are signed in:</span>
<UserButton afterSignOutUrl="/" />
</div>
<Content />
</div>
</Authenticated>

<Unauthenticated>
<div className="flex flex-col items-center gap-4 p-6 bg-slate-800 rounded-lg">
<p className="text-lg">You are not signed in.</p>
<SignInButton mode="modal">
<button className="px-4 py-2 bg-blue-600 rounded-md hover:bg-blue-700 transition-colors">
Sign In
</button>
</SignInButton>
</div>
</Unauthenticated>
</div>
);
}

function Content() {
// This will only be called if the user is authenticated
const messages = useQuery(api.messages.list);

return (
<div className="mt-4">
<h2 className="text-xl font-semibold mb-2">Your Messages</h2>
{messages === undefined ? (
<p>Loading messages...</p>
) : messages.length === 0 ? (
<p>No messages yet.</p>
) : (
<ul className="space-y-2">
{messages.map((message) => (
<li key={message._id} className="p-3 bg-slate-700 rounded">
<p>{message.content}</p>
<p className="text-sm text-gray-400">
{new Date(message.createdAt).toLocaleString()}
</p>
</li>
))}
</ul>
)}
</div>
);
}
Loading