feat: Implement internationalization with react-i18next, add translation files, and integrate language switching across components.

This commit is contained in:
2025-12-26 15:23:15 +01:00
parent 19434cc71a
commit e44771902c
15 changed files with 505 additions and 74 deletions

View File

@@ -25,15 +25,16 @@ import {
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { toast } from "sonner"
import { useTranslation } from "react-i18next"
const items = [
{
title: "Notes",
titleKey: "Notes",
url: "/",
icon: Home,
},
{
title: "Archive",
titleKey: "Archive",
url: "/archive",
icon: Archive,
},
@@ -50,12 +51,13 @@ function TagItem({ tag, isActive }: TagItemProps) {
const { mutate: deleteTag } = useDeleteTag();
const { mutate: renameTag } = useRenameTag();
const navigate = useNavigate();
const { t } = useTranslation();
const handleDelete = () => {
if (confirm(`Delete tag "${tag.name}"? Notes will keep their content.`)) {
if (confirm(t("Delete tag \"{{name}}\"? Notes will keep their content.", { name: tag.name }))) {
deleteTag(tag.id, {
onSuccess: () => {
toast.success("Tag deleted");
toast.success(t("Tag deleted"));
navigate("/");
},
onError: (err: any) => toast.error(err.message)
@@ -67,7 +69,7 @@ function TagItem({ tag, isActive }: TagItemProps) {
if (editName.trim() && editName.trim() !== tag.name) {
renameTag({ id: tag.id, name: editName.trim() }, {
onSuccess: () => {
toast.success("Tag renamed");
toast.success(t("Tag renamed"));
setIsEditing(false);
},
onError: (err: any) => {
@@ -130,11 +132,11 @@ function TagItem({ tag, isActive }: TagItemProps) {
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem onClick={(e) => { e.preventDefault(); setIsEditing(true); }}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Rename
{t("Rename")}
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => { e.preventDefault(); handleDelete(); }} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
{t("Delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -149,6 +151,7 @@ export function AppSidebar() {
const [searchParams] = useSearchParams();
const [settingsOpen, setSettingsOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(true);
const { t } = useTranslation();
const { data: tags } = useTags();
const activeTag = searchParams.get("tag");
@@ -158,24 +161,24 @@ export function AppSidebar() {
<Sidebar collapsible="icon">
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>K-Notes</SidebarGroupLabel>
<SidebarGroupLabel>{t("K-Notes")}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={location.pathname === item.url && !activeTag} tooltip={item.title}>
<SidebarMenuItem key={item.titleKey}>
<SidebarMenuButton asChild isActive={location.pathname === item.url && !activeTag} tooltip={t(item.titleKey)}>
<Link to={item.url}>
<item.icon />
<span>{item.title}</span>
<span>{t(item.titleKey)}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton onClick={() => setSettingsOpen(true)} tooltip="Settings">
<SidebarMenuButton onClick={() => setSettingsOpen(true)} tooltip={t("Settings")}>
<Settings />
<span>Settings</span>
<span>{t("Settings")}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -189,7 +192,7 @@ export function AppSidebar() {
<CollapsibleTrigger className="flex items-center justify-between w-full cursor-pointer group/collapsible">
<div className="flex items-center gap-1.5">
<Tag className="h-3.5 w-3.5" />
<span>Tags</span>
<span>{t("Tags")}</span>
</div>
<ChevronRight className="h-3.5 w-3.5 transition-transform group-data-[state=open]/collapsible:rotate-90" />
</CollapsibleTrigger>
@@ -208,7 +211,7 @@ export function AppSidebar() {
))
) : (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
No tags yet
{t("No tags yet")}
</div>
)}
</SidebarMenu>

View File

@@ -3,11 +3,13 @@ import { useDeleteNote, useUpdateNote } from "@/hooks/use-notes";
import { Button } from "@/components/ui/button";
import { Archive, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
export function BulkActionsBar() {
const { selectedIds, clearSelection, isBulkMode } = useBulkSelection();
const { mutate: deleteNote } = useDeleteNote();
const { mutate: updateNote } = useUpdateNote();
const { t } = useTranslation();
if (!isBulkMode) return null;
@@ -16,25 +18,25 @@ export function BulkActionsBar() {
ids.forEach((id) => {
updateNote({ id, is_archived: true });
});
toast.success(`Archived ${ids.length} note${ids.length > 1 ? "s" : ""}`);
toast.success(t("Archived {{count}} note", { count: ids.length, defaultValue_other: "Archived {{count}} notes" }));
clearSelection();
};
const handleDeleteAll = () => {
if (!confirm(`Are you sure you want to delete ${selectedIds.size} note(s)?`)) return;
if (!confirm(t("Are you sure you want to delete {{count}} note?", { count: selectedIds.size, defaultValue_other: "Are you sure you want to delete {{count}} notes?" }))) return;
const ids = Array.from(selectedIds);
ids.forEach((id) => {
deleteNote(id);
});
toast.success(`Deleted ${ids.length} note${ids.length > 1 ? "s" : ""}`);
toast.success(t("Deleted {{count}} note", { count: ids.length, defaultValue_other: "Deleted {{count}} notes" }));
clearSelection();
};
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-background border rounded-full px-4 py-2 shadow-lg animate-in slide-in-from-bottom-4 duration-200">
<span className="text-sm font-medium">
{selectedIds.size} selected
{t("{{count}} selected", { count: selectedIds.size })}
</span>
<div className="h-4 w-px bg-border" />
@@ -46,7 +48,7 @@ export function BulkActionsBar() {
className="gap-2"
>
<Archive className="h-4 w-4" />
Archive
{t("Archive")}
</Button>
<Button
@@ -56,7 +58,7 @@ export function BulkActionsBar() {
className="gap-2 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
Delete
{t("Delete")}
</Button>
<div className="h-4 w-px bg-border" />

View File

@@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
import { NoteForm } from "./note-form";
import { toast } from "sonner";
import { Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
interface CreateNoteDialogProps {
trigger?: React.ReactNode;
@@ -15,6 +16,7 @@ interface CreateNoteDialogProps {
export function CreateNoteDialog({ trigger, open: controlledOpen, onOpenChange }: CreateNoteDialogProps) {
const [internalOpen, setInternalOpen] = useState(false);
const { mutate: createNote, isPending } = useCreateNote();
const { t } = useTranslation();
// Support both controlled and uncontrolled modes
const isControlled = controlledOpen !== undefined;
@@ -29,7 +31,7 @@ export function CreateNoteDialog({ trigger, open: controlledOpen, onOpenChange }
createNote({ ...data, tags }, {
onSuccess: () => {
toast.success("Note created");
toast.success(t("Note created"));
setOpen(false);
},
onError: (error: any) => {
@@ -41,7 +43,7 @@ export function CreateNoteDialog({ trigger, open: controlledOpen, onOpenChange }
const defaultTrigger = (
<Button>
<Plus className="mr-2 h-4 w-4" />
New Note
{t("New Note")}
</Button>
);
@@ -59,12 +61,12 @@ export function CreateNoteDialog({ trigger, open: controlledOpen, onOpenChange }
)}
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create Note</DialogTitle>
<DialogTitle>{t("Create Note")}</DialogTitle>
<DialogDescription>
Add a new note to your collection.
{t("Add a new note to your collection.")}
</DialogDescription>
</DialogHeader>
<NoteForm onSubmit={onSubmit} isLoading={isPending} submitLabel="Create" />
<NoteForm onSubmit={onSubmit} isLoading={isPending} submitLabel={t("Create")} />
</DialogContent>
</Dialog>
);

View File

@@ -0,0 +1,40 @@
import { useTranslation } from "react-i18next";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Languages } from "lucide-react";
const LANGUAGES = [
{ code: "en", label: "English" },
{ code: "pl", label: "Polski" },
];
export function LanguageSwitcher() {
const { i18n, t } = useTranslation();
const changeLanguage = (languageCode: string) => {
i18n.changeLanguage(languageCode);
};
return (
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="language" className="text-right flex items-center gap-2">
<Languages className="h-4 w-4" />
{t("Language")}
</Label>
<div className="col-span-3 flex gap-2">
{LANGUAGES.map((lang) => (
<Button
key={lang.code}
variant={i18n.language === lang.code ? "default" : "outline"}
size="sm"
onClick={() => changeLanguage(lang.code)}
>
{lang.label}
</Button>
))}
</div>
</div>
</div>
);
}

View File

@@ -16,6 +16,7 @@ import { VersionHistoryDialog } from "./version-history-dialog";
import { NoteViewDialog } from "./note-view-dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { useBulkSelection } from "@/components/bulk-selection-context";
import { useTranslation } from "react-i18next";
interface NoteCardProps {
note: Note;
@@ -27,6 +28,7 @@ export function NoteCard({ note }: NoteCardProps) {
const [editing, setEditing] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [viewOpen, setViewOpen] = useState(false);
const { t } = useTranslation();
// Bulk selection
const { isSelected, toggleSelection, isBulkMode } = useBulkSelection();
@@ -57,7 +59,7 @@ export function NoteCard({ note }: NoteCardProps) {
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
if (confirm("Are you sure?")) {
if (confirm(t("Are you sure?"))) {
deleteNote(note.id);
}
}
@@ -74,7 +76,7 @@ export function NoteCard({ note }: NoteCardProps) {
}, {
onSuccess: () => {
setEditing(false);
toast.success("Note updated");
toast.success(t("Note updated"));
}
});
}
@@ -129,7 +131,7 @@ export function NoteCard({ note }: NoteCardProps) {
))}
</div>
<div className="flex justify-end w-full gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-black/5 dark:hover:bg-white/10" onClick={(e) => { e.stopPropagation(); setHistoryOpen(true); }} title="History">
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-black/5 dark:hover:bg-white/10" onClick={(e) => { e.stopPropagation(); setHistoryOpen(true); }} title={t("History")}>
<History className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-black/5 dark:hover:bg-white/10" onClick={(e) => { e.stopPropagation(); setEditing(true); }}>
@@ -151,7 +153,7 @@ export function NoteCard({ note }: NoteCardProps) {
<Dialog open={editing} onOpenChange={setEditing}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Note</DialogTitle>
<DialogTitle>{t("Edit Note")}</DialogTitle>
</DialogHeader>
<NoteForm
defaultValues={{
@@ -162,7 +164,7 @@ export function NoteCard({ note }: NoteCardProps) {
tags: note.tags.map(t => t.name).join(", "),
}}
onSubmit={handleEdit}
submitLabel="Update"
submitLabel={t("Update")}
/>
</DialogContent>
</Dialog>

View File

@@ -8,16 +8,17 @@ import { Input } from "@/components/ui/input";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Checkbox } from "@/components/ui/checkbox";
import { Editor } from "@/components/editor/editor";
import { useTranslation } from "react-i18next";
const noteSchema = z.object({
title: z.string().min(1, "Title is required").max(200, "Title too long"),
const noteSchema = (t: any) => z.object({
title: z.string().min(1, t("Title is required")).max(200, t("Title too long")),
content: z.string().optional(),
is_pinned: z.boolean().default(false),
tags: z.string().optional(), // Comma separated for now
color: z.string().default("DEFAULT"),
});
type NoteFormValues = z.infer<typeof noteSchema>;
type NoteFormValues = z.infer<ReturnType<typeof noteSchema>>;
interface NoteFormProps {
defaultValues?: Partial<NoteFormValues>;
@@ -27,8 +28,9 @@ interface NoteFormProps {
}
export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Save" }: NoteFormProps) {
const { t } = useTranslation();
const form = useForm<NoteFormValues>({
resolver: zodResolver(noteSchema) as any,
resolver: zodResolver(noteSchema(t)) as any,
defaultValues: {
title: "",
content: "",
@@ -47,9 +49,9 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormLabel>{t("Title")}</FormLabel>
<FormControl>
<Input placeholder="Note title" {...field} />
<Input placeholder={t("Note title")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -60,10 +62,10 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>Content</FormLabel>
<FormLabel>{t("Content")}</FormLabel>
<FormControl>
<Editor
placeholder="Note content... Type / for commands"
placeholder={t("Note content... Type / for commands")}
value={field.value}
onChange={field.onChange}
/>
@@ -77,9 +79,9 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>Tags (comma separated)</FormLabel>
<FormLabel>{t("Tags (comma separated)")}</FormLabel>
<FormControl>
<Input placeholder="work, todo, ideas" {...field} />
<Input placeholder={t("work, todo, ideas")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -91,7 +93,7 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa
name="color"
render={({ field }) => (
<FormItem>
<FormLabel>Color</FormLabel>
<FormLabel>{t("Color")}</FormLabel>
<FormControl>
<div className="flex gap-2 flex-wrap">
{NOTE_COLORS.map((color) => (
@@ -125,13 +127,13 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Pin this note</FormLabel>
<FormLabel>{t("Pin this note")}</FormLabel>
</div>
</FormItem>
)}
/>
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? "Saving..." : submitLabel}
{isLoading ? t("Saving...") : submitLabel}
</Button>
</form>
</Form>

View File

@@ -6,6 +6,8 @@ import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Separator } from "@/components/ui/separator";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/language-switcher";
interface SettingsDialogProps {
open: boolean;
@@ -15,6 +17,7 @@ interface SettingsDialogProps {
export function SettingsDialog({ open, onOpenChange, dataManagementEnabled }: SettingsDialogProps) {
const [url, setUrl] = useState("http://localhost:3000");
const { t } = useTranslation();
useEffect(() => {
const stored = localStorage.getItem("k_notes_api_url");
@@ -30,11 +33,11 @@ export function SettingsDialog({ open, onOpenChange, dataManagementEnabled }: Se
// Remove trailing slash if present
const cleanUrl = url.replace(/\/$/, "");
localStorage.setItem("k_notes_api_url", cleanUrl);
toast.success("Settings saved. Please refresh the page.");
toast.success(t("Settings saved. Please refresh the page."));
onOpenChange(false);
window.location.reload();
} catch (e) {
toast.error("Invalid URL");
toast.error(t("Invalid URL"));
}
};
@@ -51,9 +54,9 @@ export function SettingsDialog({ open, onOpenChange, dataManagementEnabled }: Se
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success("Export successful");
toast.success(t("Export successful"));
} catch (e) {
toast.error("Export failed");
toast.error(t("Export failed"));
}
};
@@ -65,12 +68,12 @@ export function SettingsDialog({ open, onOpenChange, dataManagementEnabled }: Se
const text = await file.text();
const data = JSON.parse(text);
await api.importData(data);
toast.success("Import successful. Reloading...");
toast.success(t("Import successful. Reloading..."));
onOpenChange(false);
window.location.reload();
} catch (e) {
console.error(e);
toast.error("Import failed");
toast.error(t("Import failed"));
}
};
@@ -78,15 +81,15 @@ export function SettingsDialog({ open, onOpenChange, dataManagementEnabled }: Se
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogTitle>{t("Settings")}</DialogTitle>
<DialogDescription>
Configure the application settings.
{t("Configure the application settings.")}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="url" className="text-right">
Backend URL
{t("Backend URL")}
</Label>
<Input
id="url"
@@ -98,22 +101,25 @@ export function SettingsDialog({ open, onOpenChange, dataManagementEnabled }: Se
</div>
</div>
<Separator className="my-2" />
<LanguageSwitcher />
{dataManagementEnabled && <>
<Separator className="my-2" />
<div className="py-4 space-y-4">
<div className="flex flex-col space-y-2">
<h4 className="font-medium leading-none">Data Management</h4>
<h4 className="font-medium leading-none">{t("Data Management")}</h4>
<p className="text-sm text-muted-foreground">
Export your notes for backup or import from a JSON file.
{t("Export your notes for backup or import from a JSON file.")}
</p>
</div>
<div className="flex gap-4">
<Button variant="outline" onClick={handleExport}>
Export Data
{t("Export Data")}
</Button>
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
Import Data
{t("Import Data")}
</Button>
<input
type="file"
@@ -127,7 +133,7 @@ export function SettingsDialog({ open, onOpenChange, dataManagementEnabled }: Se
</>}
<DialogFooter>
<Button onClick={handleSave}>Save changes</Button>
<Button onClick={handleSave}>{t("Save changes")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -1,10 +1,12 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import '../i18n';
import App from './App.tsx'
import { Providers } from "@/components/providers";
import { Toaster } from "@/components/ui/sonner";
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Providers>

View File

@@ -11,6 +11,7 @@ import Masonry from "react-masonry-css";
import { NoteCardSkeletonGrid } from "@/components/note-card-skeleton";
import { Badge } from "@/components/ui/badge";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useTranslation } from "react-i18next";
// Masonry breakpoint columns configuration
const masonryBreakpoints = {
@@ -26,6 +27,7 @@ export default function DashboardPage() {
const [searchParams] = useSearchParams();
const isArchive = location.pathname === "/archive";
const activeTag = searchParams.get("tag");
const { t } = useTranslation();
// View mode state
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
@@ -99,7 +101,7 @@ export default function DashboardPage() {
<Input
ref={searchInputRef}
id="search-input"
placeholder="Search your notes..."
placeholder={t("Search your notes...")}
className="pl-9 w-full bg-background"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
@@ -113,7 +115,7 @@ export default function DashboardPage() {
size="icon"
className={clsx("h-8 w-8", viewMode === "grid" && "bg-background shadow-sm")}
onClick={() => setViewMode("grid")}
title="Grid View"
title={t("Grid View")}
>
<LayoutGrid className="h-4 w-4" />
</Button>
@@ -122,7 +124,7 @@ export default function DashboardPage() {
size="icon"
className={clsx("h-8 w-8", viewMode === "list" && "bg-background shadow-sm")}
onClick={() => setViewMode("list")}
title="List View"
title={t("List View")}
>
<List className="h-4 w-4" />
</Button>
@@ -140,7 +142,7 @@ export default function DashboardPage() {
<div className="flex items-center gap-2 mb-4">
<span className="text-sm text-muted-foreground">Filtering by:</span>
<Badge variant="secondary" className="flex items-center gap-1">
{activeTag}
{t(activeTag)}
<Link to="/" className="ml-1 hover:text-destructive">
<X className="h-3 w-3" />
</Link>
@@ -164,12 +166,12 @@ export default function DashboardPage() {
<div className="text-center py-20 bg-background rounded-lg border border-dashed">
<div className="text-muted-foreground">
{searchQuery
? "No matching notes found"
? t("No matching notes found")
: activeTag
? `No notes with tag "${activeTag}"`
? t("No notes with tag \"${activeTag}\"")
: isArchive
? "No archived notes yet"
: "Your notes will appear here. Click + to create one."
? t("No archived notes yet")
: t("Your notes will appear here. Click + to create one.")
}
</div>
</div>
@@ -180,7 +182,7 @@ export default function DashboardPage() {
<div className="mb-6">
<div className="flex items-center gap-2 mb-3 text-muted-foreground">
<Pin className="h-4 w-4 rotate-45" />
<span className="text-sm font-medium uppercase tracking-wide">Pinned</span>
<span className="text-sm font-medium uppercase tracking-wide">{t("Pinned")}</span>
</div>
{renderNotes(pinnedNotes)}
</div>
@@ -191,7 +193,7 @@ export default function DashboardPage() {
<div>
{pinnedNotes.length > 0 && (
<div className="flex items-center gap-2 mb-3 text-muted-foreground border-t pt-4">
<span className="text-sm font-medium uppercase tracking-wide">Others</span>
<span className="text-sm font-medium uppercase tracking-wide">{t("Others")}</span>
</div>
)}
{renderNotes(unpinnedNotes)}