feat(i18n): full app + docs localization (de/en)
Build & Push Docker Image / build (push) Successful in 2m33s
Build & Push Docker Image / build (push) Successful in 2m33s
- translate remaining pages/components (admin, analytics, redundancy, trash, compare, browse, watchlist, admin tools tab, category combobox, theme toggle, tool preview card, tool-new/edit) to t() calls - locales: 453 keys each, parity verified - docs: bilingual handbook + release notes via *.en.md variants, language-aware docs.tsx (markdown paths, nav titles, search index), LanguageSwitcher in docs header - generate-docs: emit per-locale handbook/release/search output, localized index.json fields (fileEn/titleEn), en-aware snapshots
This commit is contained in:
@@ -20,26 +20,32 @@ import { TagInput } from "@/components/tag-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { FieldHelp } from "@/components/field-help";
|
||||
import { GuideHelp } from "@/components/guide-help";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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<ReturnType<typeof buildToolSchema>>;
|
||||
|
||||
type ToolFormValues = z.infer<typeof toolSchema>;
|
||||
function buildToolSchema(t: (key: string) => string) {
|
||||
return z.object({
|
||||
name: z.string().min(2, t("toolForm.nameMin")),
|
||||
description: z.string().min(10, t("toolForm.descriptionMin")),
|
||||
category: z.string().min(2, t("toolForm.categoryRequired")),
|
||||
websiteUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
|
||||
iconUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
|
||||
features: z.array(z.object({ value: z.string() })).optional(),
|
||||
tags: z.array(z.object({ value: z.string() })).optional(),
|
||||
});
|
||||
}
|
||||
|
||||
export default function ToolNew() {
|
||||
const [location, setLocation] = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const createTool = useCreateTool();
|
||||
const { isAuthenticated, isLoading: authLoading, login } = useAuth();
|
||||
|
||||
const toolSchema = buildToolSchema(t);
|
||||
|
||||
const form = useForm<ToolFormValues>({
|
||||
resolver: zodResolver(toolSchema),
|
||||
defaultValues: {
|
||||
@@ -76,7 +82,7 @@ export default function ToolNew() {
|
||||
{ data: payload },
|
||||
{
|
||||
onSuccess: (newTool) => {
|
||||
toast({ title: "Tool added successfully", description: "Your tool is now available for review." });
|
||||
toast({ title: t("toolForm.toastAdded"), description: t("toolForm.toastAddedSub") });
|
||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||
@@ -86,7 +92,7 @@ export default function ToolNew() {
|
||||
setLocation(`/tools/${newTool.id}`);
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Failed to add tool", description: "An unexpected error occurred.", variant: "destructive" });
|
||||
toast({ title: t("toolForm.toastAddFailed"), description: t("detail.unexpectedError"), variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -97,24 +103,24 @@ export default function ToolNew() {
|
||||
<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
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> {t("toolForm.backToBrowse")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Add a New Tool</h1>
|
||||
<p className="text-muted-foreground">Submit a tool you use to let the community rate and review it.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("toolForm.addTitle")}</h1>
|
||||
<p className="text-muted-foreground">{t("toolForm.addSubtitle")}</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>
|
||||
<p className="text-sm font-medium">{t("toolForm.signInRequired")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("toolForm.signInRequiredSub")}</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => login(location)} data-testid="button-login-prompt">
|
||||
Sign in
|
||||
{t("auth.signIn")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -123,10 +129,10 @@ export default function ToolNew() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Wrench className="w-5 h-5 text-primary" />
|
||||
Tool Details
|
||||
<GuideHelp guide="tool-anlegen" label="Tool Details" />
|
||||
{t("toolForm.toolDetails")}
|
||||
<GuideHelp guide="tool-anlegen" label={t("toolForm.toolDetails")} />
|
||||
</CardTitle>
|
||||
<CardDescription>Provide the basic information about the tool.</CardDescription>
|
||||
<CardDescription>{t("toolForm.toolDetailsNewSub")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
@@ -138,11 +144,11 @@ export default function ToolNew() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Name
|
||||
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
|
||||
{t("toolForm.name")}
|
||||
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" />
|
||||
<Input placeholder={t("toolForm.namePlaceholder")} {...field} data-testid="input-tool-name" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -155,8 +161,8 @@ export default function ToolNew() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Category
|
||||
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
|
||||
{t("toolForm.category")}
|
||||
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<CategoryCombobox
|
||||
@@ -176,11 +182,11 @@ export default function ToolNew() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Website URL (Optional)
|
||||
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
|
||||
{t("toolForm.websiteUrlOptional")}
|
||||
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" />
|
||||
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} data-testid="input-tool-url" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -193,8 +199,8 @@ export default function ToolNew() {
|
||||
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>
|
||||
{t("toolForm.iconUrlOptional")}
|
||||
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -202,16 +208,16 @@ export default function ToolNew() {
|
||||
{field.value ? (
|
||||
<img
|
||||
src={field.value}
|
||||
alt="icon preview"
|
||||
alt={t("toolForm.iconPreview")}
|
||||
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>
|
||||
<span className="text-xs text-muted-foreground">{t("toolForm.name").charAt(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
placeholder="https://example.com/logo.png"
|
||||
placeholder={t("toolForm.logoPlaceholder")}
|
||||
type="url"
|
||||
{...field}
|
||||
data-testid="input-tool-icon-url"
|
||||
@@ -230,12 +236,12 @@ export default function ToolNew() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Description
|
||||
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
|
||||
{t("toolForm.description")}
|
||||
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="What does this tool do? Why do people use it?"
|
||||
placeholder={t("toolForm.descriptionPlaceholderNew")}
|
||||
className="min-h-[120px] resize-none"
|
||||
{...field}
|
||||
data-testid="input-tool-description"
|
||||
@@ -250,10 +256,10 @@ export default function ToolNew() {
|
||||
<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>
|
||||
{t("toolForm.features")}
|
||||
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">List key capabilities. Existing features from other tools are selectable.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("toolForm.featuresNewSub")}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -262,7 +268,7 @@ export default function ToolNew() {
|
||||
onClick={() => appendFeature({ value: "" })}
|
||||
data-testid="button-add-feature"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Feature
|
||||
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addFeature")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -278,7 +284,7 @@ export default function ToolNew() {
|
||||
<FeatureInput
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
placeholder="e.g. Real-time collaboration"
|
||||
placeholder={t("toolForm.featurePlaceholder")}
|
||||
data-testid={`input-feature-${index}`}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -297,7 +303,7 @@ export default function ToolNew() {
|
||||
/>
|
||||
))}
|
||||
{featureFields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground italic">No features added.</p>
|
||||
<p className="text-sm text-muted-foreground italic">{t("toolForm.noFeatures")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,10 +312,10 @@ export default function ToolNew() {
|
||||
<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>
|
||||
{t("toolForm.tags")}
|
||||
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">Keywords to help find this tool. Existing tags from other tools are selectable.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("toolForm.tagsHelp")}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -318,7 +324,7 @@ export default function ToolNew() {
|
||||
onClick={() => appendTag({ value: "" })}
|
||||
data-testid="button-add-tag"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Tag
|
||||
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addTag")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -332,7 +338,7 @@ export default function ToolNew() {
|
||||
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
||||
<FormControl>
|
||||
<TagInput
|
||||
placeholder="Tag"
|
||||
placeholder={t("toolForm.tagPlaceholder")}
|
||||
className="pr-8 h-9 text-sm"
|
||||
onChange={field.onChange}
|
||||
value={field.value ?? ""}
|
||||
|
||||
Reference in New Issue
Block a user