Fix create_prompt is_public bug, conditional version bump, and add Jotai prompts atoms

- Pass is_public from request body in create_prompt route
- Only bump version on content field changes (name, prompt, mode),
  not on is_public toggle
- Add prompts query and mutation atoms (atomWithQuery/atomWithMutation)
  with TanStack Query caching, replacing manual useEffect fetches
- Update PromptPicker, PromptsContent, and CommunityPromptsContent
  to consume shared atoms instead of local state
This commit is contained in:
CREDO23 2026-03-31 18:34:10 +02:00
parent 95620a4331
commit 5f4f7780d1
7 changed files with 184 additions and 88 deletions

View file

@ -41,6 +41,7 @@ async def create_prompt(
name=body.name, name=body.name,
prompt=body.prompt, prompt=body.prompt,
mode=body.mode, mode=body.mode,
is_public=body.is_public,
) )
session.add(prompt) session.add(prompt)
await session.commit() await session.commit()
@ -65,10 +66,15 @@ async def update_prompt(
if not prompt: if not prompt:
raise HTTPException(status_code=404, detail="Prompt not found") raise HTTPException(status_code=404, detail="Prompt not found")
for field, value in body.model_dump(exclude_unset=True).items(): updates = body.model_dump(exclude_unset=True)
content_fields = {"name", "prompt", "mode"}
has_content_change = bool(updates.keys() & content_fields)
for field, value in updates.items():
setattr(prompt, field, value) setattr(prompt, field, value)
prompt.version = (prompt.version or 0) + 1 if has_content_change:
prompt.version = (prompt.version or 0) + 1
session.add(prompt) session.add(prompt)
await session.commit() await session.commit()

View file

@ -1,38 +1,34 @@
"use client"; "use client";
import { useAtomValue } from "jotai";
import { Copy, Globe, Sparkles } from "lucide-react"; import { Copy, Globe, Sparkles } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { toast } from "sonner"; import { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms";
import { publicPromptsAtom } from "@/atoms/prompts/prompts-query.atoms";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import type { PublicPromptRead } from "@/contracts/types/prompts.types";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
export function CommunityPromptsContent() { export function CommunityPromptsContent() {
const [prompts, setPrompts] = useState<PublicPromptRead[]>([]); const { data: prompts, isLoading } = useAtomValue(publicPromptsAtom);
const [isLoading, setIsLoading] = useState(true); const { mutateAsync: copyPrompt, isPending: isCopying } = useAtomValue(copyPromptMutationAtom);
const [copyingId, setCopyingId] = useState<number | null>(null); const [copyingId, setCopyingId] = useState<number | null>(null);
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
useEffect(() => { const handleCopy = useCallback(
promptsApiService async (id: number) => {
.listPublic() setCopyingId(id);
.then(setPrompts) try {
.catch(() => toast.error("Failed to load community prompts")) await copyPrompt(id);
.finally(() => setIsLoading(false)); } catch {
}, []); // toast handled by mutation atom
} finally {
setCopyingId(null);
}
},
[copyPrompt]
);
const handleCopy = useCallback(async (id: number) => { const list = prompts ?? [];
setCopyingId(id);
try {
await promptsApiService.copy(id);
toast.success("Prompt added to your collection");
} catch {
toast.error("Failed to copy prompt");
} finally {
setCopyingId(null);
}
}, []);
if (isLoading) { if (isLoading) {
return ( return (
@ -48,7 +44,7 @@ export function CommunityPromptsContent() {
Prompts shared by other users. Add any to your collection with one click. Prompts shared by other users. Add any to your collection with one click.
</p> </p>
{prompts.length === 0 && ( {list.length === 0 && (
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center"> <div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
<Globe className="mx-auto size-8 text-muted-foreground/40" /> <Globe className="mx-auto size-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">No community prompts yet</p> <p className="mt-2 text-sm text-muted-foreground">No community prompts yet</p>
@ -58,9 +54,9 @@ export function CommunityPromptsContent() {
</div> </div>
)} )}
{prompts.length > 0 && ( {list.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{prompts.map((prompt) => ( {list.map((prompt) => (
<div <div
key={prompt.id} key={prompt.id}
className="group flex items-start gap-3 rounded-lg border border-border/60 bg-card p-4" className="group flex items-start gap-3 rounded-lg border border-border/60 bg-card p-4"
@ -80,7 +76,9 @@ export function CommunityPromptsContent() {
</span> </span>
)} )}
</div> </div>
<p className={`mt-1 text-xs text-muted-foreground ${expandedId === prompt.id ? "whitespace-pre-wrap" : "line-clamp-2"}`}> <p
className={`mt-1 text-xs text-muted-foreground ${expandedId === prompt.id ? "whitespace-pre-wrap" : "line-clamp-2"}`}
>
{prompt.prompt} {prompt.prompt}
</p> </p>
{prompt.prompt.length > 100 && ( {prompt.prompt.length > 100 && (
@ -97,10 +95,10 @@ export function CommunityPromptsContent() {
variant="outline" variant="outline"
size="sm" size="sm"
className="shrink-0 gap-1.5" className="shrink-0 gap-1.5"
disabled={copyingId === prompt.id} disabled={copyingId === prompt.id && isCopying}
onClick={() => handleCopy(prompt.id)} onClick={() => handleCopy(prompt.id)}
> >
{copyingId === prompt.id ? ( {copyingId === prompt.id && isCopying ? (
<Spinner className="size-3" /> <Spinner className="size-3" />
) : ( ) : (
<Copy className="size-3" /> <Copy className="size-3" />

View file

@ -1,15 +1,21 @@
"use client"; "use client";
import { useAtomValue } from "jotai";
import { Globe, Lock, PenLine, Plus, Sparkles, Trash2 } from "lucide-react"; import { Globe, Lock, PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import {
createPromptMutationAtom,
deletePromptMutationAtom,
updatePromptMutationAtom,
} from "@/atoms/prompts/prompts-mutation.atoms";
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
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";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import type { PromptRead } from "@/contracts/types/prompts.types"; import type { PromptRead } from "@/contracts/types/prompts.types";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
interface PromptFormData { interface PromptFormData {
name: string; name: string;
@ -21,22 +27,17 @@ 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 [prompts, setPrompts] = useState<PromptRead[]>([]); const { data: prompts, isLoading } = useAtomValue(promptsAtom);
const [isLoading, setIsLoading] = useState(true); const { mutateAsync: createPrompt } = useAtomValue(createPromptMutationAtom);
const { mutateAsync: updatePrompt } = useAtomValue(updatePromptMutationAtom);
const { mutateAsync: deletePrompt } = useAtomValue(deletePromptMutationAtom);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
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);
useEffect(() => {
promptsApiService
.list()
.then(setPrompts)
.catch(() => toast.error("Failed to load prompts"))
.finally(() => setIsLoading(false));
}, []);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (!formData.name.trim() || !formData.prompt.trim()) { if (!formData.name.trim() || !formData.prompt.trim()) {
toast.error("Name and prompt are required"); toast.error("Name and prompt are required");
@ -46,23 +47,19 @@ export function PromptsContent() {
setIsSaving(true); setIsSaving(true);
try { try {
if (editingId) { if (editingId) {
const updated = await promptsApiService.update(editingId, formData); await updatePrompt({ id: editingId, ...formData });
setPrompts((prev) => prev.map((p) => (p.id === editingId ? updated : p)));
toast.success("Prompt updated");
} else { } else {
const created = await promptsApiService.create(formData); await createPrompt(formData);
setPrompts((prev) => [created, ...prev]);
toast.success("Prompt created");
} }
setShowForm(false); setShowForm(false);
setFormData(EMPTY_FORM); setFormData(EMPTY_FORM);
setEditingId(null); setEditingId(null);
} catch { } catch {
toast.error("Failed to save prompt"); // toast handled by mutation atoms
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [formData, editingId]); }, [formData, editingId, createPrompt, updatePrompt]);
const handleEdit = useCallback((prompt: PromptRead) => { const handleEdit = useCallback((prompt: PromptRead) => {
setFormData({ setFormData({
@ -75,15 +72,27 @@ export function PromptsContent() {
setShowForm(true); setShowForm(true);
}, []); }, []);
const handleDelete = useCallback(async (id: number) => { const handleDelete = useCallback(
try { async (id: number) => {
await promptsApiService.delete(id); try {
setPrompts((prev) => prev.filter((p) => p.id !== id)); await deletePrompt(id);
toast.success("Prompt deleted"); } catch {
} catch { // toast handled by mutation atom
toast.error("Failed to delete prompt"); }
} },
}, []); [deletePrompt]
);
const handleTogglePublic = useCallback(
async (prompt: PromptRead) => {
try {
await updatePrompt({ id: prompt.id, is_public: !prompt.is_public });
} catch {
// toast handled by mutation atom
}
},
[updatePrompt]
);
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
setShowForm(false); setShowForm(false);
@ -91,6 +100,8 @@ export function PromptsContent() {
setEditingId(null); setEditingId(null);
}, []); }, []);
const list = prompts ?? [];
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@ -195,7 +206,7 @@ export function PromptsContent() {
</div> </div>
)} )}
{prompts.length === 0 && !showForm && ( {list.length === 0 && !showForm && (
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center"> <div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
<Sparkles className="mx-auto size-8 text-muted-foreground/40" /> <Sparkles className="mx-auto size-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">No prompts yet</p> <p className="mt-2 text-sm text-muted-foreground">No prompts yet</p>
@ -205,9 +216,9 @@ export function PromptsContent() {
</div> </div>
)} )}
{prompts.length > 0 && ( {list.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{prompts.map((prompt) => ( {list.map((prompt) => (
<div <div
key={prompt.id} key={prompt.id}
className="group flex items-start gap-3 rounded-lg border border-border/60 bg-card p-4" className="group flex items-start gap-3 rounded-lg border border-border/60 bg-card p-4"
@ -247,17 +258,7 @@ export function PromptsContent() {
<button <button
type="button" type="button"
title={prompt.is_public ? "Make private" : "Share with community"} title={prompt.is_public ? "Make private" : "Share with community"}
onClick={async () => { onClick={() => handleTogglePublic(prompt)}
try {
const updated = await promptsApiService.update(prompt.id, {
is_public: !prompt.is_public,
});
setPrompts((prev) => prev.map((p) => (p.id === prompt.id ? updated : p)));
toast.success(updated.is_public ? "Shared with community" : "Made private");
} catch {
toast.error("Failed to update");
}
}}
className="flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors" className="flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
> >
{prompt.is_public ? ( {prompt.is_public ? (

View file

@ -0,0 +1,71 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
PromptCreateRequest,
PromptRead,
PromptUpdateRequest,
} from "@/contracts/types/prompts.types";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
export const createPromptMutationAtom = atomWithMutation(() => ({
mutationKey: ["prompts", "create"],
mutationFn: async (request: PromptCreateRequest) => {
return promptsApiService.create(request);
},
onSuccess: () => {
toast.success("Prompt created");
queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.all() });
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create prompt");
},
}));
export const updatePromptMutationAtom = atomWithMutation(() => ({
mutationKey: ["prompts", "update"],
mutationFn: async ({ id, ...data }: PromptUpdateRequest & { id: number }) => {
return promptsApiService.update(id, data);
},
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");
},
}));
export const deletePromptMutationAtom = atomWithMutation(() => ({
mutationKey: ["prompts", "delete"],
mutationFn: async (id: number) => {
return promptsApiService.delete(id);
},
onSuccess: (_: unknown, id: number) => {
toast.success("Prompt deleted");
queryClient.setQueryData(cacheKeys.prompts.all(), (old: PromptRead[] | undefined) => {
if (!old) return old;
return old.filter((p) => p.id !== id);
});
queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.public() });
},
onError: (error: Error) => {
toast.error(error.message || "Failed to delete prompt");
},
}));
export const copyPromptMutationAtom = atomWithMutation(() => ({
mutationKey: ["prompts", "copy"],
mutationFn: async (promptId: number) => {
return promptsApiService.copy(promptId);
},
onSuccess: () => {
toast.success("Prompt added to your collection");
queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.all() });
},
onError: (error: Error) => {
toast.error(error.message || "Failed to copy prompt");
},
}));

View file

@ -0,0 +1,23 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export const promptsAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.prompts.all(),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
return promptsApiService.list();
},
};
});
export const publicPromptsAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.prompts.public(),
staleTime: 2 * 60 * 1000,
queryFn: async () => {
return promptsApiService.listPublic();
},
};
});

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { Plus, Zap } from "lucide-react"; import { Plus, Zap } from "lucide-react";
import { import {
forwardRef, forwardRef,
@ -12,9 +12,8 @@ import {
useState, useState,
} from "react"; } from "react";
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import type { PromptRead } from "@/contracts/types/prompts.types";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface PromptPickerRef { export interface PromptPickerRef {
@ -35,22 +34,16 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
ref ref
) { ) {
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom); const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
const { data: prompts } = useAtomValue(promptsAtom);
const [highlightedIndex, setHighlightedIndex] = useState(0); const [highlightedIndex, setHighlightedIndex] = useState(0);
const [prompts, setPrompts] = useState<PromptRead[]>([]);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(false); const shouldScrollRef = useRef(false);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map()); const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
useEffect(() => {
promptsApiService
.list()
.then(setPrompts)
.catch(() => {});
}, []);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!externalSearch) return prompts; const list = prompts ?? [];
return prompts.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase())); if (!externalSearch) return list;
return list.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase()));
}, [prompts, externalSearch]); }, [prompts, externalSearch]);
const prevSearchRef = useRef(externalSearch); const prevSearchRef = useRef(externalSearch);

View file

@ -91,6 +91,10 @@ export const cacheKeys = {
bySearchSpace: (searchSpaceId: number) => bySearchSpace: (searchSpaceId: number) =>
["public-chat-snapshots", "search-space", searchSpaceId] as const, ["public-chat-snapshots", "search-space", searchSpaceId] as const,
}, },
prompts: {
all: () => ["prompts"] as const,
public: () => ["prompts", "public"] as const,
},
notifications: { notifications: {
search: (searchSpaceId: number | null, search: string, tab: string) => search: (searchSpaceId: number | null, search: string, tab: string) =>
["notifications", "search", searchSpaceId, search, tab] as const, ["notifications", "search", searchSpaceId, search, tab] as const,