mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-05 05:42:39 +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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
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 (
|
from app.schemas.prompts import (
|
||||||
PromptCreate,
|
PromptCreate,
|
||||||
PromptRead,
|
PromptRead,
|
||||||
|
|
@ -35,6 +35,19 @@ async def create_prompt(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
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(
|
prompt = Prompt(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
search_space_id=body.search_space_id,
|
search_space_id=body.search_space_id,
|
||||||
|
|
@ -74,7 +87,7 @@ async def update_prompt(
|
||||||
setattr(prompt, field, value)
|
setattr(prompt, field, value)
|
||||||
|
|
||||||
if has_content_change:
|
if has_content_change:
|
||||||
prompt.version = (prompt.version or 0) + 1
|
prompt.version = Prompt.version + 1
|
||||||
|
|
||||||
session.add(prompt)
|
session.add(prompt)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -111,7 +124,7 @@ async def list_public_prompts(
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Prompt)
|
select(Prompt)
|
||||||
.options(selectinload(Prompt.user))
|
.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())
|
.order_by(Prompt.created_at.desc())
|
||||||
)
|
)
|
||||||
prompts = result.scalars().all()
|
prompts = result.scalars().all()
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Suspense, useEffect, useState } from "react";
|
import { Suspense, useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||||
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
|
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
|
||||||
import { AUTH_TYPE } from "@/lib/env-config";
|
import { AUTH_TYPE } from "@/lib/env-config";
|
||||||
import { AmbientBackground } from "./AmbientBackground";
|
import { AmbientBackground } from "./AmbientBackground";
|
||||||
import { GoogleLoginButton } from "./GoogleLoginButton";
|
import { GoogleLoginButton } from "./GoogleLoginButton";
|
||||||
|
|
@ -17,19 +16,11 @@ import { LocalLoginForm } from "./LocalLoginForm";
|
||||||
function LoginContent() {
|
function LoginContent() {
|
||||||
const t = useTranslations("auth");
|
const t = useTranslations("auth");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const router = useRouter();
|
|
||||||
const [authType, setAuthType] = useState<string | null>(null);
|
const [authType, setAuthType] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null);
|
const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (getBearerToken()) {
|
|
||||||
router.replace("/dashboard");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check for various URL parameters that might indicate success or error states
|
// Check for various URL parameters that might indicate success or error states
|
||||||
const registered = searchParams.get("registered");
|
const registered = searchParams.get("registered");
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
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 { useCallback, useState } from "react";
|
||||||
import { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms";
|
import { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms";
|
||||||
import { publicPromptsAtom } from "@/atoms/prompts/prompts-query.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";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
export function CommunityPromptsContent() {
|
export function CommunityPromptsContent() {
|
||||||
const { data: prompts, isLoading } = useAtomValue(publicPromptsAtom);
|
const { data: prompts, isLoading, isError } = useAtomValue(publicPromptsAtom);
|
||||||
const { mutateAsync: copyPrompt, isPending: isCopying } = useAtomValue(copyPromptMutationAtom);
|
const { mutateAsync: copyPrompt } = useAtomValue(copyPromptMutationAtom);
|
||||||
const [copyingId, setCopyingId] = useState<number | null>(null);
|
const [copyingIds, setCopyingIds] = useState<Set<number>>(new Set());
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
|
||||||
const handleCopy = useCallback(
|
const handleCopy = useCallback(
|
||||||
async (id: number) => {
|
async (id: number) => {
|
||||||
setCopyingId(id);
|
setCopyingIds((prev) => new Set(prev).add(id));
|
||||||
try {
|
try {
|
||||||
await copyPrompt(id);
|
await copyPrompt(id);
|
||||||
} catch {
|
} catch {
|
||||||
// toast handled by mutation atom
|
// toast handled by mutation atom
|
||||||
} finally {
|
} finally {
|
||||||
setCopyingId(null);
|
setCopyingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[copyPrompt]
|
[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 (
|
return (
|
||||||
<div className="space-y-6 min-w-0 overflow-hidden">
|
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|
@ -91,20 +105,20 @@ export function CommunityPromptsContent() {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="shrink-0 gap-1.5"
|
className="shrink-0 gap-1.5"
|
||||||
disabled={copyingId === prompt.id && isCopying}
|
disabled={copyingIds.has(prompt.id)}
|
||||||
onClick={() => handleCopy(prompt.id)}
|
onClick={() => handleCopy(prompt.id)}
|
||||||
>
|
>
|
||||||
{copyingId === prompt.id && isCopying ? (
|
{copyingIds.has(prompt.id) ? (
|
||||||
<Spinner className="size-3" />
|
<Spinner className="size-3" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="size-3" />
|
<Copy className="size-3" />
|
||||||
)}
|
)}
|
||||||
Add to mine
|
Add to mine
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
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 { useCallback, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -10,6 +10,16 @@ import {
|
||||||
updatePromptMutationAtom,
|
updatePromptMutationAtom,
|
||||||
} from "@/atoms/prompts/prompts-mutation.atoms";
|
} from "@/atoms/prompts/prompts-mutation.atoms";
|
||||||
import { promptsAtom } from "@/atoms/prompts/prompts-query.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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -27,7 +37,7 @@ interface PromptFormData {
|
||||||
const EMPTY_FORM: PromptFormData = { name: "", prompt: "", mode: "transform", is_public: false };
|
const EMPTY_FORM: PromptFormData = { name: "", prompt: "", mode: "transform", is_public: false };
|
||||||
|
|
||||||
export function PromptsContent() {
|
export function PromptsContent() {
|
||||||
const { data: prompts, isLoading } = useAtomValue(promptsAtom);
|
const { data: prompts, isLoading, isError } = useAtomValue(promptsAtom);
|
||||||
const { mutateAsync: createPrompt } = useAtomValue(createPromptMutationAtom);
|
const { mutateAsync: createPrompt } = useAtomValue(createPromptMutationAtom);
|
||||||
const { mutateAsync: updatePrompt } = useAtomValue(updatePromptMutationAtom);
|
const { mutateAsync: updatePrompt } = useAtomValue(updatePromptMutationAtom);
|
||||||
const { mutateAsync: deletePrompt } = useAtomValue(deletePromptMutationAtom);
|
const { mutateAsync: deletePrompt } = useAtomValue(deletePromptMutationAtom);
|
||||||
|
|
@ -37,6 +47,7 @@ export function PromptsContent() {
|
||||||
const [formData, setFormData] = useState<PromptFormData>(EMPTY_FORM);
|
const [formData, setFormData] = useState<PromptFormData>(EMPTY_FORM);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!formData.name.trim() || !formData.prompt.trim()) {
|
if (!formData.name.trim() || !formData.prompt.trim()) {
|
||||||
|
|
@ -46,7 +57,7 @@ export function PromptsContent() {
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
if (editingId) {
|
if (editingId !== null) {
|
||||||
await updatePrompt({ id: editingId, ...formData });
|
await updatePrompt({ id: editingId, ...formData });
|
||||||
} else {
|
} else {
|
||||||
await createPrompt(formData);
|
await createPrompt(formData);
|
||||||
|
|
@ -72,16 +83,16 @@ export function PromptsContent() {
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
async (id: number) => {
|
if (deleteTarget === null) return;
|
||||||
try {
|
try {
|
||||||
await deletePrompt(id);
|
await deletePrompt(deleteTarget);
|
||||||
} catch {
|
} catch {
|
||||||
// toast handled by mutation atom
|
// toast handled by mutation atom
|
||||||
}
|
} finally {
|
||||||
},
|
setDeleteTarget(null);
|
||||||
[deletePrompt]
|
}
|
||||||
);
|
}, [deleteTarget, deletePrompt]);
|
||||||
|
|
||||||
const handleTogglePublic = useCallback(
|
const handleTogglePublic = useCallback(
|
||||||
async (prompt: PromptRead) => {
|
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 (
|
return (
|
||||||
<div className="space-y-6 min-w-0 overflow-hidden">
|
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -137,7 +158,7 @@ export function PromptsContent() {
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="rounded-lg border border-border/60 bg-card p-6 space-y-4">
|
<div className="rounded-lg border border-border/60 bg-card p-6 space-y-4">
|
||||||
<h3 className="text-sm font-semibold tracking-tight">
|
<h3 className="text-sm font-semibold tracking-tight">
|
||||||
{editingId ? "Edit prompt" : "New prompt"}
|
{editingId !== null ? "Edit prompt" : "New prompt"}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -200,7 +221,7 @@ export function PromptsContent() {
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -279,7 +300,7 @@ export function PromptsContent() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7 text-destructive hover:text-destructive"
|
className="size-7 text-destructive hover:text-destructive"
|
||||||
onClick={() => handleDelete(prompt.id)}
|
onClick={() => setDeleteTarget(prompt.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -288,6 +309,21 @@ export function PromptsContent() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ export const updatePromptMutationAtom = atomWithMutation(() => ({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Prompt updated");
|
toast.success("Prompt updated");
|
||||||
queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.all() });
|
queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.all() });
|
||||||
queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.public() });
|
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(error.message || "Failed to update prompt");
|
toast.error(error.message || "Failed to update prompt");
|
||||||
|
|
|
||||||
|
|
@ -98,35 +98,37 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
|
||||||
[filtered.length, highlightedIndex, handleSelect]
|
[filtered.length, highlightedIndex, handleSelect]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (filtered.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
|
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
|
||||||
style={containerStyle}
|
style={containerStyle}
|
||||||
>
|
>
|
||||||
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
|
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
|
||||||
{filtered.map((action, index) => (
|
{filtered.length === 0 ? (
|
||||||
<button
|
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
|
||||||
key={action.id}
|
) : (
|
||||||
ref={(el) => {
|
filtered.map((action, index) => (
|
||||||
if (el) itemRefs.current.set(index, el);
|
<button
|
||||||
else itemRefs.current.delete(index);
|
key={action.id}
|
||||||
}}
|
ref={(el) => {
|
||||||
type="button"
|
if (el) itemRefs.current.set(index, el);
|
||||||
onClick={() => handleSelect(index)}
|
else itemRefs.current.delete(index);
|
||||||
onMouseEnter={() => setHighlightedIndex(index)}
|
}}
|
||||||
className={cn(
|
type="button"
|
||||||
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
|
onClick={() => handleSelect(index)}
|
||||||
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
)}
|
className={cn(
|
||||||
>
|
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
|
||||||
<span className="text-muted-foreground">
|
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
|
||||||
<Zap className="size-3.5" />
|
)}
|
||||||
</span>
|
>
|
||||||
<span className="truncate">{action.name}</span>
|
<span className="text-muted-foreground">
|
||||||
</button>
|
<Zap className="size-3.5" />
|
||||||
))}
|
</span>
|
||||||
|
<span className="truncate">{action.name}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="my-1 h-px bg-border mx-2" />
|
<div className="my-1 h-px bg-border mx-2" />
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue