diff --git a/apps/www/components/AIDemo/DotGrid.tsx b/apps/www/components/AIDemo/DotGrid.tsx new file mode 100644 index 00000000000..64ef0697bde --- /dev/null +++ b/apps/www/components/AIDemo/DotGrid.tsx @@ -0,0 +1,70 @@ +import React from 'react' +import { motion } from 'framer-motion' + +interface DotGridProps { + rows: number + columns: number + count: number +} + +const DotGrid: React.FC = ({ rows, columns, count }) => { + const container = { + hidden: { opacity: 1 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.01, + delayChildren: 0.01, + }, + }, + } + + const item = { + hidden: { opacity: 0 }, + visible: { opacity: 0.3 }, + } + + const highlightedVariants = { + visible: { + opacity: [1, 0.5, 1], + transition: { + repeat: Infinity, + duration: 0.5, + repeatDelay: 1.5, + ease: 'easeInOut', + }, + }, + } + + return ( +
+ + {Array.from({ length: rows * columns }).map((_, index) => { + const isHighlighted = index < count + return ( + + ) + })} +
+
+ +
+ ) +} + +export default DotGrid diff --git a/apps/www/components/AIDemo/Panel.tsx b/apps/www/components/AIDemo/Panel.tsx new file mode 100644 index 00000000000..ac6bb818caa --- /dev/null +++ b/apps/www/components/AIDemo/Panel.tsx @@ -0,0 +1,227 @@ +// components/AIDemoPanel.tsx +import { useState, useEffect, useRef } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { Button, Input } from 'ui' +import { AiIconAnimation, cn } from 'ui' +import errorGif from 'public/images/ai/error.gif' +import Image from 'next/image' +import styles from './assistant.module.css' + +interface DemoMessage { + id: string + role: 'user' | 'assistant' + content: string | React.ReactNode + render?: React.ReactNode + createdAt: Date +} + +export const AIDemoPanel = ({ incomingMessages = [] }: { incomingMessages?: DemoMessage[] }) => { + const [messages, setMessages] = useState([]) + const [isComplete, setIsComplete] = useState(false) + const [pendingMessages, setPendingMessages] = useState([]) + const [input, setInput] = useState('') + const [showFade, setShowFade] = useState(false) + const messagesEndRef = useRef(null) + const scrollContainerRef = useRef(null) + + const scrollToBottom = () => { + const container = messagesEndRef.current?.closest('.overflow-y-auto') + if (container) { + container.scrollTo({ + top: container.scrollHeight, + behavior: 'smooth', + }) + } + } + + const handleScroll = () => { + const container = scrollContainerRef.current + if (container) { + const scrollPercentage = + (container.scrollTop / (container.scrollHeight - container.clientHeight)) * 100 + const isScrollable = container.scrollHeight > container.clientHeight + const isAtBottom = scrollPercentage >= 100 + + setShowFade(isScrollable && !isAtBottom) + } + } + + useEffect(() => { + scrollToBottom() + handleScroll() + }, [messages]) + + useEffect(() => { + const container = scrollContainerRef.current + if (container) { + container.addEventListener('scroll', handleScroll) + handleScroll() + } + + return () => { + if (container) { + container.removeEventListener('scroll', handleScroll) + } + } + }, []) + + useEffect(() => { + if (pendingMessages.length > 0) { + const timer = setTimeout(() => { + setMessages((prev) => [...prev, pendingMessages[0]]) + setPendingMessages((prev) => prev.slice(1)) + }, 3000) + return () => clearTimeout(timer) + } else if (!isComplete) { + setIsComplete(true) + } + }, [isComplete, pendingMessages]) + + useEffect(() => { + if (incomingMessages.length > 0) { + setMessages((prev) => [...prev, ...pendingMessages, incomingMessages[0]]) + setPendingMessages(incomingMessages.slice(1)) + } + }, [incomingMessages]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!input.trim()) return + + const userMessage: DemoMessage = { + id: Date.now().toString(), + role: 'user', + content: input, + createdAt: new Date(), + } + + const assistantMessage: DemoMessage = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: ( +
+ Try the assistant in your dashboard for real results +
+ ), + createdAt: new Date(), + } + + const followUp: DemoMessage = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: "Sorry, custom queries aren't supported in this demo.", + createdAt: new Date(), + } + + setMessages((prev) => [...prev, userMessage]) + setPendingMessages((prev) => [...prev, assistantMessage, followUp]) + setInput('') + } + + return ( +
+
+ {/* Header */} +
+ +
Assistant
+ + + +
+ + {/* Messages */} +
+ {messages.map((message, index) => ( + <> + + {message.content} + {message.render &&
{message.render}
} +
+ + ))} + {messages.length > 0 && messages[messages.length - 1].role === 'user' && ( + + Thinking +
+ + . + + + . + + + . + +
+
+ )} +
+
+ + {/* Input */} + + {showFade && ( + +
+ + )} + +
+
+ setInput(e.target.value)} + placeholder="Ask your data anything..." + className={cn( + 'w-full bg-background-muted rounded-md [&>textarea]:border-1 [&>textarea]:rounded-md [&>textarea]:!outline-none [&>textarea]:!ring-offset-0 [&>textarea]:!ring-0 focus:outline-none' + )} + /> +
+
+
+
+ ) +} diff --git a/apps/www/components/AIDemo/SqlSnippet.tsx b/apps/www/components/AIDemo/SqlSnippet.tsx new file mode 100644 index 00000000000..345ad2e2173 --- /dev/null +++ b/apps/www/components/AIDemo/SqlSnippet.tsx @@ -0,0 +1,240 @@ +import { useState, useEffect } from 'react' +import { motion } from 'framer-motion' +import { Code, Play, DatabaseIcon } from 'lucide-react' +import { Button, cn, ChartContainer, ChartTooltip, ChartTooltipContent, SQL_ICON } from 'ui' +import { BarChart, Bar, XAxis, CartesianGrid } from 'recharts' +import CodeBlock from '../CodeBlock/CodeBlock' + +interface SqlSnippetProps { + id: string + sql: string + title?: string + resultType?: 'chart' | 'table' + mockData?: any +} + +// Example mock data matching the Results component format +const MOCK_TABLE_DATA = { + rows: [ + { + id: 1, + name: 'John Doe', + email: 'john@example.com', + created_at: '2024-01-15T10:00:00Z', + status: 'active', + metadata: { role: 'admin', last_login: '2024-01-14' }, + }, + { + id: 2, + name: 'Jane Smith', + email: 'jane@example.com', + created_at: '2024-01-14T15:30:00Z', + status: 'pending', + metadata: null, + }, + { + id: 3, + name: 'Bob Wilson', + email: 'bob@example.com', + created_at: '2024-01-13T09:15:00Z', + status: 'active', + metadata: { role: 'user', last_login: '2024-01-12' }, + }, + ], +} + +const MOCK_CHART_DATA = [ + { name: 'Jan', value: 400 }, + { name: 'Feb', value: 300 }, + { name: 'Mar', value: 600 }, + { name: 'Apr', value: 800 }, + { name: 'May', value: 700 }, +] + +export const SqlSnippet = ({ + id, + sql, + title = 'SQL Query', + resultType = 'table', + mockData, +}: SqlSnippetProps) => { + const [showCode, setShowCode] = useState(!mockData) + const [isExecuting, setIsExecuting] = useState(false) + const [showResults, setShowResults] = useState(false) + const [results, setResults] = useState(null) + + const handleExecute = () => { + setIsExecuting(true) + setShowResults(false) + + // Simulate SQL execution delay + setTimeout(() => { + setIsExecuting(false) + if (mockData) { + setShowResults(true) + // Use provided mock data or default mock data + setResults( + resultType === 'chart' + ? mockData || MOCK_CHART_DATA + : mockData?.rows || MOCK_TABLE_DATA.rows + ) + } + }, 1000) + } + + // Auto-execute on mount + useEffect(() => { + handleExecute() + }, []) + + const ResultsDisplay = () => { + if (resultType === 'chart') { + return ( +
+ + + + + } + /> + + + +
+ ) + } + + return ( + <> +
+ + + + {results?.length > 0 && + Object.keys(results[0]).map((key) => ( + + ))} + + + + {results?.map((row: any, i: number) => ( + + {Object.values(row).map((value: any, j: number) => ( + + ))} + + ))} + +
+ {key} +
+ {value === null + ? 'NULL' + : typeof value === 'object' + ? JSON.stringify(value) + : String(value)} +
+
+
+

+ {results.length} rows + {results.length >= 100 && ' (Limited to only 100 rows)'} +

+
+ + ) + } + + return ( +
+
+
+ +

{title}

+
+
+
+ + {showCode && ( +
+ + {sql} + +
+ )} + + {isExecuting ? ( + + Executing query +
+ + . + + + . + + + . + +
+
+ ) : showResults && results ? ( + + ) : null} +
+
+ ) +} diff --git a/apps/www/components/AIDemo/assistant.module.css b/apps/www/components/AIDemo/assistant.module.css new file mode 100644 index 00000000000..30adaf69f8c --- /dev/null +++ b/apps/www/components/AIDemo/assistant.module.css @@ -0,0 +1,113 @@ +.border-gradient { + --color1: #0a5c36; + --color2: #2ecc71; + --color3: #b44bff; + --angle: 0deg; + + border: none; + position: relative; + transform-style: preserve-3d; +} + +.border-gradient::after { + content: ''; + position: absolute; + left: 0; + right: 0; + margin-inline: auto; + width: calc(100% + 2px); + height: calc(100% + 2px); + left: 50%; + top: 50%; + border-radius: 6px; + transform: translate3d(-50%, -50%, -2px); + background: linear-gradient(var(--angle), var(--color1), var(--color2), var(--color3)); + filter: blur(0); + animation: animate-bg 10s linear infinite alternate; +} + +.border-gradient::before { + transform: translate3d(-50%, -50%, -1px); + backdrop-filter: blur(0); +} + +@property --angle { + syntax: ''; + initial-value: 0deg; + inherits: false; +} + +@property --color1 { + syntax: ''; + initial-value: #0a5c36; + inherits: false; +} + +@property --color2 { + syntax: ''; + initial-value: #2ecc71; + inherits: false; +} + +@property --color3 { + syntax: ''; + initial-value: #b44bff; + inherits: false; +} + +@keyframes animate-bg { + 0% { + --angle: 0deg; + --color1: #0a5c36; + --color2: #2ecc71; + --color3: #b44bff; + } + 12.5% { + --angle: 45deg; + --color1: #2ecc71; + --color2: #b44bff; + --color3: #0a5c36; + } + 25% { + --angle: 90deg; + --color1: #b44bff; + --color2: #0a5c36; + --color3: #2ecc71; + } + 37.5% { + --angle: 135deg; + --color1: #0a5c36; + --color2: #2ecc71; + --color3: #b44bff; + } + 50% { + --angle: 180deg; + --color1: #2ecc71; + --color2: #b44bff; + --color3: #0a5c36; + } + 62.5% { + --angle: 225deg; + --color1: #b44bff; + --color2: #0a5c36; + --color3: #2ecc71; + } + 75% { + --angle: 270deg; + --color1: #0a5c36; + --color2: #2ecc71; + --color3: #b44bff; + } + 87.5% { + --angle: 315deg; + --color1: #2ecc71; + --color2: #b44bff; + --color3: #0a5c36; + } + 100% { + --angle: 360deg; + --color1: #0a5c36; + --color2: #2ecc71; + --color3: #b44bff; + } +} diff --git a/apps/www/pages/assistant.tsx b/apps/www/pages/assistant.tsx new file mode 100644 index 00000000000..341964aefd5 --- /dev/null +++ b/apps/www/pages/assistant.tsx @@ -0,0 +1,808 @@ +import { Button } from 'ui' +import dynamic from 'next/dynamic' +import { AIDemoPanel } from '~/components/AIDemo/Panel' +import { useEffect, useState } from 'react' +import { SqlSnippet } from '~/components/AIDemo/SqlSnippet' +import { NextSeo } from 'next-seo' +import { motion } from 'framer-motion' +import { useRouter } from 'next/router' +import { useIsLoggedIn, useIsUserLoading } from 'common' +import Link from 'next/link' +import DotGrid from '~/components/AIDemo/DotGrid' +import { EASE_OUT } from '../lib/animations' + +const DefaultLayout = dynamic(() => import('~/components/Layouts/Default')) +const SectionContainer = dynamic(() => import('~/components/Layouts/SectionContainer')) + +interface Message { + id: string + role: 'user' | 'assistant' + content: string | JSX.Element + createdAt: Date + render?: JSX.Element +} + +const welcomeMessages = [ + { + id: 'ph-1', + role: 'user' as const, + content: 'I have come from ProductHunt. Show me the goods!', + createdAt: new Date(), + }, + { + id: 'ph-2', + role: 'assistant' as const, + content: ( +
+ + + + + + +

+ Welcome Product Hunter! 👋 Thanks for checking out the Supabase assistant. Let me show you + what I can do! +

+
+ ), + createdAt: new Date(), + }, +] + +const demoQueries = [ + { + label: 'User Growth Over Time', + messages: [ + { + id: '1', + role: 'user' as const, + content: 'Show me user signups over the past 12 months', + createdAt: new Date(), + }, + { + id: '2', + role: 'assistant' as const, + content: 'Here is a chart showing user growth by month:', + createdAt: new Date(), + render: ( + + ), + }, + ], + }, + { + label: 'Create Function', + messages: [ + { + id: '19', + role: 'user' as const, + content: 'Help me create a function to calculate user points', + createdAt: new Date(), + }, + { + id: '20', + role: 'assistant' as const, + content: "Here's a Postgres function that calculates user points based on their activity:", + createdAt: new Date(), + render: ( + + ), + }, + ], + }, + { + label: 'RLS Policy', + messages: [ + { + id: '21', + role: 'user' as const, + content: 'Show me how to create an RLS policy for a team members table', + createdAt: new Date(), + }, + { + id: '22', + role: 'assistant' as const, + content: + "Here's an RLS policy that allows team members to only see other members in their team:", + createdAt: new Date(), + render: ( + + ), + }, + ], + }, + { + label: 'SQL to Supabase-js', + messages: [ + { + id: '23', + role: 'user' as const, + content: 'How do I query all projects?', + createdAt: new Date(), + }, + { + id: '24', + role: 'assistant' as const, + content: "Here's the SQL query to get all projects:", + createdAt: new Date(), + render: ( + + ), + }, + { + id: '25', + role: 'user' as const, + content: 'Can you show me how to do this with the Supabase client?', + createdAt: new Date(), + }, + { + id: '26', + role: 'assistant' as const, + content: "Here's how to perform the same query using the Supabase JavaScript client:", + createdAt: new Date(), + render: ( + + ), + }, + ], + }, + { + label: 'Recent Orders', + messages: [ + { + id: '3', + role: 'user' as const, + content: 'Show me the most recent orders', + createdAt: new Date(), + }, + { + id: '4', + role: 'assistant' as const, + content: 'Here are the 5 most recent orders:', + createdAt: new Date(), + render: ( + + ), + }, + ], + }, + { + label: 'Revenue by Category', + messages: [ + { + id: '5', + role: 'user' as const, + content: 'What are our top performing product categories?', + createdAt: new Date(), + }, + { + id: '6', + role: 'assistant' as const, + content: 'Here is the revenue breakdown by product category:', + createdAt: new Date(), + render: ( + + ), + }, + ], + }, + { + label: 'Active Users', + messages: [ + { + id: '7', + role: 'user' as const, + content: 'Show me our currently active users', + createdAt: new Date(), + }, + { + id: '8', + role: 'assistant' as const, + content: 'Here are the currently active users:', + createdAt: new Date(), + render: ( + now() - interval '24 hours' +ORDER BY last_login DESC;`} + mockData={{ + rows: [ + { + username: 'alice_smith', + last_login: '2024-01-15T15:30:00Z', + session_count: 45, + }, + { + username: 'bob_jones', + last_login: '2024-01-15T15:15:00Z', + session_count: 32, + }, + { + username: 'carol_white', + last_login: '2024-01-15T14:45:00Z', + session_count: 28, + }, + ], + }} + /> + ), + }, + ], + }, + { + label: 'Storage Usage', + messages: [ + { + id: '9', + role: 'user' as const, + content: 'What is our current storage usage?', + createdAt: new Date(), + }, + { + id: '10', + role: 'assistant' as const, + content: 'Here is the storage usage by table:', + createdAt: new Date(), + render: ( + + ), + }, + ], + }, + { + label: 'Daily Page Views', + messages: [ + { + id: '11', + role: 'user' as const, + content: 'Show me daily page views for the last 2 weeks', + createdAt: new Date(), + }, + { + id: '12', + role: 'assistant' as const, + content: 'Here are the daily page views:', + createdAt: new Date(), + render: ( + now() - interval '14 days' +GROUP BY day +ORDER BY day;`} + mockData={[ + { name: '2024-01-01', value: 15234 }, + { name: '2024-01-02', value: 14567 }, + { name: '2024-01-03', value: 16789 }, + { name: '2024-01-04', value: 15678 }, + { name: '2024-01-05', value: 17890 }, + { name: '2024-01-06', value: 13456 }, + { name: '2024-01-07', value: 12345 }, + { name: '2024-01-08', value: 16543 }, + { name: '2024-01-09', value: 18765 }, + { name: '2024-01-10', value: 19876 }, + { name: '2024-01-11', value: 20123 }, + { name: '2024-01-12', value: 21234 }, + { name: '2024-01-13', value: 19876 }, + { name: '2024-01-14', value: 18765 }, + ]} + /> + ), + }, + ], + }, + { + label: 'Error Distribution', + messages: [ + { + id: '1', + role: 'user' as const, + content: 'Show me the distribution of error types', + createdAt: new Date(), + }, + { + id: '15', + role: 'assistant' as const, + content: 'Here is the distribution of error types:', + createdAt: new Date(), + render: ( + + ), + }, + ], + }, + { + label: 'Database Performance', + messages: [ + { + id: '1', + role: 'user' as const, + content: 'Show me database performance metrics', + createdAt: new Date(), + }, + { + id: '16', + role: 'assistant' as const, + content: 'Here are the current database performance metrics:', + createdAt: new Date(), + render: ( + + ), + }, + ], + }, + { + label: 'API Usage Trends', + messages: [ + { + id: '17', + role: 'user' as const, + content: 'Show me API usage trends over time', + createdAt: new Date(), + }, + { + id: '18', + role: 'assistant' as const, + content: 'Here are the API usage trends:', + createdAt: new Date(), + render: ( + now() - interval '24 hours' +GROUP BY hour +ORDER BY hour;`} + mockData={[ + { name: '00:00', value: 1200 }, + { name: '02:00', value: 800 }, + { name: '04:00', value: 600 }, + { name: '06:00', value: 900 }, + { name: '08:00', value: 2500 }, + { name: '10:00', value: 3500 }, + { name: '12:00', value: 4000 }, + { name: '14:00', value: 3800 }, + { name: '16:00', value: 3200 }, + { name: '18:00', value: 2800 }, + { name: '20:00', value: 2000 }, + { name: '22:00', value: 1500 }, + ]} + /> + ), + }, + ], + }, +] + +function Assistant() { + const [incomingMessages, setIncomingMessages] = useState([]) + const isLoggedIn = useIsLoggedIn() + const isUserLoading = useIsUserLoading() + const meta_title = 'AI | Supabase' + const meta_description = 'Build AI-powered applications with Supabase' + const router = useRouter() + const { query } = router + + useEffect(() => { + if (incomingMessages.length === 0) { + const timeoutId = setTimeout(() => { + const isFromProductHunt = query.ref === 'producthunt' + const initialMessages = isFromProductHunt + ? [...welcomeMessages, ...demoQueries[0].messages] + : demoQueries[Math.floor(Math.random() * demoQueries.length)].messages + + setIncomingMessages(initialMessages) + }, 3000) + return () => clearTimeout(timeoutId) + } + }, [query.ref]) + + const handleNewMessage = (messages: Message[]) => { + setIncomingMessages(messages.map((message) => ({ ...message, id: Date.now().toString() }))) + } + + return ( + <> + + + + + + +
+ {/* Left Column */} +
+ {/* Main content */} +
+

Chat with Postgres

+

+ Generate, run and debug queries, chart your data, create functions, policies and + more. The assistant is here to help. +

+
+ {!isUserLoading && + (isLoggedIn ? ( + + ) : ( + + ))} +
+
+ + {/* Grid of secondary buttons */} +
+

Try me

+
+ + {demoQueries.map((query, i) => ( + + + + ))} + +
+
+
+ + {/* Right Column */} + + + + + + + + + +
+
+
+ + ) +} + +export default Assistant diff --git a/apps/www/public/images/ai/error.gif b/apps/www/public/images/ai/error.gif new file mode 100644 index 00000000000..37b9e76b260 Binary files /dev/null and b/apps/www/public/images/ai/error.gif differ