mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-04 05:12:38 +02:00
Fix review issues: authz, race conditions, UX safety, and consolidate migrations
This commit is contained in:
parent
5f4f7780d1
commit
aecb58e22b
10 changed files with 227 additions and 217 deletions
|
|
@ -1,24 +0,0 @@
|
|||
"""add is_public to prompts
|
||||
|
||||
Revision ID: 112
|
||||
Revises: 111
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "112"
|
||||
down_revision: str | None = "111"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS is_public")
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
"""add prompt library schema: is_public, default_prompt_slug, version, drop icon
|
||||
|
||||
Revision ID: 112
|
||||
Revises: 111
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "112"
|
||||
down_revision: str | None = "111"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS"
|
||||
" is_public BOOLEAN NOT NULL DEFAULT false"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_prompts_is_public"
|
||||
" ON prompts (is_public) WHERE is_public = true"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS"
|
||||
" default_prompt_slug VARCHAR(100)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_prompts_default_prompt_slug"
|
||||
" ON prompts (default_prompt_slug)"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE prompts ADD CONSTRAINT uq_prompt_user_default_slug"
|
||||
" UNIQUE (user_id, default_prompt_slug)"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS"
|
||||
" version INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS icon")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE prompts ADD COLUMN IF NOT EXISTS icon VARCHAR(50)")
|
||||
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS version")
|
||||
op.execute(
|
||||
"ALTER TABLE prompts DROP CONSTRAINT IF EXISTS uq_prompt_user_default_slug"
|
||||
)
|
||||
op.execute("DROP INDEX IF EXISTS ix_prompts_default_prompt_slug")
|
||||
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS default_prompt_slug")
|
||||
op.execute("DROP INDEX IF EXISTS ix_prompts_is_public")
|
||||
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS is_public")
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
"""add default_prompt_slug, version, drop icon, seed defaults
|
||||
|
||||
Revision ID: 113
|
||||
Revises: 112
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "113"
|
||||
down_revision: str | None = "112"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
DEFAULTS = [
|
||||
(
|
||||
"fix-grammar",
|
||||
"Fix grammar",
|
||||
"Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}",
|
||||
"transform",
|
||||
),
|
||||
(
|
||||
"make-shorter",
|
||||
"Make shorter",
|
||||
"Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}",
|
||||
"transform",
|
||||
),
|
||||
(
|
||||
"translate",
|
||||
"Translate",
|
||||
"Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}",
|
||||
"transform",
|
||||
),
|
||||
(
|
||||
"rewrite",
|
||||
"Rewrite",
|
||||
"Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}",
|
||||
"transform",
|
||||
),
|
||||
(
|
||||
"summarize",
|
||||
"Summarize",
|
||||
"Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}",
|
||||
"transform",
|
||||
),
|
||||
(
|
||||
"explain",
|
||||
"Explain",
|
||||
"Explain the following text in simple terms:\n\n{selection}",
|
||||
"explore",
|
||||
),
|
||||
(
|
||||
"ask-knowledge-base",
|
||||
"Ask my knowledge base",
|
||||
"Search my knowledge base for information related to:\n\n{selection}",
|
||||
"explore",
|
||||
),
|
||||
(
|
||||
"look-up-web",
|
||||
"Look up on the web",
|
||||
"Search the web for information about:\n\n{selection}",
|
||||
"explore",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS default_prompt_slug VARCHAR(100)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_prompts_default_prompt_slug"
|
||||
" ON prompts (default_prompt_slug)"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE prompts ADD CONSTRAINT uq_prompt_user_default_slug"
|
||||
" UNIQUE (user_id, default_prompt_slug)"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS"
|
||||
" version INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS icon")
|
||||
|
||||
conn = op.get_bind()
|
||||
users = conn.execute(sa.text('SELECT id FROM "user"')).fetchall()
|
||||
|
||||
for user_row in users:
|
||||
user_id = user_row[0]
|
||||
for slug, name, prompt, mode in DEFAULTS:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO prompts"
|
||||
" (user_id, default_prompt_slug, name, prompt, mode, version, is_public, created_at)"
|
||||
" VALUES (:user_id, :slug, :name, :prompt, :mode::prompt_mode, :version, false, now())"
|
||||
" ON CONFLICT (user_id, default_prompt_slug) DO NOTHING"
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"slug": slug,
|
||||
"name": name,
|
||||
"prompt": prompt,
|
||||
"mode": mode,
|
||||
"version": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DELETE FROM prompts WHERE default_prompt_slug IS NOT NULL")
|
||||
op.execute("ALTER TABLE prompts ADD COLUMN IF NOT EXISTS icon VARCHAR(50)")
|
||||
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS version")
|
||||
op.execute(
|
||||
"ALTER TABLE prompts DROP CONSTRAINT IF EXISTS uq_prompt_user_default_slug"
|
||||
)
|
||||
op.execute("DROP INDEX IF EXISTS ix_prompts_default_prompt_slug")
|
||||
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS default_prompt_slug")
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"""seed default prompts for all existing users
|
||||
|
||||
Revision ID: 113
|
||||
Revises: 112
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "113"
|
||||
down_revision: str | None = "112"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO prompts
|
||||
(user_id, default_prompt_slug, name, prompt, mode, version, is_public, created_at)
|
||||
SELECT u.id, d.slug, d.name, d.prompt, d.mode::prompt_mode, 1, false, now()
|
||||
FROM "user" u
|
||||
CROSS JOIN (VALUES
|
||||
('fix-grammar', 'Fix grammar', 'Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}', 'transform'),
|
||||
('make-shorter', 'Make shorter', 'Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}', 'transform'),
|
||||
('translate', 'Translate', 'Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}', 'transform'),
|
||||
('rewrite', 'Rewrite', 'Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}', 'transform'),
|
||||
('summarize', 'Summarize', 'Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}', 'transform'),
|
||||
('explain', 'Explain', 'Explain the following text in simple terms:\n\n{selection}', 'explore'),
|
||||
('ask-knowledge-base','Ask my knowledge base', 'Search my knowledge base for information related to:\n\n{selection}', 'explore'),
|
||||
('look-up-web', 'Look up on the web', 'Search the web for information about:\n\n{selection}', 'explore')
|
||||
) AS d(slug, name, prompt, mode)
|
||||
ON CONFLICT (user_id, default_prompt_slug) DO NOTHING
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DELETE FROM prompts WHERE default_prompt_slug IS NOT NULL")
|
||||
|
|
@ -3,7 +3,7 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db import Prompt, User, get_async_session
|
||||
from app.db import Prompt, SearchSpaceMembership, User, get_async_session
|
||||
from app.schemas.prompts import (
|
||||
PromptCreate,
|
||||
PromptRead,
|
||||
|
|
@ -35,6 +35,19 @@ async def create_prompt(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
if body.search_space_id is not None:
|
||||
membership = await session.execute(
|
||||
select(SearchSpaceMembership).where(
|
||||
SearchSpaceMembership.user_id == user.id,
|
||||
SearchSpaceMembership.search_space_id == body.search_space_id,
|
||||
)
|
||||
)
|
||||
if not membership.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You are not a member of this search space",
|
||||
)
|
||||
|
||||
prompt = Prompt(
|
||||
user_id=user.id,
|
||||
search_space_id=body.search_space_id,
|
||||
|
|
@ -74,7 +87,7 @@ async def update_prompt(
|
|||
setattr(prompt, field, value)
|
||||
|
||||
if has_content_change:
|
||||
prompt.version = (prompt.version or 0) + 1
|
||||
prompt.version = Prompt.version + 1
|
||||
|
||||
session.add(prompt)
|
||||
await session.commit()
|
||||
|
|
@ -111,7 +124,7 @@ async def list_public_prompts(
|
|||
result = await session.execute(
|
||||
select(Prompt)
|
||||
.options(selectinload(Prompt.user))
|
||||
.where(Prompt.is_public.is_(True))
|
||||
.where(Prompt.is_public.is_(True), Prompt.user_id != user.id)
|
||||
.order_by(Prompt.created_at.desc())
|
||||
)
|
||||
prompts = result.scalars().all()
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { AUTH_TYPE } from "@/lib/env-config";
|
||||
import { AmbientBackground } from "./AmbientBackground";
|
||||
import { GoogleLoginButton } from "./GoogleLoginButton";
|
||||
|
|
@ -17,19 +16,11 @@ import { LocalLoginForm } from "./LocalLoginForm";
|
|||
function LoginContent() {
|
||||
const t = useTranslations("auth");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const [authType, setAuthType] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (getBearerToken()) {
|
||||
router.replace("/dashboard");
|
||||
return;
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for various URL parameters that might indicate success or error states
|
||||
const registered = searchParams.get("registered");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Copy, Globe, Sparkles } from "lucide-react";
|
||||
import { AlertTriangle, Copy, Globe, Sparkles } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms";
|
||||
import { publicPromptsAtom } from "@/atoms/prompts/prompts-query.atoms";
|
||||
|
|
@ -9,20 +9,24 @@ import { Button } from "@/components/ui/button";
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
export function CommunityPromptsContent() {
|
||||
const { data: prompts, isLoading } = useAtomValue(publicPromptsAtom);
|
||||
const { mutateAsync: copyPrompt, isPending: isCopying } = useAtomValue(copyPromptMutationAtom);
|
||||
const [copyingId, setCopyingId] = useState<number | null>(null);
|
||||
const { data: prompts, isLoading, isError } = useAtomValue(publicPromptsAtom);
|
||||
const { mutateAsync: copyPrompt } = useAtomValue(copyPromptMutationAtom);
|
||||
const [copyingIds, setCopyingIds] = useState<Set<number>>(new Set());
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
async (id: number) => {
|
||||
setCopyingId(id);
|
||||
setCopyingIds((prev) => new Set(prev).add(id));
|
||||
try {
|
||||
await copyPrompt(id);
|
||||
} catch {
|
||||
// toast handled by mutation atom
|
||||
} finally {
|
||||
setCopyingId(null);
|
||||
setCopyingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[copyPrompt]
|
||||
|
|
@ -38,6 +42,16 @@ export function CommunityPromptsContent() {
|
|||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
|
||||
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
|
||||
<p className="mt-2 text-sm text-destructive">Failed to load community prompts</p>
|
||||
<p className="text-xs text-muted-foreground">Please try refreshing the page.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
@ -91,20 +105,20 @@ export function CommunityPromptsContent() {
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
disabled={copyingId === prompt.id && isCopying}
|
||||
onClick={() => handleCopy(prompt.id)}
|
||||
>
|
||||
{copyingId === prompt.id && isCopying ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
Add to mine
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
disabled={copyingIds.has(prompt.id)}
|
||||
onClick={() => handleCopy(prompt.id)}
|
||||
>
|
||||
{copyingIds.has(prompt.id) ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
Add to mine
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Globe, Lock, PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
|
||||
import { AlertTriangle, Globe, Lock, PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -10,6 +10,16 @@ import {
|
|||
updatePromptMutationAtom,
|
||||
} from "@/atoms/prompts/prompts-mutation.atoms";
|
||||
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -27,7 +37,7 @@ interface PromptFormData {
|
|||
const EMPTY_FORM: PromptFormData = { name: "", prompt: "", mode: "transform", is_public: false };
|
||||
|
||||
export function PromptsContent() {
|
||||
const { data: prompts, isLoading } = useAtomValue(promptsAtom);
|
||||
const { data: prompts, isLoading, isError } = useAtomValue(promptsAtom);
|
||||
const { mutateAsync: createPrompt } = useAtomValue(createPromptMutationAtom);
|
||||
const { mutateAsync: updatePrompt } = useAtomValue(updatePromptMutationAtom);
|
||||
const { mutateAsync: deletePrompt } = useAtomValue(deletePromptMutationAtom);
|
||||
|
|
@ -37,6 +47,7 @@ export function PromptsContent() {
|
|||
const [formData, setFormData] = useState<PromptFormData>(EMPTY_FORM);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!formData.name.trim() || !formData.prompt.trim()) {
|
||||
|
|
@ -46,7 +57,7 @@ export function PromptsContent() {
|
|||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (editingId) {
|
||||
if (editingId !== null) {
|
||||
await updatePrompt({ id: editingId, ...formData });
|
||||
} else {
|
||||
await createPrompt(formData);
|
||||
|
|
@ -72,16 +83,16 @@ export function PromptsContent() {
|
|||
setShowForm(true);
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: number) => {
|
||||
try {
|
||||
await deletePrompt(id);
|
||||
} catch {
|
||||
// toast handled by mutation atom
|
||||
}
|
||||
},
|
||||
[deletePrompt]
|
||||
);
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (deleteTarget === null) return;
|
||||
try {
|
||||
await deletePrompt(deleteTarget);
|
||||
} catch {
|
||||
// toast handled by mutation atom
|
||||
} finally {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}, [deleteTarget, deletePrompt]);
|
||||
|
||||
const handleTogglePublic = useCallback(
|
||||
async (prompt: PromptRead) => {
|
||||
|
|
@ -110,6 +121,16 @@ export function PromptsContent() {
|
|||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
|
||||
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
|
||||
<p className="mt-2 text-sm text-destructive">Failed to load prompts</p>
|
||||
<p className="text-xs text-muted-foreground">Please try refreshing the page.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -137,7 +158,7 @@ export function PromptsContent() {
|
|||
{showForm && (
|
||||
<div className="rounded-lg border border-border/60 bg-card p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold tracking-tight">
|
||||
{editingId ? "Edit prompt" : "New prompt"}
|
||||
{editingId !== null ? "Edit prompt" : "New prompt"}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
@ -200,7 +221,7 @@ export function PromptsContent() {
|
|||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? <Spinner className="size-3.5" /> : editingId ? "Update" : "Create"}
|
||||
{isSaving ? <Spinner className="size-3.5" /> : editingId !== null ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -279,7 +300,7 @@ export function PromptsContent() {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(prompt.id)}
|
||||
onClick={() => setDeleteTarget(prompt.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
|
|
@ -288,6 +309,21 @@ export function PromptsContent() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete prompt</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. The prompt will be permanently removed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmDelete}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ export const updatePromptMutationAtom = atomWithMutation(() => ({
|
|||
onSuccess: () => {
|
||||
toast.success("Prompt updated");
|
||||
queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.all() });
|
||||
queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.public() });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update prompt");
|
||||
|
|
|
|||
|
|
@ -98,35 +98,37 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
|
|||
[filtered.length, highlightedIndex, handleSelect]
|
||||
);
|
||||
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
|
||||
style={containerStyle}
|
||||
>
|
||||
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
|
||||
{filtered.map((action, index) => (
|
||||
<button
|
||||
key={action.id}
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(index, el);
|
||||
else itemRefs.current.delete(index);
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => handleSelect(index)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
|
||||
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Zap className="size-3.5" />
|
||||
</span>
|
||||
<span className="truncate">{action.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
|
||||
) : (
|
||||
filtered.map((action, index) => (
|
||||
<button
|
||||
key={action.id}
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(index, el);
|
||||
else itemRefs.current.delete(index);
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => handleSelect(index)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
|
||||
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Zap className="size-3.5" />
|
||||
</span>
|
||||
<span className="truncate">{action.name}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="my-1 h-px bg-border mx-2" />
|
||||
<button
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue