[dyad] Adding multi-language support - wrote 15 file(s), deleted 5 file(s), added next-intl package(s)

This commit is contained in:
[dyad]
2026-01-18 14:08:01 +01:00
parent 92d31b0051
commit 3866176416
18 changed files with 1045 additions and 262 deletions

View File

@@ -2,15 +2,17 @@ import { Changelog } from "@/components/changelog";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { getTranslations } from "next-intl/server";
export default function ChangelogPage() {
export default async function ChangelogPage() {
const t = await getTranslations("ChangelogPage");
return (
<div className="relative flex flex-col items-center min-h-screen p-4 sm:p-8 bg-gray-50 dark:bg-background font-[family-name:var(--font-geist-sans)]">
<div className="w-full max-w-4xl mx-auto">
<Button asChild variant="ghost" className="mb-4 -ml-4">
<Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Converter
{t("backToConverter")}
</Link>
</Button>
</div>

View File

@@ -0,0 +1,47 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getTranslations } from "next-intl/server";
export default async function ImprintPage() {
const t = await getTranslations("ImprintPage");
return (
<div className="relative flex flex-col items-center min-h-screen p-4 sm:p-8 bg-gray-50 dark:bg-background">
<div className="w-full max-w-4xl mx-auto">
<Button asChild variant="ghost" className="mb-4 -ml-4">
<Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" />
{t("backToConverter")}
</Link>
</Button>
<main className="w-full">
<Card>
<CardHeader>
<CardTitle className="text-3xl font-bold">{t("title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6 text-muted-foreground">
<p>{t("tmgInfo")}</p>
<div className="space-y-2">
<h3 className="font-semibold text-foreground">{t("contactInfoTitle")}</h3>
<p>{t("contactName")}</p>
<p>{t("contactStreet")}</p>
<p>{t("contactCity")}</p>
<p>{t("contactEmail")}</p>
<p>{t("contactPhone")}</p>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-foreground">{t("representedByTitle")}</h3>
<p>{t("representedByName")}</p>
</div>
<div className="pt-4 border-t">
<h3 className="font-semibold text-foreground">{t("disclaimerTitle")}</h3>
<p className="text-sm">{t("disclaimerText")}</p>
</div>
</CardContent>
</Card>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { Footer } from "@/components/footer";
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
type Props = {
params: { locale: string };
};
export async function generateMetadata({
params: { locale },
}: Props): Promise<Metadata> {
// Using a dynamic import for messages to avoid bundling all of them
const messages = (await import(`../../../messages/${locale}.json`)).default;
const t = (key: string) => messages.Metadata[key as keyof typeof messages.Metadata] || key;
return {
title: t("title"),
description: t("description"),
};
}
export default async function RootLayout({
children,
params: { locale },
}: Readonly<{
children: React.ReactNode;
params: { locale: string };
}>) {
const messages = await getMessages();
return (
<html lang={locale} suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<NextIntlClientProvider messages={messages}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Footer />
<Toaster />
</ThemeProvider>
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@@ -1,15 +1,17 @@
import { ImageConverter } from "@/components/image-converter";
import { getTranslations } from "next-intl/server";
export default function Home() {
export default async function Home() {
const t = await getTranslations("HomePage");
return (
<div className="relative flex flex-col items-center justify-center min-h-screen p-4 sm:p-8 bg-gray-50 dark:bg-background font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col items-center w-full max-w-6xl z-10">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-5xl">
Image Web Exporter
{t("title")}
</h1>
<p className="mt-3 text-lg text-gray-600 dark:text-gray-400">
Upload a picture, then export it in a different resolution and format.
{t("subtitle")}
</p>
</div>
<ImageConverter />

View File

@@ -0,0 +1,46 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getTranslations } from "next-intl/server";
export default async function PrivacyPage() {
const t = await getTranslations("PrivacyPage");
return (
<div className="relative flex flex-col items-center min-h-screen p-4 sm:p-8 bg-gray-50 dark:bg-background">
<div className="w-full max-w-4xl mx-auto">
<Button asChild variant="ghost" className="mb-4 -ml-4">
<Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" />
{t("backToConverter")}
</Link>
</Button>
<main className="w-full">
<Card>
<CardHeader>
<CardTitle className="text-3xl font-bold">{t("title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6 text-muted-foreground">
<div className="space-y-2">
<h3 className="font-semibold text-foreground">{t("generalInfoTitle")}</h3>
<p>{t("generalInfoText")}</p>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-foreground">{t("dataCollectionTitle")}</h3>
<p>{t("dataCollectionText")}</p>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-foreground">{t("yourRightsTitle")}</h3>
<p>{t("yourRightsText")}</p>
</div>
<div className="pt-4 border-t">
<h3 className="font-semibold text-foreground">{t("disclaimerTitle")}</h3>
<p className="text-sm">{t("disclaimerText")}</p>
</div>
</CardContent>
</Card>
</main>
</div>
</div>
);
}

View File

@@ -1,49 +0,0 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function ImprintPage() {
return (
<div className="relative flex flex-col items-center min-h-screen p-4 sm:p-8 bg-gray-50 dark:bg-background">
<div className="w-full max-w-4xl mx-auto">
<Button asChild variant="ghost" className="mb-4 -ml-4">
<Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Converter
</Link>
</Button>
<main className="w-full">
<Card>
<CardHeader>
<CardTitle className="text-3xl font-bold">Imprint</CardTitle>
</CardHeader>
<CardContent className="space-y-6 text-muted-foreground">
<p>
Information according to § 5 TMG (German Telemedia Act)
</p>
<div className="space-y-2">
<h3 className="font-semibold text-foreground">Contact Information:</h3>
<p>[Your Company Name]</p>
<p>[Street Name & Number]</p>
<p>[Postal Code & City]</p>
<p>Email: [your-email@example.com]</p>
<p>Phone: [your-phone-number]</p>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-foreground">Represented by:</h3>
<p>[Your Name/CEO's Name]</p>
</div>
<div className="pt-4 border-t">
<h3 className="font-semibold text-foreground">Disclaimer:</h3>
<p className="text-sm">
This is a sample imprint and not legally binding. Please replace the placeholder content with your own information and consult a legal professional to ensure compliance with all applicable laws.
</p>
</div>
</CardContent>
</Card>
</main>
</div>
</div>
);
}

View File

@@ -1,46 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { Footer } from "@/components/footer";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Image Web Exporter",
description: "Upload a picture, then export it in a different resolution and format.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Footer />
<Toaster />
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -1,52 +0,0 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function PrivacyPage() {
return (
<div className="relative flex flex-col items-center min-h-screen p-4 sm:p-8 bg-gray-50 dark:bg-background">
<div className="w-full max-w-4xl mx-auto">
<Button asChild variant="ghost" className="mb-4 -ml-4">
<Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Converter
</Link>
</Button>
<main className="w-full">
<Card>
<CardHeader>
<CardTitle className="text-3xl font-bold">Data Privacy Policy</CardTitle>
</CardHeader>
<CardContent className="space-y-6 text-muted-foreground">
<div className="space-y-2">
<h3 className="font-semibold text-foreground">1. General Information</h3>
<p>
This is a placeholder for your data privacy policy. It outlines how personal data is collected, used, and protected when you use this website.
</p>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-foreground">2. Data Collection on This Website</h3>
<p>
All image processing happens directly in your browser. The images you upload are not sent to any server and are not stored by us. We do not collect any personal data from the images.
</p>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-foreground">3. Your Rights</h3>
<p>
As no personal data is collected, rights regarding access, rectification, or erasure of personal data are not applicable in this context.
</p>
</div>
<div className="pt-4 border-t">
<h3 className="font-semibold text-foreground">Disclaimer:</h3>
<p className="text-sm">
This is a sample privacy policy and not legally binding. It is crucial to adapt this text to your specific data processing activities and to consult with a legal professional to ensure full GDPR compliance.
</p>
</div>
</CardContent>
</Card>
</main>
</div>
</div>
);
}

View File

@@ -4,16 +4,33 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { changelogData } from "@/lib/changelog-data";
import { useTranslations } from "next-intl";
export function Changelog() {
const tPage = useTranslations("ChangelogPage");
const t = useTranslations("Changelog");
const getChangeTypeTranslation = (type: string) => {
switch (type) {
case "New":
return t("new");
case "Improved":
return t("improved");
case "Fixed":
return t("fixed");
default:
return type;
}
};
return (
<div className="w-full max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-5xl">
Changelog
{tPage("title")}
</h1>
<p className="mt-3 text-lg text-gray-600 dark:text-gray-400">
Tracking all the new features, improvements, and bug fixes.
{tPage("subtitle")}
</p>
</div>
<div className="space-y-8">
@@ -21,7 +38,7 @@ export function Changelog() {
<Card key={entry.version}>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<CardTitle className="text-2xl font-bold">Version {entry.version}</CardTitle>
<CardTitle className="text-2xl font-bold">{t("version")} {entry.version}</CardTitle>
<p className="text-sm text-muted-foreground">{entry.date}</p>
</div>
</CardHeader>
@@ -37,7 +54,7 @@ export function Changelog() {
"border-red-500/50 bg-red-500/10 text-red-700 dark:text-red-300": change.type === "Fixed",
})}
>
{change.type}
{getChangeTypeTranslation(change.type)}
</Badge>
<p className="text-foreground">{change.text}</p>
</li>

View File

@@ -1,34 +1,28 @@
"use client";
import Link from "next/link";
import { Github, Twitter } from "lucide-react";
import { Button } from "@/components/ui/button";
import { changelogData } from "@/lib/changelog-data";
import { useTranslations } from "next-intl";
import { LanguageSwitcher } from "./language-switcher";
export function Footer() {
const t = useTranslations("Footer");
const latestVersion = changelogData[0]?.version;
return (
<footer className="w-full border-t bg-background">
<div className="container relative mx-auto flex h-16 items-center justify-between px-4 md:px-6">
<div className="text-sm text-muted-foreground">
<p>© {new Date().getFullYear()} Pascal Linxweiler</p>
<p>{t("copyright", { year: new Date().getFullYear() })}</p>
</div>
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-1">
<Button variant="ghost" size="icon" asChild>
<Link href="https://github.com/" target="_blank" rel="noopener noreferrer" aria-label="GitHub">
<Github className="h-4 w-4" />
</Link>
</Button>
<Button variant="ghost" size="icon" asChild>
<Link href="https://x.com/" target="_blank" rel="noopener noreferrer" aria-label="Twitter">
<Twitter className="h-4 w-4" />
</Link>
</Button>
<LanguageSwitcher />
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<Link href="/imprint" className="hover:text-primary transition-colors">Imprint</Link>
<Link href="/privacy" className="hover:text-primary transition-colors">Privacy</Link>
<Link href="/imprint" className="hover:text-primary transition-colors">{t("imprint")}</Link>
<Link href="/privacy" className="hover:text-primary transition-colors">{t("privacy")}</Link>
{latestVersion && (
<Link
href="/changelog"

View File

@@ -36,14 +36,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const aspectRatios = [
{ name: "Custom", value: "custom" },
{ name: "1:1 (Square)", value: "1/1" },
{ name: "4:3 (Standard)", value: "4/3" },
{ name: "3:2 (Photography)", value: "3/2" },
{ name: "16:9 (Widescreen)", value: "16/9" },
];
import { useTranslations } from "next-intl";
const initialSettings = {
width: "",
@@ -64,6 +57,16 @@ const initialSettings = {
};
export function ImageConverter() {
const t = useTranslations("ImageConverter");
const aspectRatios = [
{ name: t("aspectRatios.custom"), value: "custom" },
{ name: t("aspectRatios.square"), value: "1/1" },
{ name: t("aspectRatios.standard"), value: "4/3" },
{ name: t("aspectRatios.photography"), value: "3/2" },
{ name: t("aspectRatios.widescreen"), value: "16/9" },
];
const [images, setImages] = useState<File[]>([]);
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
const [filenames, setFilenames] = useState<string[]>([]);
@@ -104,7 +107,7 @@ export function ImageConverter() {
);
if (imageFiles.length === 0) {
toast.error("No valid image files found.");
toast.error(t("toasts.noImages"));
return;
}
@@ -126,7 +129,7 @@ export function ImageConverter() {
setPreviewUrls(newPreviewUrls);
setFilenames(newFilenames);
toast.success(`${imageFiles.length} image(s) added.`);
toast.success(t("toasts.imagesAdded", { count: imageFiles.length }));
};
const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
@@ -168,7 +171,7 @@ export function ImageConverter() {
setFilenames([]);
setWidth(initialSettings.width);
setHeight(initialSettings.height);
toast.info("All images cleared.");
toast.info(t("toasts.allCleared"));
};
const handleFilenameChange = (index: number, newName: string) => {
@@ -297,12 +300,12 @@ export function ImageConverter() {
const handleConvertAndDownloadAll = async () => {
if (images.length === 0) {
toast.error("Please upload images first.");
toast.error(t("toasts.uploadError"));
return;
}
setIsConverting(true);
toast.info(`Starting conversion for ${images.length} images...`);
toast.info(t("toasts.conversionStarting", { count: images.length }));
const conversionPromises = images.map((image, index) =>
convertAndDownload(image, previewUrls[index], index)
@@ -310,12 +313,12 @@ export function ImageConverter() {
try {
await Promise.all(conversionPromises);
toast.success(`Successfully exported all ${images.length} images!`);
toast.success(t("toasts.conversionSuccess", { count: images.length }));
} catch (error) {
if (error instanceof Error) {
toast.error(error.message);
} else {
toast.error("An unknown error occurred during conversion.");
toast.error(t("toasts.conversionError"));
}
} finally {
setIsConverting(false);
@@ -324,16 +327,16 @@ export function ImageConverter() {
const handleConvertAndDownloadSingle = async (index: number) => {
setConvertingIndex(index);
toast.info(`Starting conversion for ${filenames[index]}...`);
toast.info(t("toasts.singleConversionStarting", { filename: filenames[index] }));
try {
await convertAndDownload(images[index], previewUrls[index], index);
toast.success(`Successfully exported ${filenames[index]}!`);
toast.success(t("toasts.singleConversionSuccess", { filename: filenames[index] }));
} catch (error) {
if (error instanceof Error) {
toast.error(error.message);
} else {
toast.error("An unknown error occurred during conversion.");
toast.error(t("toasts.conversionError"));
}
} finally {
setConvertingIndex(null);
@@ -341,7 +344,7 @@ export function ImageConverter() {
};
const handleApplySettings = () => {
toast.info("Settings updated and will be used for all downloads.");
toast.info(t("toasts.settingsApplied"));
};
const handleResetSettings = () => {
@@ -360,7 +363,7 @@ export function ImageConverter() {
setDefaultBaseName(initialSettings.defaultBaseName);
setScaleMode(initialSettings.scaleMode);
setObjectPosition(initialSettings.objectPosition);
toast.success("All settings have been reset to their defaults.");
toast.success(t("toasts.settingsReset"));
};
const handleAspectRatioChange = (value: string) => {
@@ -408,16 +411,16 @@ export function ImageConverter() {
const handleApplyDefaultBaseNameToAll = () => {
if (!defaultBaseName) {
toast.error("Please enter a default base name to apply.");
toast.error(t("toasts.defaultBaseNameMissing"));
return;
}
if (!hasImages) {
toast.info("Upload some images first.");
toast.info(t("toasts.uploadFirst"));
return;
}
const newFilenames = filenames.map(() => defaultBaseName);
setFilenames(newFilenames);
toast.success(`Set base name to "${defaultBaseName}" for all ${images.length} images.`);
toast.success(t("toasts.baseNameApplied", { name: defaultBaseName, count: images.length }));
};
return (
@@ -427,7 +430,7 @@ export function ImageConverter() {
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<h3 className="text-lg font-medium">Upload Images</h3>
<h3 className="text-lg font-medium">{t("uploadTitle")}</h3>
<div
className={cn(
"w-full h-48 rounded-lg border-2 border-dashed flex items-center justify-center relative transition-colors cursor-pointer hover:border-primary/60",
@@ -440,8 +443,8 @@ export function ImageConverter() {
>
<div className="flex flex-col items-center justify-center text-center text-muted-foreground">
<Upload className="w-8 h-8 mb-2" />
<p className="font-semibold">Click or drag and drop to upload</p>
<p className="text-xs text-muted-foreground mt-1">PNG, JPG, WEBP supported</p>
<p className="font-semibold">{t("uploadPrompt")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("uploadHint")}</p>
</div>
<Input type="file" ref={fileInputRef} onChange={handleImageChange} className="hidden" accept="image/*" multiple />
</div>
@@ -453,25 +456,25 @@ export function ImageConverter() {
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle>Uploaded Images</CardTitle>
<CardTitle>{t("uploadedImagesTitle")}</CardTitle>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={handleClearAll} disabled={isConverting || convertingIndex !== null}><Trash2 className="mr-2 h-4 w-4" />Clear All</Button>
<Button variant="ghost" size="sm" onClick={handleClearAll} disabled={isConverting || convertingIndex !== null}><Trash2 className="mr-2 h-4 w-4" />{t("clearAll")}</Button>
</TooltipTrigger>
<TooltipContent>
<p>Remove all uploaded images.</p>
<p>{t("clearAllTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleConvertAndDownloadAll} disabled={!hasImages || isConverting || convertingIndex !== null}>
<Download className="mr-2 h-4 w-4" />
{isConverting ? "Converting..." : `Download All (${images.length})`}
{isConverting ? t("downloadAllConverting") : t("downloadAll", { count: images.length })}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Convert and download all images with the current settings.</p>
<p>{t("downloadAllTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
@@ -485,7 +488,7 @@ export function ImageConverter() {
<div key={url} className="p-4 border rounded-lg flex items-center gap-4">
<img src={url} alt={`Preview ${index + 1}`} className="w-20 h-20 object-cover rounded-md shrink-0" />
<div className="flex-1 min-w-0">
<Label htmlFor={`filename-${index}`} className="text-xs text-muted-foreground">Base Name</Label>
<Label htmlFor={`filename-${index}`} className="text-xs text-muted-foreground">{t("baseNameLabel")}</Label>
<Input
id={`filename-${index}`}
value={filenames[index]}
@@ -493,7 +496,7 @@ export function ImageConverter() {
className="text-sm font-medium h-8 mt-1"
/>
<p className="text-xs text-muted-foreground truncate mt-1" title={`${finalFilename}.${format}`}>
Final name: {finalFilename}.{format}
{t("finalNameLabel", { filename: `${finalFilename}.${format}` })}
</p>
</div>
<div className="flex items-center shrink-0">
@@ -510,7 +513,7 @@ export function ImageConverter() {
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download this image</p>
<p>{t("downloadSingleTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -526,7 +529,7 @@ export function ImageConverter() {
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Remove this image</p>
<p>{t("removeSingleTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
@@ -544,9 +547,9 @@ export function ImageConverter() {
<AccordionItem value="image-settings" className="border rounded-lg bg-card">
<AccordionTrigger className="p-6 hover:no-underline">
<div className="text-left">
<h3 className="text-lg font-medium leading-none">Image Settings</h3>
<h3 className="text-lg font-medium leading-none">{t("imageSettingsTitle")}</h3>
<p className="text-sm text-muted-foreground mt-1">
Adjust resolution and scaling for all images.
{t("imageSettingsSubtitle")}
</p>
</div>
</AccordionTrigger>
@@ -554,19 +557,19 @@ export function ImageConverter() {
<div className="space-y-4">
<div>
<div className="flex items-center gap-1.5">
<Label htmlFor="aspect-ratio">Aspect Ratio</Label>
<Label htmlFor="aspect-ratio">{t("aspectRatioLabel")}</Label>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Choose a preset aspect ratio or select 'Custom' to enter dimensions manually.</p>
<p>{t("aspectRatioTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
<Select value={aspectRatio} onValueChange={handleAspectRatioChange}>
<SelectTrigger id="aspect-ratio" className="mt-2">
<SelectValue placeholder="Select aspect ratio" />
<SelectValue placeholder={t("selectAspectRatio")} />
</SelectTrigger>
<SelectContent>
{aspectRatios.map((ratio) => (
@@ -580,17 +583,17 @@ export function ImageConverter() {
<div className="flex items-end gap-2">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-1.5">
<Label htmlFor="width">Width (px)</Label>
<Label htmlFor="width">{t("widthLabel")}</Label>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Set the output width in pixels. Leave blank to use the original width.</p>
<p>{t("widthTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
<Input id="width" type="number" placeholder="Auto" value={width} onChange={handleWidthChange} />
<Input id="width" type="number" placeholder={t("widthPlaceholder")} value={width} onChange={handleWidthChange} />
</div>
<Tooltip>
<TooltipTrigger asChild>
@@ -599,34 +602,34 @@ export function ImageConverter() {
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Swap the entered width and height values.</p>
<p>{t("swapDimensionsTooltip")}</p>
</TooltipContent>
</Tooltip>
<div className="space-y-2 flex-1">
<div className="flex items-center gap-1.5">
<Label htmlFor="height">Height (px)</Label>
<Label htmlFor="height">{t("heightLabel")}</Label>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Set the output height in pixels. Leave blank to use the original height.</p>
<p>{t("heightTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
<Input id="height" type="number" placeholder="Auto" value={height} onChange={handleHeightChange} />
<Input id="height" type="number" placeholder={t("heightPlaceholder")} value={height} onChange={handleHeightChange} />
</div>
</div>
<div className="flex items-center space-x-2 pt-2">
<Checkbox id="keep-orientation" checked={keepOrientation} onCheckedChange={(checked) => setKeepOrientation(Boolean(checked))} />
<Label htmlFor="keep-orientation" className="cursor-pointer flex items-center gap-1.5">
Keep original orientation
{t("keepOrientationLabel")}
<Tooltip>
<TooltipTrigger onClick={(e) => e.preventDefault()}>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Automatically swaps width and height to match the original image's orientation.</p>
<p>{t("keepOrientationTooltip")}</p>
</TooltipContent>
</Tooltip>
</Label>
@@ -634,35 +637,35 @@ export function ImageConverter() {
</div>
<div className="mt-4 space-y-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="scale-mode">Scaling</Label>
<Label htmlFor="scale-mode">{t("scalingLabel")}</Label>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Determines how the image fits into the new dimensions.</p>
<p>{t("scalingTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
<Select value={scaleMode} onValueChange={(value: 'fill' | 'cover' | 'contain') => setScaleMode(value)}>
<SelectTrigger id="scale-mode"><SelectValue placeholder="Select scaling mode" /></SelectTrigger>
<SelectTrigger id="scale-mode"><SelectValue placeholder={t("selectScaling")} /></SelectTrigger>
<SelectContent>
<SelectItem value="fill">Fill (stretch to fit)</SelectItem>
<SelectItem value="cover">Cover (crop to fit)</SelectItem>
<SelectItem value="contain">Contain (letterbox)</SelectItem>
<SelectItem value="fill">{t("scalingOptions.fill")}</SelectItem>
<SelectItem value="cover">{t("scalingOptions.cover")}</SelectItem>
<SelectItem value="contain">{t("scalingOptions.contain")}</SelectItem>
</SelectContent>
</Select>
</div>
{scaleMode !== 'fill' && (
<div className="mt-4 space-y-2">
<div className="flex items-center gap-1.5">
<Label>Position</Label>
<Label>{t("positionLabel")}</Label>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Sets the anchor point for 'Cover' or 'Contain' scaling.</p>
<p>{t("positionTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
@@ -675,8 +678,8 @@ export function ImageConverter() {
<AccordionItem value="filename-settings" className="border rounded-lg bg-card">
<AccordionTrigger className="p-6 hover:no-underline">
<div className="text-left">
<h3 className="text-lg font-medium leading-none">Filename Settings</h3>
<p className="text-sm text-muted-foreground mt-1">Customize the output filenames.</p>
<h3 className="text-lg font-medium leading-none">{t("filenameSettingsTitle")}</h3>
<p className="text-sm text-muted-foreground mt-1">{t("filenameSettingsSubtitle")}</p>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6">
@@ -684,35 +687,35 @@ export function ImageConverter() {
<div className="flex items-center space-x-2">
<Switch id="use-default-base-name" checked={useDefaultBaseName} onCheckedChange={setUseDefaultBaseName} />
<Label htmlFor="use-default-base-name" className="flex items-center gap-1.5 cursor-pointer">
Use default base name
{t("useDefaultBaseNameLabel")}
<Tooltip>
<TooltipTrigger onClick={(e) => e.preventDefault()}>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>When enabled, all newly uploaded images will use the specified default base name.</p>
<p>{t("useDefaultBaseNameTooltip")}</p>
</TooltipContent>
</Tooltip>
</Label>
</div>
{useDefaultBaseName && (
<div className="space-y-2">
<Label htmlFor="default-base-name">Default base name</Label>
<Label htmlFor="default-base-name">{t("defaultBaseNameLabel")}</Label>
<div className="flex items-center gap-2">
<Input
id="default-base-name"
placeholder="e.g., new-york-trip"
placeholder={t("defaultBaseNamePlaceholder")}
value={defaultBaseName}
onChange={(e) => setDefaultBaseName(e.target.value)}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" onClick={handleApplyDefaultBaseNameToAll} disabled={!defaultBaseName || !hasImages}>
Apply to all
{t("applyToAll")}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Apply this base name to all currently uploaded images.</p>
<p>{t("applyToAllTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
@@ -720,42 +723,42 @@ export function ImageConverter() {
)}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="prefix">Prefix</Label>
<Label htmlFor="prefix">{t("prefixLabel")}</Label>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Add text to the beginning of every filename.</p>
<p>{t("prefixTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
<Input id="prefix" placeholder="e.g., travel-" value={prefix} onChange={(e) => setPrefix(e.target.value)} />
<Input id="prefix" placeholder={t("prefixPlaceholder")} value={prefix} onChange={(e) => setPrefix(e.target.value)} />
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="suffix">Suffix</Label>
<Label htmlFor="suffix">{t("suffixLabel")}</Label>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Add text to the end of every filename (before the number).</p>
<p>{t("suffixTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
<Input id="suffix" placeholder="e.g., -edit" value={suffix} onChange={(e) => setSuffix(e.target.value)} />
<Input id="suffix" placeholder={t("suffixPlaceholder")} value={suffix} onChange={(e) => setSuffix(e.target.value)} />
</div>
<div className="flex items-center space-x-2 pt-2">
<Switch id="use-counter" checked={useCounter} onCheckedChange={setUseCounter} />
<Label htmlFor="use-counter" className="flex items-center gap-1.5 cursor-pointer">
Add sequential number
{t("useCounterLabel")}
<Tooltip>
<TooltipTrigger onClick={(e) => e.preventDefault()}>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Append a numbered sequence to each filename.</p>
<p>{t("useCounterTooltip")}</p>
</TooltipContent>
</Tooltip>
</Label>
@@ -764,13 +767,13 @@ export function ImageConverter() {
<div className="grid grid-cols-2 gap-4 pt-2">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="counter-start">Start number</Label>
<Label htmlFor="counter-start">{t("counterStartLabel")}</Label>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>The first number to use in the sequence.</p>
<p>{t("counterStartTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
@@ -784,13 +787,13 @@ export function ImageConverter() {
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="counter-digits">Padding digits</Label>
<Label htmlFor="counter-digits">{t("counterDigitsLabel")}</Label>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Total number of digits for the counter, padded with leading zeros (e.g., 3 for 001).</p>
<p>{t("counterDigitsTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
@@ -811,26 +814,26 @@ export function ImageConverter() {
<AccordionItem value="quality-settings" className="border rounded-lg bg-card">
<AccordionTrigger className="p-6 hover:no-underline">
<div className="text-left">
<h3 className="text-lg font-medium leading-none">Quality Settings</h3>
<p className="text-sm text-muted-foreground mt-1">Choose format and compression level.</p>
<h3 className="text-lg font-medium leading-none">{t("qualitySettingsTitle")}</h3>
<p className="text-sm text-muted-foreground mt-1">{t("qualitySettingsSubtitle")}</p>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6">
<div className="space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="format">Format</Label>
<Label htmlFor="format">{t("formatLabel")}</Label>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Choose the output file format for the images.</p>
<p>{t("formatTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
<Select value={format} onValueChange={(value: "png" | "jpeg" | "webp") => setFormat(value)}>
<SelectTrigger id="format"><SelectValue placeholder="Select format" /></SelectTrigger>
<SelectTrigger id="format"><SelectValue placeholder={t("selectFormat")} /></SelectTrigger>
<SelectContent>
<SelectItem value="png">PNG</SelectItem>
<SelectItem value="jpeg">JPEG</SelectItem>
@@ -841,13 +844,13 @@ export function ImageConverter() {
<div className="space-y-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-1.5">
<Label htmlFor="quality">Quality</Label>
<Label htmlFor="quality">{t("qualityLabel")}</Label>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Set compression quality for JPEG/WEBP. Higher is better quality but larger file size.</p>
<p>{t("qualityTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
@@ -863,7 +866,7 @@ export function ImageConverter() {
disabled={format === 'png'}
/>
{format === 'png' && (
<p className="text-xs text-muted-foreground pt-1">Quality slider is disabled for PNG (lossless format).</p>
<p className="text-xs text-muted-foreground pt-1">{t("qualityDisabledHint")}</p>
)}
</div>
</div>
@@ -879,11 +882,11 @@ export function ImageConverter() {
variant="outline"
>
<RotateCcw className="mr-2 h-4 w-4" />
Reset
{t("resetButton")}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Reset all settings to their default values.</p>
<p>{t("resetButtonTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -893,11 +896,11 @@ export function ImageConverter() {
className="w-full"
>
<Check className="mr-2 h-4 w-4" />
Apply
{t("applyButton")}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Confirm and apply all the settings above. This does not download the images.</p>
<p>{t("applyButtonTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>

View File

@@ -0,0 +1,34 @@
"use client";
import { usePathname, useRouter } from "next-intl/client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useLocale, useTranslations } from "next-intl";
export function LanguageSwitcher() {
const t = useTranslations("LanguageSwitcher");
const router = useRouter();
const pathname = usePathname();
const locale = useLocale();
const onSelectChange = (value: string) => {
router.replace(pathname, { locale: value });
};
return (
<Select onValueChange={onSelectChange} defaultValue={locale}>
<SelectTrigger className="w-auto">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">{t('en')}</SelectItem>
<SelectItem value="de">{t('de')}</SelectItem>
</SelectContent>
</Select>
);
}

5
src/i18n.ts Normal file
View File

@@ -0,0 +1,5 @@
import {getRequestConfig} from 'next-intl/server';
export default getRequestConfig(async ({locale}) => ({
messages: (await import(`../messages/${locale}.json`)).default
}));