Files
grimoire/src/components/CreateSpellDialog.tsx
2026-01-29 10:20:47 +01:00

706 lines
24 KiB
TypeScript

import * as React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
ChevronDown,
WandSparkles,
Plus,
X,
Clock,
User,
FileText,
Search,
Wifi,
Tag,
AtSign,
} from "lucide-react";
import { KindSelector } from "./KindSelector";
import { ProfileSelector } from "./ProfileSelector";
import { KindBadge } from "./KindBadge";
import { UserName } from "./nostr/UserName";
import { reconstructCommand } from "@/lib/spell-conversion";
import { SpellDialog } from "./nostr/SpellDialog";
interface CreateSpellDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* CreateSpellDialog - A newbie-friendly UI for building Nostr REQ commands
* Allows users to visually construct filters without knowing the CLI syntax
*/
export function CreateSpellDialog({
open,
onOpenChange,
}: CreateSpellDialogProps) {
// Filter state
const [kinds, setKinds] = React.useState<number[]>([]);
const [authors, setAuthors] = React.useState<string[]>([]);
const [mentions, setMentions] = React.useState<string[]>([]);
const [search, setSearch] = React.useState("");
const [hashtags, setHashtags] = React.useState<string[]>([]);
const [since, setSince] = React.useState<string>("");
const [until, setUntil] = React.useState<string>("");
const [relays, setRelays] = React.useState<string[]>([]);
const [closeOnEose, setCloseOnEose] = React.useState(false);
const [limit, setLimit] = React.useState<number | "">("");
const [genericTags, setGenericTags] = React.useState<
Record<string, string[]>
>({});
const [activeTagLetter, setActiveTagLetter] = React.useState("e");
// Sub-dialog state
const [showSaveDialog, setShowSaveDialog] = React.useState(false);
// Reconstruct command string for preview and saving
const generatedCommand = React.useMemo(() => {
const filter: any = {
kinds: kinds.length > 0 ? kinds : undefined,
authors: authors.length > 0 ? authors : undefined,
"#p": mentions.length > 0 ? mentions : undefined,
"#t": hashtags.length > 0 ? hashtags : undefined,
search: search || undefined,
limit: limit !== "" ? limit : undefined,
};
// Add generic tags
for (const [letter, values] of Object.entries(genericTags)) {
if (values.length > 0) {
filter[`#${letter}`] = values;
}
}
return reconstructCommand(
filter,
relays.length > 0 ? relays : undefined,
since || undefined,
until || undefined,
closeOnEose,
);
}, [
kinds,
authors,
mentions,
search,
hashtags,
since,
until,
relays,
closeOnEose,
limit,
genericTags,
]);
const handleAddKind = (kind: number) => {
if (!kinds.includes(kind)) setKinds([...kinds, kind]);
};
const handleRemoveKind = (kind: number) => {
setKinds(kinds.filter((k) => k !== kind));
};
const handleAddAuthor = (pubkey: string) => {
if (!authors.includes(pubkey)) setAuthors([...authors, pubkey]);
};
const handleRemoveAuthor = (pubkey: string) => {
setAuthors(authors.filter((p) => p !== pubkey));
};
const handleAddMention = (pubkey: string) => {
if (!mentions.includes(pubkey)) setMentions([...mentions, pubkey]);
};
const handleRemoveMention = (pubkey: string) => {
setMentions(mentions.filter((p) => p !== pubkey));
};
const handleAddHashtag = (tag: string) => {
const clean = tag.replace(/^#/, "").trim();
if (clean && !hashtags.includes(clean)) setHashtags([...hashtags, clean]);
};
const handleRemoveHashtag = (tag: string) => {
setHashtags(hashtags.filter((t) => t !== tag));
};
const handleAddRelay = (url: string) => {
if (url && !relays.includes(url)) setRelays([...relays, url]);
};
const handleRemoveRelay = (url: string) => {
setRelays(relays.filter((r) => r !== url));
};
const handleAddGenericTag = (letter: string, value: string) => {
if (!letter || !value) return;
const cleanLetter = letter.trim().slice(0, 1);
if (!/[a-zA-Z]/.test(cleanLetter)) return;
setGenericTags((prev) => {
const existing = prev[cleanLetter] || [];
if (existing.includes(value)) return prev;
return {
...prev,
[cleanLetter]: [...existing, value],
};
});
};
const handleRemoveGenericTag = (letter: string, value: string) => {
setGenericTags((prev) => {
const existing = prev[letter] || [];
const filtered = existing.filter((v) => v !== value);
if (filtered.length === 0) {
const { [letter]: _, ...rest } = prev;
return rest;
}
return {
...prev,
[letter]: filtered,
};
});
};
const resetForm = () => {
setKinds([]);
setAuthors([]);
setMentions([]);
setSearch("");
setHashtags([]);
setSince("");
setUntil("");
setRelays([]);
setCloseOnEose(false);
setLimit("");
setGenericTags({});
setActiveTagLetter("e");
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-hidden flex flex-col p-0">
<DialogHeader className="p-6 pb-2">
<DialogTitle className="flex items-center gap-2">
<WandSparkles className="size-5 text-accent" />
Create New Spell
</DialogTitle>
<DialogDescription>
Build a custom view of Nostr events by selecting filters below.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-2 space-y-4">
{/* Kinds Section */}
<CollapsibleSection
title="Event Types"
icon={<FileText className="size-4" />}
defaultOpen={true}
>
<div className="space-y-3">
<KindSelector onSelect={handleAddKind} />
<div className="flex flex-wrap gap-2">
{kinds.map((k) => (
<Badge
key={k}
variant="secondary"
className="flex items-center gap-1.5"
>
<KindBadge
kind={k}
className="border-0 bg-transparent p-0 h-auto"
/>
<button
onClick={() => handleRemoveKind(k)}
className="hover:text-accent transition-colors"
>
<X className="size-3" />
</button>
</Badge>
))}
{kinds.length === 0 && (
<span className="text-xs text-muted-foreground italic">
All event types (notes, profiles, etc.)
</span>
)}
</div>
</div>
</CollapsibleSection>
{/* Authors Section */}
<CollapsibleSection
title="From People"
icon={<User className="size-4" />}
>
<div className="space-y-3">
<ProfileSelector
onSelect={handleAddAuthor}
placeholder="Add person or $me, $contacts..."
/>
<div className="flex flex-wrap gap-2">
{authors.map((p) => (
<Badge
key={p}
variant="secondary"
className="flex items-center gap-1.5"
>
{p === "$me" || p === "$contacts" ? (
<span className="font-mono font-bold text-accent px-1">
{p}
</span>
) : (
<UserName pubkey={p} className="text-xs" />
)}
<button
onClick={() => handleRemoveAuthor(p)}
className="hover:text-accent transition-colors"
>
<X className="size-3" />
</button>
</Badge>
))}
{authors.length === 0 && (
<span className="text-xs text-muted-foreground italic">
Anyone on the network
</span>
)}
</div>
</div>
</CollapsibleSection>
{/* Mentions Section */}
<CollapsibleSection
title="Mentioning People"
icon={<AtSign className="size-4" />}
>
<div className="space-y-3">
<ProfileSelector
onSelect={handleAddMention}
placeholder="Add person mentioned..."
/>
<div className="flex flex-wrap gap-2">
{mentions.map((p) => (
<Badge
key={p}
variant="secondary"
className="flex items-center gap-1.5"
>
{p === "$me" || p === "$contacts" ? (
<span className="font-mono font-bold text-accent px-1">
{p}
</span>
) : (
<UserName pubkey={p} className="text-xs" />
)}
<button
onClick={() => handleRemoveMention(p)}
className="hover:text-accent transition-colors"
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
</div>
</CollapsibleSection>
{/* Content Section */}
<CollapsibleSection
title="Content & Hashtags"
icon={<Search className="size-4" />}
>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-medium uppercase text-muted-foreground">
Search Text
</label>
<Input
placeholder="e.g. bitcoin, nostr..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase text-muted-foreground">
Hashtags
</label>
<div className="flex gap-2">
<Input
placeholder="Add tag (press enter)"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleAddHashtag(e.currentTarget.value);
e.currentTarget.value = "";
}
}}
/>
</div>
<div className="flex flex-wrap gap-2">
{hashtags.map((t) => (
<Badge
key={t}
variant="secondary"
className="flex items-center gap-1.5"
>
<span className="text-accent">#</span>
{t}
<button
onClick={() => handleRemoveHashtag(t)}
className="hover:text-accent transition-colors"
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
</div>
</div>
</CollapsibleSection>
{/* Generic Tags Section */}
<CollapsibleSection
title="Generic Tags"
icon={<Tag className="size-4" />}
>
<div className="space-y-4">
<div className="flex gap-2 items-end">
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold text-muted-foreground">
Letter
</label>
<Input
value={activeTagLetter}
onChange={(e) =>
setActiveTagLetter(e.target.value.trim().slice(0, 1))
}
className="w-12 text-center font-mono font-bold"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-[10px] uppercase font-bold text-muted-foreground">
Value
</label>
{activeTagLetter === "k" ? (
<KindSelector
onSelect={(k) => handleAddGenericTag("k", k.toString())}
/>
) : activeTagLetter === "p" || activeTagLetter === "P" ? (
<ProfileSelector
onSelect={(pk) =>
handleAddGenericTag(activeTagLetter, pk)
}
placeholder={`Add ${activeTagLetter} pubkey...`}
/>
) : (
<Input
placeholder="Tag value..."
onKeyDown={(e) => {
if (e.key === "Enter") {
handleAddGenericTag(
activeTagLetter,
e.currentTarget.value,
);
e.currentTarget.value = "";
}
}}
/>
)}
</div>
</div>
<div className="space-y-3">
{Object.entries(genericTags).map(([letter, values]) => (
<div key={letter} className="space-y-1">
<div className="text-[10px] uppercase font-bold text-muted-foreground">
#{letter}
</div>
<div className="flex flex-wrap gap-2">
{values.map((val) => (
<Badge
key={val}
variant="secondary"
className="flex items-center gap-1.5"
>
{letter === "k" ? (
<KindBadge
kind={parseInt(val)}
className="border-0 bg-transparent p-0 h-auto"
/>
) : letter === "p" || letter === "P" ? (
val.startsWith("$") ? (
<span className="font-mono font-bold text-accent px-1">
{val}
</span>
) : (
<UserName pubkey={val} className="text-xs" />
)
) : (
<span
className="max-w-[200px] truncate"
title={val}
>
{val}
</span>
)}
<button
onClick={() =>
handleRemoveGenericTag(letter, val)
}
className="hover:text-accent transition-colors"
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
</div>
))}
</div>
</div>
</CollapsibleSection>
{/* Time Section */}
<CollapsibleSection
title="Time Range"
icon={<Clock className="size-4" />}
>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-medium uppercase text-muted-foreground">
Since
</label>
<Input
placeholder="e.g. 24h, 7d, 1mo"
value={since}
onChange={(e) => setSince(e.target.value)}
/>
<div className="flex flex-wrap gap-1">
{["today", "now", "1h", "24h", "7d", "30d"].map((t) => (
<button
key={t}
onClick={() => setSince(t)}
className="text-[10px] bg-muted px-1.5 py-0.5 rounded hover:bg-accent hover:text-accent-foreground transition-colors"
>
{t}
</button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase text-muted-foreground">
Until
</label>
<Input
placeholder="e.g. now, 24h"
value={until}
onChange={(e) => setUntil(e.target.value)}
/>
<div className="flex flex-wrap gap-1">
{["now", "1h", "24h", "7d", "30d"].map((t) => (
<button
key={t}
onClick={() => setUntil(t)}
className="text-[10px] bg-muted px-1.5 py-0.5 rounded hover:bg-accent hover:text-accent-foreground transition-colors"
>
{t}
</button>
))}
</div>
</div>
</div>
</CollapsibleSection>
{/* Options Section */}
<CollapsibleSection
title="Advanced Options"
icon={<Wifi className="size-4" />}
>
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Limit results</label>
<Input
type="number"
value={limit}
onChange={(e) => {
const val = e.target.value;
setLimit(val === "" ? "" : parseInt(val));
}}
placeholder="No limit"
className="w-24"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<label className="text-sm font-medium">
Close after loading
</label>
<p className="text-xs text-muted-foreground">
Don't listen for new events in real-time
</p>
</div>
<input
type="checkbox"
checked={closeOnEose}
onChange={(e) => setCloseOnEose(e.target.checked)}
className="size-4 rounded border-gray-300 text-accent focus:ring-accent"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase text-muted-foreground">
Custom Relays (optional)
</label>
<div className="flex gap-2">
<Input
placeholder="relay.damus.io"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleAddRelay(e.currentTarget.value);
e.currentTarget.value = "";
}
}}
/>
</div>
<div className="flex flex-wrap gap-2">
{relays.map((r) => (
<Badge
key={r}
variant="outline"
className="flex items-center gap-1.5 font-mono text-[10px]"
>
{r}
<button
onClick={() => handleRemoveRelay(r)}
className="hover:text-accent transition-colors"
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
</div>
</div>
</CollapsibleSection>
</div>
<div className="p-6 pt-2 bg-muted/30 border-t space-y-4">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground uppercase">
Generated Command
</label>
<div className="bg-background border rounded-md p-3 font-mono text-xs break-words overflow-x-auto text-primary">
{generatedCommand}
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="ghost" onClick={resetForm}>
Reset
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
className="bg-accent text-accent-foreground hover:bg-accent/90"
onClick={() => setShowSaveDialog(true)}
>
<Plus className="size-4 mr-2" />
Continue to Save
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Actual saving/publishing dialog */}
{showSaveDialog && (
<SpellDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
mode="create"
initialCommand={generatedCommand}
onSuccess={() => {
setShowSaveDialog(false);
onOpenChange(false);
resetForm();
}}
/>
)}
</>
);
}
function CollapsibleSection({
title,
icon,
children,
defaultOpen = false,
}: {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [isOpen, setIsOpen] = React.useState(defaultOpen);
return (
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className="border rounded-lg overflow-hidden"
>
<CollapsibleTrigger className="flex items-center justify-between w-full px-4 py-3 bg-muted/50 hover:bg-muted transition-colors">
<div className="flex items-center gap-3">
<span className="text-accent">{icon}</span>
<span className="font-semibold text-sm">{title}</span>
</div>
<ChevronDown
className={`size-4 text-muted-foreground transition-transform ${
isOpen ? "rotate-180" : ""
}`}
/>
</CollapsibleTrigger>
<CollapsibleContent className="p-4 bg-background">
{children}
</CollapsibleContent>
</Collapsible>
);
}
function Badge({
children,
variant = "default",
className = "",
}: {
children: React.ReactNode;
variant?: "default" | "secondary" | "outline";
className?: string;
}) {
const variants = {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
outline: "border border-input bg-background",
};
return (
<div
className={`inline-flex items-center rounded-full 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]} ${className}`}
>
{children}
</div>
);
}