Files
kandev/apps/web/components/settings/prompts-settings.tsx
T
2026-02-19 22:18:01 +00:00

501 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
import { IconEdit, IconTrash, IconLock } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { Input } from "@kandev/ui/input";
import { Textarea } from "@kandev/ui/textarea";
import { SettingsPageTemplate } from "@/components/settings/settings-page-template";
import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts";
import { useAppStore } from "@/components/state-provider";
import { createPrompt, deletePrompt, updatePrompt } from "@/lib/api";
import { useRequest } from "@/lib/http/use-request";
import type { CustomPrompt } from "@/lib/types/http";
const defaultFormState = {
name: "",
content: "",
};
type PromptFormState = typeof defaultFormState;
type PromptCreateFormProps = {
formState: PromptFormState;
onFormChange: (patch: Partial<PromptFormState>) => void;
onSubmit: () => void;
onCancel: () => void;
isValid: boolean;
isBusy: boolean;
};
function PromptCreateForm({
formState,
onFormChange,
onSubmit,
onCancel,
isValid,
isBusy,
}: PromptCreateFormProps) {
return (
<div className="rounded-lg border border-border/70 bg-background p-4 space-y-3">
<div className="text-sm font-medium text-foreground">Add prompt</div>
<Input
value={formState.name}
onChange={(event) => onFormChange({ name: event.target.value })}
placeholder="Prompt name"
/>
<Textarea
value={formState.content}
onChange={(event) => onFormChange({ content: event.target.value })}
placeholder="Prompt content"
rows={5}
className="resize-y max-h-60 overflow-auto"
/>
<div className="flex items-center gap-2">
<Button onClick={onSubmit} disabled={!isValid || isBusy}>
Add prompt
</Button>
<Button variant="ghost" onClick={onCancel} disabled={isBusy}>
Cancel
</Button>
</div>
</div>
);
}
type PromptListItemProps = {
prompt: CustomPrompt;
isEditing: boolean;
editingRef: React.RefObject<HTMLDivElement | null>;
formState: PromptFormState;
onFormChange: (patch: Partial<PromptFormState>) => void;
onStartEditing: (prompt: CustomPrompt) => void;
onOpenDelete: (prompt: CustomPrompt) => void;
onUpdate: () => void;
onCancel: () => void;
isValid: boolean;
isBusy: boolean;
showCreate: boolean;
};
function PromptListItem({
prompt,
isEditing,
editingRef,
formState,
onFormChange,
onStartEditing,
onOpenDelete,
onUpdate,
onCancel,
isValid,
isBusy,
showCreate,
}: PromptListItemProps) {
const getPromptPreview = (content: string) => {
return content.split(/\r?\n/)[0] ?? "";
};
return (
<div
className="rounded-lg border border-border/70 bg-background p-4 flex flex-col gap-3"
ref={isEditing ? editingRef : null}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-foreground">@{prompt.name}</div>
{prompt.builtin && (
<Badge variant="secondary" className="text-xs">
<IconLock className="h-3 w-3 mr-1" />
Built-in
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => onStartEditing(prompt)}
disabled={isBusy || showCreate}
className="cursor-pointer"
>
<IconEdit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenDelete(prompt)}
disabled={isBusy}
className="cursor-pointer"
>
<IconTrash className="h-4 w-4" />
</Button>
</div>
</div>
{isEditing ? (
<div className="space-y-3">
<Input
value={formState.name}
onChange={(event) => onFormChange({ name: event.target.value })}
placeholder="Prompt name"
/>
<Textarea
value={formState.content}
onChange={(event) => onFormChange({ content: event.target.value })}
placeholder="Prompt content"
rows={5}
className="resize-y max-h-60 overflow-auto"
/>
<div className="flex items-center gap-2">
<Button onClick={onUpdate} disabled={!isValid || isBusy}>
Save changes
</Button>
<Button variant="ghost" onClick={onCancel} disabled={isBusy}>
Cancel
</Button>
</div>
</div>
) : (
<div className="text-xs text-muted-foreground whitespace-pre-wrap">
<div className="truncate">{getPromptPreview(prompt.content)}</div>
</div>
)}
</div>
);
}
type PromptListContentProps = {
promptsLoaded: boolean;
prompts: CustomPrompt[];
editingId: string | null;
editingRef: React.RefObject<HTMLDivElement | null>;
formState: PromptFormState;
onFormChange: (patch: Partial<PromptFormState>) => void;
onStartEditing: (prompt: CustomPrompt) => void;
onOpenDelete: (prompt: CustomPrompt) => void;
onUpdate: () => void;
onCancel: () => void;
isValid: boolean;
isBusy: boolean;
showCreate: boolean;
};
function PromptListContent({
promptsLoaded,
prompts,
editingId,
editingRef,
formState,
onFormChange,
onStartEditing,
onOpenDelete,
onUpdate,
onCancel,
isValid,
isBusy,
showCreate,
}: PromptListContentProps) {
if (!promptsLoaded) {
return (
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
Loading prompts…
</div>
);
}
if (prompts.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
No prompts yet. Add your first prompt to get started.
</div>
);
}
return (
<>
{prompts.map((prompt: CustomPrompt) => (
<PromptListItem
key={prompt.id}
prompt={prompt}
isEditing={editingId === prompt.id}
editingRef={editingRef}
formState={formState}
onFormChange={onFormChange}
onStartEditing={onStartEditing}
onOpenDelete={onOpenDelete}
onUpdate={onUpdate}
onCancel={onCancel}
isValid={isValid}
isBusy={isBusy}
showCreate={showCreate}
/>
))}
</>
);
}
type DeletePromptDialogProps = {
deleteTarget: CustomPrompt | null;
onClose: () => void;
onConfirm: () => void;
isBusy: boolean;
};
function DeletePromptDialog({ deleteTarget, onClose, onConfirm, isBusy }: DeletePromptDialogProps) {
return (
<Dialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete prompt</DialogTitle>
<DialogDescription>
This will permanently remove{" "}
<span className="font-medium text-foreground">
{deleteTarget ? `@${deleteTarget.name}` : "this prompt"}
</span>
. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="button" variant="destructive" onClick={onConfirm} disabled={isBusy}>
Delete prompt
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function usePromptsState() {
const { loaded: promptsLoaded } = useCustomPrompts();
const prompts = useAppStore((state) => state.prompts.items);
const setPrompts = useAppStore((state) => state.setPrompts);
const [editingId, setEditingId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [formState, setFormState] = useState(defaultFormState);
const editingRef = useRef<HTMLDivElement | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CustomPrompt | null>(null);
return {
promptsLoaded,
prompts,
setPrompts,
editingId,
setEditingId,
showCreate,
setShowCreate,
formState,
setFormState,
editingRef,
deleteTarget,
setDeleteTarget,
};
}
function usePromptsActions(state: ReturnType<typeof usePromptsState>) {
const {
prompts,
setPrompts,
editingId,
setEditingId,
setShowCreate,
setFormState,
setDeleteTarget,
deleteTarget,
formState,
} = state;
const resetForm = useCallback(() => {
setEditingId(null);
setShowCreate(false);
setFormState(defaultFormState);
}, [setEditingId, setShowCreate, setFormState]);
const applyPrompts = useCallback(
(next: CustomPrompt[]) => {
setPrompts([...next].sort((a, b) => a.name.localeCompare(b.name)));
},
[setPrompts],
);
const isValid = useMemo(
() => Boolean(formState.name.trim() && formState.content.trim()),
[formState],
);
const createRequest = useRequest(async (s: typeof defaultFormState) => {
const prompt = await createPrompt(
{ name: s.name.trim(), content: s.content.trim() },
{ cache: "no-store" },
);
applyPrompts([...prompts, prompt]);
resetForm();
});
const updateRequest = useRequest(async (id: string, s: typeof defaultFormState) => {
const updated = await updatePrompt(
id,
{ name: s.name.trim(), content: s.content.trim() },
{ cache: "no-store" },
);
applyPrompts(prompts.map((p: CustomPrompt) => (p.id === id ? updated : p)));
resetForm();
});
const deleteRequest = useRequest(async (id: string) => {
await deletePrompt(id, { cache: "no-store" });
applyPrompts(prompts.filter((p: CustomPrompt) => p.id !== id));
if (editingId === id) resetForm();
});
const isBusy = createRequest.isLoading || updateRequest.isLoading || deleteRequest.isLoading;
const handleCreate = () => {
if (!isValid || isBusy) return;
createRequest.run(formState).catch(() => undefined);
};
const handleUpdate = () => {
if (!isValid || isBusy || !editingId) return;
updateRequest.run(editingId, formState).catch(() => undefined);
};
const startEditing = (prompt: CustomPrompt) => {
setEditingId(prompt.id);
setShowCreate(false);
setFormState({ name: prompt.name, content: prompt.content });
};
const startCreate = () => {
setEditingId(null);
setShowCreate(true);
setFormState(defaultFormState);
};
const openDeleteDialog = (prompt: CustomPrompt) => {
setDeleteTarget(prompt);
};
const closeDeleteDialog = () => {
setDeleteTarget(null);
};
const confirmDelete = () => {
if (!deleteTarget) return;
deleteRequest.run(deleteTarget.id).catch(() => undefined);
closeDeleteDialog();
};
return {
resetForm,
isValid,
isBusy,
handleCreate,
handleUpdate,
startEditing,
startCreate,
openDeleteDialog,
closeDeleteDialog,
confirmDelete,
};
}
export function PromptsSettings() {
const state = usePromptsState();
const {
editingId,
showCreate,
formState,
setFormState,
editingRef,
deleteTarget,
promptsLoaded,
prompts,
} = state;
const {
isValid,
isBusy,
handleCreate,
handleUpdate,
startEditing,
startCreate,
openDeleteDialog,
closeDeleteDialog,
confirmDelete,
resetForm,
} = usePromptsActions(state);
const isEditing = Boolean(editingId);
useEffect(() => {
if (!editingId) return;
editingRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, [editingId, editingRef]);
return (
<SettingsPageTemplate
title="Prompts"
description="Create reusable prompt snippets for the chat input."
isDirty={false}
saveStatus="idle"
onSave={() => undefined}
showSaveButton={false}
>
<div className="rounded-lg border border-border/70 bg-muted/30 p-4 text-xs text-muted-foreground">
Use <span className="font-medium text-foreground">@name</span> in the chat input to insert a
prompt’s content. Prompts are matched by name and expanded in place.
</div>
<div className="space-y-6 mt-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-foreground">Custom prompts</div>
<Button onClick={startCreate} disabled={isBusy || isEditing || showCreate}>
Add prompt
</Button>
</div>
{showCreate && (
<PromptCreateForm
formState={formState}
onFormChange={(patch) => setFormState((prev) => ({ ...prev, ...patch }))}
onSubmit={handleCreate}
onCancel={resetForm}
isValid={isValid}
isBusy={isBusy}
/>
)}
<div className="space-y-3">
<PromptListContent
promptsLoaded={promptsLoaded}
prompts={prompts}
editingId={editingId}
editingRef={editingRef}
formState={formState}
onFormChange={(patch) => setFormState((prev) => ({ ...prev, ...patch }))}
onStartEditing={startEditing}
onOpenDelete={openDeleteDialog}
onUpdate={handleUpdate}
onCancel={resetForm}
isValid={isValid}
isBusy={isBusy}
showCreate={showCreate}
/>
</div>
</div>
<DeletePromptDialog
deleteTarget={deleteTarget}
onClose={closeDeleteDialog}
onConfirm={confirmDelete}
isBusy={isBusy}
/>
</SettingsPageTemplate>
);
}