Add user authentication and dynamic feature/category inputs
Implement Keycloak authentication, protected routes, and add combobox and autocomplete components for tool categories and features. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 776963d0-f75d-42e2-a57b-cc36bdff8495 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 0b145113-c016-4f54-b000-13bd3b0ba8f0 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/0683fb79-a27c-485c-9333-5f4b288c4567/776963d0-f75d-42e2-a57b-cc36bdff8495/z4uWN6A Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -10,11 +9,14 @@ 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, FormDescription } from "@/components/ui/form";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Wrench, Plus, X, ArrowLeft } from "lucide-react";
|
||||
import { Wrench, Plus, X, ArrowLeft, LogIn } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { CategoryCombobox } from "@/components/category-combobox";
|
||||
import { FeatureInput } from "@/components/feature-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
const toolSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
@@ -28,10 +30,11 @@ const toolSchema = z.object({
|
||||
type ToolFormValues = z.infer<typeof toolSchema>;
|
||||
|
||||
export default function ToolNew() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [location, setLocation] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const createTool = useCreateTool();
|
||||
const { isAuthenticated, isLoading: authLoading, login } = useAuth();
|
||||
|
||||
const form = useForm<ToolFormValues>({
|
||||
resolver: zodResolver(toolSchema),
|
||||
@@ -41,7 +44,7 @@ export default function ToolNew() {
|
||||
category: "",
|
||||
websiteUrl: "",
|
||||
features: [{ value: "" }],
|
||||
tags: [{ value: "" }]
|
||||
tags: [{ value: "" }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,38 +59,35 @@ export default function ToolNew() {
|
||||
});
|
||||
|
||||
const onSubmit = (data: ToolFormValues) => {
|
||||
// Transform arrays back to strings
|
||||
const payload = {
|
||||
...data,
|
||||
websiteUrl: data.websiteUrl || undefined,
|
||||
features: data.features?.map(f => f.value).filter(v => v.trim() !== ""),
|
||||
tags: data.tags?.map(t => t.value).filter(v => v.trim() !== "")
|
||||
features: data.features?.map((f) => f.value).filter((v) => v.trim() !== ""),
|
||||
tags: data.tags?.map((t) => t.value).filter((v) => v.trim() !== ""),
|
||||
};
|
||||
|
||||
createTool.mutate({ data: payload }, {
|
||||
onSuccess: (newTool) => {
|
||||
toast({
|
||||
title: "Tool added successfully",
|
||||
description: "Your tool is now available for review.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||
setLocation(`/tools/${newTool.id}`);
|
||||
createTool.mutate(
|
||||
{ data: payload },
|
||||
{
|
||||
onSuccess: (newTool) => {
|
||||
toast({ title: "Tool added successfully", description: "Your tool is now available for review." });
|
||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||
setLocation(`/tools/${newTool.id}`);
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Failed to add tool", description: "An unexpected error occurred.", variant: "destructive" });
|
||||
},
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Failed to add tool",
|
||||
description: error.error || "An unexpected error occurred",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
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"><ArrowLeft className="w-4 h-4 mr-2" /> Back to browse</Link>
|
||||
<Link href="/tools">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Back to browse
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
@@ -95,6 +95,19 @@ export default function ToolNew() {
|
||||
<p className="text-muted-foreground">Submit a tool you use to let the community rate and review it.</p>
|
||||
</div>
|
||||
|
||||
{!authLoading && !isAuthenticated && (
|
||||
<div className="flex items-center gap-4 rounded-md border border-primary/20 bg-primary/5 px-4 py-3">
|
||||
<LogIn className="w-5 h-5 text-primary shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Sign in required</p>
|
||||
<p className="text-xs text-muted-foreground">You must be signed in to submit a tool.</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => login(location)} data-testid="button-login-prompt">
|
||||
Sign in
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -106,7 +119,6 @@ export default function ToolNew() {
|
||||
<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}
|
||||
@@ -115,7 +127,7 @@ export default function ToolNew() {
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. React, Next.js, Postgres" {...field} />
|
||||
<Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -129,7 +141,10 @@ export default function ToolNew() {
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. Framework, Database, CI/CD" {...field} />
|
||||
<CategoryCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -144,7 +159,7 @@ export default function ToolNew() {
|
||||
<FormItem>
|
||||
<FormLabel>Website URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://..." type="url" {...field} />
|
||||
<Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -158,10 +173,11 @@ export default function ToolNew() {
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="What does this tool do? Why do people use it?"
|
||||
<Textarea
|
||||
placeholder="What does this tool do? Why do people use it?"
|
||||
className="min-h-[120px] resize-none"
|
||||
{...field}
|
||||
{...field}
|
||||
data-testid="input-tool-description"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -173,18 +189,19 @@ export default function ToolNew() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Features</h3>
|
||||
<p className="text-sm text-muted-foreground">List key capabilities of the tool.</p>
|
||||
<p className="text-sm text-muted-foreground">List key capabilities. Start typing to see suggestions from existing tools.</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendFeature({ value: "" })}
|
||||
data-testid="button-add-feature"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Feature
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-3">
|
||||
{featureFields.map((field, index) => (
|
||||
<FormField
|
||||
@@ -194,14 +211,20 @@ export default function ToolNew() {
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-start gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. Real-time collaboration" {...field} />
|
||||
<FeatureInput
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="e.g. Real-time collaboration"
|
||||
data-testid={`input-feature-${index}`}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeFeature(index)}
|
||||
data-testid={`button-remove-feature-${index}`}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -221,16 +244,17 @@ export default function ToolNew() {
|
||||
<h3 className="text-lg font-medium">Tags</h3>
|
||||
<p className="text-sm text-muted-foreground">Keywords to help find this tool.</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendTag({ value: "" })}
|
||||
data-testid="button-add-tag"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Tag
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tagFields.map((field, index) => (
|
||||
<FormField
|
||||
@@ -240,14 +264,20 @@ export default function ToolNew() {
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-y-0 relative w-[150px]">
|
||||
<FormControl>
|
||||
<Input placeholder="Tag" className="pr-8 h-9 text-sm" {...field} />
|
||||
<Input
|
||||
placeholder="Tag"
|
||||
className="pr-8 h-9 text-sm"
|
||||
{...field}
|
||||
data-testid={`input-tag-${index}`}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
<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)}
|
||||
data-testid={`button-remove-tag-${index}`}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
@@ -259,7 +289,12 @@ export default function ToolNew() {
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t flex justify-end">
|
||||
<Button type="submit" disabled={createTool.isPending} className="w-full sm:w-auto">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createTool.isPending || (!authLoading && !isAuthenticated)}
|
||||
className="w-full sm:w-auto"
|
||||
data-testid="button-submit-tool"
|
||||
>
|
||||
{createTool.isPending ? "Adding Tool..." : "Submit Tool"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user