+ {/* Summary Stats */}
+
+
+
+
+
+
Total Prompts
+
{data.totals.prompts}
+
+
+
+
+
+
+
+
+
+
+
Total Runs
+
{data.totals.runs.toLocaleString()}
+
+
+
+
+
+
+
+
+
+
+
Total Stars
+
{data.totals.stars.toLocaleString()}
+
+
+
+
+
+
+
+
+
+
+
Total Remixes
+
{data.totals.remixes.toLocaleString()}
+
+
+
+
+
+
+
+ {/* Charts Row */}
+
+ {/* Runs per Day Chart */}
+
+
+
+
+ Runs Per Day
+
+ Last 30 days
+
+
+
+ {data.dailyData.map((day, i) => (
+
+
0 ? "4px" : "1px",
+ }}
+ />
+
+ {day.runs} runs
+
+
+ ))}
+
+
+ 30 days ago
+ Today
+
+
+
+
+ {/* Model Usage */}
+
+
+
+
+ Model Usage
+
+
+ {data.totalTokens.toLocaleString()} total tokens used
+
+
+
+
+ {Object.entries(data.modelUsage).length > 0 ? (
+ Object.entries(data.modelUsage)
+ .sort(([, a], [, b]) => b - a)
+ .map(([model, count]) => {
+ const percentage = Math.round(
+ (count / Object.values(data.modelUsage).reduce((a, b) => a + b, 0)) * 100
+ )
+ return (
+
+
+ {model}
+
+ {count} runs ({percentage}%)
+
+
+
+
+ )
+ })
+ ) : (
+
+ No usage data yet
+
+ )}
+
+
+
+
+
+ {/* Top Prompts */}
+
+
+
+
+ Top Performing Prompts
+
+ Sorted by total runs
+
+
+ {data.prompts.length > 0 ? (
+
+ {data.prompts.slice(0, 10).map((prompt, i) => (
+
+
+ {i + 1}
+
+
+
{prompt.title}
+
+
+
+ {prompt.totalRuns.toLocaleString()} runs
+
+
+
+ {prompt.starsCount} stars
+
+
+
+ {prompt.remixesCount} remixes
+
+
+
+
{prompt.totalRuns} runs
+
+ ))}
+
+ ) : (
+
+
+
No prompts yet
+
+
+ Create your first prompt
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/analytics/enhanced-analytics.tsx b/src/components/analytics/enhanced-analytics.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f33318c4609a2fb76690ccbdc3a54b34b7068121
--- /dev/null
+++ b/src/components/analytics/enhanced-analytics.tsx
@@ -0,0 +1,228 @@
+"use client"
+
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Progress } from "@/components/ui/progress"
+import {
+ BarChart3,
+ TrendingUp,
+ TrendingDown,
+ Users,
+ Play,
+ Star,
+ GitFork,
+ Eye,
+ Clock,
+ Zap
+} from "lucide-react"
+
+interface AnalyticsStat {
+ label: string
+ value: string | number
+ change?: number
+ changeLabel?: string
+ icon: React.ReactNode
+}
+
+interface EnhancedAnalyticsProps {
+ stats: {
+ totalRuns: number
+ totalStars: number
+ totalRemixes: number
+ totalViews: number
+ averageResponseTime: number
+ tokenUsage: number
+ topPrompts: { title: string; runs: number; stars: number }[]
+ runsByDay: { date: string; runs: number }[]
+ modelUsage: { model: string; percentage: number }[]
+ }
+}
+
+export function EnhancedAnalytics({ stats }: EnhancedAnalyticsProps) {
+ const mainStats: AnalyticsStat[] = [
+ {
+ label: "Total Runs",
+ value: stats.totalRuns.toLocaleString(),
+ change: 12.5,
+ changeLabel: "vs last week",
+ icon:
+ },
+ {
+ label: "Total Stars",
+ value: stats.totalStars.toLocaleString(),
+ change: 8.3,
+ changeLabel: "vs last week",
+ icon:
+ },
+ {
+ label: "Remixes",
+ value: stats.totalRemixes.toLocaleString(),
+ change: 25.0,
+ changeLabel: "vs last week",
+ icon:
+ },
+ {
+ label: "Total Views",
+ value: stats.totalViews.toLocaleString(),
+ change: -2.1,
+ changeLabel: "vs last week",
+ icon:
+ }
+ ]
+
+ return (
+
+ {/* Main Stats Grid */}
+
+ {mainStats.map((stat, index) => (
+
+
+
+ {stat.icon}
+ {stat.change !== undefined && (
+ = 0 ? "text-green-500" : "text-red-500"}
+ >
+ {stat.change >= 0 ? (
+
+ ) : (
+
+ )}
+ {Math.abs(stat.change)}%
+
+ )}
+
+ {stat.value}
+ {stat.label}
+
+
+ ))}
+
+
+ {/* Performance Metrics */}
+
+
+
+
+
+ Performance Metrics
+
+
+
+
+
+ Avg Response Time
+ {stats.averageResponseTime}ms
+
+
+
+ {stats.averageResponseTime < 2000 ? "Excellent" :
+ stats.averageResponseTime < 4000 ? "Good" : "Needs improvement"}
+
+
+
+
+
+ Token Efficiency
+ {(stats.tokenUsage / 1000).toFixed(1)}k tokens
+
+
+
Above average efficiency
+
+
+
+
+
+
+
+
+ Model Usage
+
+
+
+ {stats.modelUsage.map((model, index) => (
+
+
+ {model.model}
+ {model.percentage}%
+
+
+
+ ))}
+
+
+
+
+ {/* Top Prompts */}
+
+
+
+
+ Top Performing Prompts
+
+ Your most popular prompts by runs
+
+
+
+ {stats.topPrompts.map((prompt, index) => (
+
+
+ {index + 1}
+
+
+
{prompt.title}
+
+
+
+ {prompt.runs} runs
+
+
+
+ {prompt.stars} stars
+
+
+
+
+
+ ))}
+
+
+
+
+ {/* Activity Chart (Simple Bar Representation) */}
+
+
+ Weekly Activity
+ Runs per day over the last 7 days
+
+
+
+ {stats.runsByDay.map((day, index) => {
+ const maxRuns = Math.max(...stats.runsByDay.map(d => d.runs))
+ const height = (day.runs / maxRuns) * 100
+
+ return (
+
+ )
+ })}
+
+
+
+
+ )
+}
diff --git a/src/components/auth/index.ts b/src/components/auth/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fd51cad0ea23d20802ad359bba7924b219a60933
--- /dev/null
+++ b/src/components/auth/index.ts
@@ -0,0 +1 @@
+export * from './user-button'
diff --git a/src/components/auth/user-button.tsx b/src/components/auth/user-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b7717e43eb4299545c2fe5a8f2281c3a3d42ac12
--- /dev/null
+++ b/src/components/auth/user-button.tsx
@@ -0,0 +1,85 @@
+"use client"
+
+import { useUser, useStackApp } from "@stackframe/stack"
+import Link from "next/link"
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { User, Settings, Star, FileText, LogOut, ChevronDown } from "lucide-react"
+
+export function UserButton() {
+ const user = useUser()
+ const app = useStackApp()
+
+ if (!user) {
+ return (
+
+
+
+ Sign In
+
+
+
+
+ Sign Up
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ {user.displayName?.[0]?.toUpperCase() || user.primaryEmail?.[0]?.toUpperCase() || "U"}
+
+
+ {user.displayName || user.primaryEmail?.split("@")[0]}
+
+
+
+
+
+
+
{user.displayName || "User"}
+
{user.primaryEmail}
+
+
+
+
+
+ My Prompts
+
+
+
+
+
+ Starred
+
+
+
+
+
+
+ Settings
+
+
+
+ user.signOut()}
+ className="text-destructive focus:text-destructive cursor-pointer"
+ >
+
+ Sign Out
+
+
+
+ )
+}
diff --git a/src/components/coach/prompt-coach.tsx b/src/components/coach/prompt-coach.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..69e89026dabfe75b1406ac8109856a0d1d11ba07
--- /dev/null
+++ b/src/components/coach/prompt-coach.tsx
@@ -0,0 +1,297 @@
+"use client"
+
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Progress } from "@/components/ui/progress"
+import {
+ Lightbulb,
+ Loader2,
+ AlertCircle,
+ CheckCircle,
+ Info,
+ Sparkles,
+ ChevronDown,
+ ChevronUp,
+ Wand2
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+
+interface Suggestion {
+ type: "clarity" | "context" | "framework" | "examples" | "format" | "edge-cases" | "general"
+ severity: "info" | "warning" | "critical"
+ title: string
+ description: string
+ example?: string | null
+}
+
+interface CoachAnalysis {
+ score: number
+ suggestions: Suggestion[]
+ recommendedFramework: string | null
+ summary: string
+}
+
+interface PromptCoachProps {
+ prompt: string
+ variables?: Record
+ onSuggestionApply?: (suggestion: Suggestion) => void
+}
+
+const SEVERITY_CONFIG = {
+ info: {
+ icon: Info,
+ color: "text-blue-500",
+ bgColor: "bg-blue-500/10 border-blue-500/20"
+ },
+ warning: {
+ icon: AlertCircle,
+ color: "text-yellow-500",
+ bgColor: "bg-yellow-500/10 border-yellow-500/20"
+ },
+ critical: {
+ icon: AlertCircle,
+ color: "text-red-500",
+ bgColor: "bg-red-500/10 border-red-500/20"
+ }
+}
+
+const TYPE_LABELS: Record = {
+ clarity: "Clarity",
+ context: "Context",
+ framework: "Framework",
+ examples: "Examples",
+ format: "Format",
+ "edge-cases": "Edge Cases",
+ general: "General"
+}
+
+export function PromptCoach({ prompt, variables, onSuggestionApply }: PromptCoachProps) {
+ const [analysis, setAnalysis] = useState(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [isExpanded, setIsExpanded] = useState(true)
+ const [expandedSuggestion, setExpandedSuggestion] = useState(null)
+
+ const analyzePrompt = async () => {
+ if (!prompt.trim()) {
+ setError("Please enter a prompt to analyze")
+ return
+ }
+
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const response = await fetch("/api/coach", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ prompt, variables })
+ })
+
+ if (!response.ok) {
+ throw new Error("Failed to analyze prompt")
+ }
+
+ const data = await response.json()
+ setAnalysis(data)
+ } catch (err) {
+ setError("Failed to analyze prompt. Please try again.")
+ console.error(err)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const getScoreColor = (score: number) => {
+ if (score >= 80) return "text-green-500"
+ if (score >= 60) return "text-yellow-500"
+ return "text-red-500"
+ }
+
+ const getScoreLabel = (score: number) => {
+ if (score >= 90) return "Excellent"
+ if (score >= 80) return "Good"
+ if (score >= 60) return "Needs Improvement"
+ return "Poor"
+ }
+
+ return (
+
+
+ setIsExpanded(!isExpanded)}
+ className="flex items-center justify-between w-full text-left"
+ >
+
+
+ AI Prompt Coach
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ Get AI-powered suggestions to improve your prompt
+
+
+
+ {isExpanded && (
+
+ {/* Analyze Button */}
+ {!analysis && (
+
+ {isLoading ? (
+ <>
+
+ Analyzing...
+ >
+ ) : (
+ <>
+
+ Analyze Prompt
+ >
+ )}
+
+ )}
+
+ {/* Error */}
+ {error && (
+
+ )}
+
+ {/* Analysis Results */}
+ {analysis && (
+
+ {/* Score */}
+
+
+
Prompt Score
+
+ {analysis.score}/100
+
+
+ {getScoreLabel(analysis.score)}
+
+
+
+
+
+ {/* Summary */}
+
+
+ {/* Recommended Framework */}
+ {analysis.recommendedFramework && (
+
+
+
+ Consider using the{" "}
+
+ {analysis.recommendedFramework}
+
+ {" "}framework
+
+
+ )}
+
+ {/* Suggestions */}
+
+
Suggestions ({analysis.suggestions.length})
+ {analysis.suggestions.map((suggestion, index) => {
+ const config = SEVERITY_CONFIG[suggestion.severity]
+ const Icon = config.icon
+
+ return (
+
setExpandedSuggestion(
+ expandedSuggestion === index ? null : index
+ )}
+ >
+
+
+
+
+
{suggestion.title}
+
+ {TYPE_LABELS[suggestion.type]}
+
+
+
+ {expandedSuggestion === index && (
+
+
+ {suggestion.description}
+
+ {suggestion.example && (
+
+ {suggestion.example}
+
+ )}
+ {onSuggestionApply && suggestion.example && (
+
{
+ e.stopPropagation()
+ onSuggestionApply(suggestion)
+ }}
+ className="gap-1"
+ >
+
+ Apply Suggestion
+
+ )}
+
+ )}
+
+
+
+ )
+ })}
+
+
+ {/* Re-analyze button */}
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+ Re-analyze
+
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/src/components/comments/comments.tsx b/src/components/comments/comments.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c0cf61ec813d27dd551802c343096a17de303c47
--- /dev/null
+++ b/src/components/comments/comments.tsx
@@ -0,0 +1,428 @@
+"use client";
+
+import { useState } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import {
+ MessageSquare,
+ Heart,
+ Reply,
+ MoreHorizontal,
+ Send,
+ User,
+ ChevronDown,
+ ChevronUp,
+ Flag,
+ Trash2,
+ Edit
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { formatDistanceToNow } from "date-fns";
+
+interface CommentUser {
+ id: string;
+ name: string;
+ username: string;
+ image?: string;
+ rank?: string;
+}
+
+interface Comment {
+ id: string;
+ content: string;
+ user: CommentUser;
+ likes: number;
+ liked?: boolean;
+ createdAt: Date;
+ replies?: Comment[];
+}
+
+interface CommentsProps {
+ promptId: string;
+ comments?: Comment[];
+ currentUser?: CommentUser | null;
+}
+
+// Sample comments for demo
+const SAMPLE_COMMENTS: Comment[] = [
+ {
+ id: "1",
+ content: "This prompt is amazing! I've been using it for all my blog posts and the quality is incredible. Definitely recommend tweaking the tone variable for different audiences.",
+ user: {
+ id: "u1",
+ name: "Sarah Chen",
+ username: "sarahchen",
+ image: "https://api.dicebear.com/7.x/avataaars/svg?seed=sarah",
+ rank: "gold"
+ },
+ likes: 24,
+ liked: false,
+ createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
+ replies: [
+ {
+ id: "1-1",
+ content: "Thanks for the tip! What tone setting works best for technical documentation?",
+ user: {
+ id: "u2",
+ name: "Mike Johnson",
+ username: "mikej",
+ image: "https://api.dicebear.com/7.x/avataaars/svg?seed=mike",
+ rank: "silver"
+ },
+ likes: 8,
+ createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000),
+ },
+ {
+ id: "1-2",
+ content: "For technical docs I use 'professional and precise'. Works great!",
+ user: {
+ id: "u1",
+ name: "Sarah Chen",
+ username: "sarahchen",
+ image: "https://api.dicebear.com/7.x/avataaars/svg?seed=sarah",
+ rank: "gold"
+ },
+ likes: 12,
+ createdAt: new Date(Date.now() - 30 * 60 * 1000),
+ }
+ ]
+ },
+ {
+ id: "2",
+ content: "I remixed this with a few tweaks for SEO optimization. Check out my version if you're looking for better search rankings!",
+ user: {
+ id: "u3",
+ name: "Alex Rivera",
+ username: "alexr",
+ image: "https://api.dicebear.com/7.x/avataaars/svg?seed=alex",
+ rank: "verified"
+ },
+ likes: 45,
+ liked: true,
+ createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
+ },
+ {
+ id: "3",
+ content: "Does this work well with Claude or is it optimized specifically for GPT-4?",
+ user: {
+ id: "u4",
+ name: "Jordan Lee",
+ username: "jordanl",
+ image: "https://api.dicebear.com/7.x/avataaars/svg?seed=jordan",
+ },
+ likes: 6,
+ createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000),
+ },
+];
+
+function RankBadge({ rank }: { rank?: string }) {
+ if (!rank) return null;
+
+ const colors: Record = {
+ bronze: "bg-orange-900/50 text-orange-300 border-orange-700",
+ silver: "bg-gray-700/50 text-gray-300 border-gray-600",
+ gold: "bg-yellow-900/50 text-yellow-300 border-yellow-700",
+ verified: "bg-blue-900/50 text-blue-300 border-blue-700",
+ };
+
+ return (
+
+ {rank === "verified" ? "โ Verified" : rank.charAt(0).toUpperCase() + rank.slice(1)}
+
+ );
+}
+
+function CommentItem({
+ comment,
+ isReply = false,
+ onReply,
+ onLike,
+ currentUser
+}: {
+ comment: Comment;
+ isReply?: boolean;
+ onReply: (commentId: string) => void;
+ onLike: (commentId: string) => void;
+ currentUser?: CommentUser | null;
+}) {
+ const [showReplies, setShowReplies] = useState(true);
+ const [isLiked, setIsLiked] = useState(comment.liked || false);
+ const [likesCount, setLikesCount] = useState(comment.likes);
+
+ const handleLike = () => {
+ if (isLiked) {
+ setLikesCount(prev => prev - 1);
+ } else {
+ setLikesCount(prev => prev + 1);
+ }
+ setIsLiked(!isLiked);
+ onLike(comment.id);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {comment.user.name}
+ @{comment.user.username}
+
+
+ ยท {formatDistanceToNow(comment.createdAt, { addSuffix: true })}
+
+
+
+
{comment.content}
+
+
+
+
+ {likesCount}
+
+
+ {!isReply && (
+ onReply(comment.id)}
+ >
+
+ Reply
+
+ )}
+
+
+
+
+
+
+
+
+ {currentUser?.id === comment.user.id && (
+ <>
+
+
+ Edit
+
+
+
+ Delete
+
+
+ >
+ )}
+
+
+ Report
+
+
+
+
+
+
+
+ {/* Replies */}
+ {comment.replies && comment.replies.length > 0 && (
+
+
setShowReplies(!showReplies)}
+ >
+ {showReplies ? (
+ <>
+
+ Hide {comment.replies.length} {comment.replies.length === 1 ? "reply" : "replies"}
+ >
+ ) : (
+ <>
+
+ Show {comment.replies.length} {comment.replies.length === 1 ? "reply" : "replies"}
+ >
+ )}
+
+
+
+ {showReplies && (
+
+ {comment.replies.map((reply) => (
+
+ ))}
+
+ )}
+
+
+ )}
+
+ );
+}
+
+export function Comments({ promptId, comments = SAMPLE_COMMENTS, currentUser }: CommentsProps) {
+ const [newComment, setNewComment] = useState("");
+ const [replyingTo, setReplyingTo] = useState(null);
+ const [allComments, setAllComments] = useState(comments);
+
+ const handleSubmit = () => {
+ if (!newComment.trim()) return;
+
+ // In real app, this would be an API call
+ const comment: Comment = {
+ id: Date.now().toString(),
+ content: newComment,
+ user: currentUser || {
+ id: "guest",
+ name: "Guest User",
+ username: "guest"
+ },
+ likes: 0,
+ createdAt: new Date(),
+ };
+
+ if (replyingTo) {
+ setAllComments(prev =>
+ prev.map(c => {
+ if (c.id === replyingTo) {
+ return {
+ ...c,
+ replies: [...(c.replies || []), comment]
+ };
+ }
+ return c;
+ })
+ );
+ setReplyingTo(null);
+ } else {
+ setAllComments(prev => [comment, ...prev]);
+ }
+
+ setNewComment("");
+ };
+
+ const handleReply = (commentId: string) => {
+ setReplyingTo(commentId);
+ // Focus the textarea
+ document.getElementById("comment-input")?.focus();
+ };
+
+ const handleLike = (commentId: string) => {
+ // In real app, this would be an API call
+ console.log("Liked comment:", commentId);
+ };
+
+ return (
+
+
+
+
+ Comments ({allComments.length})
+
+
+
+ {/* Comment Input */}
+
+ {replyingTo && (
+
+
+ Replying to comment
+ setReplyingTo(null)}
+ >
+ Cancel
+
+
+ )}
+
+
+
+
+ {/* Comments List */}
+
+ {allComments.map((comment) => (
+
+ ))}
+
+
+ {allComments.length === 0 && (
+
+
+
No comments yet. Be the first to comment!
+
+ )}
+
+ );
+}
diff --git a/src/components/comments/index.ts b/src/components/comments/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ccf6267b01deda67151a44311cab4073e57c6f66
--- /dev/null
+++ b/src/components/comments/index.ts
@@ -0,0 +1 @@
+export { Comments } from "./comments";
diff --git a/src/components/create/framework-scaffold.tsx b/src/components/create/framework-scaffold.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..cea82ba7bdc1f26468fc82e11169b0161e00e9e2
--- /dev/null
+++ b/src/components/create/framework-scaffold.tsx
@@ -0,0 +1,94 @@
+"use client"
+
+import { useState } from 'react'
+import { Framework } from '@/lib/frameworks'
+import { Target } from 'lucide-react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Label } from '@/components/ui/label'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { Badge } from '@/components/ui/badge'
+
+interface FrameworkScaffoldProps {
+ framework: Framework
+ sections: Record
+ onSectionsChange: (sections: Record) => void
+ className?: string
+}
+
+export function FrameworkScaffold({ framework, sections, onSectionsChange, className = '' }: FrameworkScaffoldProps) {
+ const updateSection = (sectionId: string, value: string) => {
+ onSectionsChange({
+ ...sections,
+ [sectionId]: value,
+ })
+ }
+
+ return (
+
+
+
Fill in {framework.name} Framework
+ Structured
+
+
+
+
+
+ {framework.description}
+
+
+
+ {framework.sections.map((section) => (
+
+
+
+ {section.label}
+ {section.required && (
+ *
+ )}
+
+
+ {section.description}
+
+
+
+ {section.label.toLowerCase().includes('example') ||
+ section.label.toLowerCase().includes('steps') ||
+ section.id === 'context' ||
+ section.id === 'instructions' ? (
+
+ ))}
+
+
+
+ {framework.example && (
+
+
+ Example Output
+
+
+
+ {framework.example}
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/create/framework-selector.tsx b/src/components/create/framework-selector.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d3c8ba528ef45374d2edd2c12011b76a2d03b86e
--- /dev/null
+++ b/src/components/create/framework-selector.tsx
@@ -0,0 +1,80 @@
+"use client"
+
+import { useState } from 'react'
+import { Framework, getAllFrameworks } from '@/lib/frameworks'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Check } from 'lucide-react'
+
+interface FrameworkSelectorProps {
+ selectedFramework?: string
+ onSelect: (frameworkId: string | null) => void
+ className?: string
+}
+
+export function FrameworkSelector({ selectedFramework, onSelect, className = '' }: FrameworkSelectorProps) {
+ const frameworks = getAllFrameworks()
+
+ return (
+
+
+
Choose a Framework (Optional)
+
+ Structured frameworks help you write better prompts. Select one to get started with a template.
+
+
+
+
+ {/* No Framework Option */}
+
onSelect(null)}
+ >
+
+
+
+ No Framework
+
+ Start from scratch
+
+
+ {!selectedFramework && (
+
+ )}
+
+
+
+
+ {/* Framework Options */}
+ {frameworks.map((framework) => (
+
onSelect(framework.id)}
+ >
+
+
+
+
+ {framework.name}
+
+ {framework.sections.length} sections
+
+
+
+ {framework.description}
+
+
+ {selectedFramework === framework.id && (
+
+ )}
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/create/prompt-coach.tsx b/src/components/create/prompt-coach.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8083d9f3679ccaeb40285eadd13b4adb6fdf5240
--- /dev/null
+++ b/src/components/create/prompt-coach.tsx
@@ -0,0 +1,389 @@
+"use client"
+
+import { useState, useEffect, useMemo, useCallback, useRef } from "react"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Progress } from "@/components/ui/progress"
+import {
+ Lightbulb,
+ AlertTriangle,
+ CheckCircle2,
+ Sparkles,
+ Zap,
+ Wand2,
+ Loader2,
+ GitBranch,
+ Target,
+} from "lucide-react"
+
+interface PromptCoachProps {
+ prompt: string
+ /** Variables defined in the prompt schema */
+ schemaVariables?: Array<{ name: string; type: string }>
+ /** Prompt category for context-specific tips */
+ category?: string
+ onApplySuggestion?: (newPrompt: string) => void
+}
+
+interface Suggestion {
+ type: "error" | "warning" | "tip" | "success"
+ title: string
+ description: string
+ fix?: string
+}
+
+export function PromptCoach({ prompt, schemaVariables = [], category, onApplySuggestion }: PromptCoachProps) {
+ const [suggestions, setSuggestions] = useState([])
+ const [score, setScore] = useState(0)
+ const [aiLoading, setAiLoading] = useState(false)
+ const [aiInsight, setAiInsight] = useState(null)
+ const lastAiPrompt = useRef("")
+
+ // Analyze prompt and generate suggestions
+ const analysis = useMemo(() => {
+ if (!prompt.trim()) {
+ return { suggestions: [], score: 0 }
+ }
+
+ const newSuggestions: Suggestion[] = []
+ let points = 0
+
+ // โโ 1. Length โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ const wordCount = prompt.split(/\s+/).filter(Boolean).length
+ if (wordCount < 10) {
+ newSuggestions.push({
+ type: "warning",
+ title: "Too Short",
+ description: "Your prompt is very brief. Add more context for better results.",
+ fix: `${prompt}\n\nPlease provide a detailed response with concrete examples.`
+ })
+ } else if (wordCount >= 30) {
+ points += 20
+ newSuggestions.push({
+ type: "success",
+ title: "Good Length",
+ description: `${wordCount} words โ well within the sweet spot for clear, focused prompts.`
+ })
+ } else {
+ points += 15
+ }
+
+ // โโ 2. Role / persona โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ const hasRole = /act as|you are|pretend|imagine you're|as a|you're an? /i.test(prompt)
+ if (hasRole) {
+ points += 20
+ newSuggestions.push({
+ type: "success",
+ title: "Role Defined โ",
+ description: "You've given the AI a persona. This significantly improves output quality."
+ })
+ } else {
+ newSuggestions.push({
+ type: "tip",
+ title: "Add a Persona",
+ description: "Define who the AI should act as (e.g., 'You are an expert copywriterโฆ') for more authoritative responses.",
+ fix: `You are an expert in this field with 10+ years of experience.\n\n${prompt}`
+ })
+ }
+
+ // โโ 3. Output format โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ const hasFormat = /list|bullet|step|numbered|format|json|table|markdown|example|structure/i.test(prompt)
+ if (hasFormat) {
+ points += 15
+ newSuggestions.push({
+ type: "success",
+ title: "Output Format Specified โ",
+ description: "You've defined the response format. Consistent formatting improves usability."
+ })
+ } else {
+ newSuggestions.push({
+ type: "tip",
+ title: "Specify Output Format",
+ description: "Tell the AI how to structure its response (numbered list, bullet points, JSON, paragraphs, etc.).",
+ fix: `${prompt}\n\nFormat your response as a numbered list with clear section headers.`
+ })
+ }
+
+ // โโ 4. Vague language โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ const vagueWords = ["good", "nice", "thing", "stuff", "something", "anything", "whatever", "great", "amazing"]
+ const foundVague = vagueWords.filter(word => new RegExp(`\\b${word}\\b`, "i").test(prompt))
+ if (foundVague.length > 0) {
+ newSuggestions.push({
+ type: "warning",
+ title: "Vague Language Detected",
+ description: `Words like "${foundVague[0]}" are imprecise. Replace with specific, measurable criteria for better results.`
+ })
+ } else {
+ points += 10
+ }
+
+ // โโ 5. Constraints โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ const hasConstraints = /don't|avoid|must|should|limit|only|exclude|without|no more than|at least/i.test(prompt)
+ if (hasConstraints) {
+ points += 15
+ newSuggestions.push({
+ type: "success",
+ title: "Constraints Added โ",
+ description: "You've set boundaries. Constraints help focus the AI on what you actually need."
+ })
+ } else {
+ newSuggestions.push({
+ type: "tip",
+ title: "Add Constraints",
+ description: "Tell the AI what to avoid, tone boundaries, length limits, or what to exclude.",
+ fix: `${prompt}\n\nAvoid technical jargon. Keep explanations concise (under 200 words). Do not include disclaimers.`
+ })
+ }
+
+ // โโ 6. Examples (few-shot) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ const hasExamples = /example|e\.g\.|for instance|such as|like this|here is|sample/i.test(prompt)
+ if (hasExamples) {
+ points += 20
+ newSuggestions.push({
+ type: "success",
+ title: "Examples Included โ",
+ description: "Few-shot examples significantly improve output consistency and quality."
+ })
+ } else if (wordCount > 20) {
+ newSuggestions.push({
+ type: "tip",
+ title: "Add an Example",
+ description: "Show the AI what a good response looks like. Even one example dramatically improves quality.",
+ fix: `${prompt}\n\nFor example, a good response would look like: [add your example here]`
+ })
+ }
+
+ // โโ 7. Variable usage โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ const usedVars = (prompt.match(/\{\{([^}]+)\}\}/g) || []).map(m => m.replace(/\{\{|\}\}/g, '').trim())
+ const definedVars = schemaVariables.map(v => v.name)
+ const unusedDefined = definedVars.filter(n => !usedVars.includes(n))
+ const undefinedUsed = usedVars.filter(n => !definedVars.includes(n))
+
+ if (unusedDefined.length > 0) {
+ newSuggestions.push({
+ type: "warning",
+ title: "Unused Variables",
+ description: `Defined but not used in template: ${unusedDefined.map(v => `{{${v}}}`).join(', ')}`
+ })
+ }
+ if (undefinedUsed.length > 0) {
+ newSuggestions.push({
+ type: "error" as const,
+ title: "Undefined Variables",
+ description: `Used in template but not defined in Fields: ${undefinedUsed.map(v => `{{${v}}}`).join(', ')} โ add them in the Fields section.`
+ })
+ }
+ if (usedVars.length > 0 && unusedDefined.length === 0 && undefinedUsed.length === 0) {
+ points += 10
+ newSuggestions.push({
+ type: "success",
+ title: `${usedVars.length} Variable${usedVars.length > 1 ? 's' : ''} Wired Correctly โ`,
+ description: "All template variables match your defined fields."
+ })
+ }
+ if (usedVars.length === 0 && wordCount > 20) {
+ newSuggestions.push({
+ type: "tip",
+ title: "No Variables",
+ description: "Add {{variables}} to make this prompt reusable. Others can run it with their own inputs.",
+ fix: `${prompt} about {{topic}}`
+ })
+ }
+
+ // โโ 8. Framework check (category-specific) โโโโโโโโโโโโโโโโโโโโ
+ if (category?.toLowerCase().includes('market') || category?.toLowerCase().includes('copy')) {
+ if (!/AIDA|PAS|FAB|attention|interest|desire|action|problem|solution/i.test(prompt)) {
+ newSuggestions.push({
+ type: "tip",
+ title: "Try a Copywriting Framework",
+ description: "For marketing content, the AIDA (Attention โ Interest โ Desire โ Action) or PAS (Problem โ Agitate โ Solve) frameworks dramatically improve persuasiveness.",
+ })
+ }
+ }
+
+ const finalScore = Math.min(Math.round(points), 100)
+ return { suggestions: newSuggestions, score: finalScore }
+ }, [prompt, schemaVariables, category])
+
+ useEffect(() => {
+ setSuggestions(analysis.suggestions)
+ setScore(analysis.score)
+ // Reset AI insight when prompt changes significantly
+ if (Math.abs(prompt.length - lastAiPrompt.current.length) > 50) {
+ setAiInsight(null)
+ }
+ }, [analysis, prompt])
+
+ const getAiInsight = useCallback(async () => {
+ if (aiLoading || !prompt.trim()) return
+ setAiLoading(true)
+ setAiInsight(null)
+ lastAiPrompt.current = prompt
+
+ try {
+ const res = await fetch('/api/coach', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ template: prompt.slice(0, 2000), category }),
+ })
+ if (res.ok) {
+ const text = await res.text()
+ setAiInsight(text)
+ } else {
+ setAiInsight("Could not get AI analysis. Please try again.")
+ }
+ } catch {
+ setAiInsight("Network error. Please try again.")
+ } finally {
+ setAiLoading(false)
+ }
+ }, [prompt, category, aiLoading])
+
+ const getIcon = (type: Suggestion["type"]) => {
+ switch (type) {
+ case "error": return
+ case "warning": return
+ case "tip": return
+ case "success": return
+ }
+ }
+
+ const getScoreColor = () => {
+ if (score >= 80) return "text-green-500"
+ if (score >= 50) return "text-amber-500"
+ return "text-red-500"
+ }
+
+ const getScoreLabel = () => {
+ if (score >= 80) return "Excellent"
+ if (score >= 60) return "Good"
+ if (score >= 40) return "Fair"
+ if (score >= 20) return "Needs Work"
+ return "Start Writing"
+ }
+
+ if (!prompt.trim()) {
+ return (
+
+
+
+ Start writing your prompt to get real-time AI coaching suggestions
+
+
+ )
+ }
+
+ const errorCount = suggestions.filter(s => s.type === 'error').length
+ const warningCount = suggestions.filter(s => s.type === 'warning').length
+
+ return (
+
+
+
+
+
+ AI Prompt Coach
+
+
+ {errorCount > 0 && (
+ {errorCount} error{errorCount > 1 ? 's' : ''}
+ )}
+ {warningCount > 0 && (
+
+ {warningCount} warning{warningCount > 1 ? 's' : ''}
+
+ )}
+
+ {getScoreLabel()}
+
+
+
+
+
+ {/* Score bar */}
+
+
+
+
+ Prompt Quality Score
+
+ {score}/100
+
+
+
+
+ {/* Suggestions */}
+
+ {suggestions.map((suggestion, index) => (
+
+ {getIcon(suggestion.type)}
+
+
{suggestion.title}
+
{suggestion.description}
+ {suggestion.fix && onApplySuggestion && (
+
onApplySuggestion(suggestion.fix!)}
+ >
+ โฆ Apply suggestion โ
+
+ )}
+
+
+ ))}
+
+
+ {/* AI deeper analysis */}
+
+ {!aiInsight ? (
+
+ {aiLoading ? (
+ <> Analysing with AIโฆ>
+ ) : (
+ <> Get deeper AI analysis>
+ )}
+
+ ) : (
+
+
+ AI Analysis
+
+
{aiInsight}
+
{ setAiInsight(null); lastAiPrompt.current = '' }}
+ >Refresh
+
+ )}
+
+ {/* Variable summary */}
+ {schemaVariables.length > 0 && (
+
+
+ {schemaVariables.length} variable{schemaVariables.length > 1 ? 's' : ''} defined: {schemaVariables.map(v => `{{${v.name}}}`).join(', ')}
+
+ )}
+
+ {score < 80 && (
+
+
+ Pro tip: Use the RACE framework โ Role, Action, Context, Expectation
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/creator/rank-badge.tsx b/src/components/creator/rank-badge.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c25a4f7edb09d9943ba3424070a206ddc76aa40d
--- /dev/null
+++ b/src/components/creator/rank-badge.tsx
@@ -0,0 +1,43 @@
+"use client"
+
+import { Badge } from "@/components/ui/badge"
+import { cn } from "@/lib/utils"
+import { CreatorRankInfo } from "@/lib/creator-ranks"
+
+interface CreatorRankBadgeProps {
+ rank: CreatorRankInfo
+ showIcon?: boolean
+ showName?: boolean
+ size?: "sm" | "md" | "lg"
+ className?: string
+}
+
+export function CreatorRankBadge({
+ rank,
+ showIcon = true,
+ showName = true,
+ size = "md",
+ className
+}: CreatorRankBadgeProps) {
+ const sizeClasses = {
+ sm: "text-xs px-1.5 py-0.5",
+ md: "text-sm px-2 py-1",
+ lg: "text-base px-3 py-1.5"
+ }
+
+ return (
+
+ {showIcon && {rank.icon} }
+ {showName && {rank.name} }
+
+ )
+}
diff --git a/src/components/editor/index.ts b/src/components/editor/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dc293171a116e4c9d4091e84245bcc4dda6937fc
--- /dev/null
+++ b/src/components/editor/index.ts
@@ -0,0 +1 @@
+export * from './prompt-editor'
diff --git a/src/components/editor/prompt-coach.tsx b/src/components/editor/prompt-coach.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..dc1053c80228b4c96d131a1cbe060ff689e21544
--- /dev/null
+++ b/src/components/editor/prompt-coach.tsx
@@ -0,0 +1,142 @@
+"use client"
+
+import { useState, useCallback } from "react"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Wand2, ArrowRight, Minimize2, Maximize2, BarChart3, Loader2, X } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+interface PromptCoachProps {
+ prompt: string
+ onApply: (improvedPrompt: string) => void
+ className?: string
+}
+
+type CoachAction = 'improve' | 'shorten' | 'expand' | 'analyze'
+
+export function PromptCoach({ prompt, onApply, className }: PromptCoachProps) {
+ const [isOpen, setIsOpen] = useState(false)
+ const [isLoading, setIsLoading] = useState(false)
+ const [result, setResult] = useState("")
+ const [activeAction, setActiveAction] = useState(null)
+
+ const runCoach = useCallback(async (action: CoachAction) => {
+ if (!prompt.trim()) return
+
+ setIsLoading(true)
+ setActiveAction(action)
+ setResult("")
+ setIsOpen(true)
+
+ try {
+ const res = await fetch('/api/coach', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ prompt, action }),
+ })
+
+ if (!res.ok) {
+ throw new Error('Failed to improve prompt')
+ }
+
+ // Read streaming response
+ const reader = res.body?.getReader()
+ const decoder = new TextDecoder()
+ let fullText = ""
+
+ if (reader) {
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ const chunk = decoder.decode(value, { stream: true })
+ fullText += chunk
+ setResult(fullText)
+ }
+ }
+ } catch (error) {
+ setResult("Failed to process your prompt. Please try again.")
+ } finally {
+ setIsLoading(false)
+ }
+ }, [prompt])
+
+ const actions: { action: CoachAction; label: string; icon: React.ReactNode; description: string }[] = [
+ { action: 'improve', label: 'Improve', icon: , description: 'Make it clearer and more specific' },
+ { action: 'shorten', label: 'Shorten', icon: , description: 'Make it more concise' },
+ { action: 'expand', label: 'Expand', icon: , description: 'Add more detail and context' },
+ { action: 'analyze', label: 'Analyze', icon: , description: 'Get feedback and scoring' },
+ ]
+
+ return (
+
+ {/* Action buttons row */}
+
+
+ AI Coach:
+
+ {actions.map(({ action, label, icon }) => (
+ runCoach(action)}
+ disabled={isLoading || !prompt.trim()}
+ >
+ {isLoading && activeAction === action ? (
+
+ ) : (
+ icon
+ )}
+ {label}
+
+ ))}
+
+
+ {/* Result pane */}
+ {isOpen && (
+
+
+
+
+
+ {activeAction === 'analyze' ? 'Prompt Analysis' : 'Improved Prompt'}
+
+ setIsOpen(false)}>
+
+
+
+
+
+ {isLoading && !result ? (
+
+
+ Analyzing your prompt...
+
+ ) : (
+
+
+ {result}
+
+ {activeAction !== 'analyze' && result && !isLoading && (
+
{
+ onApply(result)
+ setIsOpen(false)
+ }}
+ >
+
+ Apply to Template
+
+ )}
+
+ )}
+
+
+ )}
+
+ )
+}
diff --git a/src/components/editor/prompt-editor.tsx b/src/components/editor/prompt-editor.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c46cc7ee9455c6dc8f9abc6ef30891796edc29ba
--- /dev/null
+++ b/src/components/editor/prompt-editor.tsx
@@ -0,0 +1,604 @@
+"use client"
+
+import { useState, useCallback, useMemo } from "react"
+import { useUser } from "@stackframe/stack"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input, Textarea } from "@/components/ui/input"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel } from "@/components/ui/select"
+import { Badge } from "@/components/ui/badge"
+import { extractVariables, generateSlug } from "@/lib/utils"
+import { VariableSchema, PromptSchema, AI_MODELS, ModelId, DEFAULT_MODEL } from "@/types/prompt"
+import { useOllama } from "@/contexts/ollama-context"
+import { FrameworkSelector } from "@/components/create/framework-selector"
+import { FrameworkScaffold } from "@/components/create/framework-scaffold"
+import { PromptCoach } from "@/components/editor/prompt-coach"
+import { getFramework, generateFromFramework, Framework } from "@/lib/frameworks"
+import {
+ Sparkles,
+ Play,
+ Save,
+ Eye,
+ Code,
+ Plus,
+ Trash2,
+ GripVertical,
+ ChevronDown,
+ AlertCircle,
+ Target
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+
+type VariableType = "text" | "textarea" | "dropdown" | "number" | "boolean"
+
+const VARIABLE_TYPES: { value: VariableType; label: string }[] = [
+ { value: "text", label: "Text Input" },
+ { value: "textarea", label: "Text Area" },
+ { value: "dropdown", label: "Dropdown" },
+ { value: "number", label: "Number" },
+ { value: "boolean", label: "Checkbox" },
+]
+
+const CATEGORIES = [
+ "Content",
+ "Development",
+ "Marketing",
+ "Business",
+ "Education",
+ "Creative",
+ "Research",
+ "Other",
+]
+
+interface EditorState {
+ title: string
+ description: string
+ template: string
+ category: string
+ tags: string
+ modelDefault: string
+ visibility: "public" | "unlisted" | "private"
+}
+
+interface PromptEditorProps {
+ initialData?: {
+ title: string
+ description: string | null
+ template: string
+ category: string | null
+ tags: string[]
+ modelDefault: string
+ visibility: string
+ schema: PromptSchema | null
+ }
+ promptSlug?: string // If provided, we're editing an existing prompt
+}
+
+export function PromptEditor({ initialData, promptSlug }: PromptEditorProps = {}) {
+ const user = useUser()
+ const { settings: ollamaSettings } = useOllama()
+ const [state, setState] = useState({
+ title: initialData?.title || "",
+ description: initialData?.description || "",
+ template: initialData?.template || "",
+ category: initialData?.category || "",
+ tags: initialData?.tags?.join(", ") || "",
+ modelDefault: initialData?.modelDefault || DEFAULT_MODEL,
+ visibility: (initialData?.visibility as EditorState["visibility"]) || "public",
+ })
+
+ // Initialize variables from initialData if editing
+ const initialVariables = initialData?.schema?.variables || []
+
+ const [variables, setVariables] = useState(initialVariables)
+ const [isPreview, setIsPreview] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const [error, setError] = useState(null)
+
+ // Framework Engine state
+ const [selectedFramework, setSelectedFramework] = useState(null)
+ const [frameworkSections, setFrameworkSections] = useState>({})
+ const [showFrameworkSelector, setShowFrameworkSelector] = useState(true)
+
+ // Get the selected framework object
+ const currentFramework = useMemo(() => {
+ return selectedFramework ? getFramework(selectedFramework) : null
+ }, [selectedFramework])
+
+ // Handle framework selection
+ const handleFrameworkSelect = (frameworkId: string | null) => {
+ setSelectedFramework(frameworkId)
+ setFrameworkSections({})
+ if (!frameworkId) {
+ // Clear template if deselecting framework
+ setShowFrameworkSelector(false)
+ } else {
+ setShowFrameworkSelector(false)
+ }
+ }
+
+ // Generate template from framework sections
+ const handleGenerateFromFramework = useCallback(() => {
+ if (currentFramework) {
+ const generatedTemplate = generateFromFramework(currentFramework, frameworkSections)
+ setState(s => ({ ...s, template: generatedTemplate }))
+ }
+ }, [currentFramework, frameworkSections])
+
+ // Auto-detect variables from template
+ const detectedVariables = useMemo(() => {
+ return extractVariables(state.template)
+ }, [state.template])
+
+ // Sync detected variables with current variable configs
+ const handleDetectVariables = useCallback(() => {
+ const newVariables = detectedVariables.map((name) => {
+ // Keep existing config if variable already exists
+ const existing = variables.find((v) => v.name === name)
+ if (existing) return existing
+
+ return {
+ name,
+ type: "text" as const,
+ label: name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, " "),
+ placeholder: `Enter ${name.replace(/_/g, " ")}...`,
+ required: true,
+ }
+ })
+ setVariables(newVariables)
+ }, [detectedVariables, variables])
+
+ // Update a variable config
+ const updateVariable = (index: number, updates: Partial) => {
+ setVariables((prev) =>
+ prev.map((v, i) => (i === index ? { ...v, ...updates } : v))
+ )
+ }
+
+ // Handle form submission
+ const handleSave = async (publish: boolean = false) => {
+ setError(null)
+
+ if (!state.title.trim()) {
+ setError("Title is required")
+ return
+ }
+
+ if (!state.template.trim()) {
+ setError("Prompt template is required")
+ return
+ }
+
+ setIsSaving(true)
+
+ try {
+ const schema: PromptSchema = {
+ variables,
+ output: {
+ format: "markdown",
+ streaming: true,
+ },
+ }
+
+ const isEditing = !!promptSlug
+ const url = isEditing ? `/api/prompts/${promptSlug}` : "/api/prompts"
+ const method = isEditing ? "PUT" : "POST"
+
+ const response = await fetch(url, {
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ title: state.title,
+ description: state.description,
+ template: state.template,
+ schema,
+ category: state.category || null,
+ tags: state.tags.split(",").map((t) => t.trim()).filter(Boolean),
+ modelDefault: state.modelDefault,
+ visibility: publish ? state.visibility : "private",
+ ...(isEditing ? {} : {}), // creatorId is now handled server-side via auth
+ }),
+ })
+
+ if (!response.ok) {
+ throw new Error(isEditing ? "Failed to update prompt" : "Failed to save prompt")
+ }
+
+ const data = await response.json()
+
+ // Redirect to the prompt page
+ window.location.href = `/p/${data.slug || promptSlug}`
+
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to save prompt")
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ return (
+
+ {/* Left Pane - Template Editor */}
+
+ {/* Framework Selector */}
+ {showFrameworkSelector && (
+
+
+
+
+
+ )}
+
+ {/* Framework Scaffold - when a framework is selected */}
+ {currentFramework && !showFrameworkSelector && (
+
+
+
+
+
+ Using {currentFramework.name} Framework
+
+
+ setShowFrameworkSelector(true)}
+ >
+ Change
+
+ {
+ setSelectedFramework(null)
+ setFrameworkSections({})
+ }}
+ >
+ Clear
+
+
+
+
+
+
+
+
+ Generate Template from Framework
+
+
+
+ )}
+
+ {/* Button to show framework selector if hidden and no framework selected */}
+ {!showFrameworkSelector && !currentFramework && (
+
setShowFrameworkSelector(true)}
+ className="gap-2"
+ >
+
+ Use a Framework
+
+ )}
+
+
+
+
+
+
+ Prompt Template
+
+ {selectedFramework && (
+
+
+ {selectedFramework}
+
+ )}
+
+
+ Use {"{{variable_name}}"} syntax to create input fields
+
+
+
+ setState((s) => ({ ...s, title: e.target.value }))}
+ className="text-lg font-medium"
+ />
+
+
+
+
+ {/* Settings Card */}
+
+
+ Settings
+
+
+
+
+ Category
+ setState((s) => ({ ...s, category: val }))}
+ >
+
+
+
+
+ {CATEGORIES.map((cat) => (
+ {cat}
+ ))}
+
+
+
+
+
+ Default Model
+ setState((s) => ({ ...s, modelDefault: val }))}
+ >
+
+
+
+
+
+ โ๏ธ Cloud Models
+ {Object.entries(AI_MODELS).map(([id, model]) => (
+ {model.name}
+ ))}
+
+
+ {ollamaSettings.enabled && ollamaSettings.availableModels.length > 0 && (
+
+ ๐ฅ๏ธ Ollama (Local)
+ {ollamaSettings.availableModels.map((model) => (
+ {model}
+ ))}
+
+ )}
+
+
+
+
+
+
+ Tags (comma-separated)
+ setState((s) => ({ ...s, tags: e.target.value }))}
+ />
+
+
+
+
Visibility
+
+ {(["public", "unlisted", "private"] as const).map((v) => (
+ setState((s) => ({ ...s, visibility: v }))}
+ className="capitalize"
+ >
+ {v}
+
+ ))}
+
+
+
+
+
+
+ {/* Right Pane - Variable Schema */}
+
+
+
+
+
+
+ Variable Configuration
+
+ setIsPreview(!isPreview)}
+ className="gap-1.5"
+ >
+
+ {isPreview ? "Edit" : "Preview"}
+
+
+
+ Configure how each variable appears to users
+
+
+
+ {variables.length === 0 ? (
+
+
+
No variables configured
+
+ Add {"{{variables}}"} to your template and click "Configure"
+
+
+ ) : (
+
+ )}
+
+
+
+ {/* Error Display */}
+ {error && (
+
+
+
+ {error}
+
+
+ )}
+
+ {/* Action Buttons */}
+
+
handleSave(false)}
+ disabled={isSaving}
+ >
+
+ Save Draft
+
+
handleSave(true)}
+ disabled={isSaving}
+ >
+
+ {isSaving ? "Publishing..." : "Publish"}
+
+
+
+
+ )
+}
diff --git a/src/components/engagement/engagement-metrics.tsx b/src/components/engagement/engagement-metrics.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d2ba8d8c93b48f3cfe62e82b12fba9859cbe2b33
--- /dev/null
+++ b/src/components/engagement/engagement-metrics.tsx
@@ -0,0 +1,357 @@
+"use client";
+
+import { motion } from "framer-motion";
+import {
+ Heart,
+ Eye,
+ Download,
+ GitFork,
+ Star,
+ Share2,
+ MessageSquare,
+ TrendingUp,
+ Zap
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+
+interface EngagementMetricsProps {
+ votes?: number;
+ uses?: number;
+ saves?: number;
+ remixes?: number;
+ stars?: number;
+ comments?: number;
+ shares?: number;
+ views?: number;
+ growth?: number;
+ variant?: "default" | "compact" | "large";
+ showLabels?: boolean;
+ interactive?: boolean;
+ isVoted?: boolean;
+ isStarred?: boolean;
+ isSaved?: boolean;
+ onVote?: () => void;
+ onStar?: () => void;
+ onSave?: () => void;
+ onShare?: () => void;
+ className?: string;
+}
+
+function formatNumber(num: number): string {
+ if (num >= 1000000) {
+ return (num / 1000000).toFixed(1) + "M";
+ }
+ if (num >= 1000) {
+ return (num / 1000).toFixed(1) + "K";
+ }
+ return num.toString();
+}
+
+export function EngagementMetrics({
+ votes = 0,
+ uses = 0,
+ saves = 0,
+ remixes = 0,
+ stars = 0,
+ comments = 0,
+ shares = 0,
+ views = 0,
+ growth,
+ variant = "default",
+ showLabels = false,
+ interactive = false,
+ isVoted = false,
+ isStarred = false,
+ isSaved = false,
+ onVote,
+ onStar,
+ onSave,
+ onShare,
+ className
+}: EngagementMetricsProps) {
+ const iconSize = variant === "large" ? "w-5 h-5" : variant === "compact" ? "w-3 h-3" : "w-4 h-4";
+ const textSize = variant === "large" ? "text-base" : variant === "compact" ? "text-xs" : "text-sm";
+ const gap = variant === "large" ? "gap-6" : variant === "compact" ? "gap-2" : "gap-4";
+
+ const MetricItem = ({
+ icon: Icon,
+ value,
+ label,
+ isActive = false,
+ activeColor = "text-pink-500",
+ onClick
+ }: {
+ icon: React.ElementType;
+ value: number;
+ label: string;
+ isActive?: boolean;
+ activeColor?: string;
+ onClick?: () => void;
+ }) => {
+ const content = (
+
+
+
+ {formatNumber(value)}
+
+ {showLabels && (
+
+ {label}
+
+ )}
+
+ );
+
+ if (!showLabels) {
+ return (
+
+
+
+ {content}
+
+
+ {formatNumber(value)} {label}
+
+
+
+ );
+ }
+
+ return content;
+ };
+
+ return (
+
+ {votes !== undefined && votes > 0 && (
+
+ )}
+
+ {uses !== undefined && uses > 0 && (
+
+ )}
+
+ {views !== undefined && views > 0 && (
+
+ )}
+
+ {stars !== undefined && stars > 0 && (
+
+ )}
+
+ {saves !== undefined && saves > 0 && (
+
+ )}
+
+ {remixes !== undefined && remixes > 0 && (
+
+ )}
+
+ {comments !== undefined && comments > 0 && (
+
+ )}
+
+ {shares !== undefined && shares > 0 && (
+
+ )}
+
+ {growth !== undefined && (
+
0 ? "text-green-500" : growth < 0 ? "text-red-500" : "text-muted-foreground"
+ )}>
+
+
+ {growth > 0 ? "+" : ""}{growth.toFixed(1)}%
+
+
+ )}
+
+ );
+}
+
+// Vote Button Component
+interface VoteButtonProps {
+ votes: number;
+ isVoted?: boolean;
+ onVote?: () => void;
+ size?: "sm" | "default" | "lg";
+ showCount?: boolean;
+ className?: string;
+}
+
+export function VoteButton({
+ votes,
+ isVoted = false,
+ onVote,
+ size = "default",
+ showCount = true,
+ className
+}: VoteButtonProps) {
+ return (
+
+
+
+
+ {showCount && (
+ {formatNumber(votes)}
+ )}
+
+ );
+}
+
+// Social Proof Badge
+interface SocialProofBadgeProps {
+ type: "popular" | "trending" | "hot" | "top-rated";
+ value?: number;
+ className?: string;
+}
+
+export function SocialProofBadge({ type, value, className }: SocialProofBadgeProps) {
+ const configs = {
+ popular: {
+ label: "Popular",
+ icon: Heart,
+ color: "bg-pink-500/20 text-pink-400 border-pink-500/30"
+ },
+ trending: {
+ label: "Trending",
+ icon: TrendingUp,
+ color: "bg-green-500/20 text-green-400 border-green-500/30"
+ },
+ hot: {
+ label: "Hot",
+ icon: Zap,
+ color: "bg-orange-500/20 text-orange-400 border-orange-500/30"
+ },
+ "top-rated": {
+ label: "Top Rated",
+ icon: Star,
+ color: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30"
+ }
+ };
+
+ const config = configs[type];
+ const Icon = config.icon;
+
+ return (
+
+
+ {config.label}
+ {value && ({formatNumber(value)}) }
+
+ );
+}
+
+// Usage Stats Display
+interface UsageStatsProps {
+ totalUses: number;
+ uniqueUsers?: number;
+ avgRating?: number;
+ className?: string;
+}
+
+export function UsageStats({ totalUses, uniqueUsers, avgRating, className }: UsageStatsProps) {
+ return (
+
+
+
+ {formatNumber(totalUses)}
+ uses
+
+
+ {uniqueUsers !== undefined && (
+
+
+ {formatNumber(uniqueUsers)}
+ users
+
+ )}
+
+ {avgRating !== undefined && (
+
+
+ {avgRating.toFixed(1)}
+ rating
+
+ )}
+
+ );
+}
diff --git a/src/components/engagement/index.ts b/src/components/engagement/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..335a3ac24a05b3e70e6873e3a0015aa8395602ea
--- /dev/null
+++ b/src/components/engagement/index.ts
@@ -0,0 +1,6 @@
+export {
+ EngagementMetrics,
+ VoteButton,
+ SocialProofBadge,
+ UsageStats
+} from "./engagement-metrics";
diff --git a/src/components/explore/explore-client.tsx b/src/components/explore/explore-client.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8acdb6b43362571a5c3703457ea4635ea7180715
--- /dev/null
+++ b/src/components/explore/explore-client.tsx
@@ -0,0 +1,162 @@
+"use client"
+
+import { useState, useEffect, useCallback } from "react"
+import { useSearchParams, useRouter } from "next/navigation"
+import { PromptCard } from "@/components/explore/prompt-card"
+import { SearchBar, SearchFilters } from "@/components/explore/search-bar"
+import { Button } from "@/components/ui/button"
+import { Loader2, Sparkles } from "lucide-react"
+
+interface Prompt {
+ id: string
+ slug: string
+ title: string
+ description: string | null
+ category: string | null
+ totalRuns: number
+ starsCount: number
+ remixesCount: number
+ badges: string[]
+ creator: {
+ name: string | null
+ username: string | null
+ image: string | null
+ } | null
+}
+
+interface ExploreClientProps {
+ initialPrompts: Prompt[]
+}
+
+export function ExploreClient({ initialPrompts }: ExploreClientProps) {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+
+ const [prompts, setPrompts] = useState(initialPrompts)
+ const [isLoading, setIsLoading] = useState(false)
+ const [hasMore, setHasMore] = useState(true)
+ const [offset, setOffset] = useState(initialPrompts.length)
+
+ // Fetch prompts with current filters
+ const fetchPrompts = useCallback(async (query: string, filters: SearchFilters, reset = false) => {
+ setIsLoading(true)
+
+ try {
+ const params = new URLSearchParams()
+ if (query) params.set("search", query)
+ if (filters.category) params.set("category", filters.category)
+ if (filters.sortBy) params.set("sort", filters.sortBy)
+ if (filters.dateRange && filters.dateRange !== 'all') params.set("dateRange", filters.dateRange)
+ params.set("limit", "12")
+ params.set("offset", reset ? "0" : offset.toString())
+
+ const res = await fetch(`/api/prompts?${params.toString()}`)
+ const data = await res.json()
+
+ if (reset) {
+ setPrompts(data)
+ setOffset(data.length)
+ } else {
+ setPrompts((prev) => {
+ const existingIds = new Set(prev.map(p => p.id))
+ const newPrompts = data.filter((p: Prompt) => !existingIds.has(p.id))
+ return [...prev, ...newPrompts]
+ })
+ setOffset((prev) => prev + data.length)
+ }
+
+ setHasMore(data.length === 12)
+ } catch (error) {
+ console.error("Failed to fetch prompts:", error)
+ } finally {
+ setIsLoading(false)
+ }
+ }, [offset])
+
+ const handleSearch = (query: string, filters: SearchFilters) => {
+ // Update URL with search params
+ const params = new URLSearchParams()
+ if (query) params.set("q", query)
+ if (filters.category) params.set("category", filters.category)
+ if (filters.sortBy && filters.sortBy !== 'trending') params.set("sort", filters.sortBy)
+ if (filters.dateRange && filters.dateRange !== 'all') params.set("date", filters.dateRange)
+
+ const queryString = params.toString()
+ router.push(queryString ? `/explore?${queryString}` : "/explore", { scroll: false })
+
+ // Fetch with new filters
+ fetchPrompts(query, filters, true)
+ }
+
+ return (
+
+ {/* Advanced Search Bar */}
+
+
+ {/* Results Count */}
+
+ {prompts.length} prompt{prompts.length !== 1 ? "s" : ""} found
+
+
+ {/* Prompts Grid */}
+ {prompts.length > 0 ? (
+
+ {prompts.map((prompt) => (
+
+ ))}
+
+ ) : (
+
+
+
No prompts found
+
+ Try adjusting your search or filters
+
+
+ )}
+
+ {/* Load More */}
+ {hasMore && prompts.length > 0 && (
+
+ {
+ const query = searchParams.get("q") || ""
+ const filters: SearchFilters = {
+ category: searchParams.get("category") || undefined,
+ sortBy: (searchParams.get("sort") as any) || 'trending',
+ dateRange: (searchParams.get("date") as any) || 'all',
+ }
+ fetchPrompts(query, filters, false)
+ }}
+ disabled={isLoading}
+ className="gap-2"
+ >
+ {isLoading ? (
+ <>
+
+ Loading...
+ >
+ ) : (
+ "Load More"
+ )}
+
+
+ )}
+
+ )
+}
diff --git a/src/components/explore/index.ts b/src/components/explore/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e01743146070a9500d01d531f533efcbf027509a
--- /dev/null
+++ b/src/components/explore/index.ts
@@ -0,0 +1,3 @@
+export * from './prompt-card'
+export * from './search-filter-bar'
+export * from './explore-client'
diff --git a/src/components/explore/prompt-card.tsx b/src/components/explore/prompt-card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d0452700fdb2957e7fb7e8355d9a6d7766488bd2
--- /dev/null
+++ b/src/components/explore/prompt-card.tsx
@@ -0,0 +1,109 @@
+"use client"
+
+import Link from "next/link"
+import { Card, CardContent } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { BadgeList } from "@/components/ui/badge-list"
+import { Play, Star, GitFork, User } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+interface PromptCardProps {
+ slug: string
+ title: string
+ description?: string | null
+ category?: string | null
+ runs: number
+ stars: number
+ remixes: number
+ badges?: string[]
+ creator?: {
+ name: string | null
+ username: string | null
+ image: string | null
+ } | null
+ className?: string
+}
+
+function formatNumber(num: number): string {
+ if (num >= 1000000) {
+ return (num / 1000000).toFixed(1) + "M"
+ }
+ if (num >= 1000) {
+ return (num / 1000).toFixed(1) + "K"
+ }
+ return num.toString()
+}
+
+export function PromptCard({
+ slug,
+ title,
+ description,
+ category,
+ runs,
+ stars,
+ remixes,
+ badges = [],
+ creator,
+ className,
+}: PromptCardProps) {
+ return (
+
+
+
+ {/* Category Badge */}
+ {category && (
+
+ {category}
+
+ )}
+
+ {/* Quality Badges */}
+ {badges.length > 0 && (
+
+ )}
+
+ {/* Title */}
+
+ {title}
+
+
+ {/* Description */}
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Stats Row */}
+
+
+
+
+ {formatNumber(runs)}
+
+
+
+ {formatNumber(stars)}
+
+
+
+ {formatNumber(remixes)}
+
+
+
+ {/* Creator */}
+ {creator && (
+
+
+ {creator.name || creator.username || "Anonymous"}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/components/explore/search-bar.tsx b/src/components/explore/search-bar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..00f8d0004991f0b49e969823a679485ff7d82c26
--- /dev/null
+++ b/src/components/explore/search-bar.tsx
@@ -0,0 +1,219 @@
+"use client"
+
+import { useState } from 'react'
+import { Search, SlidersHorizontal, X } from 'lucide-react'
+import { DynamicIcon } from '@/components/ui/dynamic-icon'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuCheckboxItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+
+interface SearchBarProps {
+ initialQuery?: string
+ onSearch: (query: string, filters: SearchFilters) => void
+ className?: string
+}
+
+export interface SearchFilters {
+ category?: string
+ tags?: string[]
+ sortBy?: 'trending' | 'recent' | 'stars' | 'runs'
+ dateRange?: 'day' | 'week' | 'month' | 'all'
+}
+
+const categories = ['Content', 'Development', 'Marketing', 'Business', 'Education', 'Creative', 'Research']
+const sortOptions = [
+ { value: 'trending', label: 'Trending', icon: 'Flame' },
+ { value: 'recent', label: 'Recent', icon: 'Clock' },
+ { value: 'stars', label: 'Most Stars', icon: 'Star' },
+ { value: 'runs', label: 'Most Runs', icon: 'Play' },
+]
+
+export function SearchBar({ initialQuery = '', onSearch, className = '' }: SearchBarProps) {
+ const [query, setQuery] = useState(initialQuery)
+ const [filters, setFilters] = useState({
+ sortBy: 'trending',
+ dateRange: 'all',
+ })
+ const [showFilters, setShowFilters] = useState(false)
+
+ const handleSearch = () => {
+ onSearch(query, filters)
+ }
+
+ const handleKeyPress = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleSearch()
+ }
+ }
+
+ const updateFilter = (key: keyof SearchFilters, value: any) => {
+ const newFilters = { ...filters, [key]: value }
+ setFilters(newFilters)
+ onSearch(query, newFilters)
+ }
+
+ const clearFilters = () => {
+ const defaultFilters: SearchFilters = {
+ sortBy: 'trending',
+ dateRange: 'all',
+ }
+ setFilters(defaultFilters)
+ onSearch(query, defaultFilters)
+ }
+
+ const activeFilterCount = [
+ filters.category,
+ filters.tags?.length,
+ filters.dateRange !== 'all',
+ ].filter(Boolean).length
+
+ return (
+
+ {/* Search Input */}
+
+
+
+ setQuery(e.target.value)}
+ onKeyPress={handleKeyPress}
+ className="pl-10"
+ />
+
+
+ Search
+
+
setShowFilters(!showFilters)}
+ >
+
+ {activeFilterCount > 0 && (
+
+ {activeFilterCount}
+
+ )}
+
+
+
+ {/* Active Filters */}
+ {(filters.category || filters.dateRange !== 'all') && (
+
+ Filters:
+ {filters.category && (
+
+ Category: {filters.category}
+ updateFilter('category', undefined)}
+ />
+
+ )}
+ {filters.dateRange !== 'all' && (
+
+ Date: {filters.dateRange}
+ updateFilter('dateRange', 'all')}
+ />
+
+ )}
+
+ Clear all
+
+
+ )}
+
+ {/* Filter Panel */}
+ {showFilters && (
+
+
+
Filters
+ setShowFilters(false)}
+ >
+ Done
+
+
+
+ {/* Category Filter */}
+
+
Category
+
+ {categories.map((cat) => (
+
+ updateFilter('category', filters.category === cat ? undefined : cat)
+ }
+ >
+ {cat}
+
+ ))}
+
+
+
+ {/* Sort By */}
+
+
Sort By
+
+ {sortOptions.map((option) => (
+ updateFilter('sortBy', option.value)}
+ >
+
+ {option.label}
+
+ ))}
+
+
+
+ {/* Date Range */}
+
+
Created
+
+ {[
+ { value: 'day', label: 'Today' },
+ { value: 'week', label: 'This Week' },
+ { value: 'month', label: 'This Month' },
+ { value: 'all', label: 'All Time' },
+ ].map((option) => (
+ updateFilter('dateRange', option.value)}
+ >
+ {option.label}
+
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/explore/search-filter-bar.tsx b/src/components/explore/search-filter-bar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..700549e8465944272ecdc99249f385279af0de7b
--- /dev/null
+++ b/src/components/explore/search-filter-bar.tsx
@@ -0,0 +1,97 @@
+"use client"
+
+import { Search, X } from "lucide-react"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { cn } from "@/lib/utils"
+
+interface SearchFilterBarProps {
+ search: string
+ onSearchChange: (value: string) => void
+ selectedCategory: string | null
+ onCategoryChange: (category: string | null) => void
+ sortBy: string
+ onSortChange: (sort: string) => void
+ categories: string[]
+ className?: string
+}
+
+const SORT_OPTIONS = [
+ { value: "popular", label: "Most Popular" },
+ { value: "recent", label: "Most Recent" },
+ { value: "stars", label: "Most Stars" },
+ { value: "runs", label: "Most Runs" },
+]
+
+export function SearchFilterBar({
+ search,
+ onSearchChange,
+ selectedCategory,
+ onCategoryChange,
+ sortBy,
+ onSortChange,
+ categories,
+ className,
+}: SearchFilterBarProps) {
+ return (
+
+ {/* Search Bar */}
+
+
+ onSearchChange(e.target.value)}
+ className="pl-10 pr-10"
+ />
+ {search && (
+ onSearchChange("")}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+ >
+
+
+ )}
+
+
+ {/* Filters Row */}
+
+ {/* Category Pills */}
+
+ onCategoryChange(null)}
+ >
+ All
+
+ {categories.map((cat) => (
+ onCategoryChange(cat)}
+ >
+ {cat}
+
+ ))}
+
+
+ {/* Sort Dropdown */}
+
onSortChange(e.target.value)}
+ className="h-9 px-3 rounded-lg border border-border bg-background text-sm focus:ring-2 focus:ring-primary"
+ >
+ {SORT_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/layout/footer.tsx b/src/components/layout/footer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..658bebe1aa1faafb9982756af72a0c3d7a941e99
--- /dev/null
+++ b/src/components/layout/footer.tsx
@@ -0,0 +1,151 @@
+import Link from "next/link"
+import { Sparkles, Github, Twitter, Heart } from "lucide-react"
+
+const footerLinks = {
+ product: [
+ { href: "/explore", label: "Explore Prompts" },
+ { href: "/categories", label: "Categories" },
+ { href: "/image-prompts", label: "Image Prompts" },
+ { href: "/characters", label: "AI Characters" },
+ { href: "/create", label: "Create" },
+ { href: "/pricing", label: "Pricing" },
+ ],
+ resources: [
+ { href: "/docs", label: "Documentation" },
+ { href: "/guides", label: "Guides" },
+ { href: "/frameworks", label: "Frameworks" },
+ { href: "/leaderboard", label: "Leaderboard" },
+ { href: "/api", label: "API" },
+ ],
+ company: [
+ { href: "/about", label: "About" },
+ { href: "/blog", label: "Blog" },
+ { href: "/careers", label: "Careers" },
+ { href: "/contact", label: "Contact" },
+ ],
+ legal: [
+ { href: "/privacy", label: "Privacy" },
+ { href: "/terms", label: "Terms" },
+ ],
+}
+
+export function Footer() {
+ return (
+
+
+
+ {/* Brand Column */}
+
+
+
+
+
+
OpenPrompt
+
+
+ Transform AI prompts into shareable micro-apps with zero friction.
+
+
+
+
+ {/* Product Links */}
+
+
Product
+
+ {footerLinks.product.map((link) => (
+
+
+ {link.label}
+
+
+ ))}
+
+
+
+ {/* Resources Links */}
+
+
Resources
+
+ {footerLinks.resources.map((link) => (
+
+
+ {link.label}
+
+
+ ))}
+
+
+
+ {/* Company Links */}
+
+
Company
+
+ {footerLinks.company.map((link) => (
+
+
+ {link.label}
+
+
+ ))}
+
+
+
+ {/* Legal Links */}
+
+
Legal
+
+ {footerLinks.legal.map((link) => (
+
+
+ {link.label}
+
+
+ ))}
+
+
+
+
+ {/* Bottom Bar */}
+
+
+ ยฉ {new Date().getFullYear()} OpenPrompt. All rights reserved.
+
+
+ Made with for the AI community
+
+
+
+
+ )
+}
diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b4fa7d91b2660eaada670c512cf40fc64803e4fc
--- /dev/null
+++ b/src/components/layout/header.tsx
@@ -0,0 +1,149 @@
+"use client"
+
+import Link from "next/link"
+import { usePathname } from "next/navigation"
+import { Suspense } from "react"
+import { Sparkles, Menu, X, Plus, Loader2 } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { ThemeToggle } from "@/components/ui/theme-toggle"
+import { UserButton } from "@/components/auth/user-button"
+import { NotificationBell } from "@/components/notifications/notification-center"
+import { OllamaSettingsModal } from "@/components/ollama/ollama-settings"
+import { useState } from "react"
+import { cn } from "@/lib/utils"
+
+const navLinks = [
+ { href: "/explore", label: "Explore" },
+ { href: "/tools", label: "Tools" },
+ { href: "/thunderdome", label: "Thunderdome" },
+ { href: "/workflows", label: "Workflows" },
+ { href: "/frameworks", label: "Frameworks" },
+ { href: "/performance", label: "Performance" },
+ { href: "/dashboard", label: "Dashboard" },
+]
+
+// Loading fallback for UserButton
+function UserButtonFallback() {
+ return (
+
+
+
+ )
+}
+
+export function Header() {
+ const pathname = usePathname()
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
+
+ return (
+
+ )
+}
diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..82d58a305ce59410eee9b03215ff30e5716054a0
--- /dev/null
+++ b/src/components/layout/index.ts
@@ -0,0 +1,2 @@
+export * from './header'
+export * from './footer'
diff --git a/src/components/layout/keyboard-shortcuts.tsx b/src/components/layout/keyboard-shortcuts.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e56bf0920ec932cb9ee9af57805dc10f7dff97a8
--- /dev/null
+++ b/src/components/layout/keyboard-shortcuts.tsx
@@ -0,0 +1,111 @@
+"use client"
+
+import { useEffect, useState, useCallback } from "react"
+import { useRouter } from "next/navigation"
+import { Search, X } from "lucide-react"
+
+export function KeyboardShortcuts() {
+ const [showSearch, setShowSearch] = useState(false)
+ const [query, setQuery] = useState("")
+ const router = useRouter()
+
+ const handleKeyDown = useCallback((e: KeyboardEvent) => {
+ // Ctrl+K or Cmd+K to open search
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
+ e.preventDefault()
+ setShowSearch(prev => !prev)
+ }
+ // Escape to close
+ if (e.key === 'Escape') {
+ setShowSearch(false)
+ setQuery("")
+ }
+ }, [])
+
+ useEffect(() => {
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [handleKeyDown])
+
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault()
+ if (query.trim()) {
+ router.push(`/explore?q=${encodeURIComponent(query.trim())}`)
+ setShowSearch(false)
+ setQuery("")
+ }
+ }
+
+ if (!showSearch) return null
+
+ return (
+
+ {/* Backdrop */}
+
{ setShowSearch(false); setQuery("") }}
+ />
+
+ {/* Search Modal */}
+
+
+
+
+ setQuery(e.target.value)}
+ autoFocus
+ />
+ { setShowSearch(false); setQuery("") }}
+ className="p-1 hover:bg-muted rounded-lg transition-colors"
+ >
+
+
+
+
+
+ {/* Quick Links */}
+
+
Quick Actions
+
+ { router.push('/create'); setShowSearch(false) }}
+ className="text-left text-sm p-2 rounded-lg hover:bg-muted transition-colors flex items-center gap-2"
+ >
+ + Create Prompt
+
+ { router.push('/tools'); setShowSearch(false) }}
+ className="text-left text-sm p-2 rounded-lg hover:bg-muted transition-colors flex items-center gap-2"
+ >
+ ๐ง AI Tools
+
+ { router.push('/thunderdome'); setShowSearch(false) }}
+ className="text-left text-sm p-2 rounded-lg hover:bg-muted transition-colors flex items-center gap-2"
+ >
+ โ๏ธ Thunderdome
+
+ { router.push('/image-prompts'); setShowSearch(false) }}
+ className="text-left text-sm p-2 rounded-lg hover:bg-muted transition-colors flex items-center gap-2"
+ >
+ ๐จ Image Prompts
+
+
+
+
+ {/* Footer */}
+
+ Press Enter to search
+ Esc to close
+
+
+
+ )
+}
diff --git a/src/components/model-selector.tsx b/src/components/model-selector.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..44721e2b8be17393be8b051bc0a0c153e48e804c
--- /dev/null
+++ b/src/components/model-selector.tsx
@@ -0,0 +1,192 @@
+"use client"
+
+import { useOllama } from "@/contexts/ollama-context"
+import { AI_MODELS, OLLAMA_MODELS, isOllamaModel } from "@/types/prompt"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Cloud,
+ Server,
+ ChevronDown,
+ Check
+} from "lucide-react"
+import { useState, useRef, useEffect } from "react"
+import { cn } from "@/lib/utils"
+
+interface ModelSelectorProps {
+ value: string
+ onChange: (model: string) => void
+ disabled?: boolean
+ showOllama?: boolean
+ compact?: boolean
+}
+
+export function ModelSelector({
+ value,
+ onChange,
+ disabled = false,
+ showOllama = true,
+ compact = false
+}: ModelSelectorProps) {
+ const { settings } = useOllama()
+ const [open, setOpen] = useState(false)
+ const dropdownRef = useRef
(null)
+
+ // Close on click outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setOpen(false)
+ }
+ }
+ document.addEventListener("mousedown", handleClickOutside)
+ return () => document.removeEventListener("mousedown", handleClickOutside)
+ }, [])
+
+ // Get current model info
+ const isOllama = isOllamaModel(value)
+ const currentModel = isOllama
+ ? (OLLAMA_MODELS as Record)[value] || { name: value }
+ : (AI_MODELS as Record)[value] || { name: value }
+
+ // Build cloud models list
+ const cloudModels = Object.entries(AI_MODELS).map(([id, config]) => ({
+ id,
+ ...config,
+ isOllama: false,
+ }))
+
+ // Build Ollama models list (from detected + predefined)
+ const ollamaModels = settings.enabled
+ ? settings.availableModels.map(name => ({
+ id: name,
+ name: name,
+ description: "Local Ollama model",
+ isOllama: true,
+ }))
+ : []
+
+ return (
+
+
!disabled && setOpen(!open)}
+ disabled={disabled}
+ className={cn(
+ "justify-between gap-2",
+ compact ? "w-auto" : "w-full"
+ )}
+ >
+
+ {isOllama ? (
+
+ ) : (
+
+ )}
+ {currentModel.name || value}
+
+
+
+
+ {open && (
+
+ {/* Cloud Models */}
+
+
+
+ Cloud Models
+
+ {cloudModels.map((model) => (
+
{
+ onChange(model.id)
+ setOpen(false)
+ }}
+ className={cn(
+ "w-full px-2 py-2 text-left rounded-md hover:bg-muted flex items-center justify-between",
+ value === model.id && "bg-muted"
+ )}
+ >
+
+
{model.name}
+
{model.description}
+
+ {value === model.id && }
+
+ ))}
+
+
+ {/* Ollama Models */}
+ {showOllama && (
+
+
+
+ Ollama Models
+ {!settings.enabled && (
+
+ Not connected
+
+ )}
+
+ {ollamaModels.length > 0 ? (
+ ollamaModels.map((model) => (
+
{
+ onChange(model.id)
+ setOpen(false)
+ }}
+ className={cn(
+ "w-full px-2 py-2 text-left rounded-md hover:bg-muted flex items-center justify-between",
+ value === model.id && "bg-muted"
+ )}
+ >
+
+
{model.name}
+
{model.description}
+
+ {value === model.id && }
+
+ ))
+ ) : (
+
+ {settings.enabled
+ ? "No models found. Run: ollama pull llama3.2"
+ : "Click 'Ollama' in header to connect"
+ }
+
+ )}
+
+ )}
+
+ )}
+
+ )
+}
+
+// Simple inline selector for compact spaces
+export function ModelBadge({ model }: { model: string }) {
+ const isOllama = isOllamaModel(model)
+ const modelInfo = isOllama
+ ? (OLLAMA_MODELS as Record)[model]
+ : (AI_MODELS as Record)[model]
+
+ return (
+
+ {isOllama ? (
+
+ ) : (
+
+ )}
+ {modelInfo?.name || model}
+
+ )
+}
diff --git a/src/components/notifications/notification-bell.tsx b/src/components/notifications/notification-bell.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ef617cefddfa4133885b0806e805a586d94b5bbe
--- /dev/null
+++ b/src/components/notifications/notification-bell.tsx
@@ -0,0 +1,148 @@
+"use client"
+
+import { useEffect, useState, useCallback } from "react"
+import { useUser } from "@stackframe/stack"
+import { Bell, Check } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import Link from "next/link"
+
+interface Notification {
+ id: string
+ type: string
+ title: string
+ message: string | null
+ targetSlug: string | null
+ targetType: string | null
+ isRead: boolean
+ createdAt: string
+}
+
+export function NotificationBell() {
+ const user = useUser()
+ const [notifications, setNotifications] = useState([])
+ const [unreadCount, setUnreadCount] = useState(0)
+ const [isOpen, setIsOpen] = useState(false)
+
+ const fetchNotifications = useCallback(async () => {
+ if (!user) return
+ try {
+ const res = await fetch('/api/notifications?limit=10')
+ if (res.ok) {
+ const data = await res.json()
+ setNotifications(data.notifications || [])
+ setUnreadCount(data.unreadCount || 0)
+ }
+ } catch {
+ // Silently fail
+ }
+ }, [user])
+
+ useEffect(() => {
+ fetchNotifications()
+ // Poll every 30 seconds
+ const interval = setInterval(fetchNotifications, 30000)
+ return () => clearInterval(interval)
+ }, [fetchNotifications])
+
+ const markAllRead = async () => {
+ try {
+ await fetch('/api/notifications', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ markAllRead: true }),
+ })
+ setUnreadCount(0)
+ setNotifications(prev => prev.map(n => ({ ...n, isRead: true })))
+ } catch {
+ // Silently fail
+ }
+ }
+
+ if (!user) return null
+
+ const getTargetLink = (n: Notification) => {
+ if (n.targetType === 'prompt' && n.targetSlug) return `/p/${n.targetSlug}`
+ if (n.targetType === 'workflow' && n.targetSlug) return `/workflows/${n.targetSlug}`
+ if (n.targetType === 'user' && n.targetSlug) return `/creator/${n.targetSlug}`
+ return '#'
+ }
+
+ const getIcon = (type: string) => {
+ switch (type) {
+ case 'star': return 'โญ'
+ case 'remix': return '๐'
+ case 'comment': return '๐ฌ'
+ case 'follow': return '๐ค'
+ case 'mention': return '๐ฃ'
+ default: return '๐'
+ }
+ }
+
+ return (
+
+
setIsOpen(!isOpen)}
+ >
+
+ {unreadCount > 0 && (
+
+ {unreadCount > 9 ? '9+' : unreadCount}
+
+ )}
+
+
+ {isOpen && (
+ <>
+
setIsOpen(false)} />
+
+
+
Notifications
+ {unreadCount > 0 && (
+
+ Mark all read
+
+ )}
+
+
+
+ {notifications.length > 0 ? (
+ notifications.map(n => (
+
setIsOpen(false)}>
+
+
+
{getIcon(n.type)}
+
+
{n.title}
+ {n.message && (
+
{n.message}
+ )}
+
+ {new Date(n.createdAt).toLocaleDateString()}
+
+
+ {!n.isRead && (
+
+ )}
+
+
+
+ ))
+ ) : (
+
+
+
No notifications yet
+
+ )}
+
+
+ >
+ )}
+
+ )
+}
diff --git a/src/components/notifications/notification-center.tsx b/src/components/notifications/notification-center.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6c77f711b2f0a2492f980aba17a3371517a2bb30
--- /dev/null
+++ b/src/components/notifications/notification-center.tsx
@@ -0,0 +1,272 @@
+"use client"
+
+import { useState, useEffect, useCallback } from "react"
+import { useUser } from "@stackframe/stack"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import {
+ Bell,
+ Star,
+ GitFork,
+ Play,
+ MessageSquare,
+ Check,
+ Trash2,
+ X,
+ UserPlus
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+
+export interface Notification {
+ id: string
+ type: 'star' | 'remix' | 'run' | 'comment' | 'follow' | 'system'
+ title: string
+ message: string | null
+ isRead: boolean
+ createdAt: string
+ targetSlug?: string | null
+ targetType?: string | null
+}
+
+interface NotificationCenterProps {
+ notifications: Notification[]
+ onMarkRead: (id: string) => void
+ onMarkAllRead: () => void
+ onDelete: (id: string) => void
+ onClose: () => void
+}
+
+export function NotificationCenter({
+ notifications,
+ onMarkRead,
+ onMarkAllRead,
+ onDelete,
+ onClose
+}: NotificationCenterProps) {
+ const unreadCount = notifications.filter(n => !n.isRead).length
+
+ const getIcon = (type: Notification['type']) => {
+ switch (type) {
+ case 'star': return
+ case 'remix': return
+ case 'run': return
+ case 'comment': return
+ case 'follow': return
+ case 'system': return
+ }
+ }
+
+ const formatTime = (dateStr: string) => {
+ const date = new Date(dateStr)
+ const now = new Date()
+ const diff = now.getTime() - date.getTime()
+ const minutes = Math.floor(diff / 60000)
+ const hours = Math.floor(minutes / 60)
+ const days = Math.floor(hours / 24)
+
+ if (minutes < 1) return 'Just now'
+ if (minutes < 60) return `${minutes}m ago`
+ if (hours < 24) return `${hours}h ago`
+ if (days < 7) return `${days}d ago`
+ return date.toLocaleDateString()
+ }
+
+ const getLink = (n: Notification) => {
+ if (n.targetType === 'prompt' && n.targetSlug) return `/p/${n.targetSlug}`
+ if (n.targetType === 'workflow' && n.targetSlug) return `/workflows/${n.targetSlug}`
+ return undefined
+ }
+
+ return (
+
+
+
+
+
+ Notifications
+ {unreadCount > 0 && (
+
+ {unreadCount}
+
+ )}
+
+
+ {unreadCount > 0 && (
+
+
+ Mark all read
+
+ )}
+
+
+
+
+
+
+
+ {notifications.length === 0 ? (
+
+
+
No notifications yet
+
+ ) : (
+
+ {notifications.map(notification => (
+
{
+ onMarkRead(notification.id)
+ const link = getLink(notification)
+ if (link) {
+ window.location.href = link
+ }
+ }}
+ >
+
+
+ {getIcon(notification.type)}
+
+
+
+ {notification.title}
+
+ {notification.message && (
+
+ {notification.message}
+
+ )}
+
+ {formatTime(notification.createdAt)}
+
+
+ {!notification.isRead && (
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ )
+}
+
+// Notification bell button with dropdown โ fetches REAL data from API
+interface NotificationBellProps {
+ className?: string
+}
+
+export function NotificationBell({ className }: NotificationBellProps) {
+ const user = useUser()
+ const [isOpen, setIsOpen] = useState(false)
+ const [notifications, setNotifications] = useState
([])
+ const [unreadCount, setUnreadCount] = useState(0)
+
+ const fetchNotifications = useCallback(async () => {
+ if (!user) return
+ try {
+ const res = await fetch('/api/notifications?limit=20')
+ if (res.ok) {
+ const data = await res.json()
+ setNotifications(data.notifications || [])
+ setUnreadCount(data.unreadCount || 0)
+ }
+ } catch {
+ // Silently fail
+ }
+ }, [user])
+
+ useEffect(() => {
+ fetchNotifications()
+ const interval = setInterval(fetchNotifications, 30000) // Poll every 30s
+ return () => clearInterval(interval)
+ }, [fetchNotifications])
+
+ const handleMarkRead = async (id: string) => {
+ setNotifications(prev =>
+ prev.map(n => n.id === id ? { ...n, isRead: true } : n)
+ )
+ setUnreadCount(prev => Math.max(0, prev - 1))
+
+ try {
+ await fetch('/api/notifications', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ notificationIds: [id] }),
+ })
+ } catch {
+ // Silently fail
+ }
+ }
+
+ const handleMarkAllRead = async () => {
+ setNotifications(prev => prev.map(n => ({ ...n, isRead: true })))
+ setUnreadCount(0)
+
+ try {
+ await fetch('/api/notifications', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ markAllRead: true }),
+ })
+ } catch {
+ // Silently fail
+ }
+ }
+
+ const handleDelete = (id: string) => {
+ setNotifications(prev => prev.filter(n => n.id !== id))
+ }
+
+ if (!user) return null
+
+ return (
+
+
setIsOpen(!isOpen)}
+ >
+
+ {unreadCount > 0 && (
+
+ {unreadCount > 9 ? '9+' : unreadCount}
+
+ )}
+
+
+ {isOpen && (
+ <>
+
setIsOpen(false)}
+ />
+
+ setIsOpen(false)}
+ />
+
+ >
+ )}
+
+ )
+}
diff --git a/src/components/ollama/ollama-settings.tsx b/src/components/ollama/ollama-settings.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fdc5627ea6363dafca9653ea20dd11522f78e971
--- /dev/null
+++ b/src/components/ollama/ollama-settings.tsx
@@ -0,0 +1,367 @@
+"use client"
+
+import { useState, useEffect, useRef } from "react"
+import { useOllama } from "@/contexts/ollama-context"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import {
+ Server,
+ Loader2,
+ Check,
+ X,
+ RefreshCw,
+ Cloud,
+ Key,
+ ExternalLink,
+ Eye,
+ EyeOff,
+ AlertTriangle,
+} from "lucide-react"
+import { SiOllama } from "react-icons/si"
+
+export function OllamaSettingsModal() {
+ const { settings, updateSettings, testConnection, isLoading } = useOllama()
+ const [apiUrl, setApiUrl] = useState(settings.apiUrl)
+ const [cloudApiKey, setCloudApiKey] = useState(settings.cloudApiKey)
+ const [showApiKey, setShowApiKey] = useState(false)
+ const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
+ const [open, setOpen] = useState(false)
+ const [activeTab, setActiveTab] = useState<"local" | "cloud">(settings.mode)
+ const debounceRef = useRef
(null)
+ const initialLoadRef = useRef(true)
+
+ // Sync local state with saved settings when they load from localStorage
+ useEffect(() => {
+ if (settings.apiUrl) setApiUrl(settings.apiUrl)
+ if (settings.cloudApiKey) setCloudApiKey(settings.cloudApiKey)
+ if (settings.mode) setActiveTab(settings.mode)
+ }, [settings.apiUrl, settings.cloudApiKey, settings.mode])
+
+ // Auto-fetch when API key changes (debounced) - skip initial load
+ useEffect(() => {
+ // Skip auto-fetch on initial load (when restoring from localStorage)
+ if (initialLoadRef.current) {
+ initialLoadRef.current = false
+ return
+ }
+
+ if (activeTab === "cloud" && cloudApiKey && cloudApiKey.length > 10) {
+ // Clear previous timeout
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current)
+ }
+
+ // Debounce the auto-fetch to avoid too many requests while typing
+ debounceRef.current = setTimeout(async () => {
+ updateSettings({ mode: "cloud", cloudApiKey })
+ await new Promise(resolve => setTimeout(resolve, 100))
+ const result = await testConnection()
+ if (result.success) {
+ setTestResult({ success: true, message: `Connected! Found ${result.models?.length || 0} models` })
+ } else {
+ setTestResult({ success: false, message: result.error || "Connection failed" })
+ }
+ }, 800) // Wait 800ms after user stops typing
+ }
+
+ return () => {
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current)
+ }
+ }
+ }, [cloudApiKey, activeTab])
+
+ const handleTest = async () => {
+ // Update settings before testing
+ if (activeTab === "local") {
+ updateSettings({ mode: "local", apiUrl })
+ } else {
+ updateSettings({ mode: "cloud", cloudApiKey })
+ }
+
+ // Small delay to ensure state is updated
+ await new Promise(resolve => setTimeout(resolve, 100))
+
+ const result = await testConnection()
+ if (result.success) {
+ setTestResult({ success: true, message: `Connected! Found ${result.models?.length || 0} models` })
+ } else {
+ setTestResult({ success: false, message: result.error || "Connection failed" })
+ }
+ }
+
+ const handleSave = () => {
+ if (activeTab === "local") {
+ updateSettings({ mode: "local", apiUrl })
+ } else {
+ updateSettings({ mode: "cloud", cloudApiKey })
+ }
+ setOpen(false)
+ }
+
+ const handleDisable = () => {
+ updateSettings({ enabled: false, availableModels: [] })
+ setTestResult(null)
+ }
+
+ const handleTabChange = (value: string) => {
+ setActiveTab(value as "local" | "cloud")
+ setTestResult(null)
+ }
+
+ return (
+
+
+
+
+ Ollama
+ {settings.enabled && (
+
+ {settings.availableModels.length}
+
+ )}
+
+
+
+
+
+
+ Ollama Settings
+
+
+ Run models locally or use Ollama Cloud for larger models without a GPU
+
+
+
+
+
+
+
+ Local
+
+
+
+ Cloud
+
+
+
+ {/* Local Mode */}
+
+
+
Ollama API URL
+
+ setApiUrl(e.target.value)}
+ placeholder="http://localhost:11434"
+ />
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+ Make sure Ollama is running: ollama serve
+
+
+
+ {/* Local Installation Help */}
+
+
+ Quick Setup:
+
+ Install Ollama from ollama.ai
+ Run: ollama pull llama3.2
+ Start server: ollama serve
+ Click Test Connection above
+
+
+
+
+ {/* CORS Setup for Production */}
+
+
+
+
+ Important: Enable CORS for this site
+
+
+ Ollama streams directly from your browser to your local machine. For this to work, Ollama needs to allow requests from this site.
+
+
+
Stop Ollama, then restart with:
+
+ {typeof window !== "undefined"
+ ? `OLLAMA_ORIGINS=${window.location.origin} ollama serve`
+ : "OLLAMA_ORIGINS=https://openprompt.co ollama serve"}
+
+
+ On Windows, set OLLAMA_ORIGINS as a system environment variable and restart Ollama.
+
+
+
+
+
+
+ {/* Cloud Mode */}
+
+
+
Ollama Cloud API Key
+
+
+ setCloudApiKey(e.target.value)}
+ placeholder="Enter your Ollama API key"
+ className="pr-10"
+ />
+ setShowApiKey(!showApiKey)}
+ >
+ {showApiKey ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Cloud Setup Help */}
+
+
+
+
+ Ollama Cloud (Preview)
+
+
+ Run larger models like gpt-oss:120b without needing a powerful GPU!
+
+
+ Create an account at ollama.com
+ Go to Settings โ API Keys
+ Create a new API key
+ Paste it above and test connection
+
+
+
+
+ Your API key is stored locally in your browser
+
+
+
+
+
+
+
+ {/* Test Result - Shared between tabs */}
+ {testResult && (
+
+ {testResult.success ? (
+
+ ) : (
+
+ )}
+ {testResult.message}
+
+ )}
+
+ {/* Available Models - Shared between tabs */}
+ {settings.enabled && settings.availableModels.length > 0 && (
+
+
+ Available Models
+ {settings.mode === "cloud" && (
+
+
+ Cloud
+
+ )}
+
+
+
+ {settings.availableModels.map((model) => (
+
+ {model}
+
+ ))}
+
+
+
+ )}
+
+ {/* Actions */}
+
+ {settings.enabled && (
+
+ Disable
+
+ )}
+
+ Save Settings
+
+
+
+
+ )
+}
+
+// Inline settings button for compact spaces
+export function OllamaQuickSettings() {
+ const { settings } = useOllama()
+
+ return (
+
+
+ {settings.enabled && (
+
+
+ {settings.mode === "cloud" ? "Cloud" : "Local"}
+
+ )}
+
+ )
+}
diff --git a/src/components/onboarding/onboarding-tour.tsx b/src/components/onboarding/onboarding-tour.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6d0e06069156ebd281b3bbf6f54165c528040bd8
--- /dev/null
+++ b/src/components/onboarding/onboarding-tour.tsx
@@ -0,0 +1,171 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { useUser } from "@stackframe/stack"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent } from "@/components/ui/card"
+import {
+ Sparkles,
+ Search,
+ Plus,
+ Keyboard,
+ Wand2,
+ ArrowRight,
+ X,
+ Check,
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+
+const ONBOARDING_KEY = "openprompt-onboarding-v1"
+
+interface OnboardingStep {
+ title: string
+ description: string
+ icon: React.ReactNode
+ action?: { label: string; href: string }
+}
+
+const STEPS: OnboardingStep[] = [
+ {
+ title: "Welcome to OpenPrompt! ๐",
+ description: "The GitHub for AI Prompts. Create, share, and run prompts with zero friction.",
+ icon: ,
+ },
+ {
+ title: "Explore 1000+ Prompts",
+ description: "Browse prompts by category, search by tag, or discover trending ones in Thunderdome.",
+ icon: ,
+ action: { label: "Explore", href: "/explore" },
+ },
+ {
+ title: "Create Your Own",
+ description: "Use our prompt editor with AI Coach to write better prompts. Add variables for dynamic inputs.",
+ icon: ,
+ action: { label: "Create", href: "/create" },
+ },
+ {
+ title: "AI Prompt Coach",
+ description: "Let AI improve, shorten, expand, or analyze your prompts with one click.",
+ icon: ,
+ },
+ {
+ title: "Keyboard Power User",
+ description: "Press Ctrl+K anytime to search โ fast navigation at your fingertips.",
+ icon: ,
+ },
+]
+
+export function OnboardingTour() {
+ const user = useUser()
+ const [currentStep, setCurrentStep] = useState(0)
+ const [isVisible, setIsVisible] = useState(false)
+
+ useEffect(() => {
+ // Only show for logged-in users who haven't completed onboarding
+ if (!user) return
+ const completed = localStorage.getItem(ONBOARDING_KEY)
+ if (!completed) {
+ // Small delay so the page loads first
+ const timer = setTimeout(() => setIsVisible(true), 1500)
+ return () => clearTimeout(timer)
+ }
+ }, [user])
+
+ const handleNext = () => {
+ if (currentStep < STEPS.length - 1) {
+ setCurrentStep(prev => prev + 1)
+ } else {
+ handleComplete()
+ }
+ }
+
+ const handleComplete = () => {
+ localStorage.setItem(ONBOARDING_KEY, "true")
+ setIsVisible(false)
+ }
+
+ const handleSkip = () => {
+ localStorage.setItem(ONBOARDING_KEY, "true")
+ setIsVisible(false)
+ }
+
+ if (!isVisible) return null
+
+ const step = STEPS[currentStep]
+ const isLast = currentStep === STEPS.length - 1
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+ {/* Close button */}
+
+
+
+
+ {/* Step content */}
+
+
+ {/* Progress + navigation */}
+
+
+ {STEPS.map((_, i) => (
+
+ ))}
+
+
+
+ Skip
+
+
+ {isLast ? (
+ <>
+
+ Get Started
+ >
+ ) : (
+ <>
+ Next
+
+ >
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/performance/performance-comparison.tsx b/src/components/performance/performance-comparison.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1797023969d569b75f5bc41c81bf9a3fe343a71f
--- /dev/null
+++ b/src/components/performance/performance-comparison.tsx
@@ -0,0 +1,594 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { useUser } from "@stackframe/stack"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Textarea } from "@/components/ui/textarea"
+import { Input } from "@/components/ui/input"
+import { Progress } from "@/components/ui/progress"
+import { ShareMenu } from "@/components/share/share-menu"
+import { useOllama } from "@/contexts/ollama-context"
+import {
+ BarChart3,
+ Zap,
+ Clock,
+ DollarSign,
+ Play,
+ Loader2,
+ Trophy,
+ TrendingUp,
+ TrendingDown,
+ Save,
+ Check,
+ History,
+ ChevronDown,
+ ChevronUp,
+ Rocket,
+ Coins,
+ Star,
+ Server
+} from "lucide-react"
+import { ModelId } from "@/types/prompt"
+
+interface ModelPerformance {
+ model: string
+ name: string
+ provider: string
+ responseTime: number
+ estimatedTokens: number
+ estimatedCost: number
+ qualityScore: number
+ output: string
+ isOllama?: boolean
+}
+
+const MODEL_PRICING: Record = {
+ "gemini-2.0-flash": { input: 0.00001, output: 0.00004 },
+ "gpt-4o": { input: 0.0025, output: 0.01 },
+ "gpt-4o-mini": { input: 0.00015, output: 0.0006 },
+ "claude-3-5-sonnet-latest": { input: 0.003, output: 0.015 },
+}
+
+const MODELS_TO_COMPARE: { id: ModelId; name: string; provider: string }[] = [
+ { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", provider: "Google" },
+ { id: "gpt-4o-mini", name: "GPT-4o Mini", provider: "OpenAI" },
+ { id: "gpt-4o", name: "GPT-4o", provider: "OpenAI" },
+ { id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", provider: "Anthropic" },
+]
+
+interface SavedBenchmark {
+ id: string
+ name: string | null
+ prompt: string
+ results: { model: string; name: string; responseTime: number; tokens: number; cost: number }[]
+ createdAt: string
+}
+
+export function PerformanceComparison() {
+ const user = useUser()
+ const { settings: ollamaSettings } = useOllama()
+ const [prompt, setPrompt] = useState("")
+ const [results, setResults] = useState([])
+ const [isRunning, setIsRunning] = useState(false)
+ const [currentModel, setCurrentModel] = useState(null)
+
+ // Save state
+ const [saving, setSaving] = useState(false)
+ const [saved, setSaved] = useState(false)
+ const [saveName, setSaveName] = useState("")
+ const [savedBenchmarks, setSavedBenchmarks] = useState([])
+ const [loadingHistory, setLoadingHistory] = useState(false)
+ const [showHistory, setShowHistory] = useState(false)
+ const [expandedId, setExpandedId] = useState(null)
+
+ // Fetch saved benchmarks
+ useEffect(() => {
+ const fetchHistory = async () => {
+ if (!user?.id) return
+ setLoadingHistory(true)
+ try {
+ const res = await fetch(`/api/comparisons?userId=${user.id}&type=performance`)
+ if (res.ok) {
+ const data = await res.json()
+ setSavedBenchmarks(data.comparisons || [])
+ }
+ } catch (err) {
+ console.error("Failed to fetch history:", err)
+ } finally {
+ setLoadingHistory(false)
+ }
+ }
+ fetchHistory()
+ }, [user?.id])
+
+ const runComparison = async () => {
+ if (!prompt.trim()) return
+
+ setIsRunning(true)
+ setResults([])
+
+ const newResults: ModelPerformance[] = []
+
+ // Combine cloud models with Ollama models
+ const allModels = [
+ ...MODELS_TO_COMPARE,
+ ...ollamaSettings.availableModels.map(name => ({
+ id: name,
+ name: name,
+ provider: "Ollama",
+ isOllama: true
+ }))
+ ]
+
+ for (const model of allModels) {
+ setCurrentModel(model.name)
+ const startTime = Date.now()
+
+ try {
+ const isOllama = 'isOllama' in model && model.isOllama
+ let fullOutput = ""
+
+ if (isOllama) {
+ // Ollama: stream directly from browser
+ const ollamaUrl = (ollamaSettings.apiUrl || "http://localhost:11434").replace(/\/+$/, "")
+ const response = await fetch(`${ollamaUrl}/api/generate`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: model.id, prompt, stream: true }),
+ })
+
+ const reader = response.body?.getReader()
+ const decoder = new TextDecoder()
+
+ if (reader) {
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ const chunk = decoder.decode(value, { stream: true })
+ const lines = chunk.split("\n").filter(l => l.trim())
+ for (const line of lines) {
+ try {
+ const json = JSON.parse(line)
+ if (json.response) fullOutput += json.response
+ } catch { /* skip */ }
+ }
+ }
+ }
+ } else {
+ // Cloud models: stream via server /api/run
+ const response = await fetch("/api/run", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ prompt, model: model.id }),
+ })
+
+ const reader = response.body?.getReader()
+ const decoder = new TextDecoder()
+
+ if (reader) {
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ fullOutput += decoder.decode(value)
+ }
+ }
+ }
+
+ const endTime = Date.now()
+ const responseTime = endTime - startTime
+
+ // Estimate tokens (rough: ~4 chars per token)
+ const inputTokens = Math.ceil(prompt.length / 4)
+ const outputTokens = Math.ceil(fullOutput.length / 4)
+ const totalTokens = inputTokens + outputTokens
+
+ // Calculate cost (Ollama is free)
+ const pricing = isOllama ? { input: 0, output: 0 } : (MODEL_PRICING[model.id] || { input: 0, output: 0 })
+ const cost = (inputTokens * pricing.input / 1000) + (outputTokens * pricing.output / 1000)
+
+ newResults.push({
+ model: model.id,
+ name: model.name,
+ provider: model.provider,
+ responseTime,
+ estimatedTokens: totalTokens,
+ estimatedCost: cost,
+ qualityScore: 0,
+ output: fullOutput,
+ isOllama: isOllama,
+ })
+
+ setResults([...newResults])
+ } catch (error) {
+ console.error(`Error with ${model.name}:`, error)
+ }
+ }
+
+ setCurrentModel(null)
+ setIsRunning(false)
+ }
+
+ // Rate quality of a model's output
+ const rateQuality = (modelId: string, score: number) => {
+ setResults(prev => prev.map(r =>
+ r.model === modelId ? { ...r, qualityScore: score } : r
+ ))
+ }
+
+ // Find best performers
+ const fastestModel = results.length > 0
+ ? results.reduce((a, b) => a.responseTime < b.responseTime ? a : b)
+ : null
+ const cheapestModel = results.length > 0
+ ? results.reduce((a, b) => a.estimatedCost < b.estimatedCost ? a : b)
+ : null
+ const mostEfficient = results.length > 0
+ ? results.reduce((a, b) => (a.estimatedTokens / a.responseTime) > (b.estimatedTokens / b.responseTime) ? a : b)
+ : null
+
+ // Save benchmark
+ const handleSave = async () => {
+ if (results.length === 0) return
+ setSaving(true)
+ try {
+ const res = await fetch('/api/comparisons', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ userId: user?.id,
+ name: saveName || null,
+ type: 'performance',
+ prompt,
+ results: results.map(r => ({
+ model: r.model,
+ name: r.name,
+ responseTime: r.responseTime,
+ tokens: r.estimatedTokens,
+ cost: r.estimatedCost
+ })),
+ winner: fastestModel?.model
+ })
+ })
+ if (res.ok) {
+ const newBench = await res.json()
+ setSavedBenchmarks(prev => [newBench, ...prev])
+ setSaved(true)
+ setSaveName("")
+ setTimeout(() => setSaved(false), 3000)
+ }
+ } catch (err) {
+ console.error("Failed to save:", err)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ {/* Hero */}
+
+
+
+
+
+ Performance Comparison
+
+
+ Compare speed, token usage, and cost across AI models
+
+
+
+ {/* Input */}
+
+
+ Test Prompt
+ Enter a prompt to benchmark across all models
+
+
+ setPrompt(e.target.value)}
+ rows={4}
+ />
+
+
+ {isRunning ? (
+ <>
+
+ Testing {currentModel}...
+ >
+ ) : (
+ <>
+
+ Run Benchmark
+ >
+ )}
+
+ {results.length > 0 && (
+
+ )}
+
+
+
+
+ {/* Winners */}
+ {results.length === MODELS_TO_COMPARE.length && (
+
+
+
+
+
+
Fastest
+
{fastestModel?.name}
+
{(fastestModel?.responseTime || 0) / 1000}s
+
+
+
+
+
+
+
+
Cheapest
+
{cheapestModel?.name}
+
${cheapestModel?.estimatedCost.toFixed(6)}
+
+
+
+
+
+
+
+
Most Efficient
+
{mostEfficient?.name}
+
Best tokens/sec ratio
+
+
+
+
+ )}
+
+ {/* Results Table */}
+ {results.length > 0 && (
+
+
+ Benchmark Results
+ Comparison of {results.length} models
+
+
+
+ {results.map((result, index) => {
+ const maxTime = Math.max(...results.map(r => r.responseTime))
+ const maxTokens = Math.max(...results.map(r => r.estimatedTokens))
+ const maxCost = Math.max(...results.map(r => r.estimatedCost))
+
+ return (
+
+
+
+
+ #{index + 1}
+
+
+
{result.name}
+
{result.provider}
+
+
+
+ {result === fastestModel && (
+ Fastest
+ )}
+ {result === cheapestModel && (
+ Cheapest
+ )}
+
+
+
+
+ {/* Response Time */}
+
+
+
+
+ Response Time
+
+ {(result.responseTime / 1000).toFixed(2)}s
+
+
+
+
+ {/* Tokens */}
+
+
+
+
+ Est. Tokens
+
+ {result.estimatedTokens.toLocaleString()}
+
+
+
+
+ {/* Cost */}
+
+
+
+
+ Est. Cost
+
+
+ {result.isOllama && Local }
+ ${result.estimatedCost.toFixed(6)}
+
+
+
0 ? (result.estimatedCost / maxCost) * 100 : 0}
+ className="h-2"
+ />
+
+
+
+ {/* Quality Rating */}
+
+
+
Rate Quality:
+
+ {[1, 2, 3, 4, 5].map(score => (
+ rateQuality(result.model, score)}
+ className="p-1 hover:scale-110 transition-transform"
+ >
+
+
+ ))}
+
+ {result.qualityScore > 0 && (
+
{result.qualityScore}/5
+ )}
+
+ {result.output && (
+
+ {result.output.length} chars
+
+ )}
+
+
+ )
+ })}
+
+
+
+ )}
+
+ {/* Save Benchmark */}
+ {results.length === MODELS_TO_COMPARE.length && (
+
+
+
+
+ Save Benchmark
+
+ Save these results to compare later
+
+
+
+
+ setSaveName(e.target.value)}
+ placeholder="Name this benchmark (optional)"
+ />
+
+
+ {saving ? (
+ <> Saving...>
+ ) : saved ? (
+ <> Saved!>
+ ) : (
+ <> Save Result>
+ )}
+
+
+
+
+ )}
+
+ {/* Saved Benchmarks History */}
+
+
+ setShowHistory(!showHistory)}
+ className="flex items-center justify-between w-full text-left"
+ >
+
+
+ Saved Benchmarks
+ {savedBenchmarks.length}
+
+ {showHistory ? (
+
+ ) : (
+
+ )}
+
+ View previously saved benchmarks
+
+ {showHistory && (
+
+ {loadingHistory ? (
+
+
+
+ ) : savedBenchmarks.length === 0 ? (
+
+ No saved benchmarks yet. Run a benchmark and save it!
+
+ ) : (
+
+ {savedBenchmarks.map((bench) => (
+
+
setExpandedId(expandedId === bench.id ? null : bench.id)}
+ className="w-full p-3 flex items-center justify-between text-left hover:bg-muted/50"
+ >
+
+
+ {bench.name || `Benchmark from ${new Date(bench.createdAt).toLocaleDateString()}`}
+
+
+ {new Date(bench.createdAt).toLocaleString()}
+
+
+ {expandedId === bench.id ? (
+
+ ) : (
+
+ )}
+
+ {expandedId === bench.id && (
+
+
Prompt: {bench.prompt.slice(0, 100)}{bench.prompt.length > 100 ? '...' : ''}
+ {bench.results.map((r, idx) => (
+
+
{r.name}
+
+ {(r.responseTime / 1000).toFixed(2)}s
+ {r.tokens} tokens
+ ${r.cost.toFixed(6)}
+
+
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/src/components/prompt-runner/auto-form.tsx b/src/components/prompt-runner/auto-form.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b2098eeab6a09467f9df5b473755b09523e2dedf
--- /dev/null
+++ b/src/components/prompt-runner/auto-form.tsx
@@ -0,0 +1,231 @@
+"use client"
+
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import * as z from "zod"
+import { Button } from "@/components/ui/button"
+import { Input, Textarea } from "@/components/ui/input"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { VariableSchema } from "@/types/prompt"
+import { Play, Loader2 } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+interface AutoFormProps {
+ variables: VariableSchema[]
+ onSubmit: (values: Record) => void
+ isLoading?: boolean
+ className?: string
+}
+
+// Build a dynamic Zod schema from variable definitions
+function buildZodSchema(variables: VariableSchema[]) {
+ const shape: Record = {}
+
+ for (const variable of variables) {
+ let fieldSchema: z.ZodTypeAny
+
+ switch (variable.type) {
+ case "number":
+ fieldSchema = z.coerce.number()
+ if (variable.min !== undefined) {
+ fieldSchema = (fieldSchema as z.ZodNumber).min(variable.min)
+ }
+ if (variable.max !== undefined) {
+ fieldSchema = (fieldSchema as z.ZodNumber).max(variable.max)
+ }
+ break
+ case "boolean":
+ fieldSchema = z.boolean()
+ break
+ default:
+ fieldSchema = z.string()
+ if (variable.required) {
+ fieldSchema = (fieldSchema as z.ZodString).min(1, `${variable.label} is required`)
+ }
+ }
+
+ if (!variable.required && variable.type !== "boolean") {
+ fieldSchema = fieldSchema.optional()
+ }
+
+ shape[variable.name] = fieldSchema
+ }
+
+ return z.object(shape)
+}
+
+// Build default values from variable definitions
+function buildDefaultValues(variables: VariableSchema[]): Record {
+ const defaults: Record = {}
+
+ for (const variable of variables) {
+ if (variable.default !== undefined) {
+ defaults[variable.name] = variable.default
+ } else {
+ switch (variable.type) {
+ case "number":
+ defaults[variable.name] = 0
+ break
+ case "boolean":
+ defaults[variable.name] = false
+ break
+ default:
+ defaults[variable.name] = ""
+ }
+ }
+ }
+
+ return defaults
+}
+
+export function AutoForm({ variables, onSubmit, isLoading, className }: AutoFormProps) {
+ const schema = buildZodSchema(variables)
+ const defaultValues = buildDefaultValues(variables)
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues,
+ })
+
+ const handleSubmit = form.handleSubmit((data) => {
+ onSubmit(data as Record)
+ })
+
+ if (variables.length === 0) {
+ return (
+
+
+
+ This prompt has no variables. Click Run to execute.
+
+ onSubmit({})}
+ className="w-full mt-4 gap-2"
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ <>
+
+ Running...
+ >
+ ) : (
+ <>
+
+ Run Prompt
+ >
+ )}
+
+
+
+ )
+ }
+
+ return (
+
+
+ Input Variables
+ Fill in the values below to run this prompt
+
+
+
+ {variables.map((variable) => (
+
+
+ {variable.label}
+ {variable.required && (
+
+ Required
+
+ )}
+
+
+ {variable.description && (
+
+ {variable.description}
+
+ )}
+
+ {variable.type === "textarea" ? (
+
+ ) : variable.type === "dropdown" ? (
+
+ Select an option...
+ {variable.options?.map((option) => (
+
+ {option}
+
+ ))}
+
+ ) : variable.type === "boolean" ? (
+
+
+
+ Enable {variable.label.toLowerCase()}
+
+
+ ) : variable.type === "number" ? (
+
+ ) : (
+
+ )}
+
+ {form.formState.errors[variable.name] && (
+
+ {form.formState.errors[variable.name]?.message as string}
+
+ )}
+
+ ))}
+
+
+ {isLoading ? (
+ <>
+
+ Running...
+ >
+ ) : (
+ <>
+
+ Run Prompt
+ >
+ )}
+
+
+
+
+ )
+}
diff --git a/src/components/prompt-runner/index.ts b/src/components/prompt-runner/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1da8a2c75874d9af60acdb1b9fd33cd1fe46bea0
--- /dev/null
+++ b/src/components/prompt-runner/index.ts
@@ -0,0 +1,5 @@
+export * from './prompt-runner'
+export * from './auto-form'
+export * from './output-display'
+export * from './model-selector'
+export * from './remix-modal'
diff --git a/src/components/prompt-runner/model-comparison.tsx b/src/components/prompt-runner/model-comparison.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..88e62a05ca691cabcf19451088005a05a08b2830
--- /dev/null
+++ b/src/components/prompt-runner/model-comparison.tsx
@@ -0,0 +1,364 @@
+"use client"
+
+import { useState, useCallback } from "react"
+import { useCompletion } from "@ai-sdk/react"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Checkbox } from "@/components/ui/checkbox"
+import { OutputDisplay } from "./output-display"
+import { AI_MODELS, ModelId, isOllamaModel } from "@/types/prompt"
+import { useOllama } from "@/contexts/ollama-context"
+import { fillTemplate } from "@/lib/utils"
+import { Beaker, Play, Square, RefreshCw, Cloud, Server } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+interface ModelComparisonProps {
+ promptId: string
+ template: string
+ variables: Record
+ allowedModels?: string[]
+ onClose: () => void
+}
+
+interface ModelResult {
+ modelId: string
+ output: string
+ isLoading: boolean
+ error?: string
+ isOllama?: boolean
+}
+
+const DEFAULT_COMPARISON_MODELS: string[] = ["gpt-4o-mini", "claude-3-5-sonnet", "gemini-2.0-flash"]
+
+export function ModelComparison({
+ promptId,
+ template,
+ variables,
+ allowedModels,
+ onClose,
+}: ModelComparisonProps) {
+ const { settings: ollamaSettings } = useOllama()
+ const [selectedModels, setSelectedModels] = useState(
+ allowedModels?.slice(0, 3) || DEFAULT_COMPARISON_MODELS.slice(0, 2)
+ )
+ const [results, setResults] = useState([])
+ const [isRunning, setIsRunning] = useState(false)
+
+ // Build available models (cloud + Ollama)
+ const cloudModels = Object.entries(AI_MODELS).filter(
+ ([id]) => !allowedModels || allowedModels.includes(id)
+ )
+ const ollamaModels = ollamaSettings.enabled
+ ? ollamaSettings.availableModels.filter(
+ m => !allowedModels || allowedModels.includes(m)
+ )
+ : []
+
+ // Toggle model selection
+ const toggleModel = (modelId: string) => {
+ setSelectedModels(prev => {
+ if (prev.includes(modelId)) {
+ return prev.filter(m => m !== modelId)
+ }
+ if (prev.length >= 3) {
+ // Max 3 models at once
+ return [...prev.slice(1), modelId]
+ }
+ return [...prev, modelId]
+ })
+ }
+
+ // Run comparison across all selected models
+ const runComparison = async () => {
+ if (selectedModels.length === 0) return
+
+ setIsRunning(true)
+ const filledPrompt = fillTemplate(template, variables)
+
+ // Initialize results with loading state
+ const initialResults: ModelResult[] = selectedModels.map(modelId => ({
+ modelId,
+ output: "",
+ isLoading: true,
+ isOllama: isOllamaModel(modelId),
+ }))
+ setResults(initialResults)
+
+ // Run all models in parallel
+ const promises = selectedModels.map(async (modelId, index) => {
+ try {
+ let output = ""
+
+ if (isOllamaModel(modelId)) {
+ // Ollama: stream directly from browser โ user's local Ollama
+ const ollamaUrl = (ollamaSettings.apiUrl || "http://localhost:11434").replace(/\/+$/, "")
+ const response = await fetch(`${ollamaUrl}/api/generate`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: modelId, prompt: filledPrompt, stream: true }),
+ })
+
+ if (!response.ok) {
+ throw new Error(`Ollama error ${response.status} for ${modelId}`)
+ }
+
+ const reader = response.body?.getReader()
+ if (!reader) throw new Error("No reader available")
+
+ const decoder = new TextDecoder()
+
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ const chunk = decoder.decode(value, { stream: true })
+ const lines = chunk.split("\n").filter(l => l.trim())
+ for (const line of lines) {
+ try {
+ const json = JSON.parse(line)
+ if (json.response) output += json.response
+ } catch { /* skip */ }
+ }
+ setResults(prev => prev.map((r, i) =>
+ i === index ? { ...r, output, isLoading: false } : r
+ ))
+ }
+ } else {
+ // Cloud models: stream via server /api/run
+ const response = await fetch("/api/run", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ prompt: filledPrompt,
+ promptId,
+ model: modelId,
+ variables,
+ }),
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to get response from ${modelId}`)
+ }
+
+ const reader = response.body?.getReader()
+ if (!reader) throw new Error("No reader available")
+
+ const decoder = new TextDecoder()
+
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ output += decoder.decode(value, { stream: true })
+ setResults(prev => prev.map((r, i) =>
+ i === index ? { ...r, output, isLoading: false } : r
+ ))
+ }
+ }
+
+ return { modelId, output, isLoading: false, isOllama: isOllamaModel(modelId) }
+ } catch (err) {
+ return {
+ modelId,
+ output: "",
+ isLoading: false,
+ isOllama: isOllamaModel(modelId),
+ error: err instanceof Error ? err.message : "Unknown error",
+ }
+ }
+ })
+
+ const finalResults = await Promise.all(promises)
+ setResults(finalResults)
+ setIsRunning(false)
+ }
+
+ // Clear and reset
+ const reset = () => {
+ setResults([])
+ setIsRunning(false)
+ }
+
+ // Get model display info
+ const getModelInfo = (modelId: string) => {
+ if (isOllamaModel(modelId)) {
+ return { name: modelId, provider: "Ollama" }
+ }
+ const model = AI_MODELS[modelId as ModelId]
+ return model ? { name: model.name, provider: model.provider } : { name: modelId, provider: "Unknown" }
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
A/B Model Comparison
+ Compare up to 3 models
+
+
+ Close
+
+
+
+ {/* Model Selection */}
+
+
+ Select Models to Compare
+
+
+ {/* Cloud Models */}
+
+
+
+ Cloud Models
+
+
+ {cloudModels.map(([id, model]) => (
+
+ toggleModel(id)}
+ />
+ {model.name}
+
+ {model.provider}
+
+
+ ))}
+
+
+
+ {/* Ollama Models */}
+ {ollamaModels.length > 0 && (
+
+
+
+ Ollama (Local)
+
+
+ {ollamaModels.map((model) => (
+
+ toggleModel(model)}
+ />
+ {model}
+
+ Local
+
+
+ ))}
+
+
+ )}
+
+ {!ollamaSettings.enabled && (
+
+
+ Connect Ollama in header to add local models
+
+ )}
+
+
+
+ {/* Action Buttons */}
+
+
+ {isRunning ? (
+ <>
+
+ Running...
+ >
+ ) : (
+ <>
+
+ Run Comparison ({selectedModels.length} models)
+ >
+ )}
+
+ {results.length > 0 && (
+
+
+ Reset
+
+ )}
+
+
+ {/* Results Grid */}
+ {results.length > 0 && (
+
+ {results.map((result) => {
+ const info = getModelInfo(result.modelId)
+ return (
+
+
+
+
+ {result.isOllama ? (
+
+ ) : (
+
+ )}
+ {info.name}
+
+
+ {info.provider}
+
+
+
+
+ {result.error ? (
+ {result.error}
+ ) : (
+
+ )}
+
+
+ )
+ })}
+
+ )}
+
+ {/* Instructions */}
+ {results.length === 0 && !isRunning && (
+
+
+
+ Compare Model Outputs
+
+ Select 2-3 models above and click "Run Comparison" to see side-by-side results
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/prompt-runner/model-selector.tsx b/src/components/prompt-runner/model-selector.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..422fd9753f4343d8825c25190198f3d8074c7ce0
--- /dev/null
+++ b/src/components/prompt-runner/model-selector.tsx
@@ -0,0 +1,162 @@
+"use client"
+
+import { useState } from "react"
+import { Card, CardContent } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { AI_MODELS, ModelId, DEFAULT_MODEL, isOllamaModel } from "@/types/prompt"
+import { useOllama } from "@/contexts/ollama-context"
+import { Check, ChevronDown, Sparkles, Server, Cloud } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+interface ModelSelectorProps {
+ allowedModels?: string[]
+ selectedModel: string
+ onModelChange: (model: string) => void
+ className?: string
+}
+
+export function ModelSelector({
+ allowedModels,
+ selectedModel,
+ onModelChange,
+ className
+}: ModelSelectorProps) {
+ const [isOpen, setIsOpen] = useState(false)
+ const { settings: ollamaSettings } = useOllama()
+
+ // Filter cloud models based on allowed list, or show all if not specified
+ const cloudModels = Object.entries(AI_MODELS).filter(
+ ([id]) => !allowedModels || allowedModels.includes(id)
+ )
+
+ // Get Ollama models if connected - always show all Ollama models (don't filter by allowedModels)
+ const ollamaModels = ollamaSettings.enabled
+ ? ollamaSettings.availableModels
+ : []
+
+ // Get current model info
+ const isOllama = isOllamaModel(selectedModel)
+ const currentModelName = isOllama
+ ? selectedModel
+ : AI_MODELS[selectedModel as ModelId]?.name || selectedModel
+
+ return (
+
+
setIsOpen(!isOpen)}
+ className="w-full justify-between gap-2"
+ >
+
+ {isOllama ? (
+
+ ) : (
+
+ )}
+ {currentModelName || "Select Model"}
+
+
+
+
+ {isOpen && (
+ <>
+
setIsOpen(false)}
+ />
+
+
+ {/* Cloud Models Section */}
+
+
+ Cloud Models
+
+ {cloudModels.map(([id, model]) => (
+ {
+ onModelChange(id)
+ setIsOpen(false)
+ }}
+ className={cn(
+ "w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors",
+ selectedModel === id
+ ? "bg-primary/10"
+ : "hover:bg-muted"
+ )}
+ >
+
+
+ {model.name}
+
+ {model.provider}
+
+
+
+ {model.description}
+
+
+ {selectedModel === id && (
+
+ )}
+
+ ))}
+
+ {/* Ollama Models Section */}
+ {ollamaModels.length > 0 && (
+ <>
+
+
+ Ollama (Local)
+
+ {ollamaModels.map((model) => (
+ {
+ onModelChange(model)
+ setIsOpen(false)
+ }}
+ className={cn(
+ "w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors",
+ selectedModel === model
+ ? "bg-green-500/10"
+ : "hover:bg-muted"
+ )}
+ >
+
+
+ {model}
+
+ Local
+
+
+
+ Ollama local model
+
+
+ {selectedModel === model && (
+
+ )}
+
+ ))}
+ >
+ )}
+
+ {/* No Ollama connection hint */}
+ {!ollamaSettings.enabled && (
+
+
+ Connect Ollama in header for local models
+
+ )}
+
+
+ >
+ )}
+
+ )
+}
diff --git a/src/components/prompt-runner/output-display.tsx b/src/components/prompt-runner/output-display.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..abd5b0209603098d1b3a29154ec5882e51d628ff
--- /dev/null
+++ b/src/components/prompt-runner/output-display.tsx
@@ -0,0 +1,153 @@
+"use client"
+
+import { useState } from "react"
+import ReactMarkdown from "react-markdown"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Copy, Check, Download, FileText, Code } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+interface OutputDisplayProps {
+ output: string
+ isStreaming?: boolean
+ format?: "markdown" | "text" | "code"
+ tokensUsed?: number
+ model?: string
+ className?: string
+}
+
+export function OutputDisplay({
+ output,
+ isStreaming,
+ format = "markdown",
+ tokensUsed,
+ model,
+ className
+}: OutputDisplayProps) {
+ const [copied, setCopied] = useState(false)
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(output)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ const handleExport = (type: "md" | "txt") => {
+ const blob = new Blob([output], { type: "text/plain" })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = `output.${type}`
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+ }
+
+ if (!output && !isStreaming) {
+ return null
+ }
+
+ return (
+
+
+
+
+ Output
+ {isStreaming && (
+
+ Streaming...
+
+ )}
+
+
+
+ {copied ? (
+ <>
+
+ Copied
+ >
+ ) : (
+ <>
+
+ Copy
+ >
+ )}
+
+ handleExport("md")}
+ className="h-8 gap-1.5"
+ disabled={!output}
+ >
+
+ MD
+
+ handleExport("txt")}
+ className="h-8 gap-1.5"
+ disabled={!output}
+ >
+
+ TXT
+
+
+
+
+
+
+ {output ? (
+
+ {output}
+ {isStreaming && (
+
+ )}
+
+ ) : (
+
+ {isStreaming ? (
+
+
+ Generating response...
+
+ ) : (
+ "Output will appear here..."
+ )}
+
+ )}
+
+
+ {(tokensUsed || model) && (
+
+ {model && (
+
+
+ {model}
+
+ )}
+ {tokensUsed && (
+
+ {tokensUsed.toLocaleString()} tokens
+
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/src/components/prompt-runner/prompt-runner.tsx b/src/components/prompt-runner/prompt-runner.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5b7134a077e77f32935fca3e8d4f5ef3d253e009
--- /dev/null
+++ b/src/components/prompt-runner/prompt-runner.tsx
@@ -0,0 +1,486 @@
+"use client"
+
+import { useState, useCallback, useEffect } from "react"
+import { useCompletion } from "@ai-sdk/react"
+import { useUser } from "@stackframe/stack"
+import { AutoForm } from "./auto-form"
+import { OutputDisplay } from "./output-display"
+import { ModelSelector } from "./model-selector"
+import { RemixModal } from "./remix-modal"
+import { SaveRunPanel } from "@/components/saved-runs/save-run-panel"
+import { ShareMenu } from "@/components/share/share-menu"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { VariableSchema, ModelId, DEFAULT_MODEL, PromptSchema, AI_MODELS, isOllamaModel } from "@/types/prompt"
+import { fillTemplate, extractVariables } from "@/lib/utils"
+import { GitFork, Star, Play, User, AlertCircle, Pencil } from "lucide-react"
+import { cn } from "@/lib/utils"
+import Link from "next/link"
+import { useRouter } from "next/navigation"
+import { OpenInButton } from "@/components/prompts/open-in-button"
+import { useOllamaStream } from "@/hooks/use-ollama-stream"
+import { useOllama } from "@/contexts/ollama-context"
+
+interface PromptRunnerProps {
+ promptId: string
+ slug: string
+ title: string
+ description?: string
+ template: string
+ schema: PromptSchema
+ modelDefault?: string
+ modelAllowed?: string[]
+ creator?: {
+ id?: string
+ name: string | null
+ username: string | null
+ image: string | null
+ } | null
+ stats?: {
+ runs: number
+ stars: number
+ remixes: number
+ }
+ parentSlug?: string
+ className?: string
+}
+
+interface SavedRun {
+ id: string
+ name: string | null
+ variables: Record
| null
+ output: string
+ model: string
+ createdAt: string
+}
+
+export function PromptRunner({
+ promptId,
+ slug,
+ title,
+ description,
+ template,
+ schema,
+ modelDefault,
+ modelAllowed,
+ creator,
+ stats,
+ parentSlug,
+ className,
+}: PromptRunnerProps) {
+ const user = useUser()
+ const router = useRouter()
+
+ const [selectedModel, setSelectedModel] = useState(
+ modelDefault || DEFAULT_MODEL
+ )
+ const [error, setError] = useState(null)
+ const [isStarred, setIsStarred] = useState(false)
+ const [starCount, setStarCount] = useState(stats?.stars || 0)
+ const [isStarring, setIsStarring] = useState(false)
+ const [isRemixModalOpen, setIsRemixModalOpen] = useState(false)
+ const [lastVariables, setLastVariables] = useState>({})
+
+ // Saved runs state
+ const [savedRuns, setSavedRuns] = useState([])
+ const [loadingRuns, setLoadingRuns] = useState(false)
+
+ // Ollama context for getting the user's Ollama URL
+ const { settings: ollamaSettings } = useOllama()
+
+ // Cloud model streaming (via server /api/run)
+ const {
+ completion: cloudCompletion,
+ complete: cloudComplete,
+ isLoading: cloudLoading,
+ stop: cloudStop,
+ } = useCompletion({
+ api: "/api/run",
+ streamProtocol: "text",
+ onError: (err) => {
+ setError(err.message || "Something went wrong. Please try again.")
+ },
+ })
+
+ // Ollama model streaming (direct browser โ user's Ollama, no server hop)
+ const {
+ completion: ollamaCompletion,
+ isLoading: ollamaLoading,
+ streamOllama,
+ stop: ollamaStop,
+ reset: ollamaReset,
+ } = useOllamaStream({
+ ollamaUrl: ollamaSettings.apiUrl,
+ onError: (err) => {
+ setError(err.message || "Failed to connect to Ollama.")
+ },
+ })
+
+ // Unified interface โ pick the active source based on selected model
+ const isOllama = isOllamaModel(selectedModel)
+ const completion = isOllama ? ollamaCompletion : cloudCompletion
+ const isLoading = isOllama ? ollamaLoading : cloudLoading
+ const stop = isOllama ? ollamaStop : cloudStop
+
+ // Check if user has starred this prompt
+ useEffect(() => {
+ if (user) {
+ fetch(`/api/star?promptId=${promptId}&userId=${user.id}`)
+ .then(res => res.json())
+ .then(data => setIsStarred(data.starred))
+ .catch((err: unknown) => console.error('Failed to check star status:', err))
+ }
+ }, [user, promptId])
+
+ // Fetch saved runs
+ useEffect(() => {
+ const fetchRuns = async () => {
+ if (!slug) return
+ setLoadingRuns(true)
+ try {
+ const url = user?.id
+ ? `/api/prompts/${slug}/saved-runs?userId=${user.id}`
+ : `/api/prompts/${slug}/saved-runs`
+ const res = await fetch(url)
+ if (res.ok) {
+ const data = await res.json()
+ setSavedRuns(data.runs || [])
+ }
+ } catch (err) {
+ console.error("Failed to fetch runs:", err)
+ } finally {
+ setLoadingRuns(false)
+ }
+ }
+ fetchRuns()
+ }, [slug, user?.id])
+
+ // Handle star toggle
+ const handleStar = async () => {
+ if (!user) {
+ router.push('/sign-in')
+ return
+ }
+
+ setIsStarring(true)
+ try {
+ const res = await fetch('/api/star', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ promptId,
+ userId: user.id,
+ userEmail: user.primaryEmail,
+ userName: user.displayName,
+ }),
+ })
+
+ const data = await res.json()
+ setIsStarred(data.starred)
+ setStarCount(prev => data.starred ? prev + 1 : prev - 1)
+ } catch (err) {
+ console.error('Star error:', err)
+ setError('Failed to star prompt')
+ } finally {
+ setIsStarring(false)
+ }
+ }
+
+ // Handle remix
+ const handleRemix = () => {
+ if (!user) {
+ router.push('/sign-in')
+ return
+ }
+ setIsRemixModalOpen(true)
+ }
+
+ const handleRun = useCallback(async (variables: Record) => {
+ setError(null)
+ setLastVariables(variables as Record)
+
+ // Fill template with variables
+ const filledPrompt = fillTemplate(template, variables as Record)
+
+ try {
+ if (isOllamaModel(selectedModel)) {
+ // Ollama: stream directly from the browser to user's local Ollama
+ ollamaReset()
+ await streamOllama(filledPrompt, selectedModel)
+
+ // Track the run server-side (fire-and-forget)
+ fetch("/api/engagement", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ targetType: "prompt",
+ targetId: promptId,
+ action: "use",
+ }),
+ }).catch(() => {})
+ } else {
+ // Cloud models: stream via server /api/run
+ await cloudComplete(filledPrompt, {
+ body: {
+ promptId,
+ model: selectedModel,
+ variables,
+ },
+ })
+ }
+ } catch (err) {
+ console.error("Run error:", err)
+ }
+ }, [template, selectedModel, promptId, cloudComplete, streamOllama, ollamaReset])
+
+ // Handle save run
+ const handleSaveRun = async (name: string) => {
+ if (!completion || !slug) return
+
+ const res = await fetch(`/api/prompts/${slug}/saved-runs`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ userId: user?.id,
+ name: name || null,
+ variables: lastVariables,
+ output: completion,
+ model: selectedModel,
+ }),
+ })
+
+ if (res.ok) {
+ const newRun = await res.json()
+ setSavedRuns(prev => [newRun, ...prev])
+ } else {
+ throw new Error("Failed to save")
+ }
+ }
+
+ const getModelName = (modelId: string) => {
+ if (isOllamaModel(modelId)) {
+ return modelId.split(':')[0] || modelId
+ }
+ return AI_MODELS[modelId as keyof typeof AI_MODELS]?.name || modelId
+ }
+
+ // Extract variables from schema or auto-detect from template
+ const variables = schema?.variables?.length > 0
+ ? schema.variables
+ : extractVariables(template).map(name => ({
+ name,
+ type: "text" as const,
+ label: name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, " "),
+ required: true,
+ }))
+
+ return (
+
+ {/* Prompt Header Card */}
+
+
+
+
+
{title}
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Creator & Stats */}
+
+ {creator && (
+
+
+ {creator.name || creator.username || "Anonymous"}
+
+ )}
+ {stats && (
+ <>
+
+
+ {stats.runs.toLocaleString()} runs
+
+
+
+ {starCount.toLocaleString()} stars
+
+
+
+ {stats.remixes.toLocaleString()} remixes
+
+ >
+ )}
+
+
+ {/* Parent/Remix indicator */}
+ {parentSlug && (
+
+
+
+ Remixed from{" "}
+
+ {parentSlug}
+
+
+
+ )}
+
+
+ {/* Action Buttons */}
+
+
0 ? fillTemplate(template, lastVariables) : template}
+ title={title}
+ size="sm"
+ />
+
+
+ {isStarred ? 'Starred' : 'Star'}
+
+
+
+ Remix
+
+ {/* Edit button - only show for owner */}
+ {user && creator?.id === user.id && (
+
+
+
+ Edit
+
+
+ )}
+
+
+
+
+
+
+ {/* Main Runner Layout */}
+
+ {/* Left: Form + Model Selector */}
+
+
+
+
+
+ {isLoading && (
+
+ Stop Generation
+
+ )}
+
+
+ {/* Right: Output */}
+
+ {error && (
+
+
+
+
+
+
+ )}
+
+
+
+ {!completion && !isLoading && (
+
+
+
+ Ready to run
+
+ Fill in the variables and click "Run Prompt" to see the AI response
+
+
+
+ )}
+
+
+
+ {/* Prompt Template Preview */}
+
+
+ Prompt Template
+
+ Variables are shown in {"{{brackets}}"}
+
+
+
+
+ {template.split(/(\{\{[^}]+\}\})/).map((part, i) => {
+ if (part.match(/^\{\{[^}]+\}\}$/)) {
+ return (
+
+ {part}
+
+ )
+ }
+ return part
+ })}
+
+
+
+
+ {/* Save Run Panel */}
+
+
+ {/* Remix Modal */}
+
setIsRemixModalOpen(false)}
+ promptId={promptId}
+ originalTitle={title}
+ originalTemplate={template}
+ />
+
+ )
+}
diff --git a/src/components/prompt-runner/remix-modal.tsx b/src/components/prompt-runner/remix-modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0ef66e5381f80ac5ba5f8b816e9a731dce478700
--- /dev/null
+++ b/src/components/prompt-runner/remix-modal.tsx
@@ -0,0 +1,143 @@
+"use client"
+
+import { useState } from "react"
+import { useRouter } from "next/navigation"
+import { useUser } from "@stackframe/stack"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Input, Textarea } from "@/components/ui/input"
+import { GitFork, Loader2, X } from "lucide-react"
+
+interface RemixModalProps {
+ isOpen: boolean
+ onClose: () => void
+ promptId: string
+ originalTitle: string
+ originalTemplate: string
+}
+
+export function RemixModal({
+ isOpen,
+ onClose,
+ promptId,
+ originalTitle,
+ originalTemplate,
+}: RemixModalProps) {
+ const router = useRouter()
+ const user = useUser()
+ const [title, setTitle] = useState(`${originalTitle} (Remix)`)
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ if (!isOpen) return null
+
+ const handleRemix = async () => {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const res = await fetch("/api/remix", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ promptId,
+ creatorId: user?.id || null,
+ }),
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create remix")
+ }
+
+ const data = await res.json()
+ // Redirect to edit page so user can customize their remix
+ router.push(`/p/${data.slug}/edit`)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Something went wrong")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+
+
+
+
+
+ Remix This Prompt
+
+
+ Create your own version of this prompt
+
+
+
+
+ New Title
+ setTitle(e.target.value)}
+ placeholder="Enter a title for your remix"
+ />
+
+
+
+
Template Preview
+
+ {originalTemplate.slice(0, 500)}
+ {originalTemplate.length > 500 && "..."}
+
+
+ You can edit the template after creating the remix
+
+
+
+ {error && (
+ {error}
+ )}
+
+
+
+ Cancel
+
+
+ {isLoading ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ <>
+
+ Create Remix
+ >
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/components/prompts/embed-code-generator.tsx b/src/components/prompts/embed-code-generator.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ed9c7b7d91d39065354af8b7fc8642c70cc8f78e
--- /dev/null
+++ b/src/components/prompts/embed-code-generator.tsx
@@ -0,0 +1,156 @@
+"use client"
+
+import { useState } from "react"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Label } from "@/components/ui/label"
+import {
+ Code,
+ Copy,
+ Check,
+ ExternalLink,
+ Moon,
+ Sun
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+
+interface EmbedCodeGeneratorProps {
+ promptSlug: string
+ promptTitle: string
+}
+
+export function EmbedCodeGenerator({ promptSlug, promptTitle }: EmbedCodeGeneratorProps) {
+ const [theme, setTheme] = useState<"light" | "dark">("light")
+ const [minimal, setMinimal] = useState(false)
+ const [copied, setCopied] = useState(false)
+
+ const baseUrl = typeof window !== 'undefined'
+ ? window.location.origin
+ : 'https://open-prompt.netlify.app'
+
+ const embedUrl = `${baseUrl}/embed/${promptSlug}?theme=${theme}${minimal ? '&minimal=true' : ''}`
+
+ const iframeCode = ``
+
+ const copyCode = async () => {
+ await navigator.clipboard.writeText(iframeCode)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ return (
+
+
+
+
+ Embed This Prompt
+
+
+ Add this prompt to your website, blog, or documentation
+
+
+
+ {/* Options */}
+
+
+
Theme
+
+ setTheme("light")}
+ className="gap-1"
+ >
+
+ Light
+
+ setTheme("dark")}
+ className="gap-1"
+ >
+
+ Dark
+
+
+
+
+
+
Style
+
+ setMinimal(false)}
+ >
+ Full
+
+ setMinimal(true)}
+ >
+ Minimal
+
+
+
+
+
+ {/* Preview Info */}
+
+
+ {/* Code Block */}
+
+
+ {iframeCode}
+
+
+ {copied ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy
+ >
+ )}
+
+
+
+ {/* Footer Note */}
+
+ The embed includes a "Powered by OpenPrompt" footer that links back to your prompt.
+
+
+
+ )
+}
diff --git a/src/components/prompts/fork-tree-loader.tsx b/src/components/prompts/fork-tree-loader.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0c346944c7650fbf3c9d97dbdaaa6d223ad0c6c9
--- /dev/null
+++ b/src/components/prompts/fork-tree-loader.tsx
@@ -0,0 +1,73 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { ForkTreeView, ForkNode, buildForkTree } from "./fork-tree-view"
+import { Card, CardContent } from "@/components/ui/card"
+import { Loader2, GitBranch } from "lucide-react"
+
+interface ForkTreeLoaderProps {
+ slug: string
+ className?: string
+}
+
+export function ForkTreeLoader({ slug, className }: ForkTreeLoaderProps) {
+ const [tree, setTree] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ const fetchTree = async () => {
+ try {
+ setLoading(true)
+ const response = await fetch(`/api/prompts/${slug}/fork-tree`)
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch fork tree')
+ }
+
+ const data = await response.json()
+
+ if (data.prompts && data.rootId) {
+ const treeData = buildForkTree(data.prompts, data.rootId)
+ setTree(treeData)
+ }
+ } catch (err) {
+ setError('Failed to load fork tree')
+ console.error(err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchTree()
+ }, [slug])
+
+ if (loading) {
+ return (
+
+
+
+
+
+ )
+ }
+
+ if (error || !tree) {
+ return (
+
+
+
+ No fork history available
+
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/prompts/fork-tree-view.tsx b/src/components/prompts/fork-tree-view.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1da279d61e0c13a536505f38a53a4041f4c60402
--- /dev/null
+++ b/src/components/prompts/fork-tree-view.tsx
@@ -0,0 +1,268 @@
+"use client"
+
+import { useState } from "react"
+import Link from "next/link"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ GitFork,
+ ChevronDown,
+ ChevronRight,
+ GitBranch,
+ FileText,
+ User,
+ Play,
+ Star,
+ Eye,
+ EyeOff
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+
+export interface ForkNode {
+ id: string
+ slug: string
+ title: string
+ creator?: {
+ name: string | null
+ username: string | null
+ } | null
+ runs: number
+ stars: number
+ remixCount: number
+ isOriginal?: boolean
+ isCurrent?: boolean
+ children?: ForkNode[]
+}
+
+interface ForkTreeNodeProps {
+ node: ForkNode
+ level: number
+ isLast: boolean
+ parentPath: boolean[]
+}
+
+function ForkTreeNode({ node, level, isLast, parentPath }: ForkTreeNodeProps) {
+ const [isExpanded, setIsExpanded] = useState(level < 2)
+ const hasChildren = node.children && node.children.length > 0
+
+ return (
+
+ {/* Connection lines */}
+
+ {parentPath.map((showLine, index) => (
+
+ ))}
+
+
+ {/* Node content */}
+
+ {/* Branch connector */}
+ {level > 0 && (
+
+ )}
+
+ {/* Expand/Collapse button */}
+ {hasChildren ? (
+
setIsExpanded(!isExpanded)}
+ >
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+
+
+ )}
+
+ {/* Node card */}
+
+
+
+ {node.isOriginal && (
+
+ Original
+
+ )}
+ {node.isCurrent && (
+
+ Current
+
+ )}
+ {node.title}
+
+
+
+ {node.creator && (
+
+
+ {node.creator.name || node.creator.username || "Anonymous"}
+
+ )}
+
+
+ {node.runs}
+
+
+
+ {node.stars}
+
+ {node.remixCount > 0 && (
+
+
+ {node.remixCount}
+
+ )}
+
+
+
+
+
+ {/* Children */}
+ {hasChildren && isExpanded && (
+
+ {node.children!.map((child, index) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+interface ForkTreeViewProps {
+ root: ForkNode
+ currentSlug?: string
+ className?: string
+}
+
+export function ForkTreeView({ root, currentSlug, className }: ForkTreeViewProps) {
+ const [showAll, setShowAll] = useState(false)
+
+ // Mark current node
+ const markCurrent = (node: ForkNode): ForkNode => ({
+ ...node,
+ isCurrent: node.slug === currentSlug,
+ children: node.children?.map(markCurrent)
+ })
+
+ const markedRoot = markCurrent(root)
+
+ // Count total nodes
+ const countNodes = (node: ForkNode): number => {
+ return 1 + (node.children?.reduce((sum, child) => sum + countNodes(child), 0) || 0)
+ }
+ const totalNodes = countNodes(root)
+
+ return (
+
+
+
+
+
+
+ Fork Tree
+
+
+ {totalNodes} prompts in this lineage
+
+
+
setShowAll(!showAll)}
+ className="gap-1"
+ >
+ {showAll ? (
+ <>
+
+ Collapse
+ >
+ ) : (
+ <>
+
+ Expand All
+ >
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// Helper function to build tree from flat list
+export function buildForkTree(prompts: {
+ id: string
+ slug: string
+ title: string
+ parentId: string | null
+ creator?: { name: string | null; username: string | null } | null
+ totalRuns: number
+ starsCount: number
+ remixesCount: number
+}[], rootId: string): ForkNode | null {
+ const findNode = (id: string): ForkNode | null => {
+ const prompt = prompts.find(p => p.id === id)
+ if (!prompt) return null
+
+ const children = prompts
+ .filter(p => p.parentId === id)
+ .map(child => findNode(child.id))
+ .filter((node): node is ForkNode => node !== null)
+
+ return {
+ id: prompt.id,
+ slug: prompt.slug,
+ title: prompt.title,
+ creator: prompt.creator,
+ runs: prompt.totalRuns,
+ stars: prompt.starsCount,
+ remixCount: prompt.remixesCount,
+ children: children.length > 0 ? children : undefined
+ }
+ }
+
+ return findNode(rootId)
+}
diff --git a/src/components/prompts/forked-prompts-display.tsx b/src/components/prompts/forked-prompts-display.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e223c8c8e3a4a0037a2d765ddf2676580a1de763
--- /dev/null
+++ b/src/components/prompts/forked-prompts-display.tsx
@@ -0,0 +1,139 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import Link from "next/link"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { GitFork, Star, Play, User, ChevronDown, ChevronUp } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { cn } from "@/lib/utils"
+
+interface RemixedPrompt {
+ id: string
+ slug: string
+ title: string
+ description: string | null
+ totalRuns: number
+ starsCount: number
+ remixesCount: number
+ createdAt: string
+ creator: {
+ id: string
+ name: string | null
+ username: string | null
+ image: string | null
+ } | null
+}
+
+interface ForkedPromptsDisplayProps {
+ promptSlug: string
+ className?: string
+}
+
+export function ForkedPromptsDisplay({ promptSlug, className }: ForkedPromptsDisplayProps) {
+ const [remixes, setRemixes] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [isExpanded, setIsExpanded] = useState(false)
+
+ useEffect(() => {
+ const fetchRemixes = async () => {
+ try {
+ const res = await fetch(`/api/prompts/${promptSlug}/remixes`)
+ if (res.ok) {
+ const data = await res.json()
+ setRemixes(data.remixes || [])
+ }
+ } catch (err) {
+ console.error("Failed to fetch remixes:", err)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ fetchRemixes()
+ }, [promptSlug])
+
+ if (isLoading) {
+ return null // Don't show loading state to avoid layout shift
+ }
+
+ if (remixes.length === 0) {
+ return null // Don't show anything if no remixes
+ }
+
+ const displayedRemixes = isExpanded ? remixes : remixes.slice(0, 3)
+
+ return (
+
+
+
+
+
+ Community Remixes
+
+ {remixes.length}
+
+
+ {remixes.length > 3 && (
+ setIsExpanded(!isExpanded)}
+ className="gap-1"
+ >
+ {isExpanded ? (
+ <>
+ Show Less
+ >
+ ) : (
+ <>
+ Show All
+ >
+ )}
+
+ )}
+
+
+
+
+ {displayedRemixes.map((remix) => (
+
+
+
+
+ {remix.title}
+
+ {remix.description && (
+
+ {remix.description}
+
+ )}
+
+ {remix.creator && (
+
+
+ {remix.creator.name || remix.creator.username || "Anonymous"}
+
+ )}
+
+
+ {remix.totalRuns}
+
+
+
+ {remix.starsCount}
+
+
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/prompts/open-in-button.tsx b/src/components/prompts/open-in-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d34330918d0a17fa1aa2a8a7ac05febd7e7b1106
--- /dev/null
+++ b/src/components/prompts/open-in-button.tsx
@@ -0,0 +1,347 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import {
+ ExternalLink,
+ ChevronDown,
+ Check,
+ AlertCircle,
+ Download,
+ Bot,
+ Brain,
+ Sparkles,
+ Search,
+ Wind,
+ Rocket
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+// Supported AI platforms
+const AI_PLATFORMS = [
+ {
+ id: "chatgpt",
+ name: "ChatGPT",
+ icon: Bot,
+ color: "text-green-500",
+ },
+ {
+ id: "claude",
+ name: "Claude",
+ icon: Brain,
+ color: "text-orange-500",
+ },
+ {
+ id: "gemini",
+ name: "Gemini",
+ icon: Sparkles,
+ color: "text-blue-500",
+ },
+ {
+ id: "perplexity",
+ name: "Perplexity",
+ icon: Search,
+ color: "text-cyan-500",
+ },
+ {
+ id: "mistral",
+ name: "Mistral",
+ icon: Wind,
+ color: "text-purple-500",
+ },
+ {
+ id: "copilot",
+ name: "Copilot",
+ icon: Rocket,
+ color: "text-sky-500",
+ },
+];
+
+interface OpenInButtonProps {
+ prompt: string;
+ title?: string;
+ className?: string;
+ variant?: "default" | "outline" | "ghost";
+ size?: "default" | "sm" | "lg" | "icon";
+}
+
+export function OpenInButton({
+ prompt,
+ title,
+ className,
+ variant = "outline",
+ size = "default"
+}: OpenInButtonProps) {
+ const [extensionInstalled, setExtensionInstalled] = useState(null);
+ const [showInstallDialog, setShowInstallDialog] = useState(false);
+ const [showRefreshDialog, setShowRefreshDialog] = useState(false);
+ const [sending, setSending] = useState(null);
+ const [sent, setSent] = useState(null);
+
+ useEffect(() => {
+ // Check if extension is installed
+ const checkExtension = () => {
+ // Check for data attribute set by content script
+ if (document.body.getAttribute("data-openprompt-extension") === "true") {
+ setExtensionInstalled(true);
+ return;
+ }
+
+ // Extension not detected yet - might still be loading
+ setExtensionInstalled(false);
+ };
+
+ // Listen for extension ready event and responses
+ const handleExtensionMessage = (event: Event | MessageEvent) => {
+ if (event.type === 'message') {
+ const messageEvent = event as MessageEvent;
+ if (messageEvent.data?.type === 'OPENPROMPT_EXTENSION_READY') {
+ setExtensionInstalled(true);
+ }
+ // Handle extension context invalidated error
+ if (messageEvent.data?.type === 'OPEN_IN_AI_RECEIVED' && messageEvent.data?.success === false) {
+ setShowRefreshDialog(true);
+ setSending(null);
+ }
+ } else if (event.type === 'openprompt-extension-ready') {
+ setExtensionInstalled(true);
+ }
+ };
+
+ window.addEventListener("openprompt-extension-ready", handleExtensionMessage);
+ window.addEventListener("message", handleExtensionMessage);
+
+ // Initial check
+ checkExtension();
+
+ // Recheck after a delay (in case extension loads slowly)
+ const timeout = setTimeout(checkExtension, 1000);
+
+ return () => {
+ window.removeEventListener("openprompt-extension-ready", handleExtensionMessage);
+ window.removeEventListener("message", handleExtensionMessage);
+ clearTimeout(timeout);
+ };
+ }, []);
+
+ const handleOpenIn = async (platformId: string) => {
+ if (!extensionInstalled) {
+ setShowInstallDialog(true);
+ return;
+ }
+
+ setSending(platformId);
+
+ try {
+ // Send message to the extension via postMessage
+ window.postMessage({
+ type: "OPEN_IN_AI",
+ prompt,
+ title,
+ platform: platformId,
+ }, "*");
+
+ // Show success state
+ setSent(platformId);
+ setTimeout(() => {
+ setSent(null);
+ setSending(null);
+ }, 2000);
+ } catch (error) {
+ console.error("Error sending to extension:", error);
+ setSending(null);
+ }
+ };
+
+ const handleOpenWithoutExtension = (platformId: string) => {
+ // Copy prompt to clipboard and open the platform
+ navigator.clipboard.writeText(prompt);
+
+ const platformUrls: Record = {
+ chatgpt: "https://chatgpt.com",
+ claude: "https://claude.ai",
+ gemini: "https://gemini.google.com",
+ perplexity: "https://www.perplexity.ai",
+ mistral: "https://chat.mistral.ai",
+ copilot: "https://copilot.microsoft.com",
+ };
+
+ window.open(platformUrls[platformId], "_blank");
+ };
+
+ return (
+ <>
+
+
+
+
+ Open In...
+
+
+
+
+ {AI_PLATFORMS.map((platform) => {
+ const IconComponent = platform.icon;
+ return (
+
+ extensionInstalled
+ ? handleOpenIn(platform.id)
+ : handleOpenWithoutExtension(platform.id)
+ }
+ className="cursor-pointer"
+ >
+
+ {platform.name}
+ {sending === platform.id && (
+
+ )}
+ {sent === platform.id && (
+
+ )}
+
+ );
+ })}
+
+ {!extensionInstalled && (
+ <>
+
+ setShowInstallDialog(true)}
+ className="cursor-pointer text-muted-foreground"
+ >
+
+ Install Extension
+
+ >
+ )}
+
+
+
+ {/* Install Extension Dialog */}
+
+
+
+
+
+ Install OpenPrompt Extension
+
+
+ For the best experience, install our browser extension to automatically inject prompts into AI chat interfaces.
+
+
+
+
+
+
+
+
+
+
OpenPrompt Extension
+
+ Open prompts in ChatGPT, Claude, Gemini & more with one click
+
+
+
+
+
+
Features:
+
+ โ One-click prompt injection
+ โ Supports 6+ AI platforms
+ โ Recent prompts history
+ โ Default platform setting
+
+
+
+
+ {
+ // TODO: Update with actual Chrome Web Store URL once published
+ window.open("/extension", "_blank");
+ }}
+ >
+
+ Get Extension
+
+ setShowInstallDialog(false)}
+ >
+ Maybe Later
+
+
+
+
+ Without the extension, prompts are copied to clipboard when opening AI platforms.
+
+
+
+
+
+ {/* Refresh Required Dialog */}
+
+
+
+
+
+ Page Refresh Required
+
+
+ The extension was updated or reloaded. Please refresh this page to restore the connection.
+
+
+
+
+ This happens when the browser extension is updated or reloaded while this page was open.
+
+
+ {
+ window.location.reload();
+ }}
+ >
+ Refresh Page
+
+ setShowRefreshDialog(false)}
+ >
+ Cancel
+
+
+
+
+
+ >
+ );
+}
+
+// Compact version for inline use
+export function OpenInButtonCompact({ prompt, title }: OpenInButtonProps) {
+ return (
+
+ );
+}
diff --git a/src/components/prompts/quality-badge-display.tsx b/src/components/prompts/quality-badge-display.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..df01f1c155f91ebf4c83baf180a43705abdd1408
--- /dev/null
+++ b/src/components/prompts/quality-badge-display.tsx
@@ -0,0 +1,105 @@
+"use client"
+
+import { Badge } from "@/components/ui/badge"
+import { DynamicIcon } from "@/components/ui/dynamic-icon"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger
+} from "@/components/ui/tooltip"
+import { cn } from "@/lib/utils"
+import { QualityBadge } from "@/lib/quality-badges"
+
+interface QualityBadgeDisplayProps {
+ badge: QualityBadge
+ size?: "sm" | "md" | "lg"
+ showTooltip?: boolean
+ className?: string
+}
+
+export function QualityBadgeDisplay({
+ badge,
+ size = "md",
+ showTooltip = true,
+ className
+}: QualityBadgeDisplayProps) {
+ const sizeClasses = {
+ sm: "text-xs px-1.5 py-0.5",
+ md: "text-sm px-2 py-1",
+ lg: "text-base px-3 py-1.5"
+ }
+
+ const badgeElement = (
+
+
+ {badge.name}
+
+ )
+
+ if (!showTooltip) {
+ return badgeElement
+ }
+
+ return (
+
+
+
+ {badgeElement}
+
+
+ {badge.name}
+ {badge.description}
+
+
+
+ )
+}
+
+interface QualityBadgeListProps {
+ badges: QualityBadge[]
+ size?: "sm" | "md" | "lg"
+ maxDisplay?: number
+ className?: string
+}
+
+export function QualityBadgeList({
+ badges,
+ size = "sm",
+ maxDisplay = 5,
+ className
+}: QualityBadgeListProps) {
+ const displayBadges = badges.slice(0, maxDisplay)
+ const remaining = badges.length - maxDisplay
+
+ return (
+
+ {displayBadges.map(badge => (
+
+ ))}
+ {remaining > 0 && (
+
+ +{remaining} more
+
+ )}
+
+ )
+}
diff --git a/src/components/prompts/quality-badges.tsx b/src/components/prompts/quality-badges.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e68649ea355c8a29a00acf89a847425aeab0c2d5
--- /dev/null
+++ b/src/components/prompts/quality-badges.tsx
@@ -0,0 +1,200 @@
+"use client"
+
+import { Badge } from "@/components/ui/badge"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { CheckCircle, Zap, Heart, Flame, Star, Ruler } from "lucide-react"
+
+export type BadgeType =
+ | "verified-quality"
+ | "framework-compliant"
+ | "high-performance"
+ | "community-favorite"
+ | "trending"
+ | "prolific-creator"
+
+interface QualityBadge {
+ type: BadgeType
+ label: string
+ description: string
+}
+
+const BADGE_CONFIG: Record
+ color: string
+ bgColor: string
+}> = {
+ "verified-quality": {
+ icon: CheckCircle,
+ color: "text-emerald-500",
+ bgColor: "bg-emerald-500/10 border-emerald-500/20"
+ },
+ "framework-compliant": {
+ icon: Ruler,
+ color: "text-blue-500",
+ bgColor: "bg-blue-500/10 border-blue-500/20"
+ },
+ "high-performance": {
+ icon: Zap,
+ color: "text-yellow-500",
+ bgColor: "bg-yellow-500/10 border-yellow-500/20"
+ },
+ "community-favorite": {
+ icon: Heart,
+ color: "text-pink-500",
+ bgColor: "bg-pink-500/10 border-pink-500/20"
+ },
+ "trending": {
+ icon: Flame,
+ color: "text-orange-500",
+ bgColor: "bg-orange-500/10 border-orange-500/20"
+ },
+ "prolific-creator": {
+ icon: Star,
+ color: "text-purple-500",
+ bgColor: "bg-purple-500/10 border-purple-500/20"
+ }
+}
+
+interface QualityBadgesProps {
+ badges: QualityBadge[] | BadgeType[]
+ showLabels?: boolean
+ size?: "sm" | "md" | "lg"
+ maxDisplay?: number
+}
+
+export function QualityBadges({
+ badges,
+ showLabels = false,
+ size = "sm",
+ maxDisplay = 3
+}: QualityBadgesProps) {
+ if (!badges || badges.length === 0) return null
+
+ // Normalize badges to BadgeType array
+ const badgeTypes: BadgeType[] = badges.map(b =>
+ typeof b === "string" ? b : b.type
+ )
+
+ const displayBadges = badgeTypes.slice(0, maxDisplay)
+ const remaining = badgeTypes.length - maxDisplay
+
+ const sizeClasses = {
+ sm: "h-4 w-4",
+ md: "h-5 w-5",
+ lg: "h-6 w-6"
+ }
+
+ const badgeLabels: Record = {
+ "verified-quality": {
+ label: "Verified Quality",
+ description: "95%+ success rate"
+ },
+ "framework-compliant": {
+ label: "Framework",
+ description: "Uses structured prompting framework"
+ },
+ "high-performance": {
+ label: "Fast",
+ description: "High performance and efficient"
+ },
+ "community-favorite": {
+ label: "Favorite",
+ description: "Community favorite"
+ },
+ "trending": {
+ label: "Trending",
+ description: "High recent activity"
+ },
+ "prolific-creator": {
+ label: "Prolific",
+ description: "From a prolific creator"
+ }
+ }
+
+ return (
+
+
+ {displayBadges.map((badgeType) => {
+ const config = BADGE_CONFIG[badgeType]
+ if (!config) return null
+
+ const Icon = config.icon
+ const info = badgeLabels[badgeType]
+
+ return (
+
+
+
+
+ {showLabels && (
+ {info.label}
+ )}
+
+
+
+ {info.label}
+
+ {info.description}
+
+
+
+ )
+ })}
+
+ {remaining > 0 && (
+
+
+
+ +{remaining}
+
+
+
+ {remaining} more badge{remaining > 1 ? "s" : ""}
+
+
+ )}
+
+
+ )
+}
+
+// Compact version for cards
+export function QualityBadgesCompact({ badges }: { badges: BadgeType[] }) {
+ if (!badges || badges.length === 0) return null
+
+ return (
+
+
+ {badges.slice(0, 3).map((badgeType) => {
+ const config = BADGE_CONFIG[badgeType]
+ if (!config) return null
+
+ const Icon = config.icon
+
+ return (
+
+
+
+
+
+
+
+
+ {badgeType.replace(/-/g, " ")}
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/components/prompts/remix-diff-view.tsx b/src/components/prompts/remix-diff-view.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1d104ce2eae05d5b12ee4e0b40fcb70422aaadec
--- /dev/null
+++ b/src/components/prompts/remix-diff-view.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import { useMemo } from "react"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { cn } from "@/lib/utils"
+
+interface RemixDiffViewProps {
+ originalPrompt: string
+ remixedPrompt: string
+ originalTitle?: string
+ remixedTitle?: string
+}
+
+interface DiffLine {
+ type: 'unchanged' | 'added' | 'removed'
+ content: string
+ lineNumber: { original?: number; remix?: number }
+}
+
+export function RemixDiffView({
+ originalPrompt,
+ remixedPrompt,
+ originalTitle = "Original",
+ remixedTitle = "Remix"
+}: RemixDiffViewProps) {
+ // Calculate diff
+ const diffLines = useMemo(() => {
+ const originalLines = originalPrompt.split('\n')
+ const remixLines = remixedPrompt.split('\n')
+ const result: DiffLine[] = []
+
+ // Simple line-by-line diff
+ let origIdx = 0
+ let remixIdx = 0
+
+ while (origIdx < originalLines.length || remixIdx < remixLines.length) {
+ const origLine = originalLines[origIdx]
+ const remixLine = remixLines[remixIdx]
+
+ if (origLine === remixLine) {
+ result.push({
+ type: 'unchanged',
+ content: origLine || '',
+ lineNumber: { original: origIdx + 1, remix: remixIdx + 1 }
+ })
+ origIdx++
+ remixIdx++
+ } else if (origIdx < originalLines.length && !remixLines.slice(remixIdx).includes(origLine)) {
+ result.push({
+ type: 'removed',
+ content: origLine,
+ lineNumber: { original: origIdx + 1 }
+ })
+ origIdx++
+ } else if (remixIdx < remixLines.length && !originalLines.slice(origIdx).includes(remixLine)) {
+ result.push({
+ type: 'added',
+ content: remixLine,
+ lineNumber: { remix: remixIdx + 1 }
+ })
+ remixIdx++
+ } else {
+ // Lines are different but exist in both
+ result.push({
+ type: 'removed',
+ content: origLine || '',
+ lineNumber: { original: origIdx + 1 }
+ })
+ result.push({
+ type: 'added',
+ content: remixLine || '',
+ lineNumber: { remix: remixIdx + 1 }
+ })
+ origIdx++
+ remixIdx++
+ }
+ }
+
+ return result
+ }, [originalPrompt, remixedPrompt])
+
+ // Count changes
+ const stats = useMemo(() => {
+ const added = diffLines.filter(l => l.type === 'added').length
+ const removed = diffLines.filter(l => l.type === 'removed').length
+ const unchanged = diffLines.filter(l => l.type === 'unchanged').length
+ return { added, removed, unchanged }
+ }, [diffLines])
+
+ return (
+
+
+
+
Prompt Diff
+
+
+ +{stats.added} added
+
+
+ -{stats.removed} removed
+
+
+
+
+
+ {originalTitle}
+
+
+ {remixedTitle}
+
+
+
+
+
+ {diffLines.map((line, index) => (
+
+ {/* Line numbers */}
+
+
+ {line.lineNumber.original || ''}
+
+ /
+
+ {line.lineNumber.remix || ''}
+
+
+
+ {/* Change indicator */}
+
+ {line.type === 'added' && (
+ +
+ )}
+ {line.type === 'removed' && (
+ -
+ )}
+
+
+ {/* Content */}
+
+ {line.content || '\u00A0'}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/prompts/version-history.tsx b/src/components/prompts/version-history.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..49b072bb402900558e32c255f1c9ea31d281387b
--- /dev/null
+++ b/src/components/prompts/version-history.tsx
@@ -0,0 +1,208 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { formatDistanceToNow } from 'date-fns'
+import { Clock, ChevronDown, ChevronUp, GitCommit, RotateCcw } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '@/components/ui/collapsible'
+
+interface PromptVersion {
+ id: string
+ version: number
+ title: string
+ template: string
+ changeNote: string | null
+ createdAt: string
+}
+
+interface PromptVersionHistoryProps {
+ promptId: string
+ currentVersion: number
+ isOwner: boolean
+ onRevert?: (version: PromptVersion) => void
+}
+
+export function PromptVersionHistory({
+ promptId,
+ currentVersion,
+ isOwner,
+ onRevert,
+}: PromptVersionHistoryProps) {
+ const [versions, setVersions] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [expandedVersion, setExpandedVersion] = useState(null)
+ const [isOpen, setIsOpen] = useState(false)
+
+ useEffect(() => {
+ if (!isOpen) return
+
+ const fetchVersions = async () => {
+ setLoading(true)
+ try {
+ const res = await fetch(`/api/prompts/${promptId}/versions`)
+ if (res.ok) {
+ const data = await res.json()
+ setVersions(data.versions || [])
+ }
+ } catch (err) {
+ console.error('Failed to fetch prompt versions:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchVersions()
+ }, [promptId, isOpen])
+
+ const handleRevert = async (version: PromptVersion) => {
+ if (!onRevert) return
+ if (!confirm(`Revert to version ${version.version}? This will replace the current template.`)) return
+ onRevert(version)
+ }
+
+ return (
+
+
+
+
+ Version History
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ Version History
+
+ v{currentVersion} current
+
+
+
+
+ {loading ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : versions.length === 0 ? (
+
+ No version history yet. Versions are saved each time you update the prompt.
+
+ ) : (
+
+ {versions.map((version) => (
+
+
+
+
+
+
+
+ v{version.version}
+
+ {version.version === currentVersion && (
+
+ current
+
+ )}
+
+ {version.changeNote && (
+
+ {version.changeNote}
+
+ )}
+
+
+
+
+
+ {formatDistanceToNow(new Date(version.createdAt), {
+ addSuffix: true,
+ })}
+
+
+ {/* View diff toggle */}
+
+ setExpandedVersion(
+ expandedVersion === version.id ? null : version.id,
+ )
+ }
+ >
+ {expandedVersion === version.id ? 'Hide' : 'View'}
+
+
+ {/* Revert button โ only for owner, not current version */}
+ {isOwner && version.version !== currentVersion && (
+ handleRevert(version)}
+ >
+
+ Revert
+
+ )}
+
+
+
+ {/* Expanded template preview */}
+ {expandedVersion === version.id && (
+
+
+ Template at v{version.version}:
+
+
+ {version.template}
+
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/components/providers/auth-provider.tsx b/src/components/providers/auth-provider.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..33bb16bb940ccaaab6c686da3574e8daddbe4fba
--- /dev/null
+++ b/src/components/providers/auth-provider.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import { StackProvider, StackTheme } from "@stackframe/stack"
+import { stackClientApp } from "@/lib/stack"
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/src/components/providers/theme-provider.tsx b/src/components/providers/theme-provider.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a32b909b2edc6032943c92295dbc2aeb8e125268
--- /dev/null
+++ b/src/components/providers/theme-provider.tsx
@@ -0,0 +1,11 @@
+"use client"
+
+import * as React from "react"
+import { ThemeProvider as NextThemesProvider } from "next-themes"
+
+export function ThemeProvider({
+ children,
+ ...props
+}: React.ComponentProps) {
+ return {children}
+}
diff --git a/src/components/saved-runs/save-run-panel.tsx b/src/components/saved-runs/save-run-panel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f2a48c8b2d2d1249fb3de5b215250739c2a9742f
--- /dev/null
+++ b/src/components/saved-runs/save-run-panel.tsx
@@ -0,0 +1,205 @@
+"use client"
+
+import { useState } from "react"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import {
+ Save,
+ History,
+ ChevronDown,
+ ChevronUp,
+ Loader2,
+ Check,
+ Clock
+} from "lucide-react"
+import ReactMarkdown from "react-markdown"
+
+interface SavedRun {
+ id: string
+ name: string | null
+ variables?: Record | null
+ inputs?: Record | null
+ output: string
+ outputs?: { output: string }[]
+ model: string
+ createdAt: string
+}
+
+interface SaveRunPanelProps {
+ hasOutput: boolean
+ isRunning: boolean
+ onSave: (name: string) => Promise
+ savedRuns: SavedRun[]
+ loadingRuns: boolean
+ type: "prompt" | "tool"
+ getModelName: (modelId: string) => string
+}
+
+export function SaveRunPanel({
+ hasOutput,
+ isRunning,
+ onSave,
+ savedRuns,
+ loadingRuns,
+ type,
+ getModelName
+}: SaveRunPanelProps) {
+ const [saving, setSaving] = useState(false)
+ const [saved, setSaved] = useState(false)
+ const [runName, setRunName] = useState("")
+ const [showHistory, setShowHistory] = useState(false)
+ const [expandedRunId, setExpandedRunId] = useState(null)
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ await onSave(runName)
+ setSaved(true)
+ setRunName("")
+ setTimeout(() => setSaved(false), 3000)
+ } catch (err) {
+ console.error("Failed to save:", err)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ {/* Save Result Section */}
+ {hasOutput && !isRunning && (
+
+
+
+
+ Save This Result
+
+ Save this run to view later
+
+
+
+
+ setRunName(e.target.value)}
+ placeholder="Name this run (optional)"
+ />
+
+
+ {saving ? (
+ <>
+
+ Saving...
+ >
+ ) : saved ? (
+ <>
+
+ Saved!
+ >
+ ) : (
+ <>
+
+ Save Result
+ >
+ )}
+
+
+
+
+ )}
+
+ {/* Saved Runs History */}
+
+
+ setShowHistory(!showHistory)}
+ className="flex items-center justify-between w-full text-left"
+ >
+
+
+ Saved Runs
+ {savedRuns.length}
+
+ {showHistory ? (
+
+ ) : (
+
+ )}
+
+ View previously saved results
+
+ {showHistory && (
+
+ {loadingRuns ? (
+
+
+
+ ) : savedRuns.length === 0 ? (
+
+ No saved runs yet. Run the {type} and save the result!
+
+ ) : (
+
+ {savedRuns.map((run) => (
+
+
setExpandedRunId(expandedRunId === run.id ? null : run.id)}
+ className="w-full p-3 flex items-center justify-between text-left hover:bg-muted/50"
+ >
+
+
+ {run.name || `Run from ${new Date(run.createdAt).toLocaleDateString()}`}
+
+
+ {getModelName(run.model)}
+
+
+ {new Date(run.createdAt).toLocaleString()}
+
+
+
+ {expandedRunId === run.id ? (
+
+ ) : (
+
+ )}
+
+ {expandedRunId === run.id && (
+
+ {/* Variables/Inputs used */}
+ {(run.variables || run.inputs) && Object.keys(run.variables || run.inputs || {}).length > 0 && (
+
+
{type === "prompt" ? "Variables" : "Inputs"}:
+
+ {Object.entries(run.variables || run.inputs || {}).map(([k, v]) => (
+
+ {k}: {String(v).slice(0, 50)}{String(v).length > 50 ? "..." : ""}
+
+ ))}
+
+
+ )}
+ {/* Output */}
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/src/components/settings/billing-settings.tsx b/src/components/settings/billing-settings.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..15fc53cb3fe1e8edb84f1e6d07e00eaee4d64823
--- /dev/null
+++ b/src/components/settings/billing-settings.tsx
@@ -0,0 +1,276 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { useRouter } from 'next/navigation'
+import {
+ Zap, Check, CreditCard, AlertCircle, Loader2, ExternalLink, Calendar, Shield
+} from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Separator } from '@/components/ui/separator'
+
+interface BillingSettingsProps {
+ currentPlan: 'free' | 'pro'
+ subscriptionStatus?: string | null
+ subscriptionEnd?: Date | null
+ cancelAtPeriodEnd?: boolean
+}
+
+export function BillingSettings({
+ currentPlan,
+ subscriptionStatus,
+ subscriptionEnd,
+ cancelAtPeriodEnd,
+}: BillingSettingsProps) {
+ const router = useRouter()
+ const [isPending, startTransition] = useTransition()
+ const [error, setError] = useState(null)
+
+ const isPro = currentPlan === 'pro' && subscriptionStatus === 'active'
+ const isTrial = subscriptionStatus === 'trialing'
+ const isPastDue = subscriptionStatus === 'past_due'
+
+ const handleUpgrade = () => {
+ setError(null)
+ startTransition(async () => {
+ try {
+ const res = await fetch('/api/stripe/checkout', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ plan: 'pro_monthly' }),
+ })
+ const data = await res.json()
+ if (data.url) {
+ window.location.href = data.url
+ } else {
+ setError(data.error || 'Failed to start checkout')
+ }
+ } catch {
+ setError('Something went wrong. Please try again.')
+ }
+ })
+ }
+
+ const handleManageBilling = () => {
+ setError(null)
+ startTransition(async () => {
+ try {
+ const res = await fetch('/api/stripe/portal', { method: 'POST' })
+ const data = await res.json()
+ if (data.url) {
+ window.location.href = data.url
+ } else {
+ setError(data.error || 'Failed to open billing portal')
+ }
+ } catch {
+ setError('Something went wrong. Please try again.')
+ }
+ })
+ }
+
+ return (
+
+ {/* Current plan card */}
+
+
+
+
+ Current Plan
+
+
+ Manage your subscription and billing information
+
+
+
+
+
+
+
+
+
+
+
+ {currentPlan} Plan
+
+ {isTrial && (
+ Free Trial
+ )}
+ {isPastDue && (
+ Payment Failed
+ )}
+ {cancelAtPeriodEnd && (
+
+ Cancels soon
+
+ )}
+
+
+ {isPro || isTrial
+ ? 'Unlimited executions ยท API access ยท Private prompts'
+ : '10 executions/hr ยท 10 prompts ยท Community access'}
+
+
+
+
+
+ {isPro || isTrial ? '$9' : '$0'}
+
+
+ {isPro || isTrial ? '/month' : 'forever'}
+
+
+
+
+ {/* Subscription details */}
+ {subscriptionEnd && (
+
+
+ {cancelAtPeriodEnd ? (
+
+ Access ends on{' '}
+
+ {new Date(subscriptionEnd).toLocaleDateString('en-US', {
+ month: 'long', day: 'numeric', year: 'numeric',
+ })}
+
+
+ ) : (
+
+ Next billing date:{' '}
+
+ {new Date(subscriptionEnd).toLocaleDateString('en-US', {
+ month: 'long', day: 'numeric', year: 'numeric',
+ })}
+
+
+ )}
+
+ )}
+
+ {/* Payment failed warning */}
+ {isPastDue && (
+
+
+
+ Your last payment failed. Please update your payment method to continue Pro access.
+
+
+ )}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {/* CTA buttons */}
+
+ {isPro || isTrial ? (
+
+ {isPending ? (
+
+ ) : (
+
+ )}
+ Manage Billing
+
+ ) : (
+
+ {isPending ? (
+
+ ) : (
+
+ )}
+ Upgrade to Pro โ $9/mo
+
+ )}
+
+
+
+
+ {/* Feature comparison */}
+
+
+ Plan Features
+
+
+
+ {[
+ { feature: 'Public prompt access', free: true, pro: true },
+ { feature: 'AI tool executions', free: '10/hr', pro: 'Unlimited' },
+ { feature: 'Create prompts', free: '10 max', pro: 'Unlimited' },
+ { feature: 'Private prompts', free: false, pro: true },
+ { feature: 'REST API access', free: false, pro: true },
+ { feature: 'Execution history', free: false, pro: true },
+ { feature: 'Advanced analytics', free: false, pro: true },
+ { feature: 'Remove embed branding', free: false, pro: true },
+ { feature: 'Priority support', free: false, pro: true },
+ ].map((row) => (
+
+
{row.feature}
+
+
+ {row.free === true ? (
+
+ ) : row.free === false ? (
+ โ
+ ) : (
+ {row.free}
+ )}
+
+
+ {row.pro === true ? (
+
+ ) : (
+ {row.pro}
+ )}
+
+
+
+ ))}
+
+
+
+ FREE
+ PRO
+
+
+
+
+ {/* Security note */}
+
+
+
+ Payments are securely processed by{' '}
+
+ Stripe
+
+ . We never store your card details.
+
+
+
+ )
+}
diff --git a/src/components/share/share-menu.tsx b/src/components/share/share-menu.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6feaa835e598a478c2bac6238df8a4042c1a16cd
--- /dev/null
+++ b/src/components/share/share-menu.tsx
@@ -0,0 +1,215 @@
+"use client"
+
+import { useState } from "react"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Button } from "@/components/ui/button"
+import {
+ Share2,
+ Copy,
+ Check,
+ Twitter,
+ Linkedin,
+ Facebook,
+ Mail,
+ MessageCircle,
+ Link2,
+ QrCode,
+ Send
+} from "lucide-react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { QRCodeSVG } from "qrcode.react"
+
+interface ShareMenuProps {
+ url?: string
+ title: string
+ description?: string
+ variant?: "default" | "outline" | "ghost"
+ size?: "default" | "sm" | "lg" | "icon"
+ className?: string
+}
+
+export function ShareMenu({
+ url,
+ title,
+ description = "",
+ variant = "outline",
+ size = "sm",
+ className = ""
+}: ShareMenuProps) {
+ const [copied, setCopied] = useState(false)
+ const [showQR, setShowQR] = useState(false)
+
+ const shareUrl = url || (typeof window !== "undefined" ? window.location.href : "")
+ const encodedUrl = encodeURIComponent(shareUrl)
+ const encodedTitle = encodeURIComponent(title)
+ const encodedDescription = encodeURIComponent(description)
+
+ const copyLink = async () => {
+ await navigator.clipboard.writeText(shareUrl)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ const shareNative = async () => {
+ if (navigator.share) {
+ try {
+ await navigator.share({
+ title,
+ text: description,
+ url: shareUrl,
+ })
+ } catch (err) {
+ // User cancelled or error
+ }
+ }
+ }
+
+ const shareLinks = {
+ twitter: `https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`,
+ linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
+ facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
+ whatsapp: `https://wa.me/?text=${encodedTitle}%20${encodedUrl}`,
+ telegram: `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`,
+ email: `mailto:?subject=${encodedTitle}&body=${encodedDescription}%0A%0A${encodedUrl}`,
+ reddit: `https://reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`,
+ }
+
+ const openShare = (platform: keyof typeof shareLinks) => {
+ window.open(shareLinks[platform], "_blank", "width=600,height=400")
+ }
+
+ const hasNativeShare = typeof navigator !== "undefined" && !!navigator.share
+
+ return (
+ <>
+
+
+
+
+ {size !== "icon" && "Share"}
+
+
+
+ {/* Native Share (Mobile) */}
+ {hasNativeShare && (
+ <>
+
+
+ Share via...
+
+
+ >
+ )}
+
+ {/* Copy Link */}
+
+ {copied ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy Link
+ >
+ )}
+
+
+ {/* QR Code */}
+ setShowQR(true)} className="gap-3 cursor-pointer">
+
+ QR Code
+
+
+
+
+ {/* Social Platforms */}
+ openShare("twitter")} className="gap-3 cursor-pointer">
+
+ Twitter / X
+
+
+ openShare("linkedin")} className="gap-3 cursor-pointer">
+
+ LinkedIn
+
+
+ openShare("facebook")} className="gap-3 cursor-pointer">
+
+ Facebook
+
+
+ openShare("reddit")} className="gap-3 cursor-pointer">
+
+ Reddit
+
+
+
+
+ {/* Messaging */}
+ openShare("whatsapp")} className="gap-3 cursor-pointer">
+
+ WhatsApp
+
+
+ openShare("telegram")} className="gap-3 cursor-pointer">
+
+ Telegram
+
+
+ openShare("email")} className="gap-3 cursor-pointer">
+
+ Email
+
+
+
+
+ {/* QR Code Dialog */}
+
+
+
+ Share via QR Code
+
+
+
+
+
+
+ Scan this code to open the link
+
+
+ {copied ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy Link
+ >
+ )}
+
+
+
+
+ >
+ )
+}
diff --git a/src/components/tools/tool-executor.tsx b/src/components/tools/tool-executor.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5c302afdc335f160d21096362848d21ba4533c72
--- /dev/null
+++ b/src/components/tools/tool-executor.tsx
@@ -0,0 +1,420 @@
+"use client"
+
+import { useState, useEffect } from 'react'
+import { useRouter } from 'next/navigation'
+import { useUser } from '@stackframe/stack'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Badge } from '@/components/ui/badge'
+import { Loader2, Copy, CheckCircle2, Cloud, Server, Share2 } from 'lucide-react'
+import { ToolDefinition, ToolInput } from '@/lib/tools'
+import { AI_MODELS, DEFAULT_MODEL, isOllamaModel } from '@/types/prompt'
+import { useOllama } from '@/contexts/ollama-context'
+import { SaveRunPanel } from '@/components/saved-runs/save-run-panel'
+import { ShareMenu } from '@/components/share/share-menu'
+
+interface ToolExecutorProps {
+ tool: ToolDefinition
+}
+
+interface SavedRun {
+ id: string
+ name: string | null
+ inputs: Record | null
+ output: string
+ model: string
+ createdAt: string
+}
+
+export function ToolExecutor({ tool }: ToolExecutorProps) {
+ const router = useRouter()
+ const user = useUser()
+ const { settings: ollamaSettings } = useOllama()
+ const [inputs, setInputs] = useState>({})
+ const [output, setOutput] = useState('')
+ const [isLoading, setIsLoading] = useState(false)
+ const [isCopied, setIsCopied] = useState(false)
+ const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL)
+
+ // Saved runs state
+ const [savedRuns, setSavedRuns] = useState([])
+ const [loadingRuns, setLoadingRuns] = useState(false)
+
+ // Fetch saved runs
+ useEffect(() => {
+ const fetchRuns = async () => {
+ if (!tool.slug || !user?.id) return
+ setLoadingRuns(true)
+ try {
+ const res = await fetch(`/api/tools/${tool.slug}/saved-runs?userId=${user.id}`)
+ if (res.ok) {
+ const data = await res.json()
+ setSavedRuns(data.runs || [])
+ }
+ } catch (err) {
+ console.error("Failed to fetch runs:", err)
+ } finally {
+ setLoadingRuns(false)
+ }
+ }
+ fetchRuns()
+ }, [tool.slug, user?.id])
+
+ const handleInputChange = (name: string, value: string) => {
+ setInputs(prev => ({ ...prev, [name]: value }))
+ }
+
+ const handleExecute = async () => {
+ setIsLoading(true)
+ setOutput('')
+
+ try {
+ if (isOllamaModel(selectedModel)) {
+ // Ollama: call directly from browser instead of through server
+ const ollamaUrl = (ollamaSettings.apiUrl || "http://localhost:11434").replace(/\/+$/, "")
+
+ // Build the prompt from tool's system prompt + inputs
+ let finalPrompt = tool.systemPrompt
+ for (const [key, value] of Object.entries(inputs)) {
+ finalPrompt = finalPrompt.replace(new RegExp(`{{${key}}}`, 'g'), value)
+ }
+
+ const res = await fetch(`${ollamaUrl}/api/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: selectedModel,
+ prompt: finalPrompt,
+ stream: false,
+ }),
+ })
+
+ if (!res.ok) {
+ if (res.status === 0 || res.type === "opaque") {
+ throw new Error(
+ `Cannot reach Ollama. Make sure CORS is enabled:\n` +
+ `OLLAMA_ORIGINS=${window.location.origin} ollama serve`
+ )
+ }
+ throw new Error(`Ollama error: ${res.status}. Make sure Ollama is running.`)
+ }
+
+ const data = await res.json()
+ setOutput(data.response)
+ } else {
+ // Cloud models: call server API
+ const res = await fetch(`/api/tools/${tool.slug}/execute`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ inputs,
+ model: selectedModel,
+ }),
+ })
+
+ const data = await res.json()
+
+ if (res.ok) {
+ setOutput(data.output)
+ } else {
+ setOutput(`Error: ${data.error || 'Failed to execute tool'}`)
+ }
+ }
+ } catch (error) {
+ console.error('Execute error:', error)
+ setOutput(
+ error instanceof Error
+ ? `Error: ${error.message}`
+ : 'Error: Failed to execute tool. Please try again.'
+ )
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleCopy = async () => {
+ if (output) {
+ await navigator.clipboard.writeText(output)
+ setIsCopied(true)
+ setTimeout(() => setIsCopied(false), 2000)
+ }
+ }
+
+ const handleSaveRun = async (name: string) => {
+ if (!output) return
+
+ const res = await fetch(`/api/tools/${tool.slug}/saved-runs`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ userId: user?.id,
+ name: name || null,
+ inputs,
+ output,
+ model: selectedModel,
+ }),
+ })
+
+ if (res.ok) {
+ const newRun = await res.json()
+ setSavedRuns(prev => [newRun, ...prev])
+ } else {
+ throw new Error("Failed to save")
+ }
+ }
+
+ const getModelName = (modelId: string) => {
+ if (isOllamaModel(modelId)) {
+ return modelId.split(':')[0] || modelId
+ }
+ return AI_MODELS[modelId as keyof typeof AI_MODELS]?.name || modelId
+ }
+
+ const renderInput = (input: ToolInput) => {
+ const value = inputs[input.name] || ''
+
+ switch (input.type) {
+ case 'textarea':
+ return (
+ handleInputChange(input.name, e.target.value)}
+ rows={4}
+ className="resize-none"
+ />
+ )
+ case 'select':
+ return (
+ handleInputChange(input.name, val)}
+ >
+
+
+
+
+ {input.options?.map((option) => (
+
+ {option}
+
+ ))}
+
+
+ )
+ case 'number':
+ return (
+ handleInputChange(input.name, e.target.value)}
+ />
+ )
+ default:
+ return (
+ handleInputChange(input.name, e.target.value)}
+ />
+ )
+ }
+ }
+
+ const isFormValid = tool.inputSchema.every(
+ input => !input.required || (inputs[input.name] && inputs[input.name].trim() !== '')
+ )
+
+ // Check if selected model is Ollama
+ const isOllama = isOllamaModel(selectedModel)
+
+ return (
+
+ {/* Input Form */}
+
+
+
+ Inputs
+ {tool.isPremium && (
+
+ PRO
+
+ )}
+
+
+ Fill in the details below
+
+
+
+ {/* Model Selector */}
+
+
AI Model
+
+
+
+
+ {isOllama ? (
+
+ ) : (
+
+ )}
+
+ {isOllama
+ ? selectedModel
+ : AI_MODELS[selectedModel as keyof typeof AI_MODELS]?.name || selectedModel
+ }
+
+
+
+
+
+ {/* Cloud Models */}
+
+ Cloud Models
+
+ {Object.entries(AI_MODELS).map(([id, model]) => (
+
+
+ {model.name}
+
+ {model.provider}
+
+
+
+ ))}
+
+ {/* Ollama Models */}
+ {ollamaSettings.enabled && ollamaSettings.availableModels.length > 0 && (
+ <>
+
+ Ollama (Local)
+
+ {ollamaSettings.availableModels.map((model) => (
+
+
+ {model}
+
+ Local
+
+
+
+ ))}
+ >
+ )}
+
+ {/* Ollama not connected hint */}
+ {!ollamaSettings.enabled && (
+
+
+ Connect Ollama in header for local models
+
+ )}
+
+
+
+
+ {tool.inputSchema.map((input) => (
+
+
+ {input.label}
+ {input.required && * }
+
+ {renderInput(input)}
+
+ ))}
+
+
+ {isLoading ? (
+ <>
+
+ Generating...
+ >
+ ) : (
+ <>
+ {tool.icon} Generate
+ >
+ )}
+
+
+
+
+ {/* Output */}
+
+
+
+
Output
+
+ {output && (
+
+ {isCopied ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy
+ >
+ )}
+
+ )}
+
+
+
+
+ AI-generated result will appear here
+
+
+
+ {output ? (
+
+ {output}
+
+ ) : (
+
+
{tool.icon}
+
Fill in the inputs and click Generate
+
+ )}
+
+
+
+ {/* Save Run Panel */}
+
+
+
+
+ )
+}
diff --git a/src/components/tools/tools-browser.tsx b/src/components/tools/tools-browser.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3697cf8c213ef3c0560909335f653abeb87781d9
--- /dev/null
+++ b/src/components/tools/tools-browser.tsx
@@ -0,0 +1,199 @@
+"use client"
+
+import { useState, useRef } from "react"
+import Link from "next/link"
+import { Search, Sparkles, ChevronLeft, ChevronRight } from "lucide-react"
+import { ToolDefinition } from "@/lib/tools"
+import { DynamicIcon } from "@/components/ui/dynamic-icon"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { cn } from "@/lib/utils"
+
+interface ToolsBrowserProps {
+ tools: ToolDefinition[]
+ categories: string[]
+}
+
+export function ToolsBrowser({ tools, categories }: ToolsBrowserProps) {
+ const [selectedCategory, setSelectedCategory] = useState("All")
+ const [searchQuery, setSearchQuery] = useState("")
+ const scrollContainerRef = useRef(null)
+
+ const scroll = (direction: 'left' | 'right') => {
+ if (scrollContainerRef.current) {
+ const scrollAmount = 300
+ const currentScroll = scrollContainerRef.current.scrollLeft
+ scrollContainerRef.current.scrollTo({
+ left: direction === 'left' ? currentScroll - scrollAmount : currentScroll + scrollAmount,
+ behavior: 'smooth'
+ })
+ }
+ }
+
+ // Filter tools based on search and category
+ const filteredTools = tools.filter(tool => {
+ const matchesCategory = selectedCategory === "All" || tool.category === selectedCategory
+ const matchesSearch = tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ tool.description.toLowerCase().includes(searchQuery.toLowerCase())
+ return matchesCategory && matchesSearch
+ })
+
+ // Group for "All" view
+ const displayCategories = selectedCategory === "All"
+ ? categories
+ : [selectedCategory]
+
+ return (
+
+ {/* Controls Section */}
+
+ {/* Search */}
+
+
+ setSearchQuery(e.target.value)}
+ />
+
+
+ {/* Filter Pills - Managed Scroll */}
+
+
scroll('left')}
+ >
+
+
+
+
+ setSelectedCategory("All")}
+ className={cn(
+ "rounded-full whitespace-nowrap transition-all duration-300",
+ selectedCategory === "All" ? "btn-glow" : "hover:bg-primary/10"
+ )}
+ >
+ All Tools
+
+ {categories.map((category) => (
+ setSelectedCategory(category)}
+ className={cn(
+ "rounded-full capitalize whitespace-nowrap transition-all duration-300",
+ selectedCategory === category ? "btn-glow" : "hover:bg-primary/10"
+ )}
+ >
+ {category}
+
+ ))}
+
+
+
scroll('right')}
+ >
+
+
+
+
+
+ {/* Tools Grid */}
+
+ {filteredTools.length === 0 ? (
+
+
+
+
+
No tools found matching your criteria.
+
{
+ setSelectedCategory("All")
+ setSearchQuery("")
+ }}
+ >
+ Clear filters
+
+
+ ) : (
+ displayCategories.map((category) => {
+ // In "All" view, valid tools are those belonging to the current category iteration
+ // AND matching the search query.
+ // In specific view, 'filteredTools' already contains only the right tools.
+ const categoryTools = selectedCategory === "All"
+ ? filteredTools.filter(t => t.category === category)
+ : filteredTools
+
+ if (categoryTools.length === 0) return null
+
+ return (
+
+
+ {category}
+
+ {categoryTools.length}
+
+
+
+
+ {categoryTools.map((tool) => (
+
+
+
+
+
+
+
+ {tool.isPremium && (
+
+ PRO
+
+ )}
+
+
+ {tool.name}
+
+
+ {tool.description}
+
+
+
+
+ ))}
+
+
+ )
+ })
+ )}
+
+
+ {/* Footer Stats */}
+
+
+
+
+ Showing {filteredTools.length} of {tools.length} available tools
+
+
+
+
+ )
+}
diff --git a/src/components/turnstile/turnstile-widget.tsx b/src/components/turnstile/turnstile-widget.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b3328c6e646f6cd7d55693fdc197bf32e57ffbcf
--- /dev/null
+++ b/src/components/turnstile/turnstile-widget.tsx
@@ -0,0 +1,81 @@
+"use client"
+
+import { useEffect, useRef, useState } from 'react'
+import Script from 'next/script'
+
+interface TurnstileProps {
+ siteKey: string
+ onSuccess: (token: string) => void
+ onError?: () => void
+ onExpire?: () => void
+ className?: string
+}
+
+declare global {
+ interface Window {
+ turnstile?: {
+ render: (container: HTMLElement, options: any) => string
+ reset: (widgetId: string) => void
+ remove: (widgetId: string) => void
+ }
+ }
+}
+
+export function TurnstileWidget({
+ siteKey,
+ onSuccess,
+ onError,
+ onExpire,
+ className = '',
+}: TurnstileProps) {
+ const containerRef = useRef(null)
+ const widgetIdRef = useRef(null)
+ const [isLoaded, setIsLoaded] = useState(false)
+
+ const handleLoad = () => {
+ setIsLoaded(true)
+ }
+
+ useEffect(() => {
+ if (!isLoaded || !containerRef.current || !window.turnstile) {
+ return
+ }
+
+ // Render Turnstile widget
+ const widgetId = window.turnstile.render(containerRef.current, {
+ sitekey: siteKey,
+ callback: onSuccess,
+ 'error-callback': onError || (() => { }),
+ 'expired-callback': onExpire || (() => { }),
+ theme: 'light',
+ size: 'normal',
+ })
+
+ widgetIdRef.current = widgetId
+
+ // Cleanup
+ return () => {
+ if (widgetIdRef.current && window.turnstile) {
+ window.turnstile.remove(widgetIdRef.current)
+ }
+ }
+ }, [isLoaded, siteKey, onSuccess, onError, onExpire])
+
+ // Reset widget method
+ const reset = () => {
+ if (widgetIdRef.current && window.turnstile) {
+ window.turnstile.reset(widgetIdRef.current)
+ }
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f99164ed4b320790482f514342dab5e72f284aca
--- /dev/null
+++ b/src/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1d59187c31a1e32edf24b338392aa43d3bf42de2
--- /dev/null
+++ b/src/components/ui/avatar.tsx
@@ -0,0 +1,50 @@
+"use client"
+
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/src/components/ui/badge-list.tsx b/src/components/ui/badge-list.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3671d2cd2436c7bcd115ed532e9b39e3f4857c6b
--- /dev/null
+++ b/src/components/ui/badge-list.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import { getBadge, sortBadges } from '@/lib/badges'
+import { Badge } from '@/components/ui/badge'
+import { DynamicIcon } from '@/components/ui/dynamic-icon'
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
+
+interface BadgeListProps {
+ badges: string[]
+ maxDisplay?: number
+ className?: string
+}
+
+export function BadgeList({ badges, maxDisplay = 3, className = '' }: BadgeListProps) {
+ if (!badges || badges.length === 0) {
+ return null
+ }
+
+ const sortedBadges = sortBadges(badges)
+ const displayBadges = sortedBadges.slice(0, maxDisplay)
+ const remainingCount = sortedBadges.length - maxDisplay
+
+ return (
+
+ {displayBadges.map((badgeId) => {
+ const badge = getBadge(badgeId)
+ if (!badge) return null
+
+ return (
+
+
+
+
+
+ {badge.label}
+
+
+
+ {badge.description}
+
+
+
+ )
+ })}
+
+ {remainingCount > 0 && (
+
+ +{remainingCount} more
+
+ )}
+
+ )
+}
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e2e37575979d550f9458a1c6187ff58eb6da4a73
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,39 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground shadow",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground shadow",
+ outline: "text-foreground",
+ accent:
+ "border-transparent bg-accent text-accent-foreground shadow",
+ success:
+ "border-transparent bg-[hsl(var(--success))] text-white shadow",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps { }
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5e67c34ef1aa544b95945acd7d68118327e832c7
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,88 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
+ outline:
+ "border border-border bg-background shadow-sm hover:bg-muted hover:text-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+ ghost: "hover:bg-muted hover:text-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ accent:
+ "bg-accent text-accent-foreground shadow hover:bg-accent/90",
+ gradient:
+ "bg-gradient-sunset text-white shadow-lg hover:opacity-90 border-0",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3 text-xs",
+ lg: "h-12 rounded-lg px-8 text-base",
+ xl: "h-14 rounded-xl px-10 text-lg",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ isLoading?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, isLoading, children, disabled, ...props }, ref) => {
+ return (
+
+ {isLoading ? (
+ <>
+
+
+
+
+ Loading...
+ >
+ ) : (
+ children
+ )}
+
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..afa903fd60f0ee3fb54f1bb7b8cfc708cc917101
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,76 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & { glass?: boolean }
+>(({ className, glass, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..cb0b07b4680d227019516331d19cbd56d91c4d36
--- /dev/null
+++ b/src/components/ui/checkbox.tsx
@@ -0,0 +1,32 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { CheckIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Checkbox({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2f7a4e7fc64a8874373c1584646a63f28cd63c47
--- /dev/null
+++ b/src/components/ui/collapsible.tsx
@@ -0,0 +1,33 @@
+"use client"
+
+import { Collapsible as CollapsiblePrimitive } from "radix-ui"
+
+function Collapsible({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function CollapsibleTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CollapsibleContent({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fa81d2e11cf8ad04d756616f512c563ee182066c
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,143 @@
+"use client"
+
+import * as React from "react"
+import { cn } from "@/lib/utils"
+import { X } from "lucide-react"
+
+interface DialogProps {
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ children: React.ReactNode
+}
+
+const DialogContext = React.createContext<{
+ open: boolean
+ setOpen: (open: boolean) => void
+} | null>(null)
+
+function Dialog({ open: controlledOpen, onOpenChange, children }: DialogProps) {
+ const [internalOpen, setInternalOpen] = React.useState(false)
+
+ const open = controlledOpen !== undefined ? controlledOpen : internalOpen
+ const setOpen = onOpenChange || setInternalOpen
+
+ return (
+
+ {children}
+
+ )
+}
+
+function useDialog() {
+ const context = React.useContext(DialogContext)
+ if (!context) {
+ throw new Error("useDialog must be used within a Dialog")
+ }
+ return context
+}
+
+interface DialogTriggerProps {
+ children: React.ReactNode
+ asChild?: boolean
+}
+
+function DialogTrigger({ children, asChild }: DialogTriggerProps) {
+ const { setOpen } = useDialog()
+
+ if (asChild && React.isValidElement(children)) {
+ return React.cloneElement(children as React.ReactElement, {
+ onClick: (e: React.MouseEvent) => {
+ (children as React.ReactElement).props.onClick?.(e)
+ setOpen(true)
+ }
+ })
+ }
+
+ return (
+ setOpen(true)}>
+ {children}
+
+ )
+}
+
+interface DialogContentProps {
+ children: React.ReactNode
+ className?: string
+}
+
+function DialogContent({ children, className }: DialogContentProps) {
+ const { open, setOpen } = useDialog()
+
+ if (!open) return null
+
+ return (
+
+ {/* Backdrop */}
+
setOpen(false)}
+ />
+
+ {/* Content */}
+
+
+ {children}
+
+
+ {/* Close button */}
+
setOpen(false)}
+ >
+
+ Close
+
+
+
+ )
+}
+
+function DialogHeader({
+ className,
+ ...props
+}: React.HTMLAttributes
) {
+ return (
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+}
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bbe6fb019770a31321cd06ff5e837695fbc06cd1
--- /dev/null
+++ b/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,257 @@
+"use client"
+
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+