Prompt gallery (#957)
- [x] show prompt instead of app in autocomplete - [x] use proper array/list for db (tags) - [x] don't do <dyad-prompt> - replace inline
This commit is contained in:
235
src/components/CreatePromptDialog.tsx
Normal file
235
src/components/CreatePromptDialog.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Plus, Save, Edit2 } from "lucide-react";
|
||||
|
||||
interface CreateOrEditPromptDialogProps {
|
||||
mode: "create" | "edit";
|
||||
prompt?: {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
content: string;
|
||||
};
|
||||
onCreatePrompt?: (prompt: {
|
||||
title: string;
|
||||
description?: string;
|
||||
content: string;
|
||||
}) => Promise<any>;
|
||||
onUpdatePrompt?: (prompt: {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
content: string;
|
||||
}) => Promise<any>;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CreateOrEditPromptDialog({
|
||||
mode,
|
||||
prompt,
|
||||
onCreatePrompt,
|
||||
onUpdatePrompt,
|
||||
trigger,
|
||||
}: CreateOrEditPromptDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState({
|
||||
title: "",
|
||||
description: "",
|
||||
content: "",
|
||||
});
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-resize textarea function
|
||||
const adjustTextareaHeight = () => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
// Store current height to avoid flicker
|
||||
const currentHeight = textarea.style.height;
|
||||
textarea.style.height = "auto";
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
const maxHeight = window.innerHeight * 0.6 - 100; // 60vh in pixels
|
||||
const minHeight = 150; // 150px minimum
|
||||
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
|
||||
|
||||
// Only update if height actually changed to reduce reflows
|
||||
if (`${newHeight}px` !== currentHeight) {
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize draft with prompt data when editing
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && prompt) {
|
||||
setDraft({
|
||||
title: prompt.title,
|
||||
description: prompt.description || "",
|
||||
content: prompt.content,
|
||||
});
|
||||
} else {
|
||||
setDraft({ title: "", description: "", content: "" });
|
||||
}
|
||||
}, [mode, prompt, open]);
|
||||
|
||||
// Auto-resize textarea when content changes
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight();
|
||||
}, [draft.content]);
|
||||
|
||||
// Trigger resize when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Small delay to ensure the dialog is fully rendered
|
||||
setTimeout(adjustTextareaHeight, 0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const resetDraft = () => {
|
||||
if (mode === "edit" && prompt) {
|
||||
setDraft({
|
||||
title: prompt.title,
|
||||
description: prompt.description || "",
|
||||
content: prompt.content,
|
||||
});
|
||||
} else {
|
||||
setDraft({ title: "", description: "", content: "" });
|
||||
}
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
if (!draft.title.trim() || !draft.content.trim()) return;
|
||||
|
||||
if (mode === "create" && onCreatePrompt) {
|
||||
await onCreatePrompt({
|
||||
title: draft.title.trim(),
|
||||
description: draft.description.trim() || undefined,
|
||||
content: draft.content,
|
||||
});
|
||||
} else if (mode === "edit" && onUpdatePrompt && prompt) {
|
||||
await onUpdatePrompt({
|
||||
id: prompt.id,
|
||||
title: draft.title.trim(),
|
||||
description: draft.description.trim() || undefined,
|
||||
content: draft.content,
|
||||
});
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
resetDraft();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{trigger ? (
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
) : mode === "create" ? (
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" /> New Prompt
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
data-testid="edit-prompt-button"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit prompt</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "create" ? "Create New Prompt" : "Edit Prompt"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === "create"
|
||||
? "Create a new prompt template for your library."
|
||||
: "Edit your prompt template."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder="Title"
|
||||
value={draft.title}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Description (optional)"
|
||||
value={draft.description}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, description: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
placeholder="Content"
|
||||
value={draft.content}
|
||||
onChange={(e) => {
|
||||
setDraft((d) => ({ ...d, content: e.target.value }));
|
||||
// Use requestAnimationFrame for smoother updates
|
||||
requestAnimationFrame(adjustTextareaHeight);
|
||||
}}
|
||||
className="resize-none overflow-y-auto"
|
||||
style={{ minHeight: "150px" }}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={!draft.title.trim() || !draft.content.trim()}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" /> Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Backward compatibility wrapper for create mode
|
||||
export function CreatePromptDialog({
|
||||
onCreatePrompt,
|
||||
}: {
|
||||
onCreatePrompt: (prompt: {
|
||||
title: string;
|
||||
description?: string;
|
||||
content: string;
|
||||
}) => Promise<any>;
|
||||
}) {
|
||||
return (
|
||||
<CreateOrEditPromptDialog mode="create" onCreatePrompt={onCreatePrompt} />
|
||||
);
|
||||
}
|
||||
71
src/components/DeleteConfirmationDialog.tsx
Normal file
71
src/components/DeleteConfirmationDialog.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface DeleteConfirmationDialogProps {
|
||||
itemName: string;
|
||||
itemType?: string;
|
||||
onDelete: () => void | Promise<void>;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DeleteConfirmationDialog({
|
||||
itemName,
|
||||
itemType = "item",
|
||||
onDelete,
|
||||
trigger,
|
||||
}: DeleteConfirmationDialogProps) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
{trigger ? (
|
||||
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
data-testid="delete-prompt-button"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete {itemType.toLowerCase()}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {itemType}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{itemName}"? This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onDelete}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Home, Inbox, Settings, HelpCircle, Store } from "lucide-react";
|
||||
import {
|
||||
Home,
|
||||
Inbox,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
Store,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
import { Link, useRouterState } from "@tanstack/react-router";
|
||||
import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
@@ -39,6 +46,11 @@ const items = [
|
||||
to: "/settings",
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
title: "Library",
|
||||
to: "/library",
|
||||
icon: BookOpen,
|
||||
},
|
||||
{
|
||||
title: "Hub",
|
||||
to: "/hub",
|
||||
@@ -51,6 +63,7 @@ type HoverState =
|
||||
| "start-hover:app"
|
||||
| "start-hover:chat"
|
||||
| "start-hover:settings"
|
||||
| "start-hover:library"
|
||||
| "clear-hover"
|
||||
| "no-hover";
|
||||
|
||||
@@ -92,6 +105,8 @@ export function AppSidebar() {
|
||||
selectedItem = "Chat";
|
||||
} else if (hoverState === "start-hover:settings") {
|
||||
selectedItem = "Settings";
|
||||
} else if (hoverState === "start-hover:library") {
|
||||
selectedItem = "Library";
|
||||
} else if (state === "expanded") {
|
||||
if (isAppRoute) {
|
||||
selectedItem = "Apps";
|
||||
@@ -195,6 +210,8 @@ function AppIcons({
|
||||
onHoverChange("start-hover:chat");
|
||||
} else if (item.title === "Settings") {
|
||||
onHoverChange("start-hover:settings");
|
||||
} else if (item.title === "Library") {
|
||||
onHoverChange("start-hover:library");
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "lexical-beautiful-mentions";
|
||||
import { KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH } from "lexical";
|
||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||
import { usePrompts } from "@/hooks/usePrompts";
|
||||
import { forwardRef } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
@@ -36,26 +37,35 @@ const beautifulMentionsTheme: BeautifulMentionsTheme = {
|
||||
const CustomMenuItem = forwardRef<
|
||||
HTMLLIElement,
|
||||
BeautifulMentionsMenuItemProps
|
||||
>(({ selected, item, ...props }, ref) => (
|
||||
<li
|
||||
className={`m-0 flex items-center px-3 py-2 cursor-pointer whitespace-nowrap ${
|
||||
selected
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-popover text-popover-foreground hover:bg-accent/50"
|
||||
}`}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-primary text-primary-foreground rounded-md flex-shrink-0">
|
||||
App
|
||||
</span>
|
||||
<span className="truncate text-sm">
|
||||
{typeof item === "string" ? item : item.value}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
));
|
||||
>(({ selected, item, ...props }, ref) => {
|
||||
const isPrompt = typeof item !== "string" && item.data?.type === "prompt";
|
||||
const label = isPrompt ? "Prompt" : "App";
|
||||
const value = typeof item === "string" ? item : (item as any)?.value;
|
||||
return (
|
||||
<li
|
||||
className={`m-0 flex items-center px-3 py-2 cursor-pointer whitespace-nowrap ${
|
||||
selected
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-popover text-popover-foreground hover:bg-accent/50"
|
||||
}`}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-medium rounded-md flex-shrink-0 ${
|
||||
isPrompt
|
||||
? "bg-purple-500 text-white"
|
||||
: "bg-primary text-primary-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="truncate text-sm">{value}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
// Custom menu component
|
||||
function CustomMenu({ loading: _loading, ...props }: any) {
|
||||
@@ -136,13 +146,24 @@ function ClearEditorPlugin({
|
||||
}
|
||||
|
||||
// Plugin to sync external value prop into the editor
|
||||
function ExternalValueSyncPlugin({ value }: { value: string }) {
|
||||
function ExternalValueSyncPlugin({
|
||||
value,
|
||||
promptsById,
|
||||
}: {
|
||||
value: string;
|
||||
promptsById: Record<number, string>;
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
// Derive the display text that should appear in the editor (@Name) from the
|
||||
// internal value representation (@app:Name)
|
||||
const displayText = (value || "").replace(MENTION_REGEX, "@$1");
|
||||
let displayText = (value || "").replace(MENTION_REGEX, "@$1");
|
||||
displayText = displayText.replace(/@prompt:(\d+)/g, (_m, idStr) => {
|
||||
const id = Number(idStr);
|
||||
const title = promptsById[id];
|
||||
return title ? `@${title}` : _m;
|
||||
});
|
||||
|
||||
const currentText = editor.getEditorState().read(() => {
|
||||
const root = $getRoot();
|
||||
@@ -157,34 +178,32 @@ function ExternalValueSyncPlugin({ value }: { value: string }) {
|
||||
|
||||
const paragraph = $createParagraphNode();
|
||||
|
||||
// Build nodes from the internal value, turning @app:Name into a mention node
|
||||
const mentionRegex = /@app:([a-zA-Z0-9_-]+)/g;
|
||||
// Build nodes from internal value, turning @app:Name and @prompt:<id> into mention nodes
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = mentionRegex.exec(value)) !== null) {
|
||||
const [full, name] = match;
|
||||
const combined = /@app:([a-zA-Z0-9_-]+)|@prompt:(\d+)/g;
|
||||
while ((match = combined.exec(value)) !== null) {
|
||||
const start = match.index;
|
||||
|
||||
// Append any text before the mention
|
||||
const full = match[0];
|
||||
if (start > lastIndex) {
|
||||
const textBefore = value.slice(lastIndex, start);
|
||||
if (textBefore) paragraph.append($createTextNode(textBefore));
|
||||
}
|
||||
|
||||
// Append the actual mention node (@ trigger with value = Name)
|
||||
paragraph.append($createBeautifulMentionNode("@", name));
|
||||
|
||||
if (match[1]) {
|
||||
const appName = match[1];
|
||||
paragraph.append($createBeautifulMentionNode("@", appName));
|
||||
} else if (match[2]) {
|
||||
const id = Number(match[2]);
|
||||
const title = promptsById[id] || `prompt:${id}`;
|
||||
paragraph.append($createBeautifulMentionNode("@", title));
|
||||
}
|
||||
lastIndex = start + full.length;
|
||||
}
|
||||
|
||||
// Append any trailing text after the last mention
|
||||
if (lastIndex < value.length) {
|
||||
const trailing = value.slice(lastIndex);
|
||||
if (trailing) paragraph.append($createTextNode(trailing));
|
||||
}
|
||||
|
||||
// If there were no mentions at all, just append the raw value as text
|
||||
if (value && paragraph.getTextContent() === "") {
|
||||
paragraph.append($createTextNode(value));
|
||||
}
|
||||
@@ -192,7 +211,7 @@ function ExternalValueSyncPlugin({ value }: { value: string }) {
|
||||
root.append(paragraph);
|
||||
paragraph.selectEnd();
|
||||
});
|
||||
}, [editor, value]);
|
||||
}, [editor, value, promptsById]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -221,6 +240,7 @@ export function LexicalChatInput({
|
||||
disabled = false,
|
||||
}: LexicalChatInputProps) {
|
||||
const { apps } = useLoadApps();
|
||||
const { prompts } = usePrompts();
|
||||
const [shouldClear, setShouldClear] = useState(false);
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
|
||||
@@ -252,10 +272,17 @@ export function LexicalChatInput({
|
||||
});
|
||||
|
||||
const appMentions = filteredApps.map((app) => app.name);
|
||||
|
||||
const promptItems = (prompts || []).map((p) => ({
|
||||
value: p.title,
|
||||
type: "prompt",
|
||||
id: p.id,
|
||||
}));
|
||||
|
||||
return {
|
||||
"@": appMentions,
|
||||
"@": [...appMentions, ...promptItems],
|
||||
};
|
||||
}, [apps, selectedAppId, value, excludeCurrentApp]);
|
||||
}, [apps, selectedAppId, value, excludeCurrentApp, prompts]);
|
||||
|
||||
const initialConfig = {
|
||||
namespace: "ChatInput",
|
||||
@@ -291,11 +318,18 @@ export function LexicalChatInput({
|
||||
);
|
||||
textContent = textContent.replace(mentionRegex, "@app:$1");
|
||||
}
|
||||
// Convert @PromptTitle to @prompt:<id>
|
||||
const map = new Map((prompts || []).map((p) => [p.title, p.id]));
|
||||
for (const [title, id] of map.entries()) {
|
||||
const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`@(${escapedTitle})(?![\\w-])`, "g");
|
||||
textContent = textContent.replace(regex, `@prompt:${id}`);
|
||||
}
|
||||
}
|
||||
onChange(textContent);
|
||||
});
|
||||
},
|
||||
[onChange, apps],
|
||||
[onChange, apps, prompts],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
@@ -343,7 +377,12 @@ export function LexicalChatInput({
|
||||
<OnChangePlugin onChange={handleEditorChange} />
|
||||
<HistoryPlugin />
|
||||
<EnterKeyPlugin onSubmit={handleSubmit} />
|
||||
<ExternalValueSyncPlugin value={value} />
|
||||
<ExternalValueSyncPlugin
|
||||
value={value}
|
||||
promptsById={Object.fromEntries(
|
||||
(prompts || []).map((p) => [p.id, p.title]),
|
||||
)}
|
||||
/>
|
||||
<ClearEditorPlugin
|
||||
shouldClear={shouldClear}
|
||||
onCleared={handleCleared}
|
||||
|
||||
26
src/components/ui/textarea.tsx
Normal file
26
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background",
|
||||
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
Reference in New Issue
Block a user