3bb4598d04
- each release now carries a full docs snapshot (reference + handbook) under docs/releases/<v>/; the docs frontend loads a version's snapshot for /docs/vX.Y.Z/* and shows the handbook/reference of that version - version dropdown navigates to /docs/<version> (docs home) instead of the release notes page; old /docs/vX.Y.Z redirect removed - generator: --snapshot writes handbook + reference; build copies versioned snapshots (last 7 versions) into /docs/versions/<v>/ - fix Gitea tag links: /admin/tool-evaluator/tags/<v> was 404, correct URL is /releases/tag/<v> - add GuideHelp button to forms/pages linking to the matching handbook guide (rating, tool add/edit, costs, compare, watchlist, analytics)
389 lines
16 KiB
TypeScript
389 lines
16 KiB
TypeScript
import { useEffect } from "react";
|
|
import { useRoute, useLocation } from "wouter";
|
|
import { useForm, useFieldArray } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import * as z from "zod";
|
|
import {
|
|
useGetTool,
|
|
useUpdateTool,
|
|
getGetToolQueryKey,
|
|
getListToolsQueryKey,
|
|
getListCategoriesQueryKey,
|
|
getListAllFeaturesQueryKey,
|
|
getListAllTagsQueryKey,
|
|
getGetTopToolsQueryKey,
|
|
getGetAnalyticsSummaryQueryKey,
|
|
} from "@workspace/api-client-react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
|
|
import { Layout } from "@/components/layout";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { Pencil, Plus, X, ArrowLeft } from "lucide-react";
|
|
import { Link } from "wouter";
|
|
import { CategoryCombobox } from "@/components/category-combobox";
|
|
import { FeatureInput } from "@/components/feature-input";
|
|
import { TagInput } from "@/components/tag-input";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { FieldHelp } from "@/components/field-help";
|
|
import { GuideHelp } from "@/components/guide-help";
|
|
|
|
const toolSchema = z.object({
|
|
name: z.string().min(2, "Name must be at least 2 characters"),
|
|
description: z.string().min(10, "Description must be at least 10 characters"),
|
|
category: z.string().min(2, "Category is required"),
|
|
websiteUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
|
|
iconUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
|
|
features: z.array(z.object({ value: z.string() })).optional(),
|
|
tags: z.array(z.object({ value: z.string() })).optional(),
|
|
});
|
|
|
|
type ToolFormValues = z.infer<typeof toolSchema>;
|
|
|
|
export default function ToolEdit() {
|
|
const [match, params] = useRoute("/tools/:id/edit");
|
|
const [, setLocation] = useLocation();
|
|
const id = parseInt(params?.id || "0", 10);
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const updateTool = useUpdateTool();
|
|
const { isAuthenticated, isAdmin } = useAuth();
|
|
|
|
const { data: tool, isLoading } = useGetTool(id, {
|
|
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) },
|
|
});
|
|
|
|
const form = useForm<ToolFormValues>({
|
|
resolver: zodResolver(toolSchema),
|
|
defaultValues: {
|
|
name: "",
|
|
description: "",
|
|
category: "",
|
|
websiteUrl: "",
|
|
iconUrl: "",
|
|
features: [],
|
|
tags: [],
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (tool) {
|
|
form.reset({
|
|
name: tool.name,
|
|
description: tool.description,
|
|
category: tool.category,
|
|
websiteUrl: tool.websiteUrl || "",
|
|
iconUrl: tool.iconUrl || "",
|
|
features: (tool.features || []).map((v) => ({ value: v })),
|
|
tags: (tool.tags || []).map((v) => ({ value: v })),
|
|
});
|
|
}
|
|
}, [tool, form]);
|
|
|
|
const { fields: featureFields, append: appendFeature, remove: removeFeature } = useFieldArray({
|
|
control: form.control,
|
|
name: "features",
|
|
});
|
|
|
|
const { fields: tagFields, append: appendTag, remove: removeTag } = useFieldArray({
|
|
control: form.control,
|
|
name: "tags",
|
|
});
|
|
|
|
const onSubmit = (data: ToolFormValues) => {
|
|
const payload = {
|
|
...data,
|
|
websiteUrl: data.websiteUrl?.trim() ? data.websiteUrl : null,
|
|
iconUrl: data.iconUrl?.trim() ? data.iconUrl : null,
|
|
features: data.features?.map((f) => f.value).filter((v) => v.trim() !== ""),
|
|
tags: data.tags?.map((t) => t.value).filter((v) => v.trim() !== ""),
|
|
};
|
|
|
|
updateTool.mutate(
|
|
{ id, data: payload },
|
|
{
|
|
onSuccess: () => {
|
|
toast({ title: "Tool updated", description: "Changes saved successfully." });
|
|
queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) });
|
|
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
|
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
|
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
|
queryClient.invalidateQueries({ queryKey: getListAllTagsQueryKey() });
|
|
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
|
|
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
|
|
setLocation(`/tools/${id}`);
|
|
},
|
|
onError: (err) => {
|
|
toast({
|
|
title: "Failed to update tool",
|
|
description: err.data?.error || err.message || "An unexpected error occurred.",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
if (!match || isNaN(id)) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="max-w-3xl mx-auto space-y-6 pb-10">
|
|
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
|
<Link href={`/tools/${id}`}>
|
|
<ArrowLeft className="w-4 h-4 mr-2" /> Back to tool
|
|
</Link>
|
|
</Button>
|
|
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight mb-2">Edit Tool</h1>
|
|
<p className="text-muted-foreground">Update tool details and metadata.</p>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<Card>
|
|
<CardContent className="p-6 space-y-4">
|
|
<Skeleton className="h-10 w-full" />
|
|
<Skeleton className="h-10 w-full" />
|
|
<Skeleton className="h-24 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Pencil className="w-5 h-5 text-primary" />
|
|
Tool Details
|
|
<GuideHelp guide="tool-bearbeiten" label="Tool Details" />
|
|
</CardTitle>
|
|
<CardDescription>Modify the tool information below.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<FormField
|
|
control={form.control}
|
|
name="name"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="inline-flex items-center gap-1.5">
|
|
Name
|
|
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="Tool name" {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="category"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="inline-flex items-center gap-1.5">
|
|
Category
|
|
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
|
|
</FormLabel>
|
|
<FormControl>
|
|
<CategoryCombobox value={field.value} onChange={field.onChange} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="websiteUrl"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="inline-flex items-center gap-1.5">
|
|
Website URL (Optional)
|
|
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="https://..." type="url" {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="iconUrl"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="inline-flex items-center gap-1.5">
|
|
Icon / Logo URL (Optional)
|
|
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
|
|
</FormLabel>
|
|
<FormControl>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
|
{field.value ? (
|
|
<img
|
|
src={field.value}
|
|
alt="icon preview"
|
|
className="w-full h-full object-contain p-0.5"
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
|
/>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">img</span>
|
|
)}
|
|
</div>
|
|
<Input
|
|
placeholder="https://example.com/logo.png"
|
|
type="url"
|
|
{...field}
|
|
className="flex-1"
|
|
/>
|
|
</div>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="description"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="inline-flex items-center gap-1.5">
|
|
Description
|
|
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder="What does this tool do?"
|
|
className="min-h-[120px] resize-none"
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<div className="space-y-4 pt-4 border-t">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
|
Features
|
|
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">Key capabilities of this tool. Existing features from other tools are selectable.</p>
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
|
|
<Plus className="w-4 h-4 mr-2" /> Add Feature
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{featureFields.map((field, index) => (
|
|
<FormField
|
|
key={field.id}
|
|
control={form.control}
|
|
name={`features.${index}.value`}
|
|
render={({ field }) => (
|
|
<FormItem className="flex items-start gap-2 space-y-0">
|
|
<FormControl>
|
|
<FeatureInput
|
|
value={field.value ?? ""}
|
|
onChange={field.onChange}
|
|
placeholder="e.g. Real-time collaboration"
|
|
/>
|
|
</FormControl>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
|
onClick={() => removeFeature(index)}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
))}
|
|
{featureFields.length === 0 && (
|
|
<p className="text-sm text-muted-foreground italic">No features added.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4 pt-4 border-t">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
|
Tags
|
|
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">Keywords for this tool. Existing tags from other tools are selectable.</p>
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
|
|
<Plus className="w-4 h-4 mr-2" /> Add Tag
|
|
</Button>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{tagFields.map((field, index) => (
|
|
<FormField
|
|
key={field.id}
|
|
control={form.control}
|
|
name={`tags.${index}.value`}
|
|
render={({ field }) => (
|
|
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
|
<FormControl>
|
|
<TagInput
|
|
placeholder="Tag"
|
|
className="pr-8 h-9 text-sm"
|
|
onChange={field.onChange}
|
|
value={field.value ?? ""}
|
|
/>
|
|
</FormControl>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute right-0 top-0 h-9 w-8 text-muted-foreground hover:text-destructive"
|
|
onClick={() => removeTag(index)}
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</Button>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-6 border-t flex justify-end gap-3">
|
|
<Button type="button" variant="outline" asChild>
|
|
<Link href={`/tools/${id}`}>Cancel</Link>
|
|
</Button>
|
|
<Button type="submit" disabled={updateTool.isPending || !isAuthenticated}>
|
|
{updateTool.isPending ? "Saving…" : "Save Changes"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|