diff --git a/frontend/app/api/complete-diagram/route.ts b/frontend/app/api/complete-diagram/route.ts index 71c215b..082ab9a 100644 --- a/frontend/app/api/complete-diagram/route.ts +++ b/frontend/app/api/complete-diagram/route.ts @@ -7,15 +7,20 @@ export const maxDuration = 60; export async function POST(request: NextRequest) { try { - // Server-side only API key (no NEXT_PUBLIC prefix) - const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; + // Prefer user-provided key from header, fall back to server env var + const userKey = request.headers.get("x-gemini-api-key"); + const apiKey = userKey?.trim() || process.env.GOOGLE_GENERATIVE_AI_API_KEY; + if (!apiKey) { return NextResponse.json( - { error: "Missing GOOGLE_GENERATIVE_AI_API_KEY" }, - { status: 500 } + { error: "NO_API_KEY", message: "No Gemini API key configured. Please add your API key in the board settings." }, + { status: 401 } ); } + // Inject the key into the environment for the @ai-sdk/google provider + process.env.GOOGLE_GENERATIVE_AI_API_KEY = apiKey; + const { prompt, image_data } = (await request.json()) as { prompt: string; image_data?: string; diff --git a/frontend/app/api/validate-key/route.ts b/frontend/app/api/validate-key/route.ts new file mode 100644 index 0000000..0a25ca3 --- /dev/null +++ b/frontend/app/api/validate-key/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest) { + try { + const { api_key } = (await request.json()) as { api_key: string }; + + if (!api_key?.trim()) { + return NextResponse.json( + { valid: false, reason: "API key is required." }, + { status: 400 } + ); + } + + if (!api_key.startsWith("AIza")) { + return NextResponse.json({ + valid: false, + reason: "That doesn't look like a valid Gemini API key. Keys start with 'AIza'.", + }); + } + + // Make a real but minimal call to Gemini to validate the key + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${api_key}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ parts: [{ text: "Hi" }] }], + }), + } + ); + + if (response.ok) { + return NextResponse.json({ valid: true }); + } + + const data = await response.json().catch(() => ({})); + const errorCode = data?.error?.status ?? ""; + const errorMessage = data?.error?.message ?? ""; + + if (response.status === 400 && errorMessage.includes("API_KEY_INVALID")) { + return NextResponse.json({ + valid: false, + reason: "Invalid API key. Please check and try again.", + }); + } + + if (response.status === 403 || errorCode === "PERMISSION_DENIED") { + return NextResponse.json({ + valid: false, + reason: "API key is valid but doesn't have permission to use Gemini. Enable the Generative Language API in your Google Cloud project.", + }); + } + + if (response.status === 429 || errorCode === "RESOURCE_EXHAUSTED") { + // Key works, just rate-limited — treat as valid + return NextResponse.json({ valid: true }); + } + + return NextResponse.json({ + valid: false, + reason: `Gemini returned an error: ${errorMessage || response.statusText}`, + }); + } catch (error) { + console.error("Key validation error:", error); + return NextResponse.json( + { valid: false, reason: "Could not reach Gemini API. Check your internet connection." }, + { status: 500 } + ); + } +} diff --git a/frontend/app/board/page.tsx b/frontend/app/board/page.tsx new file mode 100644 index 0000000..ae7ebfa --- /dev/null +++ b/frontend/app/board/page.tsx @@ -0,0 +1,20 @@ +import { auth } from "@clerk/nextjs/server"; +import { redirect } from "next/navigation"; +import { BoardClient } from "@/components/board-client"; + +/** + * BoardPage is a server component that handles authentication checks + * before rendering the whiteboard client application. + */ +export default async function BoardPage() { + // Use the latest Clerk auth() API with async/await + const { userId } = await auth(); + + // If no user is authenticated, redirect to the landing page + // Note: middleware.ts also handles this, but this is a secondary safety check + if (!userId) { + redirect("/"); + } + + return ; +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index bf36c66..d58ae9f 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,19 +1,26 @@ @import "tailwindcss"; + + :root { - /* Minimal Monochrome Palette */ - --background: #ffffff; - --foreground: #000000; + --background: #fafaf7; + --foreground: #1a1a1a; + + /* Chalk AI Design Tokens */ + --chalk-ink: #1a1a1a; + --chalk-ink-soft: #3d3d3d; + --chalk-gray: #888; + --chalk-bg: #fafaf7; + --chalk-white: #ffffff; + --chalk-purple: #7b5ea7; + --chalk-purple-light: #ede8f5; + --chalk-blue-soft: #e8eef8; + --chalk-border: #d0cfc7; + --chalk-border-soft: #e8e7e0; - /* Modern Accents */ - --muted: #f4f4f5; - --muted-foreground: #737373; - --border: #eaeaea; - --input: #f4f4f5; - --primary: #000000; - --primary-foreground: #ffffff; + --font-sketch: var(--font-sketch); + --font-body: var(--font-body); - --ring: #000000; --radius: 0.75rem; } @@ -28,8 +35,27 @@ --color-primary-foreground: var(--primary-foreground); --color-ring: var(--ring); --radius-lg: var(--radius); - --font-sans: var(--font-geist-sans), system-ui, sans-serif; + --color-chalk-ink: var(--chalk-ink); + --color-chalk-ink-soft: var(--chalk-ink-soft); + --color-chalk-gray: var(--chalk-gray); + --color-chalk-bg: var(--chalk-bg); + --color-chalk-white: var(--chalk-white); + --color-chalk-purple: var(--chalk-purple); + --color-chalk-purple-light: var(--chalk-purple-light); + --color-chalk-blue-soft: var(--chalk-blue-soft); + --color-chalk-border: var(--chalk-border); + --color-chalk-border-soft: var(--chalk-border-soft); + + --font-sans: var(--font-body), system-ui, sans-serif; --font-mono: var(--font-geist-mono), monospace; + --font-sketch: var(--font-sketch); + + --animate-marquee: marquee 28s linear infinite; + + @keyframes marquee { + from { transform: translateX(0); } + to { transform: translateX(-50%); } + } } body { diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index bf7d025..823972b 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,7 +1,37 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import Link from "next/link"; +import { ClerkProvider, SignInButton, SignUpButton, UserButton } from "@clerk/nextjs"; +import { Show } from "@/components/clerk-helpers"; +import { UIProvider } from "@/components/ui-provider"; +import { ApiKeyProvider } from "@/components/api-key-provider"; +import { Header } from "@/components/header"; +import { LayoutWrapper } from "@/components/layout-wrapper"; +import { Caveat, Nunito, Kalam, Inter } from "next/font/google"; import "./globals.css"; +const kalam = Kalam({ + subsets: ["latin"], + weight: ["300", "400", "700"], + style: ["normal"], + variable: "--font-sketch", +}); + +const inter = Inter({ + subsets: ["latin"], + weight: ["300", "400", "500", "600", "700"], + style: ["normal", "italic"], + variable: "--font-body", +}); + +// const nunito = Nunito({ +// subsets: ["latin"], +// weight: ["300", "400", "500", "600", "700"], +// style: ["normal", "italic"], +// variable: "--font-body", +// }); + + const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], @@ -61,9 +91,42 @@ export default function RootLayout({ return ( - {children} + + + +
+ + + + + + + + + +
+ + Board + + +
+
+
+ + {children} + +
+
+
); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index abe0295..e27c945 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,468 +1,23 @@ "use client"; -import { useState, useCallback, useRef, useEffect } from "react"; -import { Whiteboard } from "@/components/whiteboard"; -import { IntentInput } from "@/components/intent-input"; -import { Button } from "@/components/ui/button"; -import { Loader2, Plus, RotateCcw, Sparkles, Check, X, Mic, MicOff } from "lucide-react"; -import { motion, AnimatePresence } from "framer-motion"; - -export default function Home() { - const [intent, setIntent] = useState(""); - const [generatedImage, setGeneratedImage] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [resetSignal, setResetSignal] = useState(0); - const exportFnRef = useRef<(() => Promise) | null>(null); - const [isMounted, setIsMounted] = useState(false); - - useEffect(() => { - setIsMounted(true); - }, []); - - // Voice input state - const [isListening, setIsListening] = useState(false); - const [agentTranscript, setAgentTranscript] = useState(""); - const agentTranscriptRef = useRef(""); - const recognitionRef = useRef(null); - const idleCheckIntervalRef = useRef(null); - const isListeningRef = useRef(false); - const lastActivityTimeRef = useRef(Date.now()); - - // Sync refs with state - useEffect(() => { - agentTranscriptRef.current = agentTranscript; - }, [agentTranscript]); - - useEffect(() => { - isListeningRef.current = isListening; - }, [isListening]); - - const handleExportReady = useCallback( - (exportFn: () => Promise) => { - exportFnRef.current = exportFn; - }, - [] - ); - - const handleRequestSuggestion = useCallback(async (overridePrompt?: string) => { - if (!exportFnRef.current) { - setError("Canvas not ready. Please wait a moment and try again."); - return; - } - - // Determine the prompt to use: override or current intent - // If it's an event object (from button click), ignore it - const promptText = typeof overridePrompt === "string" ? overridePrompt : intent; - - if (!promptText.trim()) { - setError("Please provide an intent description"); - return; - } - - setIsLoading(true); - setError(null); - - try { - // Export canvas to PNG - const imageDataUrl = await exportFnRef.current(); - - let base64Image: string | undefined; - - // Convert data URL to base64 string - if (imageDataUrl) { - if (imageDataUrl.startsWith("data:image/png;base64,")) { - // Extract base64 from data URL - base64Image = imageDataUrl.replace("data:image/png;base64,", ""); - } else { - // Fetch and convert - const response = await fetch(imageDataUrl); - const blob = await response.blob(); - const reader = new FileReader(); - await new Promise((resolve, reject) => { - reader.onloadend = resolve; - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - const dataUrl = reader.result as string; - base64Image = dataUrl.replace("data:image/png;base64,", ""); - } - } - - // Call API with JSON - const apiResponse = await fetch("/api/complete-diagram", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - prompt: promptText, - image_data: base64Image, - }), - }); - - if (!apiResponse.ok) { - const errorData = await apiResponse.json(); - throw new Error(errorData.error || "Failed to get AI suggestion"); - } - - const data = await apiResponse.json(); - setGeneratedImage(data.image_data); - } catch (err) { - console.error("Error:", err); - setError( - err instanceof Error ? err.message : "Failed to get AI suggestion" - ); - } finally { - setIsLoading(false); - } - }, [intent]); - - const handleAcceptSuggestion = useCallback(() => { - setGeneratedImage(null); - setIntent(""); - setResetSignal((prev) => prev + 1); - // The whiteboard component handles the actual insertion - }, []); - - const handleClearCanvas = useCallback(() => { - setGeneratedImage(null); - setError(null); - setIntent(""); - setResetSignal((prev) => prev + 1); - }, []); - - const handleRejectSuggestion = useCallback(() => { - setGeneratedImage(null); - // We keep the intent so the user can modify it significantly if needed - }, []); - - // Handle keyboard shortcuts (Esc to reject) - useEffect(() => { - if (!generatedImage) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - handleRejectSuggestion(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [generatedImage, handleRejectSuggestion]); - - // Initialize Speech Recognition - useEffect(() => { - if (typeof window === "undefined") return; - - const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition; - if (!SpeechRecognitionAPI) { - console.warn("Speech Recognition not supported in this browser"); - return; - } - - const recognition = new SpeechRecognitionAPI(); - recognition.continuous = true; - recognition.interimResults = true; - recognition.lang = "en-US"; - - recognition.onresult = (event: SpeechRecognitionEvent) => { - let finalTranscript = ""; - for (let i = event.resultIndex; i < event.results.length; i++) { - const transcript = event.results[i][0].transcript; - if (event.results[i].isFinal) { - finalTranscript += transcript + " "; - } - } - if (finalTranscript) { - console.log("Voice input:", finalTranscript); - setAgentTranscript((prev) => prev + finalTranscript); - lastActivityTimeRef.current = Date.now(); // Reset idle timer on speech - } - }; - - recognition.onend = () => { - // Auto-restart if still listening - if (isListeningRef.current) { - try { - recognition.start(); - } catch (e) { - console.error("Recognition restart failed:", e); - } - } - }; - - recognition.onerror = (event: SpeechRecognitionErrorEvent) => { - console.error("Speech recognition error:", event.error); - if (event.error !== "no-speech") { - setIsListening(false); - isListeningRef.current = false; - } - }; - - recognitionRef.current = recognition; - - return () => { - recognition.stop(); - }; - }, []); - - // Toggle listening - const toggleListening = useCallback(() => { - const recognition = recognitionRef.current; - if (!recognition) { - alert("Speech recognition is not supported in your browser."); - return; - } - - if (isListening) { - // Stop listening - recognition.stop(); - if (idleCheckIntervalRef.current) { - clearInterval(idleCheckIntervalRef.current); - idleCheckIntervalRef.current = null; - } - setIsListening(false); - isListeningRef.current = false; - - // If we have a transcript, use it for generation - if (agentTranscriptRef.current.trim()) { - handleRequestSuggestion(agentTranscriptRef.current.trim()); - } - } else { - // Start listening - setAgentTranscript(""); - agentTranscriptRef.current = ""; - lastActivityTimeRef.current = Date.now(); - try { - recognition.start(); - setIsListening(true); - isListeningRef.current = true; - - // Idle detection: check every 500ms if idle for 4.5 seconds - idleCheckIntervalRef.current = setInterval(() => { - const idleTime = Date.now() - lastActivityTimeRef.current; - if (idleTime > 4500 && agentTranscriptRef.current.trim() && isListeningRef.current) { - console.log("Idle detected, auto-generating..."); - handleRequestSuggestion(agentTranscriptRef.current.trim()); - // Stop listening after auto-generate - recognition.stop(); - setIsListening(false); - isListeningRef.current = false; - if (idleCheckIntervalRef.current) { - clearInterval(idleCheckIntervalRef.current); - idleCheckIntervalRef.current = null; - } - } - }, 500); - } catch (e) { - console.error("Failed to start recognition:", e); - } - } - }, [isListening, handleRequestSuggestion]); - - // Handle drawing activity - reset idle timer - const handleDrawingActivity = useCallback(() => { - lastActivityTimeRef.current = Date.now(); - }, []); - - if (!isMounted) return null; - +import { Hero } from "@/components/landing/hero"; +import { Features } from "@/components/landing/features"; +import { HowItWorks } from "@/components/landing/how-it-works"; +import { Testimonials } from "@/components/landing/testimonials"; +import { Founders } from "@/components/landing/founders"; +import { CTA } from "@/components/landing/cta"; +import { Footer } from "@/components/landing/footer"; + +export default function ChalkAILanding() { return ( -
- {/* 1. Fullscreen Whiteboard (Background Layer) */} -
- -
- - {/* 2. Floating Header (Top Left) */} - {/* -
-

- ChalkAI -

- */} - - {/* 3. Floating Control Island (Bottom Center) */} -
- - {/* Input Row */} -
- - - - - - - {/* Mic Button */} - -
- - {/* Live Transcript (when listening) */} - - {isListening && ( - -
-
-
- Listening... -
-

- {agentTranscript || "Start speaking..."} -

-
- - )} - - - {/* Error Message (Collapsible) */} - - {error && ( - -
- {error} -
-
- )} -
- - - {/* Reset Button (Floating nearby) */} - - - -
- - {/* 4. Preview Card (Bottom Right) */} - {/* 4. Large Preview Popover (Bottom Right) */} - - {generatedImage && ( - - {/* Header */} -
-
- - AI - - Suggestion -
- -
- - {/* Image Container */} -
- Refined diagram -
- - {/* Footer Actions */} -
- - -
-
- )} -
+
+ + + + + + +
); -} +} \ No newline at end of file diff --git a/frontend/bun.lock b/frontend/bun.lock index bb9cdc8..d9a9bee 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -19,6 +19,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.23.26", + "lightningcss-win32-x64-msvc": "^1.30.2", "lucide-react": "^0.562.0", "next": "16.1.1", "react": "19.2.3", diff --git a/frontend/components/api-key-modal.tsx b/frontend/components/api-key-modal.tsx new file mode 100644 index 0000000..79fd57e --- /dev/null +++ b/frontend/components/api-key-modal.tsx @@ -0,0 +1,236 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Eye, EyeOff, Key, ExternalLink, Check, AlertCircle, Loader2, X, Sparkles } from "lucide-react"; +import { useApiKey } from "@/components/api-key-provider"; + +interface ApiKeyModalProps { + isOpen: boolean; + onClose?: () => void; + /** If true, the modal cannot be dismissed without saving a key */ + required?: boolean; +} + +export function ApiKeyModal({ isOpen, onClose, required = false }: ApiKeyModalProps) { + const { setApiKey, apiKey } = useApiKey(); + const [inputValue, setInputValue] = useState(apiKey ?? ""); + const [showKey, setShowKey] = useState(false); + const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); + const [errorMessage, setErrorMessage] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + if (isOpen) { + setInputValue(apiKey ?? ""); + setStatus("idle"); + setErrorMessage(""); + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [isOpen, apiKey]); + + const handleSave = async () => { + const trimmed = inputValue.trim(); + if (!trimmed) { + setStatus("error"); + setErrorMessage("Please enter your Gemini API key."); + return; + } + + setStatus("loading"); + setErrorMessage(""); + + try { + const res = await fetch("/api/validate-key", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ api_key: trimmed }), + }); + const data = await res.json(); + + if (data.valid) { + setApiKey(trimmed); + setStatus("success"); + setTimeout(() => { + onClose?.(); + }, 1200); + } else { + setStatus("error"); + setErrorMessage(data.reason ?? "Invalid API key. Please try again."); + } + } catch { + setStatus("error"); + setErrorMessage("Could not connect to validation service. Check your internet connection."); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleSave(); + if (e.key === "Escape" && !required) onClose?.(); + }; + + const isExistingKey = !!apiKey; + + return ( + + {isOpen && ( +
+ {/* Backdrop */} + + + {/* Modal */} + + {/* Close button */} + {!required && ( + + )} + + {/* Purple accent header stripe */} +
+ +
+ {/* Icon + Title */} +
+
+ +
+
+

+ {isExistingKey ? "Update API Key" : "Connect Your Gemini Key"} +

+

+ {isExistingKey + ? "Replace your existing Gemini API key." + : "Chalk AI uses Google Gemini to enhance your drawings. Your key is stored locally and never sent to our servers."} +

+
+
+ + {/* Input */} +
+ +
+
+ +
+ { + setInputValue(e.target.value); + if (status !== "idle") { setStatus("idle"); setErrorMessage(""); } + }} + onKeyDown={handleKeyDown} + placeholder="AIza..." + className="w-full pl-10 pr-12 py-3.5 bg-white border-[2px] border-chalk-border rounded-xl font-mono text-sm text-chalk-ink placeholder:text-chalk-gray/50 outline-none focus:border-chalk-purple focus:shadow-[0_0_0_3px_var(--chalk-purple-light)] transition-all duration-200 shadow-[2px_2px_0px_0px_var(--chalk-border)]" + /> + +
+
+ + {/* Status / Error message */} + + {status === "error" && errorMessage && ( + +
+ + {errorMessage} +
+
+ )} + {status === "success" && ( + +
+ + API key verified and saved! You're all set. +
+
+ )} +
+ + {/* Save Button */} + + + {/* Get key link */} + +
+ +
+ )} + + ); +} diff --git a/frontend/components/api-key-provider.tsx b/frontend/components/api-key-provider.tsx new file mode 100644 index 0000000..51bf95b --- /dev/null +++ b/frontend/components/api-key-provider.tsx @@ -0,0 +1,66 @@ +"use client"; + +import React, { createContext, useContext, useState, useEffect, useCallback } from "react"; + +const STORAGE_KEY = "chalk_ai_gemini_key"; + +interface ApiKeyContextValue { + apiKey: string | null; + hasKey: boolean; + setApiKey: (key: string) => void; + clearApiKey: () => void; + isLoaded: boolean; +} + +const ApiKeyContext = createContext({ + apiKey: null, + hasKey: false, + setApiKey: () => {}, + clearApiKey: () => {}, + isLoaded: false, +}); + +export function ApiKeyProvider({ children }: { children: React.ReactNode }) { + const [apiKey, setApiKeyState] = useState(null); + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) setApiKeyState(stored); + } catch {} + setIsLoaded(true); + }, []); + + const setApiKey = useCallback((key: string) => { + try { + localStorage.setItem(STORAGE_KEY, key); + } catch {} + setApiKeyState(key); + }, []); + + const clearApiKey = useCallback(() => { + try { + localStorage.removeItem(STORAGE_KEY); + } catch {} + setApiKeyState(null); + }, []); + + return ( + + {children} + + ); +} + +export function useApiKey() { + return useContext(ApiKeyContext); +} diff --git a/frontend/components/board-client.tsx b/frontend/components/board-client.tsx new file mode 100644 index 0000000..13713e2 --- /dev/null +++ b/frontend/components/board-client.tsx @@ -0,0 +1,492 @@ +"use client"; + +import { useState, useCallback, useRef, useEffect } from "react"; +import { Whiteboard } from "@/components/whiteboard"; +import { IntentInput } from "@/components/intent-input"; +import { Button } from "@/components/ui/button"; +import { Loader2, Plus, RotateCcw, Sparkles, Check, X, Mic, MicOff, Maximize2, Minimize2, Key } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; +import { useUI } from "@/components/ui-provider"; +import { cn } from "@/lib/utils"; +import { useApiKey } from "@/components/api-key-provider"; +import { ApiKeyModal } from "@/components/api-key-modal"; + + +export function BoardClient() { + const { isFullscreen, toggleFullscreen } = useUI(); + const { apiKey, hasKey, isLoaded } = useApiKey(); + const [showApiKeyModal, setShowApiKeyModal] = useState(false); + const [intent, setIntent] = useState(""); + const [generatedImage, setGeneratedImage] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [resetSignal, setResetSignal] = useState(0); + const exportFnRef = useRef<(() => Promise) | null>(null); + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + // Voice input state + const [isListening, setIsListening] = useState(false); + const [agentTranscript, setAgentTranscript] = useState(""); + const agentTranscriptRef = useRef(""); + const recognitionRef = useRef(null); + const idleCheckIntervalRef = useRef(null); + const isListeningRef = useRef(false); + const lastActivityTimeRef = useRef(Date.now()); + + // Sync refs with state + useEffect(() => { + agentTranscriptRef.current = agentTranscript; + }, [agentTranscript]); + + useEffect(() => { + isListeningRef.current = isListening; + }, [isListening]); + + const handleExportReady = useCallback( + (exportFn: () => Promise) => { + exportFnRef.current = exportFn; + }, + [] + ); + + const handleRequestSuggestion = useCallback(async (overridePrompt?: string) => { + if (!exportFnRef.current) { + setError("Canvas not ready. Please wait a moment and try again."); + return; + } + + const promptText = typeof overridePrompt === "string" ? overridePrompt : intent; + + if (!promptText.trim()) { + setError("Please provide an intent description"); + return; + } + + setIsLoading(true); + setError(null); + + try { + const imageDataUrl = await exportFnRef.current(); + let base64Image: string | undefined; + + if (imageDataUrl) { + if (imageDataUrl.startsWith("data:image/png;base64,")) { + base64Image = imageDataUrl.replace("data:image/png;base64,", ""); + } else { + const response = await fetch(imageDataUrl); + const blob = await response.blob(); + const reader = new FileReader(); + await new Promise((resolve, reject) => { + reader.onloadend = resolve; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + const dataUrl = reader.result as string; + base64Image = dataUrl.replace("data:image/png;base64,", ""); + } + } + + const apiResponse = await fetch("/api/complete-diagram", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(apiKey ? { "x-gemini-api-key": apiKey } : {}), + }, + body: JSON.stringify({ + prompt: promptText, + image_data: base64Image, + }), + }); + + if (!apiResponse.ok) { + const errorData = await apiResponse.json(); + if (errorData.error === "NO_API_KEY") { + setShowApiKeyModal(true); + throw new Error("Please set up your Gemini API key to use AI features."); + } + throw new Error(errorData.error || "Failed to get AI suggestion"); + } + + const data = await apiResponse.json(); + setGeneratedImage(data.image_data); + } catch (err) { + console.error("Error:", err); + setError( + err instanceof Error ? err.message : "Failed to get AI suggestion" + ); + } finally { + setIsLoading(false); + } + }, [intent]); + + const handleAcceptSuggestion = useCallback(() => { + setGeneratedImage(null); + setIntent(""); + setResetSignal((prev) => prev + 1); + }, []); + + const handleClearCanvas = useCallback(() => { + setGeneratedImage(null); + setError(null); + setIntent(""); + setResetSignal((prev) => prev + 1); + }, []); + + const handleRejectSuggestion = useCallback(() => { + setGeneratedImage(null); + }, []); + + useEffect(() => { + if (!generatedImage) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + handleRejectSuggestion(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [generatedImage, handleRejectSuggestion]); + + useEffect(() => { + if (typeof window === "undefined") return; + const SpeechRecognitionAPI = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + if (!SpeechRecognitionAPI) return; + + const recognition = new SpeechRecognitionAPI(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = "en-US"; + + recognition.onresult = (event: any) => { + let finalTranscript = ""; + for (let i = event.resultIndex; i < event.results.length; i++) { + const transcript = event.results[i][0].transcript; + if (event.results[i].isFinal) { + finalTranscript += transcript + " "; + } + } + if (finalTranscript) { + setAgentTranscript((prev) => prev + finalTranscript); + lastActivityTimeRef.current = Date.now(); + } + }; + + recognition.onend = () => { + if (isListeningRef.current) { + try { recognition.start(); } catch (e) {} + } + }; + + recognitionRef.current = recognition; + return () => recognition.stop(); + }, []); + + const toggleListening = useCallback(() => { + const recognition = recognitionRef.current; + if (!recognition) return; + + if (isListening) { + recognition.stop(); + if (idleCheckIntervalRef.current) { + clearInterval(idleCheckIntervalRef.current); + idleCheckIntervalRef.current = null; + } + setIsListening(false); + isListeningRef.current = false; + if (agentTranscriptRef.current.trim()) { + handleRequestSuggestion(agentTranscriptRef.current.trim()); + } + } else { + setAgentTranscript(""); + agentTranscriptRef.current = ""; + lastActivityTimeRef.current = Date.now(); + try { + recognition.start(); + setIsListening(true); + isListeningRef.current = true; + idleCheckIntervalRef.current = setInterval(() => { + const idleTime = Date.now() - lastActivityTimeRef.current; + if (idleTime > 4500 && agentTranscriptRef.current.trim() && isListeningRef.current) { + handleRequestSuggestion(agentTranscriptRef.current.trim()); + recognition.stop(); + setIsListening(false); + isListeningRef.current = false; + if (idleCheckIntervalRef.current) { + clearInterval(idleCheckIntervalRef.current); + idleCheckIntervalRef.current = null; + } + } + }, 500); + } catch (e) {} + } + }, [isListening, handleRequestSuggestion]); + + const handleDrawingActivity = useCallback(() => { + lastActivityTimeRef.current = Date.now(); + }, []); + + if (!isMounted) return null; + + // Show API key setup if key not loaded yet or missing + if (isLoaded && !hasKey) { + return ( +
+ {/* Dot grid background */} + + + + + + + + + +
+
+ +
+
+

Almost there!

+

+ Chalk AI needs your Gemini API key to power AI diagram generation. Your key is stored locally and never shared. +

+
+ + + Get a free key at Google AI Studio → + +
+ + setShowApiKeyModal(false)} + required={!hasKey} + /> +
+ ); + } + + return ( +
+
+ +
+ +
+ +
+ + + + + + + + + {/* API Key Settings */} + + + {/* Fullscreen Button */} + +
+ + + {isListening && ( + +
+
+
+ Listening... +
+

+ {agentTranscript || "Start speaking..."} +

+
+ + )} + + + + {error && ( + +
+ {error} +
+
+ )} +
+ + + + + +
+ + + {generatedImage && ( + +
+
+ + AI + + Suggestion +
+ +
+ +
+ Refined diagram +
+ +
+ + +
+
+ )} +
+ + {/* API Key Modal */} + setShowApiKeyModal(false)} + required={false} + /> +
+ ); +} diff --git a/frontend/components/clerk-helpers.tsx b/frontend/components/clerk-helpers.tsx new file mode 100644 index 0000000..4b82c95 --- /dev/null +++ b/frontend/components/clerk-helpers.tsx @@ -0,0 +1,26 @@ +import { auth } from "@clerk/nextjs/server"; +import { ReactNode } from "react"; + +interface ShowProps { + when: "signed-in" | "signed-out"; + children: ReactNode; +} + +/** + * A helper component to conditionally show content based on authentication state. + * Uses the latest Clerk auth() API. + */ +export async function Show({ when, children }: ShowProps) { + const { userId } = await auth(); + const isSignedIn = !!userId; + + if (when === "signed-in" && isSignedIn) { + return <>{children}; + } + + if (when === "signed-out" && !isSignedIn) { + return <>{children}; + } + + return null; +} diff --git a/frontend/components/header.tsx b/frontend/components/header.tsx new file mode 100644 index 0000000..920f639 --- /dev/null +++ b/frontend/components/header.tsx @@ -0,0 +1,92 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useUI } from "@/components/ui-provider"; +import { cn } from "@/lib/utils"; +import { Maximize2, Minimize2, Pencil } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface HeaderProps { + children?: React.ReactNode; +} + +export function Header({ children }: HeaderProps) { + const { isFullscreen, toggleFullscreen } = useUI(); + const pathname = usePathname(); + const isHomePage = pathname === "/"; + const isBoardPage = pathname === "/board"; + + if (isBoardPage) { + return ( +
+ +
+ +
+ Home + +
+ ); + } + + return ( +
+
+ {/* BRAND SIGNATURE IDENTIFIER */} + + {/* Stylized Modern Pencil Container */} +
+ +
+ + Chalk AI + + + + + {/* ACTIONS RACK WRAPPER */} +
+ {/* MODERN FULLSCREEN TOGGLE */} + {!isHomePage && ( + + )} + + {/* CHALK-STYLE CONTAINER DIVIDER */} + {!isHomePage &&
} + + {/* AUTH SLOTS / CHILD INJECTIONS */} +
{children}
+
+
+
+ ); +} diff --git a/frontend/components/landing/cta.tsx b/frontend/components/landing/cta.tsx new file mode 100644 index 0000000..c1457ba --- /dev/null +++ b/frontend/components/landing/cta.tsx @@ -0,0 +1,62 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { FadeIn } from "./fade-in"; +import { LogoIcon, ArrowRightIcon } from "./icons"; +import { SignInButton, SignUpButton, useAuth } from "@clerk/nextjs"; +import { DecorativeIcon } from "./decorative-icon"; + +export function CTA() { + const { isSignedIn } = useAuth(); + + return ( +
+ {/* Decorative Icons */} + +
+ +
+ {/* Decorative circles */} +
+
+ +
+
+ +
+

+ Ready to transform the way you teach? +

+

+ Chalk AI is completely open. Just plug in your Gemini API key and start creating. +

+
+ + {!isSignedIn ? ( + + + + ) : ( + + + + )} +
+ +
+
+ ); +} diff --git a/frontend/components/landing/decorative-icon.tsx b/frontend/components/landing/decorative-icon.tsx new file mode 100644 index 0000000..61f92e2 --- /dev/null +++ b/frontend/components/landing/decorative-icon.tsx @@ -0,0 +1,48 @@ +"use client"; + +import Image from "next/image"; +import { cn } from "@/lib/utils"; +import { FadeIn } from "./fade-in"; + +interface DecorativeIconProps { + src: string; + alt: string; + className?: string; + rotate?: number; + size?: number; + delay?: number; + opacity?: number; +} + +export function DecorativeIcon({ + src, + alt, + className, + rotate = 0, + size = 60, + delay = 0, + opacity = 0.5, +}: DecorativeIconProps) { + return ( + + ); +} diff --git a/frontend/components/landing/doodles.tsx b/frontend/components/landing/doodles.tsx new file mode 100644 index 0000000..11ef057 --- /dev/null +++ b/frontend/components/landing/doodles.tsx @@ -0,0 +1,27 @@ +export const Sparkle = () => ( + + + + + + +); diff --git a/frontend/components/landing/fade-in.tsx b/frontend/components/landing/fade-in.tsx new file mode 100644 index 0000000..0252cab --- /dev/null +++ b/frontend/components/landing/fade-in.tsx @@ -0,0 +1,35 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; + +function useIntersection(ref: React.RefObject, threshold = 0.15) { + const [visible, setVisible] = useState(false); + useEffect(() => { + const obs = new IntersectionObserver( + ([e]) => { if (e.isIntersecting) { setVisible(true); obs.disconnect(); } }, + { threshold } + ); + if (ref.current) obs.observe(ref.current); + return () => obs.disconnect(); + }, [ref, threshold]); + return visible; +} + +export function FadeIn({ children, delay = 0, className = "" }: { children: React.ReactNode, delay?: number, className?: string }) { + const ref = useRef(null); + const visible = useIntersection(ref); + + return ( +
+ {children} +
+ ); +} diff --git a/frontend/components/landing/features.tsx b/frontend/components/landing/features.tsx new file mode 100644 index 0000000..1e412aa --- /dev/null +++ b/frontend/components/landing/features.tsx @@ -0,0 +1,183 @@ +"use client"; + +import React from "react"; +import { FadeIn } from "./fade-in"; +import { Cloud, Lightbulb, PencilRuler, User } from "lucide-react"; +import { DecorativeIcon } from "./decorative-icon"; + +const SketchMark = ({ flip = false }: { flip?: boolean }) => ( + + + + + +); + +const features = [ + { + icon: , + title: "Smart Drawing", + desc: "AI helps you refine your sketches, flowcharts, and complex diagrams in real-time.", + }, + { + icon: , + title: "AI Suggestions", + desc: "Get intelligent contextual prompts to improve your math proof steps and explanations.", + }, + { + icon: , + title: "Interactive Lessons", + desc: "Engage students with clean real-time whiteboard spaces built for digital collaboration.", + }, + { + icon: , + title: "Save & Share", + desc: "Instantly capture your finished sketch boards and generate quick review links.", + }, +]; + +/* ========================================================================== + SKETCHED CARD BACKGROUND COMPONENT + ========================================================================== */ +function SketchedCardFrame() { + return ( +
+ {/* Brutalist Shadow Layer */} + + + + + {/* Main Front Frame Stroke & Canvas Fill */} + + + +
+ ); +} + +export function Features() { + return ( +
+ {/* Decorative Icons */} + + + + {/* HEADER SECTION WITH SKETCH UNDERLINE ATTRIBUTES */} +
+ +

+ + + Everything you need on one{" "} + + intelligent + + + {" "} + board + +

+
+
+ + {/* FEATURE GRID SYSTEM MAPPED WITH THE CUSTOM ASSET FRAME */} +
+ {features.map((f, i) => ( + +
+ + {/* Dynamic Hand-drawn Frame Injector Asset */} + + + {/* CARD CONTAINER CONTENT MARKUP LAYER */} +
+
+ + {/* ICON BUBBLE ASSEMBLY */} +
+ + {f.icon} + +
+ + {/* HEADER CONTENT COPY ELEMENT */} +

+ + {f.title} + +

+ + {/* BODY DESCRIPTIONS PRIMITIVE */} +

+ {f.desc} +

+
+
+ +
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/landing/footer.tsx b/frontend/components/landing/footer.tsx new file mode 100644 index 0000000..9c12fdb --- /dev/null +++ b/frontend/components/landing/footer.tsx @@ -0,0 +1,142 @@ +"use client"; + +import React from "react"; +import { Github, Linkedin, Twitter } from "lucide-react"; +import { LogoIcon } from "./icons"; + +export function Footer() { + const currentYear = new Date().getFullYear(); + + return ( +
+ {/* Subtle hand-drawn aesthetic grid pattern texture overlay */} +
+ + + + + + + + +
+ +
+
+ {/* BRAND SIGNATURE IDENTIFIER */} +
+
+ + Chalk AI +
+

+ The AI-powered smart board built by students, transforming how + educators sketch and explain complex ideas. +

+
+ + {/* BRUTALIST CALL TO ACTIONS (GITHUB & SOCIAL CHANNELS) */} +
+ {/* TARGET HIGH-AGENCY GITHUB CONNECTOR */} + + + Star on GitHub + + + {/* VERTICAL DIVIDER LINK SHIM */} + | + + {/* TARGET SOCIAL LINKS BUNDLE */} + +
+
+ + {/* REINFORCED CHALKBOARD RULER DIVIDER */} +
+ + {/* BOTTOM METADATA LAYER */} +
+ + © {currentYear} Chalk AI. Handcrafted with care. All rights + reserved. + +
+ Built by + + Jaydeep + + & + + Abhishek + +
+
+
+
+ ); +} diff --git a/frontend/components/landing/founders.tsx b/frontend/components/landing/founders.tsx new file mode 100644 index 0000000..593e87a --- /dev/null +++ b/frontend/components/landing/founders.tsx @@ -0,0 +1,227 @@ +"use client"; + +import React, { useState } from "react"; +import Image from "next/image"; +import { FadeIn } from "./fade-in"; +import { Linkedin, Twitter, Mail } from "lucide-react"; + +const foundersData = [ + { + name: "Jaydeep", + role: "Co-founder", + bio: "Computer Science student obsessed with turning grand engineering concepts into lightweight, high-performance web systems for classrooms worldwide.", + initials: "J", + imageSrc: "/jay.jpg", + socials: { + twitter: "https://x.com/jayydeeppp", + linkedin: "https://www.linkedin.com/in/jaydeepwagaskar/", + email: "mailto:jaydeepwaghaskar@gmail.com", + }, + }, + { + name: "Abhishek", + role: "Co-founder", + bio: "Engineering student passionate about building delightful, high-end motion tools that empower tutors and redefine modern digital learning.", + initials: "A", + imageSrc: "/abhi.jpg", + socials: { + twitter: "https://x.com/Abhi_SDev", + linkedin: "https://www.linkedin.com/in/abhishek-sonje-83a333209/", + email: "mailto:work.abhishek036@gmail.com", + }, + }, +]; + +/* ========================================================================== + SKETCHED CARD BACKGROUND COMPONENT (WITH PAPER CANVAS FILL) + ========================================================================== */ +function SketchedPaperFrame() { + return ( +
+ {/* Brutalist Shadow Layer */} + + + + + {/* Main Front Frame Stroke & Textured Paper Fill */} + + + + + {/* Fine Drafting Paper Dot-Matrix Overlay */} +
+ + + + + + + + +
+
+ ); +} + +interface FounderCardProps { + founder: (typeof foundersData)[0]; +} + +function FounderCard({ founder }: FounderCardProps) { + const [imgError, setImgError] = useState(false); + + return ( +
+ {/* Hand-drawn Frame Asset with Paper Overlay */} + + + {/* AVATAR FRAME SYSTEM */} +
+ {!imgError ? ( + {`${founder.name} setImgError(true)} + /> + ) : ( + {founder.initials} + )} +
+ + {/* STRATIFIED PROFILE COPY */} +
+
+

+ {founder.name} +

+
+ {founder.role} +
+

+ {founder.bio} +

+
+ + {/* BRUTALIST SOCIAL ACTION BUTTONS */} + +
+
+ ); +} + +const SketchMark = ({ flip = false }: { flip?: boolean }) => ( + + + + + +); + +export function Founders() { + return ( +
+
+ {/* SECTION HEADER LAYOUT */} +
+ + +

+ Created by students, for educators + +

+ +
+
+ + {/* TARGET RESPONSIVE MAPPED LAYOUT LOOP GRID */} +
+ {foundersData.map((founder, i) => ( + + + + ))} +
+
+
+ ); +} diff --git a/frontend/components/landing/hero.tsx b/frontend/components/landing/hero.tsx new file mode 100644 index 0000000..2c27f66 --- /dev/null +++ b/frontend/components/landing/hero.tsx @@ -0,0 +1,285 @@ +"use client"; + +import React, { useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { FadeIn } from "./fade-in"; +import { PlayIcon, SparkleIcon } from "./icons"; +import { ArrowRight, Sparkles } from "lucide-react"; +import { VideoModal } from "./video-modal"; +import { SignInButton, SignUpButton, useAuth } from "@clerk/nextjs"; +import { DecorativeIcon } from "./decorative-icon"; + +const SketchMark = ({ flip = false }: { flip?: boolean }) => ( + + + + + +); + +/* ========================= + Triangle Sketch (RE-POSITIONED TO MATCH NEW FRAME) + ========================= */ +const TriangleSketch = () => ( + + {/* Grid dots */} + {[...Array(6)].map((_, r) => + [...Array(8)].map((_, c) => ( + + )), + )} + + {/* Triangle */} + + + {/* Hatching */} + {[ + ["175 170", "225 80"], + ["185 182", "245 90"], + ["195 192", "265 100"], + ["207 199", "283 112"], + ["220 200", "300 125"], + ["237 200", "316 138"], + ].map(([a, b], i) => ( + + ))} + + {/* Labels */} + + A + + + B + + + C + + + {/* Altitude */} + + + h + + + {/* Formula */} + + Area = + + + 1 + + + + 2 + + + × b × h + + +); + +/* ========================= + HERO + ========================= */ +export function Hero() { + const [isVideoOpen, setIsVideoOpen] = useState(false); + const { isSignedIn } = useAuth(); + + return ( +
+ setIsVideoOpen(false)} /> + + {/* Decorative Icons */} + + + {/* REVAMPED LEFT CONTENT */} +
+ {/* PREMIUM FLOATING SUB-ACCENT BADGE */} + +
+ + + + AI for Smarter Teaching + + + + +
+
+ + {/* HIGH-CONTRAST SKETCH TYPOGRAPHY */} + +

+ Teach. Draw. +
+ Improve with{" "} + + AI. + + +

+
+ + {/* REBALANCED SYSTEM DESCRIPTIVE PARAGRAPH */} + +

+ Chalk AI functions as your contextual, real-time smart board. Sketch + hardware configurations, geometry parameters, or raw equations, and + let custom vector vision models clean, align, and enhance your work + live. +

+
+ + {/* TACTILE CALL TO ACTIONS BAR */} + +
+ {/* Main Action Call */} + {!isSignedIn ? ( + + + + ) : ( + + + + )} + + {/* Video Interactive Layer Hook */} + +
+
+
+ + {/* RIGHT FRAME - UNTOUCHED & MAINTAINING 20% OVERFLOW */} +
+ +
+ {/* Frame PNG */} + Chalk Board Frame + + {/* Content inside frame - Inset and aligned to the whiteboard surface */} +
+ +
+ + {/* AI Assistant */} +
+
+ +
+
+
AI Assistant
+
+ Suggesting... +
+
+
+
+
+
+
+ ); +} diff --git a/frontend/components/landing/how-it-works.tsx b/frontend/components/landing/how-it-works.tsx new file mode 100644 index 0000000..53a48ff --- /dev/null +++ b/frontend/components/landing/how-it-works.tsx @@ -0,0 +1,118 @@ +"use client"; + +import React from "react"; +import { FadeIn } from "./fade-in"; +import { + PenTool, + Brain, + Sparkles, + GraduationCap, + ArrowRight, +} from "lucide-react"; +import { DecorativeIcon } from "./decorative-icon"; + +const steps = [ + { + num: "01", + icon: , + title: "Plug in your Key", + desc: "Simply add your Gemini API key in the board settings to unlock full AI power for free.", + }, + { + num: "02", + icon: , + title: "You Draw", + desc: "Sketch your geometric shapes or ideas naturally on the smart board.", + }, + { + num: "03", + icon: , + title: "AI Understands", + desc: "Our vision model instantly recognizes your drawings and mathematical context.", + }, + { + num: "04", + icon: , + title: "You Teach Better", + desc: "Deliver clear explanations and engage students with AI-enhanced visuals.", + }, +]; + +export function HowItWorks() { + return ( +
+ {/* Decorative Icons */} + +
+ {/* SECTION HEADER */} +
+ +

+ How Chalk AI works + +

+
+
+ + {/* PROCESS FLOW WRAPPER */} +
+ {/* DESKTOP BACKGROUND CONNECTOR LINE */} +
+ + {steps.map((step, i) => ( + + +
+ {/* STEP BUBBLE HEADER */} + + STEP {step.num} + + + {/* PREMIUM THEMED ICON BOX */} +
+ {step.icon} +
+ + {/* STEP TITLE */} +

+ + {step.title} + +

+ + {/* STEP DESCRIPTION */} +

+ {step.desc} +

+
+
+ + {/* TRANSLATING CONNECTOR ARROW FOR IN-BETWEEN STEPS */} + {i < steps.length - 1 && ( +
+
+ +
+
+ )} +
+ ))} +
+
+
+ ); +} diff --git a/frontend/components/landing/icons.tsx b/frontend/components/landing/icons.tsx new file mode 100644 index 0000000..561ce8c --- /dev/null +++ b/frontend/components/landing/icons.tsx @@ -0,0 +1,86 @@ + +export const StarIcon = () => ( + + + +); + +export const PencilIcon = () => ( + + + +); + +export const BulbIcon = () => ( + + + +); + +export const UsersIcon = () => ( + + + +); + +export const CloudIcon = () => ( + + + +); + +export const SparkleIcon = () => ( + + + +); + +export const PlayIcon = () => ( + + + +); + +export const ArrowRightIcon = () => ( + + + +); + +export const BrainIcon = () => ( + + + +); + +export const DrawIcon = () => ( + + + + +); + +export const ImproveIcon = () => ( + + + + + + +); + +export const TeachIcon = () => ( + + + + + + +); + +export const LogoIcon = () => ( + + + + +); diff --git a/frontend/components/landing/testimonials.tsx b/frontend/components/landing/testimonials.tsx new file mode 100644 index 0000000..61de6ef --- /dev/null +++ b/frontend/components/landing/testimonials.tsx @@ -0,0 +1,221 @@ +"use client"; + +import React, { useState } from "react"; +import { FadeIn } from "./fade-in"; +import { Star } from "lucide-react"; +import { DecorativeIcon } from "./decorative-icon"; + +const reviews = [ + { + quote: "The AI suggestions are spot on! It helps me explain complex topics in a simpler way.", + name: "Priya Mehta", + role: "Physics Tutor", + initials: "PM", + }, + { + quote: "My students are more engaged than ever. Chalk AI makes my lessons interactive and fun.", + name: "Rohit Verma", + role: "Chemistry Tutor", + initials: "RV", + }, + { + quote: "I save so much time with AI enhancements. It's a must-have tool for every tutor.", + name: "Sneha Iyer", + role: "Biology Tutor", + initials: "SI", + }, + { + quote: "Drawing diagrams used to take forever. Now it's done in seconds with smart suggestions.", + name: "Kavya Nair", + role: "Math Tutor", + initials: "KN", + }, + { + quote: "The collaboration feature is brilliant. My students can see changes in real-time.", + name: "Arjun Sinha", + role: "Science Tutor", + initials: "AS", + }, +]; + +/* ========================================================================== + SKETCHED PAPER BACKDROP FRAME ARCHITECTURE + ========================================================================== */ +function SketchedPaperFrame() { + return ( +
+ {/* Brutalist Shadow Background Path Layer */} + + + + + {/* Main Front Custom Outline & Premium Off-White Canvas Core */} + + + + + {/* Fine-grain Technical Grid Dot Matrix Blueprint Overlay */} +
+ + + + + + + + +
+
+ ); +} + +export function Testimonials() { + const reviewSet = [...reviews, ...reviews, ...reviews]; + + return ( +
+ + {/* Decorative Assets */} + + +
+ + {/* HERO FEATURED TESTIMONIAL VIEW SYSTEM */} + +
+ + {/* Structural Hand-drawn Backdrop Core */} + + + {/* Inner Content Grid Frame */} +
+
+

+ + “ + + Chalk AI has changed the way I teach. It's like having an{" "} + + intelligent co-teacher + {" "} + live on my board. +

+ +
+
+ AS +
+
+
+ Amit Sharma +
+
+ Math Tutor & Content Creator +
+
+
+
+ + {/* STICKY BADGE INSIGNIA */} +
+ +
+
+ +
+
+ + {/* MARQUEE ANCHOR HEADINGS */} +
+ +

+ + ✦ + + Loved by tutors worldwide + + ✦ + +

+
+
+ + {/* INFINITE SMOOTH MARQUEE INTERFACE TRACK */} +
+ {/* Feathered Mask Shims */} +
+
+ + {/* Running Tape Track Component */} +
+ {reviewSet.map((r, i) => ( +
+ {/* Structural Paper Frame for each item in the tape slider */} + + + {/* Card Content Elements */} +
+
+
+ “ +
+

+ {r.quote} +

+
+ +
+
+ {r.initials} +
+
+
+ {r.name} +
+
+ {r.role} +
+
+
+
+ +
+ ))} +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/landing/video-modal.tsx b/frontend/components/landing/video-modal.tsx new file mode 100644 index 0000000..d03e669 --- /dev/null +++ b/frontend/components/landing/video-modal.tsx @@ -0,0 +1,69 @@ +"use client"; + +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { X } from "lucide-react"; + +interface VideoModalProps { + isOpen: boolean; + onClose: () => void; + videoUrl?: string; +} + +export function VideoModal({ isOpen, onClose, videoUrl }: VideoModalProps) { + return ( + + {isOpen && ( +
+ {/* Backdrop */} + + + {/* Modal Content */} + + {/* Close Button */} + + + {/* Video Placeholder / Player */} +
+ {videoUrl ? ( +