Initial prototype
This commit is contained in:
parent
ef5f6bcabc
commit
0326822621
16 changed files with 626 additions and 41 deletions
BIN
.idx/icon.png
Normal file
BIN
.idx/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
0
.modified
Normal file
0
.modified
Normal file
19
docs/blueprint.md
Normal file
19
docs/blueprint.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# **App Name**: PromptVerse
|
||||
|
||||
## Core Features:
|
||||
|
||||
- Prompt Creation: Create and save new prompts with a user-friendly interface, offering options to format the prompt and add context notes.
|
||||
- Prompt Organization: Organize prompts using tags and categories. Include hierarchical categories.
|
||||
- Robust Search: Enable powerful search functionality to quickly find prompts by keywords, tags, or categories.
|
||||
- One-Click Copy: Allow users to copy prompts to the clipboard with a single click for easy use in other applications.
|
||||
- AI-Powered Tagging: Suggest relevant tags for the prompt using AI. The AI tool should extract important keywords from the prompt to improve searchability and organization.
|
||||
|
||||
## Style Guidelines:
|
||||
|
||||
- Primary color: Deep indigo (#4B0082) for a professional feel with a hint of creativity.
|
||||
- Background color: Dark slate gray (#303030) to implement a sleek dark mode theme.
|
||||
- Accent color: Electric purple (#BF40BF) for interactive elements and highlights to guide the user.
|
||||
- Body and headline font: 'Inter' sans-serif for a modern, machined, objective, neutral look.
|
||||
- Use minimalist icons that are easily recognizable on a dark background. Prefer sharp, geometric designs for a modern feel.
|
||||
- Implement a grid-based layout, similar to Google Keep, for organizing prompts. Use subtle shadows to create a frosted glass effect (similar to Apple Vision Pro). Utilize a card-based interface for each prompt.
|
||||
- Subtle animations on hover and click events. Smooth transitions between pages or sections. A brief 'copied to clipboard' confirmation that elegantly fades in and out when copying prompts.
|
||||
|
|
@ -1 +1,4 @@
|
|||
// Flows will be imported for their side effects in this file.
|
||||
import { config } from 'dotenv';
|
||||
config();
|
||||
|
||||
import '@/ai/flows/suggest-tags.ts';
|
||||
56
src/ai/flows/suggest-tags.ts
Normal file
56
src/ai/flows/suggest-tags.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// This file uses server-side code.
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* @fileOverview AI-powered tag suggestion for prompts.
|
||||
*
|
||||
* - suggestTags - A function that suggests relevant tags for a given prompt.
|
||||
* - SuggestTagsInput - The input type for the suggestTags function.
|
||||
* - SuggestTagsOutput - The return type for the suggestTags function.
|
||||
*/
|
||||
|
||||
import {ai} from '@/ai/genkit';
|
||||
import {z} from 'genkit';
|
||||
|
||||
const SuggestTagsInputSchema = z.object({
|
||||
promptText: z
|
||||
.string()
|
||||
.describe('The text content of the prompt for which tags are to be suggested.'),
|
||||
});
|
||||
export type SuggestTagsInput = z.infer<typeof SuggestTagsInputSchema>;
|
||||
|
||||
const SuggestTagsOutputSchema = z.object({
|
||||
tags: z
|
||||
.array(z.string())
|
||||
.describe('An array of suggested tags for the given prompt.'),
|
||||
});
|
||||
export type SuggestTagsOutput = z.infer<typeof SuggestTagsOutputSchema>;
|
||||
|
||||
export async function suggestTags(input: SuggestTagsInput): Promise<SuggestTagsOutput> {
|
||||
return suggestTagsFlow(input);
|
||||
}
|
||||
|
||||
const suggestTagsPrompt = ai.definePrompt({
|
||||
name: 'suggestTagsPrompt',
|
||||
input: {schema: SuggestTagsInputSchema},
|
||||
output: {schema: SuggestTagsOutputSchema},
|
||||
prompt: `You are a prompt categorization expert.
|
||||
|
||||
Given the following prompt, suggest 5 relevant tags that can be used to categorize and search for this prompt later. Return them as a simple array of strings.
|
||||
|
||||
Prompt: {{{promptText}}}
|
||||
|
||||
Tags:`,
|
||||
});
|
||||
|
||||
const suggestTagsFlow = ai.defineFlow(
|
||||
{
|
||||
name: 'suggestTagsFlow',
|
||||
inputSchema: SuggestTagsInputSchema,
|
||||
outputSchema: SuggestTagsOutputSchema,
|
||||
},
|
||||
async input => {
|
||||
const {output} = await suggestTagsPrompt(input);
|
||||
return output!;
|
||||
}
|
||||
);
|
||||
|
|
@ -14,67 +14,51 @@ body {
|
|||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary: 300 50% 50%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--secondary: 275 100% 25.1%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent: 300 50% 60%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--ring: 300 50% 50%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--background: 0 0% 18.8%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card: 0 0% 22%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover: 0 0% 18.8%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--primary: 300 50% 50%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 275 100% 35%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted: 0 0% 25%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent: 300 50% 60%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--border: 0 0% 30%;
|
||||
--input: 0 0% 25%;
|
||||
--ring: 300 50% 60%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import type {Metadata} from 'next';
|
||||
import './globals.css';
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Firebase Studio App',
|
||||
description: 'Generated by Firebase Studio',
|
||||
title: 'PromptVerse',
|
||||
description: 'An AI prompt management tool to create, organize, and share your best prompts.',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
@ -12,13 +13,16 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className="dark">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet"></link>
|
||||
</head>
|
||||
<body className="font-body antialiased">{children}</body>
|
||||
<body className="font-body antialiased bg-background text-foreground min-h-screen">
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,22 @@
|
|||
export default function Home() {
|
||||
return <></>;
|
||||
import { Header } from '@/components/header';
|
||||
import { PromptList, PromptListSkeleton } from '@/components/prompt-list';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export default function Home({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: { q?: string };
|
||||
}) {
|
||||
const query = searchParams?.q || '';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="container mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<Suspense fallback={<PromptListSkeleton />}>
|
||||
<PromptList query={query} />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
168
src/components/create-prompt-dialog.tsx
Normal file
168
src/components/create-prompt-dialog.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useTransition } from 'react';
|
||||
import { useFormState, useFormStatus } from 'react-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { createPrompt, suggestPromptTags } from '@/lib/actions';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { PlusCircle, Loader2, Sparkles, XIcon } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
|
||||
const initialState = {
|
||||
message: '',
|
||||
errors: {},
|
||||
success: false,
|
||||
};
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create Prompt
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreatePromptDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [state, formAction] = useFormState(createPrompt, initialState);
|
||||
const { toast } = useToast();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const [promptContent, setPromptContent] = useState('');
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [isSuggesting, startSuggestionTransition] = useTransition();
|
||||
|
||||
const handleSuggestTags = async () => {
|
||||
startSuggestionTransition(async () => {
|
||||
const result = await suggestPromptTags(promptContent);
|
||||
if (result.error) {
|
||||
toast({ title: 'Suggestion Failed', description: result.error, variant: 'destructive' });
|
||||
} else if (result.tags) {
|
||||
setTags((prev) => [...new Set([...prev, ...result.tags!])]);
|
||||
toast({ title: 'Tags Suggested!', description: 'AI-powered tags have been added.' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleTagInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && tagInput.trim()) {
|
||||
e.preventDefault();
|
||||
setTags((prev) => [...new Set([...prev, tagInput.trim().toLowerCase()])]);
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
setTags((prev) => prev.filter((tag) => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (state.success) {
|
||||
toast({
|
||||
title: 'Success!',
|
||||
description: state.message,
|
||||
});
|
||||
setOpen(false);
|
||||
formRef.current?.reset();
|
||||
setTags([]);
|
||||
setPromptContent('');
|
||||
} else if (state.message && !state.success && Object.keys(state.errors ?? {}).length > 0) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: state.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [state, toast]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
New Prompt
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[625px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a new prompt</DialogTitle>
|
||||
<DialogDescription>
|
||||
Craft your next masterpiece. Add notes and tags to keep it organized.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form ref={formRef} action={formAction} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input id="title" name="title" placeholder="e.g., Blog Post Ideas" required />
|
||||
{state.errors?.title && <p className="text-sm text-destructive">{state.errors.title[0]}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content">Prompt</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
name="content"
|
||||
placeholder="Your prompt text here..."
|
||||
className="min-h-[120px]"
|
||||
onChange={(e) => setPromptContent(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{state.errors?.content && <p className="text-sm text-destructive">{state.errors.content[0]}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">Notes (Optional)</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
placeholder="Add context or notes about this prompt..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label htmlFor="tags">Tags</Label>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={handleSuggestTags} disabled={isSuggesting || promptContent.length < 20}>
|
||||
{isSuggesting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||||
Suggest
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 p-2 border rounded-md min-h-[40px] bg-muted/50">
|
||||
{tags.map(tag => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag(tag)} className="ml-1 rounded-full hover:bg-destructive/80 p-0.5">
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Input id="tags-input" value={tagInput} onChange={(e) => setTagInput(e.target.value)} onKeyDown={handleTagInputKeyDown} placeholder="Type a tag and press Enter" />
|
||||
<input type="hidden" name="tags" value={JSON.stringify(tags)} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<SubmitButton />
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
52
src/components/header.tsx
Normal file
52
src/components/header.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
'use client';
|
||||
import { Logo } from '@/components/icons';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search } from 'lucide-react';
|
||||
import { CreatePromptDialog } from '@/components/create-prompt-dialog';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export function Header() {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const { replace } = useRouter();
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleSearch = (term: string) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (term) {
|
||||
params.set('q', term);
|
||||
} else {
|
||||
params.delete('q');
|
||||
}
|
||||
replace(`${pathname}?${params.toString()}`);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container flex h-16 max-w-7xl items-center justify-between mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="mr-8 flex items-center">
|
||||
<Logo className="h-6 w-6 mr-2 text-primary" />
|
||||
<h1 className="font-bold text-lg hidden sm:block">PromptVerse</h1>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-end gap-4">
|
||||
<div className="relative flex-1 max-w-xs sm:max-w-sm md:max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search prompts..."
|
||||
className="pl-9"
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
defaultValue={searchParams.get('q')?.toString()}
|
||||
/>
|
||||
</div>
|
||||
<CreatePromptDialog />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
10
src/components/icons.tsx
Normal file
10
src/components/icons.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { SVGProps } from 'react';
|
||||
|
||||
export function Logo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M12.25 5.75H8.75V18.25H12.25C14.7018 18.25 16.75 16.2018 16.75 13.75V10.25C16.75 7.79822 14.7018 5.75 12.25 5.75Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M8.75 12H13.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
66
src/components/prompt-card.tsx
Normal file
66
src/components/prompt-card.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
'use client';
|
||||
|
||||
import type { Prompt } from '@/lib/types';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface PromptCardProps {
|
||||
prompt: Prompt;
|
||||
}
|
||||
|
||||
export function PromptCard({ prompt }: PromptCardProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(prompt.content);
|
||||
toast({
|
||||
title: 'Prompt Copied!',
|
||||
description: 'The prompt text is now on your clipboard.',
|
||||
});
|
||||
};
|
||||
|
||||
const timeAgo = formatDistanceToNow(new Date(prompt.createdAt), { addSuffix: true });
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col bg-secondary/30 backdrop-blur-lg border border-white/10 shadow-lg hover:border-white/20 transition-all duration-300 h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">{prompt.title}</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground">{timeAgo}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow">
|
||||
<p className="text-sm text-foreground/80 line-clamp-4">
|
||||
{prompt.content}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start gap-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{prompt.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="bg-primary/20 text-primary-foreground/80 border-none">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCopy}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-center group"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2 group-hover:text-accent transition-colors" />
|
||||
Copy Prompt
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
51
src/components/prompt-list.tsx
Normal file
51
src/components/prompt-list.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { getPrompts } from '@/lib/actions';
|
||||
import { PromptCard } from './prompt-card';
|
||||
import { Card, CardContent, CardHeader } from './ui/card';
|
||||
import { Skeleton } from './ui/skeleton';
|
||||
|
||||
export async function PromptList({ query }: { query: string }) {
|
||||
const prompts = await getPrompts(query);
|
||||
|
||||
if (prompts.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<h2 className="text-2xl font-semibold">No Prompts Found</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Try adjusting your search or create a new prompt.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{prompts.map((prompt) => (
|
||||
<PromptCard key={prompt.id} prompt={prompt} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromptListSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Card key={i} className="bg-secondary/20 border-white/10">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/lib/actions.ts
Normal file
82
src/lib/actions.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// This file uses server-side code.
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { prompts as dbPrompts } from './data';
|
||||
import type { Prompt } from './types';
|
||||
import { suggestTags as suggestTagsFlow } from '@/ai/flows/suggest-tags';
|
||||
import { z } from 'zod';
|
||||
|
||||
// In-memory store, mimicking a database.
|
||||
let prompts: Prompt[] = [...dbPrompts];
|
||||
|
||||
export async function getPrompts(query: string = ''): Promise<Prompt[]> {
|
||||
await new Promise(resolve => setTimeout(resolve, 500)); // Simulate network delay
|
||||
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
|
||||
return prompts.filter(prompt => {
|
||||
const inTitle = prompt.title.toLowerCase().includes(lowerCaseQuery);
|
||||
const inContent = prompt.content.toLowerCase().includes(lowerCaseQuery);
|
||||
const inTags = prompt.tags.some(tag => tag.toLowerCase().includes(lowerCaseQuery));
|
||||
return inTitle || inContent || inTags;
|
||||
}).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
const CreatePromptSchema = z.object({
|
||||
title: z.string().min(1, { message: 'Title is required.' }),
|
||||
content: z.string().min(1, { message: 'Content is required.' }),
|
||||
notes: z.string().optional(),
|
||||
tags: z.string(), // JSON string of tags array
|
||||
});
|
||||
|
||||
|
||||
export async function createPrompt(prevState: any, formData: FormData) {
|
||||
const validatedFields = CreatePromptSchema.safeParse({
|
||||
title: formData.get('title'),
|
||||
content: formData.get('content'),
|
||||
notes: formData.get('notes'),
|
||||
tags: formData.get('tags'),
|
||||
});
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
errors: validatedFields.error.flatten().fieldErrors,
|
||||
message: 'Failed to create prompt.',
|
||||
};
|
||||
}
|
||||
|
||||
const { title, content, notes, tags } = validatedFields.data;
|
||||
|
||||
try {
|
||||
const newPrompt: Prompt = {
|
||||
id: Date.now().toString(),
|
||||
title,
|
||||
content,
|
||||
notes,
|
||||
tags: JSON.parse(tags),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Add to the start of the array to show newest first
|
||||
prompts.unshift(newPrompt);
|
||||
|
||||
revalidatePath('/');
|
||||
return { message: 'Prompt created successfully.', success: true };
|
||||
|
||||
} catch (e) {
|
||||
return { message: 'An unexpected error occurred.', errors: {} };
|
||||
}
|
||||
}
|
||||
|
||||
export async function suggestPromptTags(promptText: string): Promise<{tags?: string[]; error?: string}> {
|
||||
if (!promptText || promptText.trim().length < 20) {
|
||||
return { error: 'Please provide a more detailed prompt for better suggestions.' };
|
||||
}
|
||||
try {
|
||||
const result = await suggestTagsFlow({ promptText });
|
||||
return { tags: result.tags };
|
||||
} catch (error) {
|
||||
return { error: 'Failed to get AI suggestions.' };
|
||||
}
|
||||
}
|
||||
63
src/lib/data.ts
Normal file
63
src/lib/data.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { Prompt } from './types';
|
||||
|
||||
export const prompts: Prompt[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Blog Post Ideas Generator',
|
||||
content: 'Generate 10 blog post ideas for a blog about AI development. The ideas should be catchy, relevant to current trends, and suitable for a technical audience. For each idea, provide a brief description and a potential headline.',
|
||||
tags: ['writing', 'ai', 'content-creation'],
|
||||
createdAt: new Date(new Date().setDate(new Date().getDate()-1)).toISOString(),
|
||||
notes: 'Great for weekly content planning.'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Python Code Refactoring',
|
||||
content: 'Act as a senior Python developer. Take the following code snippet and refactor it for better readability, performance, and adherence to PEP 8 standards. Explain the changes you made and why.',
|
||||
tags: ['python', 'code', 'development', 'refactoring'],
|
||||
createdAt: new Date(new Date().setDate(new Date().getDate()-2)).toISOString(),
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Marketing Campaign Slogans',
|
||||
content: 'Create 5 compelling slogans for a new brand of eco-friendly coffee. The slogans should be short, memorable, and highlight the brand\'s commitment to sustainability.',
|
||||
tags: ['marketing', 'copywriting', 'branding'],
|
||||
createdAt: new Date(new Date().setDate(new Date().getDate()-3)).toISOString(),
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Explain Quantum Computing',
|
||||
content: 'Explain the concept of quantum computing to a 12-year-old. Use simple analogies and avoid technical jargon as much as possible. Cover the ideas of qubits, superposition, and entanglement.',
|
||||
tags: ['education', 'science', 'complex-topics'],
|
||||
createdAt: new Date(new Date().setDate(new Date().getDate()-4)).toISOString(),
|
||||
notes: 'Useful for breaking down difficult concepts for presentations.'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Generate SQL Query',
|
||||
content: 'Given a database schema with two tables, `users` (id, name, email, signup_date) and `orders` (id, user_id, amount, order_date), write a SQL query to find the total order amount for each user in the last 30 days.',
|
||||
tags: ['sql', 'database', 'code'],
|
||||
createdAt: new Date(new Date().setDate(new Date().getDate()-5)).toISOString(),
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Social Media Post for Product Launch',
|
||||
content: 'Write a social media post for Twitter announcing the launch of a new productivity app called "Zenith". The tone should be exciting and engaging. Include relevant hashtags.',
|
||||
tags: ['social-media', 'marketing', 'launch'],
|
||||
createdAt: new Date(new Date().setDate(new Date().getDate()-6)).toISOString(),
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'Midjourney Prompt for a Spaceship',
|
||||
content: 'Generate a detailed Midjourney prompt to create a photorealistic image of a sleek, futuristic exploration spaceship floating in front of a nebula. The design should be minimalist, with glowing blue accents. Include parameters for aspect ratio and style.',
|
||||
tags: ['midjourney', 'image-generation', 'art'],
|
||||
createdAt: new Date(new Date().setDate(new Date().getDate()-7)).toISOString(),
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'Recipe from Ingredients',
|
||||
content: 'I have chicken breasts, broccoli, garlic, and lemon in my fridge. Create a simple and healthy recipe I can make for dinner tonight. Provide step-by-step instructions.',
|
||||
tags: ['food', 'recipe', 'lifestyle'],
|
||||
createdAt: new Date(new Date().setDate(new Date().getDate()-8)).toISOString(),
|
||||
notes: 'A practical prompt for everyday use.'
|
||||
}
|
||||
];
|
||||
8
src/lib/types.ts
Normal file
8
src/lib/types.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export interface Prompt {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
notes?: string;
|
||||
tags: string[];
|
||||
createdAt: string; // Using string to avoid serialization issues between server/client
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue