mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add multi-language support for 20 locales
Add complete i18n infrastructure with 21 supported languages: English, Simplified Chinese, Traditional Chinese, Japanese, Korean, Spanish, French, Italian, Brazilian Portuguese, German, Dutch, Swedish, Russian, Polish, Ukrainian, Arabic (RTL), Turkish, Hindi, Vietnamese, Indonesian, and Thai. - I18nProvider context with three-tier locale detection (user preference > navigator.languages > instance default > English) - ~1500 translation keys per locale with TypeScript-enforced completeness - Dynamic code-splitting: only the active locale is loaded at runtime - Language selectors in footer, login page, settings, and mobile sidebar - Arabic RTL support with CSS logical properties across all components - Tool names, descriptions, and categories translated via i18n helpers - Public API endpoint GET /api/v1/config/locale for instance default - Multi-script font stack (CJK, Arabic, Devanagari, Thai, Cyrillic) - format() and plural() helpers for interpolation and pluralization - API error translation mapping (translateApiError) - 36 Playwright e2e tests verifying all 21 locales load correctly - 25 unit tests for format, plural, locale detection, and completeness - Updated translations.md docs and CLAUDE.md conventions
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
import { en } from "@snapotter/shared";
|
||||
import { Shield } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||
|
||||
const t = en.analytics;
|
||||
|
||||
export function AnalyticsConsentPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { config, configLoaded, fetchConfig, acceptAnalytics, declineAnalytics, remindLater } =
|
||||
useAnalyticsStore();
|
||||
@@ -49,11 +48,15 @@ export function AnalyticsConsentPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-center">
|
||||
<h1 className="text-lg font-semibold text-foreground">{t.consentTitle}</h1>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{t.consentDescription}</p>
|
||||
<h1 className="text-lg font-semibold text-foreground">{t.analytics.consentTitle}</h1>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{t.analytics.consentDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground/60">{t.consentChangeable}</p>
|
||||
<p className="text-center text-xs text-muted-foreground/60">
|
||||
{t.analytics.consentChangeable}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2.5">
|
||||
<button
|
||||
@@ -61,14 +64,14 @@ export function AnalyticsConsentPage() {
|
||||
onClick={handleAccept}
|
||||
className="flex-1 rounded-[10px] bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{t.acceptButton}
|
||||
{t.analytics.acceptButton}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDecline}
|
||||
className="flex-1 rounded-[10px] border border-border px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
{t.declineButton}
|
||||
{t.analytics.declineButton}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,15 +26,18 @@ import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { PipelineBuilder } from "@/components/tools/pipeline-builder";
|
||||
import { ToolPalette } from "@/components/tools/tool-palette";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { usePipelineProcessor } from "@/hooks/use-pipeline-processor";
|
||||
import { formatHeaders, getFileDownloadUrl } from "@/lib/api";
|
||||
import { formatFileSize } from "@/lib/download";
|
||||
import { format } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { type SavedPipeline, usePipelineStore } from "@/stores/pipeline-store";
|
||||
|
||||
export function AutomatePage() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
files,
|
||||
entries,
|
||||
@@ -273,19 +276,19 @@ export function AutomatePage() {
|
||||
const data = JSON.parse(text);
|
||||
|
||||
if (data.format !== "snapotter-pipeline") {
|
||||
setImportError("Not a valid SnapOtter pipeline file");
|
||||
setImportError(t.automate.invalidPipelineFile);
|
||||
return;
|
||||
}
|
||||
if (typeof data.version !== "number" || data.version > 1) {
|
||||
setImportError("This pipeline was created with a newer version of SnapOtter");
|
||||
setImportError(t.automate.newerVersion);
|
||||
return;
|
||||
}
|
||||
if (!data.name || typeof data.name !== "string") {
|
||||
setImportError("Pipeline file is missing a name");
|
||||
setImportError(t.automate.missingName);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(data.steps) || data.steps.length === 0) {
|
||||
setImportError("Pipeline file has no steps");
|
||||
setImportError(t.automate.noSteps);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -313,7 +316,7 @@ export function AutomatePage() {
|
||||
setSavedPipelines(listData.pipelines || []);
|
||||
}
|
||||
} catch {
|
||||
setImportError("Could not read pipeline file");
|
||||
setImportError(t.automate.couldNotRead);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
@@ -366,7 +369,7 @@ export function AutomatePage() {
|
||||
{/* Mobile header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border shrink-0">
|
||||
<Workflow className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold text-foreground flex-1">Automate</h1>
|
||||
<h1 className="text-base font-semibold text-foreground flex-1">{t.automate.title}</h1>
|
||||
{hasFile && (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||
{files.length} file{files.length !== 1 ? "s" : ""}
|
||||
@@ -385,7 +388,7 @@ export function AutomatePage() {
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-primary border border-dashed border-primary/40 rounded-lg hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Import from Library
|
||||
{t.automate.importFromLibrary}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -455,7 +458,7 @@ export function AutomatePage() {
|
||||
{hasFile && !hasProcessed && currentEntry?.status === "failed" && (
|
||||
<div className="mb-3 rounded-lg border border-border p-3 text-center">
|
||||
<p className="text-sm text-red-500">
|
||||
{currentEntry.error ?? "Processing failed for this file"}
|
||||
{currentEntry.error ?? t.toolPage.processingFailed}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -493,8 +496,8 @@ export function AutomatePage() {
|
||||
phase={progress.phase === "idle" ? "processing" : progress.phase}
|
||||
label={
|
||||
files.length > 1
|
||||
? `Processing ${files.length} files...`
|
||||
: "Processing pipeline..."
|
||||
? format(t.automate.processingMultiple, { count: files.length })
|
||||
: t.automate.processingPipeline
|
||||
}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
@@ -513,7 +516,7 @@ export function AutomatePage() {
|
||||
className="flex-1 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{files.length <= 1 ? "Process" : `Process (${files.length})`}
|
||||
{files.length <= 1 ? t.common.process : `${t.common.process} (${files.length})`}
|
||||
</button>
|
||||
{hasProcessed && batchZipBlob && (
|
||||
<button
|
||||
@@ -545,7 +548,7 @@ export function AutomatePage() {
|
||||
/>
|
||||
<div className="fixed inset-x-0 bottom-0 z-50 bg-background border-t border-border rounded-t-2xl shadow-xl max-h-[70vh] flex flex-col animate-in slide-in-from-bottom">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">Add Tool</h2>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t.automate.addTool}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileToolPaletteOpen(false)}
|
||||
@@ -582,8 +585,8 @@ export function AutomatePage() {
|
||||
<Layers className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">Tool Palette</h2>
|
||||
<p className="text-xs text-muted-foreground">Click to add to pipeline</p>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t.automate.toolPalette}</h2>
|
||||
<p className="text-xs text-muted-foreground">{t.automate.clickToAdd}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -594,7 +597,7 @@ export function AutomatePage() {
|
||||
<div className="px-3 py-2 border-t border-border shrink-0">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider">
|
||||
Saved
|
||||
{t.automate.savedLabel}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@@ -612,7 +615,7 @@ export function AutomatePage() {
|
||||
</div>
|
||||
)}
|
||||
{savedPipelines.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">No saved pipelines</p>
|
||||
<p className="text-xs text-muted-foreground italic">{t.automate.noSavedPipelines}</p>
|
||||
) : showAllSaved ? (
|
||||
<div className="space-y-1 max-h-36 overflow-y-auto">
|
||||
{savedPipelines.map((p) => (
|
||||
@@ -620,10 +623,10 @@ export function AutomatePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLoadPipeline(p)}
|
||||
className="flex-1 text-left text-xs text-foreground hover:text-primary truncate py-1 px-2 rounded hover:bg-muted"
|
||||
className="flex-1 text-start text-xs text-foreground hover:text-primary truncate py-1 px-2 rounded hover:bg-muted"
|
||||
>
|
||||
{p.name}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
<span className="text-muted-foreground ms-1">
|
||||
({p.steps.length} step{p.steps.length !== 1 ? "s" : ""})
|
||||
</span>
|
||||
</button>
|
||||
@@ -660,10 +663,10 @@ export function AutomatePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLoadPipeline(p)}
|
||||
className="flex-1 text-left text-xs text-foreground hover:text-primary truncate py-1 px-2 rounded hover:bg-muted"
|
||||
className="flex-1 text-start text-xs text-foreground hover:text-primary truncate py-1 px-2 rounded hover:bg-muted"
|
||||
>
|
||||
{p.name}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
<span className="text-muted-foreground ms-1">
|
||||
({p.steps.length} step{p.steps.length !== 1 ? "s" : ""})
|
||||
</span>
|
||||
</button>
|
||||
@@ -697,11 +700,13 @@ export function AutomatePage() {
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b border-border shrink-0">
|
||||
<Workflow className="h-5 w-5 text-primary" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-lg font-semibold text-foreground">Pipeline Builder</h1>
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
{t.automate.pipelineBuilder}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{steps.length === 0
|
||||
? "Add tools from the palette to get started"
|
||||
: `${steps.length} step${steps.length !== 1 ? "s" : ""} configured`}
|
||||
? t.automate.addToolsPrompt
|
||||
: `${steps.length} ${steps.length !== 1 ? "steps" : "step"} configured`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -744,14 +749,16 @@ export function AutomatePage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-muted-foreground italic">No files loaded</span>
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
{t.automate.noFilesLoaded}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLibraryModalOpen(true)}
|
||||
className="text-xs text-primary hover:text-primary/80 flex items-center gap-1"
|
||||
>
|
||||
<FolderOpen className="h-3 w-3" />
|
||||
Import from Library
|
||||
{t.automate.importFromLibrary}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -804,7 +811,9 @@ export function AutomatePage() {
|
||||
className="px-5 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium flex items-center gap-2 hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{files.length <= 1 ? "Process" : `Process All (${files.length})`}
|
||||
{files.length <= 1
|
||||
? t.common.process
|
||||
: format(t.automate.processAllButton, { count: files.length })}
|
||||
</button>
|
||||
|
||||
{hasProcessed && batchZipBlob && (
|
||||
@@ -814,7 +823,7 @@ export function AutomatePage() {
|
||||
className="px-4 py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download ZIP
|
||||
{t.automate.downloadZip}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -837,14 +846,14 @@ export function AutomatePage() {
|
||||
type="text"
|
||||
value={saveName}
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
placeholder="Pipeline name"
|
||||
placeholder={t.automate.pipelineName}
|
||||
className="text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground w-36"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={saveDescription}
|
||||
onChange={(e) => setSaveDescription(e.target.value)}
|
||||
placeholder="Description"
|
||||
placeholder={t.automate.descriptionPlaceholder}
|
||||
className="text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground w-36"
|
||||
/>
|
||||
<button
|
||||
@@ -853,7 +862,7 @@ export function AutomatePage() {
|
||||
disabled={!saveName.trim() || saving}
|
||||
className="px-3 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{saving ? "..." : "Save"}
|
||||
{saving ? "..." : t.common.save}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -889,10 +898,10 @@ export function AutomatePage() {
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Preview
|
||||
{t.automate.preview}
|
||||
</span>
|
||||
{hasFile && hasProcessed && processedSize != null && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
<span className="text-xs text-muted-foreground ms-auto">
|
||||
{formatFileSize(originalSize ?? 0)} → {formatFileSize(processedSize)}
|
||||
</span>
|
||||
)}
|
||||
@@ -948,7 +957,7 @@ export function AutomatePage() {
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-primary border border-dashed border-primary/40 rounded-lg hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Import from Library
|
||||
{t.automate.importFromLibrary}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -956,7 +965,7 @@ export function AutomatePage() {
|
||||
{hasFile && !hasProcessed && currentEntry?.status === "failed" && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 h-full text-center px-4">
|
||||
<p className="text-sm text-red-500">
|
||||
{currentEntry.error ?? "Processing failed for this file"}
|
||||
{currentEntry.error ?? t.toolPage.processingFailed}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
|
||||
/**
|
||||
@@ -61,6 +62,7 @@ function generatePassword(): string {
|
||||
}
|
||||
|
||||
export function ChangePasswordPage() {
|
||||
const { t } = useTranslation();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
@@ -80,7 +82,7 @@ export function ChangePasswordPage() {
|
||||
setError("");
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
setError(t.changePassword.passwordsMismatch);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,7 +105,7 @@ export function ChangePasswordPage() {
|
||||
triggerBrowserPasswordSave(username, newPassword);
|
||||
return; // navigation happens inside triggerBrowserPasswordSave
|
||||
} catch {
|
||||
setError("Connection error");
|
||||
setError(t.changePassword.failedError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -117,16 +119,13 @@ export function ChangePasswordPage() {
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
<span className="text-primary">SnapOtter</span>
|
||||
</h1>
|
||||
<h2 className="text-2xl font-bold mt-4 text-foreground">Change your password</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
You need to set a new password before continuing. Your password must be at least 8
|
||||
characters with uppercase, lowercase, and a number.
|
||||
</p>
|
||||
<h2 className="text-2xl font-bold mt-4 text-foreground">{t.changePassword.title}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">{t.changePassword.description}</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium mb-1 text-foreground">
|
||||
Username
|
||||
{t.changePassword.usernameLabel}
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
@@ -143,7 +142,7 @@ export function ChangePasswordPage() {
|
||||
htmlFor="current-password"
|
||||
className="block text-sm font-medium mb-1 text-foreground"
|
||||
>
|
||||
Current password
|
||||
{t.changePassword.currentPasswordLabel}
|
||||
</label>
|
||||
<input
|
||||
id="current-password"
|
||||
@@ -151,7 +150,7 @@ export function ChangePasswordPage() {
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="Enter current password"
|
||||
placeholder={t.changePassword.currentPasswordPlaceholder}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
required
|
||||
/>
|
||||
@@ -159,14 +158,14 @@ export function ChangePasswordPage() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label htmlFor="new-password" className="text-sm font-medium text-foreground">
|
||||
New password
|
||||
{t.changePassword.newPasswordLabel}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
className="text-xs text-primary hover:text-primary/80 font-medium"
|
||||
>
|
||||
Generate strong password
|
||||
{t.changePassword.generateButton}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
@@ -178,7 +177,7 @@ export function ChangePasswordPage() {
|
||||
setNewPassword(e.target.value);
|
||||
setShowGenerated(false);
|
||||
}}
|
||||
placeholder="At least 8 characters"
|
||||
placeholder={t.changePassword.newPasswordPlaceholder}
|
||||
className={`w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 ${showGenerated ? "font-mono text-sm" : ""}`}
|
||||
required
|
||||
minLength={8}
|
||||
@@ -189,7 +188,7 @@ export function ChangePasswordPage() {
|
||||
htmlFor="confirm-password"
|
||||
className="block text-sm font-medium mb-1 text-foreground"
|
||||
>
|
||||
Confirm new password
|
||||
{t.changePassword.confirmPasswordLabel}
|
||||
</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
@@ -200,7 +199,7 @@ export function ChangePasswordPage() {
|
||||
setConfirmPassword(e.target.value);
|
||||
setShowGenerated(false);
|
||||
}}
|
||||
placeholder="Repeat new password"
|
||||
placeholder={t.changePassword.confirmPasswordPlaceholder}
|
||||
className={`w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 ${showGenerated ? "font-mono text-sm" : ""}`}
|
||||
required
|
||||
minLength={8}
|
||||
@@ -212,17 +211,15 @@ export function ChangePasswordPage() {
|
||||
disabled={loading || !currentPassword || !newPassword || !confirmPassword}
|
||||
className="w-full py-3 rounded-lg bg-primary/80 text-primary-foreground font-medium hover:bg-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Changing..." : "Change password"}
|
||||
{loading ? t.changePassword.changingButton : t.changePassword.changeButton}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden lg:flex flex-1 bg-primary/90 items-center justify-center p-12 text-white rounded-l-3xl">
|
||||
<div className="hidden lg:flex flex-1 bg-primary/90 items-center justify-center p-12 text-white rounded-s-3xl">
|
||||
<div className="max-w-lg space-y-6 text-center">
|
||||
<h2 className="text-3xl font-bold">Almost there</h2>
|
||||
<p className="text-lg text-white/80">
|
||||
Set a strong password to secure your account, then you are good to go.
|
||||
</p>
|
||||
<h2 className="text-3xl font-bold">{t.changePassword.sidebarTitle}</h2>
|
||||
<p className="text-lg text-white/80">{t.changePassword.sidebarDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { EditorOptionsBar } from "@/components/editor/editor-options-bar";
|
||||
import { EditorRightPanel } from "@/components/editor/editor-right-panel";
|
||||
import { EditorStatusBar } from "@/components/editor/editor-status-bar";
|
||||
import { EditorToolbar } from "@/components/editor/editor-toolbar";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useEditorShortcuts } from "@/hooks/use-editor-shortcuts";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
@@ -26,6 +27,7 @@ import { useEditorStore } from "@/stores/editor-store";
|
||||
const SERVER_DECODED_EXTS = new Set(["psd", "tga", "exr", "hdr"]);
|
||||
|
||||
export function EditorPage() {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
||||
const isDirty = useEditorStore((s) => s.isDirty);
|
||||
@@ -141,11 +143,8 @@ export function EditorPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<Monitor size={48} className="text-muted-foreground mb-4" />
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">Desktop Recommended</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
The image editor works best on desktop screens. Please switch to a device with a larger
|
||||
display for the full editing experience.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">{t.editor.mobile.heading}</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">{t.editor.mobile.description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import { FileList } from "@/components/files/file-list";
|
||||
import { FileUploadArea } from "@/components/files/file-upload-area";
|
||||
import { FilesNav } from "@/components/files/files-nav";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||
|
||||
export function FilesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { activeTab, setActiveTab, selectedFileId } = useFilesPageStore();
|
||||
const isMobile = useMobile();
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
@@ -28,7 +30,7 @@ export function FilesPage() {
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Recent
|
||||
{t.files.recentTab}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -39,7 +41,7 @@ export function FilesPage() {
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Upload
|
||||
{t.files.uploadTab}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +79,7 @@ export function FilesPage() {
|
||||
>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-background rounded-t-xl p-4 max-h-[70vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-sm font-semibold">File Details</span>
|
||||
<span className="text-sm font-semibold">{t.files.fileDetailsHeading}</span>
|
||||
<button type="button" onClick={() => setShowDetails(false)}>
|
||||
<X className="h-5 w-5 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
@@ -4,13 +4,16 @@ import { Eye, EyeOff, FileImage, LayoutGrid, List, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { OtterLogo } from "@/components/common/otter-logo";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { ICON_MAP } from "@/lib/icon-map";
|
||||
import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFeaturesStore } from "@/stores/features-store";
|
||||
|
||||
export function FullscreenGridPage() {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [showDetails, setShowDetails] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
@@ -95,8 +98,8 @@ export function FullscreenGridPage() {
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search tools..."
|
||||
className="w-full pl-10 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
placeholder={t.common.search}
|
||||
className="w-full ps-10 pe-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +116,7 @@ export function FullscreenGridPage() {
|
||||
>
|
||||
{showDetails ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
<span className="hidden sm:inline">
|
||||
{showDetails ? "Hide Details" : "Show Details"}
|
||||
{showDetails ? t.fullscreenGrid.hideDetails : t.fullscreenGrid.showDetails}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -125,7 +128,7 @@ export function FullscreenGridPage() {
|
||||
title="Switch to sidebar view"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Sidebar</span>
|
||||
<span className="hidden sm:inline">{t.fullscreenGrid.sidebarButton}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -135,8 +138,8 @@ export function FullscreenGridPage() {
|
||||
{activeCategories.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Search className="h-12 w-12 mx-auto mb-4 opacity-30" />
|
||||
<p className="text-lg font-medium">No tools found</p>
|
||||
<p className="text-sm mt-1">Try a different search term</p>
|
||||
<p className="text-lg font-medium">{t.fullscreenGrid.noToolsFound}</p>
|
||||
<p className="text-sm mt-1">{t.fullscreenGrid.tryDifferent}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
@@ -164,6 +167,7 @@ function CategoryCard({
|
||||
tools: Tool[];
|
||||
showDetails: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const CategoryIcon =
|
||||
(ICON_MAP[category.icon] as React.ComponentType<{ className?: string }>) ?? LayoutGrid;
|
||||
|
||||
@@ -181,7 +185,9 @@ function CategoryCard({
|
||||
<CategoryIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-foreground text-sm">{category.name}</h3>
|
||||
<h3 className="font-semibold text-foreground text-sm">
|
||||
{getCategoryName(t, category.id, category.name)}
|
||||
</h3>
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
@@ -207,16 +213,18 @@ function CategoryCard({
|
||||
>
|
||||
<ToolIcon className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{tool.name}</p>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{getToolName(t, tool.id, tool.name)}
|
||||
</p>
|
||||
{showDetails && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{tool.description}
|
||||
{getToolDescription(t, tool.id, tool.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{tool.experimental && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 font-medium shrink-0">
|
||||
Experimental
|
||||
{t.common.experimental}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
@@ -5,7 +5,9 @@ import { useNavigate } from "react-router-dom";
|
||||
import { ImageViewer } from "@/components/common/image-viewer";
|
||||
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { ICON_MAP } from "@/lib/icon-map";
|
||||
import { getCategoryName, getToolName } from "@/lib/tool-i18n";
|
||||
import { useFeaturesStore } from "@/stores/features-store";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -16,6 +18,7 @@ const QUICK_ACTION_IDS = ["resize", "compress", "convert", "remove-background"];
|
||||
let hasAppliedDefaultRedirect = false;
|
||||
|
||||
export function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
setFiles,
|
||||
files,
|
||||
@@ -108,14 +111,14 @@ export function HomePage() {
|
||||
onClick={reset}
|
||||
className="text-xs text-muted-foreground hover:text-foreground mt-2"
|
||||
>
|
||||
Change file
|
||||
{t.homePage.changeFile}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-3">
|
||||
Quick Actions
|
||||
{t.homePage.quickActions}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{QUICK_ACTION_IDS.map((id) => {
|
||||
@@ -130,20 +133,22 @@ export function HomePage() {
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => navigate(tool.route)}
|
||||
className="flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
|
||||
className="flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-start"
|
||||
>
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">{tool.name}</span>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{getToolName(t, tool.id, tool.name)}
|
||||
</span>
|
||||
{status === "not_installed" && (
|
||||
<Download className="h-3.5 w-3.5 text-muted-foreground ml-auto" />
|
||||
<Download className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
|
||||
)}
|
||||
{status === "queued" && (
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground ml-auto" />
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
|
||||
)}
|
||||
{status === "installing" && (
|
||||
<Loader2 className="h-3.5 w-3.5 text-muted-foreground ml-auto animate-spin" />
|
||||
<Loader2 className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -154,7 +159,7 @@ export function HomePage() {
|
||||
{/* All tools by category */}
|
||||
<div className="p-4">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-3">
|
||||
All Tools
|
||||
{t.homePage.allTools}
|
||||
</h3>
|
||||
{CATEGORIES.map((category) => {
|
||||
const categoryTools = TOOLS.filter((t) => t.category === category.id);
|
||||
@@ -165,7 +170,7 @@ export function HomePage() {
|
||||
className="text-xs font-medium text-muted-foreground mb-1.5"
|
||||
style={{ color: category.color }}
|
||||
>
|
||||
{category.name}
|
||||
{getCategoryName(t, category.id, category.name)}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{categoryTools.map((tool) => {
|
||||
@@ -178,18 +183,18 @@ export function HomePage() {
|
||||
key={tool.id}
|
||||
type="button"
|
||||
onClick={() => navigate(tool.route)}
|
||||
className="flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors hover:bg-muted text-foreground"
|
||||
className="flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-start transition-colors hover:bg-muted text-foreground"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm">{tool.name}</span>
|
||||
<span className="text-sm">{getToolName(t, tool.id, tool.name)}</span>
|
||||
{status === "not_installed" && (
|
||||
<Download className="h-3.5 w-3.5 text-muted-foreground ml-auto" />
|
||||
<Download className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
|
||||
)}
|
||||
{status === "queued" && (
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground ml-auto" />
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
|
||||
)}
|
||||
{status === "installing" && (
|
||||
<Loader2 className="h-3.5 w-3.5 text-muted-foreground ml-auto animate-spin" />
|
||||
<Loader2 className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -208,7 +213,7 @@ export function HomePage() {
|
||||
) : currentEntry?.previewLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
|
||||
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">Generating preview...</p>
|
||||
<p className="text-sm text-muted-foreground">{t.homePage.generatingPreview}</p>
|
||||
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
|
||||
</div>
|
||||
) : originalBlobUrl ? (
|
||||
@@ -219,7 +224,7 @@ export function HomePage() {
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>Loading preview...</p>
|
||||
<p>{t.homePage.loadingPreview}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,13 @@
|
||||
import { type FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { setToken } from "@/lib/api";
|
||||
|
||||
const phrases = [
|
||||
"No signups. No accounts.",
|
||||
"No uploads to the cloud. Ever.",
|
||||
"100% local processing.",
|
||||
"Free. Forever.",
|
||||
"No limits. No hidden caps.",
|
||||
"Works fully offline.",
|
||||
"Unlimited batch processing.",
|
||||
"51 image tools.",
|
||||
"15 AI models. Your hardware.",
|
||||
"Lightning fast. Built on Sharp.",
|
||||
"Air-gapped ready.",
|
||||
"One Docker container.",
|
||||
"Open source. Always.",
|
||||
"Every pixel stays on your hardware.",
|
||||
"Your server. Your rules.",
|
||||
"Deploys in a single docker pull.",
|
||||
"The self-hosted alternative.",
|
||||
"Resize, compress, convert. Locally.",
|
||||
"Free image editor. No watermarks.",
|
||||
"Bulk image processing. Unlimited.",
|
||||
"Remove backgrounds offline.",
|
||||
"Self-hosted Canva alternative.",
|
||||
"No API keys needed.",
|
||||
"GDPR compliant by design.",
|
||||
"Works without internet.",
|
||||
];
|
||||
import { format } from "@/lib/format";
|
||||
|
||||
function RotatingPhrase() {
|
||||
const { t } = useTranslation();
|
||||
const phrases = t.auth.rotatingPhrases;
|
||||
const [index, setIndex] = useState(0);
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
@@ -41,7 +17,7 @@ function RotatingPhrase() {
|
||||
setIndex((i) => (i + 1) % phrases.length);
|
||||
setVisible(true);
|
||||
}, 300);
|
||||
}, []);
|
||||
}, [phrases.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(advance, 3000);
|
||||
@@ -61,7 +37,96 @@ function RotatingPhrase() {
|
||||
);
|
||||
}
|
||||
|
||||
function LanguageSelector() {
|
||||
const { locale, setLocale, supportedLocales } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
const current = supportedLocales.find((l) => l.code === locale);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-2 text-sm text-foreground/70 hover:text-foreground transition-colors px-3 py-2 rounded-lg border border-border hover:bg-muted/50"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role="img"
|
||||
aria-label="Language"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
{current?.nativeName ?? "English"}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute bottom-full mb-1 left-0 w-56 max-h-64 overflow-y-auto rounded-lg border border-border bg-background shadow-lg z-50">
|
||||
{supportedLocales.map((l) => (
|
||||
<button
|
||||
key={l.code}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLocale(l.code);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full text-start px-3 py-2 text-sm hover:bg-muted/50 flex items-center justify-between transition-colors"
|
||||
>
|
||||
<span
|
||||
className={l.code === locale ? "font-medium text-foreground" : "text-foreground"}
|
||||
>
|
||||
{l.nativeName}
|
||||
</span>
|
||||
{l.code === locale && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-primary shrink-0"
|
||||
role="img"
|
||||
aria-label="Selected"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
const { oidcEnabled, oidcProviderName } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [username, setUsername] = useState("");
|
||||
@@ -73,16 +138,15 @@ export function LoginPage() {
|
||||
const oidcError = searchParams.get("error");
|
||||
if (oidcError) {
|
||||
const errorMessages: Record<string, string> = {
|
||||
oidc_auth_failed: "Authentication failed. Please try again.",
|
||||
oidc_provider_unreachable: "Could not reach the identity provider. Please try again later.",
|
||||
oidc_session_expired: "Login session expired. Please try again.",
|
||||
oidc_user_not_authorized:
|
||||
"Your account is not authorized to access this application. Contact your administrator.",
|
||||
oidc_user_limit_reached: "User limit reached. Contact your administrator.",
|
||||
oidc_auth_failed: t.auth.oidcAuthFailed,
|
||||
oidc_provider_unreachable: t.auth.oidcProviderUnreachable,
|
||||
oidc_session_expired: t.auth.oidcSessionExpired,
|
||||
oidc_user_not_authorized: t.auth.oidcUserNotAuthorized,
|
||||
oidc_user_limit_reached: t.auth.oidcUserLimitReached,
|
||||
};
|
||||
setError(errorMessages[oidcError] || "Authentication error. Please try again.");
|
||||
setError(errorMessages[oidcError] || t.auth.oidcGenericError);
|
||||
}
|
||||
}, [searchParams]);
|
||||
}, [searchParams, t]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -96,21 +160,19 @@ export function LoginPage() {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error || "Invalid username or password");
|
||||
setError(t.auth.invalidCredentials);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setToken(data.token);
|
||||
// Store username for settings display
|
||||
localStorage.setItem("snapotter-username", data.user?.username || username);
|
||||
// Redirect to password change if required, otherwise go home
|
||||
if (data.user?.mustChangePassword) {
|
||||
window.location.href = "/change-password";
|
||||
} else {
|
||||
window.location.href = "/";
|
||||
}
|
||||
} catch {
|
||||
setError("Connection error");
|
||||
setError(t.auth.connectionError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -124,12 +186,12 @@ export function LoginPage() {
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
<span className="text-primary">SnapOtter</span>
|
||||
</h1>
|
||||
<h2 className="text-2xl font-bold mt-4 text-foreground">Login</h2>
|
||||
<h2 className="text-2xl font-bold mt-4 text-foreground">{t.auth.login}</h2>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium mb-1 text-foreground">
|
||||
Username
|
||||
{t.auth.username}
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
@@ -138,14 +200,14 @@ export function LoginPage() {
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter username"
|
||||
placeholder={t.auth.enterUsername}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1 text-foreground">
|
||||
Password
|
||||
{t.auth.password}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
@@ -154,7 +216,7 @@ export function LoginPage() {
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
placeholder={t.auth.enterPassword}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
required
|
||||
/>
|
||||
@@ -165,30 +227,33 @@ export function LoginPage() {
|
||||
disabled={loading || !username || !password}
|
||||
className="w-full py-3 rounded-lg bg-primary/80 text-primary-foreground font-medium hover:bg-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Logging in..." : "Login"}
|
||||
{loading ? t.auth.loggingIn : t.auth.loginButton}
|
||||
</button>
|
||||
</form>
|
||||
{oidcEnabled && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
<div className="flex-1 border-t border-border" />
|
||||
<span className="text-sm text-muted-foreground">or</span>
|
||||
<span className="text-sm text-muted-foreground">{t.auth.or}</span>
|
||||
<div className="flex-1 border-t border-border" />
|
||||
</div>
|
||||
<a
|
||||
href="/api/auth/oidc/login"
|
||||
className="w-full py-3 rounded-lg bg-secondary text-secondary-foreground font-medium hover:bg-secondary/80 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
Sign in with {oidcProviderName || "SSO"}
|
||||
{format(t.auth.signInWith, { provider: oidcProviderName || "SSO" })}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<div className="pt-2">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden lg:flex flex-1 bg-primary/90 items-center justify-center p-12 text-white rounded-l-3xl">
|
||||
<div className="hidden lg:flex flex-1 bg-primary/90 items-center justify-center p-12 text-white rounded-s-3xl">
|
||||
<div className="max-w-lg space-y-4 text-center">
|
||||
<h2 className="text-4xl font-extrabold tracking-tight">Your images. Stay yours.</h2>
|
||||
<p className="text-lg text-white/70">Every image tool you need.</p>
|
||||
<h2 className="text-4xl font-extrabold tracking-tight">{t.auth.heroTitle}</h2>
|
||||
<p className="text-lg text-white/70">{t.auth.heroSubtitle}</p>
|
||||
<p className="text-xl font-medium h-8">
|
||||
<RotatingPhrase />
|
||||
</p>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
|
||||
export function PrivacyPolicyPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="max-w-2xl mx-auto px-6 py-12">
|
||||
@@ -10,10 +12,10 @@ export function PrivacyPolicyPage() {
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors mb-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to app
|
||||
{t.common.back}
|
||||
</Link>
|
||||
|
||||
<h1 className="text-3xl font-bold mb-2">Privacy Policy</h1>
|
||||
<h1 className="text-3xl font-bold mb-2">{t.common.privacyPolicy}</h1>
|
||||
<p className="text-sm text-muted-foreground mb-8">Last updated: April 22, 2026</p>
|
||||
|
||||
<div className="space-y-6 text-sm leading-relaxed text-muted-foreground">
|
||||
@@ -42,13 +44,13 @@ export function PrivacyPolicyPage() {
|
||||
SnapOtter includes optional, anonymous product analytics. When you choose to
|
||||
participate, the following is collected:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||||
<ul className="list-disc ps-5 mt-2 space-y-1">
|
||||
<li>Which tools you use (e.g., "crop tool used")</li>
|
||||
<li>Error reports without file data</li>
|
||||
<li>App version and performance metrics</li>
|
||||
</ul>
|
||||
<p className="mt-2 font-medium">What is never collected:</p>
|
||||
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||||
<ul className="list-disc ps-5 mt-2 space-y-1">
|
||||
<li>Your images, PDFs, and files</li>
|
||||
<li>File names and contents</li>
|
||||
<li>Any personal information or IP addresses</li>
|
||||
|
||||
@@ -22,10 +22,12 @@ import { CropCanvas } from "@/components/tools/crop-canvas";
|
||||
import type { EraserCanvasRef } from "@/components/tools/eraser-canvas";
|
||||
import { EraserCanvas } from "@/components/tools/eraser-canvas";
|
||||
import type { PreviewTransform } from "@/components/tools/rotate-settings";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { formatFileSize } from "@/lib/download";
|
||||
import { ICON_MAP } from "@/lib/icon-map";
|
||||
import { getToolName } from "@/lib/tool-i18n";
|
||||
import { getToolRegistryEntry } from "@/lib/tool-registry";
|
||||
import { useBase64Store } from "@/stores/base64-store";
|
||||
import { useCollageStore } from "@/stores/collage-store";
|
||||
@@ -78,12 +80,11 @@ function FileSelectionInfo({
|
||||
onClear: () => void;
|
||||
onAddMore: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground italic">Drop or upload an image to get started</p>
|
||||
);
|
||||
return <p className="text-xs text-muted-foreground italic">{t.toolPage.emptyPrompt}</p>;
|
||||
}
|
||||
|
||||
const showToggle = files.length > COLLAPSED_LIMIT;
|
||||
@@ -110,7 +111,7 @@ function FileSelectionInfo({
|
||||
key={`${file.name}-${i}`}
|
||||
type="button"
|
||||
onClick={() => onSelect(i)}
|
||||
className={`w-full flex items-center gap-1.5 text-xs rounded px-2 py-1.5 text-left transition-colors ${isSelected ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-muted"}`}
|
||||
className={`w-full flex items-center gap-1.5 text-xs rounded px-2 py-1.5 text-start transition-colors ${isSelected ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-muted"}`}
|
||||
>
|
||||
{isSelected && <CheckCircle2 className="h-3 w-3 text-primary shrink-0" />}
|
||||
<span className="truncate flex-1 min-w-0">{file.name}</span>
|
||||
@@ -148,6 +149,7 @@ function FileSelectionInfo({
|
||||
}
|
||||
|
||||
export function ToolPage() {
|
||||
const { t } = useTranslation();
|
||||
const { toolId } = useParams<{ toolId: string }>();
|
||||
const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]);
|
||||
const registryEntry = useMemo(
|
||||
@@ -344,7 +346,7 @@ export function ToolPage() {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Tool not found
|
||||
{t.toolPage.notFound}
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
@@ -354,7 +356,7 @@ export function ToolPage() {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Loading...
|
||||
{t.common.loading}
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
@@ -444,7 +446,7 @@ export function ToolPage() {
|
||||
}
|
||||
return (
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-sm">Configure settings and generate.</p>
|
||||
<p className="text-sm">{t.toolPage.configureAndGenerate}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -455,7 +457,7 @@ export function ToolPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 h-full text-center px-4">
|
||||
<p className="text-sm text-red-500">
|
||||
{currentEntry.error ?? "Processing failed for this file"}
|
||||
{currentEntry.error ?? t.toolPage.processingFailed}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -549,7 +551,7 @@ export function ToolPage() {
|
||||
<CheckCircle2 className="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Conversion complete</p>
|
||||
<p className="text-sm font-medium">{t.toolPage.conversionComplete}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{processedFileName}</p>
|
||||
{processedSize != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -633,7 +635,7 @@ export function ToolPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
|
||||
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">Generating preview...</p>
|
||||
<p className="text-sm text-muted-foreground">{t.toolPage.generatingPreview}</p>
|
||||
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -748,7 +750,7 @@ export function ToolPage() {
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Settings</h3>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{t.common.settings}</h3>
|
||||
<Suspense fallback={<div className="text-xs text-muted-foreground">Loading...</div>}>
|
||||
<ToolSettings {...settingsProps} />
|
||||
</Suspense>
|
||||
@@ -762,7 +764,7 @@ export function ToolPage() {
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download All (ZIP)
|
||||
{t.toolPage.downloadAllZip}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -795,13 +797,15 @@ export function ToolPage() {
|
||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 className="font-semibold text-lg text-foreground flex-1">{tool.name}</h2>
|
||||
<h2 className="font-semibold text-lg text-foreground flex-1">
|
||||
{getToolName(t, tool.id, tool.name)}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)}
|
||||
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
{mobileSettingsOpen ? "Hide Settings" : "Settings"}
|
||||
{mobileSettingsOpen ? t.toolPage.hideSettings : t.common.settings}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -846,7 +850,9 @@ export function ToolPage() {
|
||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 className="font-semibold text-lg text-foreground">{tool.name}</h2>
|
||||
<h2 className="font-semibold text-lg text-foreground">
|
||||
{getToolName(t, tool.id, tool.name)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{renderSettingsContent()}
|
||||
|
||||
Reference in New Issue
Block a user