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

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

View file

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