feat: Introduce dedicated settings page with API URL configuration and data management hooks, and update Dockerfile for runtime env injection.

This commit is contained in:
2025-12-26 15:55:55 +01:00
parent 23b3c5000f
commit 7840227649
14 changed files with 368 additions and 89 deletions

View File

@@ -1,5 +1,6 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { ProtectedRoute, PublicRoute } from "@/components/auth-guard";
import SettingsPage from "@/pages/settings";
import LoginPage from "@/pages/login";
import RegisterPage from "@/pages/register";
import DashboardPage from "@/pages/dashboard";
@@ -22,6 +23,7 @@ function App() {
<Route element={<Layout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/archive" element={<DashboardPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>

View File

@@ -10,7 +10,6 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar"
import { Link, useLocation, useSearchParams, useNavigate } from "react-router-dom"
import { SettingsDialog } from "@/components/settings-dialog"
import { useState } from "react"
import { useTags, useDeleteTag, useRenameTag } from "@/hooks/use-notes"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
@@ -149,7 +148,6 @@ function TagItem({ tag, isActive }: TagItemProps) {
export function AppSidebar() {
const location = useLocation();
const [searchParams] = useSearchParams();
const [settingsOpen, setSettingsOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(true);
const { t } = useTranslation();
@@ -176,9 +174,11 @@ export function AppSidebar() {
))}
<SidebarMenuItem>
<SidebarMenuButton onClick={() => setSettingsOpen(true)} tooltip={t("Settings")}>
<Settings />
<span>{t("Settings")}</span>
<SidebarMenuButton asChild isActive={location.pathname === "/settings"} tooltip={t("Settings")}>
<Link to="/settings">
<Settings />
<span>{t("Settings")}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -222,7 +222,6 @@ export function AppSidebar() {
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} dataManagementEnabled />
</>
)
}

View File

@@ -1,13 +1,12 @@
import { useRef, useState, useEffect } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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";
import { useApiUrl } from "@/hooks/use-api-url";
import { useDataManagement } from "@/hooks/use-data-management";
interface SettingsDialogProps {
open: boolean;
@@ -16,64 +15,13 @@ 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");
if (stored) {
setUrl(stored);
}
}, [open]);
const { apiUrl, setApiUrl, saveApiUrl } = useApiUrl();
const { fileInputRef, exportData, importData, triggerImport } = useDataManagement();
const handleSave = () => {
try {
// Basic validation
new URL(url);
// Remove trailing slash if present
const cleanUrl = url.replace(/\/$/, "");
localStorage.setItem("k_notes_api_url", cleanUrl);
toast.success(t("Settings saved. Please refresh the page."));
if (saveApiUrl(apiUrl)) {
onOpenChange(false);
window.location.reload();
} catch (e) {
toast.error(t("Invalid URL"));
}
};
const fileInputRef = useRef<HTMLInputElement>(null);
const handleExport = async () => {
try {
const blob = await api.exportData();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `k-notes-backup-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success(t("Export successful"));
} catch (e) {
toast.error(t("Export failed"));
}
};
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
await api.importData(data);
toast.success(t("Import successful. Reloading..."));
onOpenChange(false);
window.location.reload();
} catch (e) {
console.error(e);
toast.error(t("Import failed"));
}
};
@@ -93,8 +41,8 @@ export function SettingsDialog({ open, onOpenChange, dataManagementEnabled }: Se
</Label>
<Input
id="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
className="col-span-3"
placeholder="http://localhost:3000"
/>
@@ -115,10 +63,10 @@ export function SettingsDialog({ open, onOpenChange, dataManagementEnabled }: Se
</p>
</div>
<div className="flex gap-4">
<Button variant="outline" onClick={handleExport}>
<Button variant="outline" onClick={exportData}>
{t("Export Data")}
</Button>
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
<Button variant="outline" onClick={triggerImport}>
{t("Import Data")}
</Button>
<input
@@ -126,7 +74,7 @@ export function SettingsDialog({ open, onOpenChange, dataManagementEnabled }: Se
ref={fileInputRef}
className="hidden"
accept=".json"
onChange={handleImport}
onChange={importData}
/>
</div>
</div>

View File

@@ -0,0 +1,63 @@
import { useState, useEffect } from "react";
import { getBaseUrl } from "@/lib/api";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
export function useApiUrl() {
const { t } = useTranslation();
const [apiUrl, setApiUrl] = useState("");
const [currentApiUrl, setCurrentApiUrl] = useState("");
useEffect(() => {
const url = getBaseUrl();
setCurrentApiUrl(url);
setApiUrl(localStorage.getItem("k_notes_api_url") || "");
}, []);
const saveApiUrl = (url: string) => {
const trimmedUrl = url.trim();
if (!trimmedUrl) {
toast.error(t("Please enter a URL"));
return false;
}
try {
new URL(trimmedUrl);
const cleanUrl = trimmedUrl.replace(/\/$/, "");
localStorage.setItem("k_notes_api_url", cleanUrl);
toast.success(t("API URL updated successfully. Please reload the page."), {
action: {
label: t("Reload"),
onClick: () => window.location.reload(),
},
});
setCurrentApiUrl(cleanUrl);
return true;
} catch {
toast.error(t("Invalid URL format. Please enter a valid URL."));
return false;
}
};
const resetApiUrl = () => {
localStorage.removeItem("k_notes_api_url");
setApiUrl("");
const defaultUrl = window.env?.API_URL || "http://localhost:3000";
setCurrentApiUrl(defaultUrl);
toast.success(t("API URL reset to default. Please reload the page."), {
action: {
label: t("Reload"),
onClick: () => window.location.reload(),
},
});
};
return {
apiUrl,
setApiUrl,
currentApiUrl,
saveApiUrl,
resetApiUrl,
};
}

View File

@@ -0,0 +1,53 @@
import { useRef } from "react";
import { api } from "@/lib/api";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
export function useDataManagement() {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const exportData = async () => {
try {
const blob = await api.exportData();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `k-notes-backup-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success(t("Export successful"));
} catch (e) {
toast.error(t("Export failed"));
}
};
const importData = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
await api.importData(data);
toast.success(t("Import successful. Reloading..."));
setTimeout(() => window.location.reload(), 1000);
} catch (e) {
console.error(e);
toast.error(t("Import failed"));
}
};
const triggerImport = () => {
fileInputRef.current?.click();
};
return {
fileInputRef,
exportData,
importData,
triggerImport,
};
}

View File

@@ -0,0 +1,125 @@
import { useTranslation } from "react-i18next";
import { useApiUrl } from "@/hooks/use-api-url";
import { useDataManagement } from "@/hooks/use-data-management";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { LanguageSwitcher } from "@/components/language-switcher";
import { Settings as SettingsIcon, RotateCcw, Save, Download, Upload } from "lucide-react";
export default function SettingsPage() {
const { t } = useTranslation();
const { apiUrl, setApiUrl, currentApiUrl, saveApiUrl, resetApiUrl } = useApiUrl();
const { fileInputRef, exportData, importData, triggerImport } = useDataManagement();
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center gap-3 mb-6">
<SettingsIcon className="h-6 w-6" />
<h1 className="text-3xl font-bold">{t("Settings")}</h1>
</div>
{/* API Configuration */}
<Card>
<CardHeader>
<CardTitle>{t("API Configuration")}</CardTitle>
<CardDescription>
{t("Configure the backend API URL for this application")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("Current API URL")}
</Label>
<div className="p-3 bg-muted rounded-md font-mono text-sm break-all">
{currentApiUrl}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="api-url">{t("Custom API URL")}</Label>
<Input
id="api-url"
type="url"
placeholder="http://localhost:3000"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
className="font-mono"
/>
<p className="text-sm text-muted-foreground">
{t("Leave empty to use the default or Docker-injected URL")}
</p>
</div>
<div className="flex gap-2 pt-4">
<Button onClick={() => saveApiUrl(apiUrl)} className="flex items-center gap-2">
<Save className="h-4 w-4" />
{t("Save")}
</Button>
<Button
variant="outline"
onClick={resetApiUrl}
className="flex items-center gap-2"
>
<RotateCcw className="h-4 w-4" />
{t("Reset to Default")}
</Button>
</div>
</CardContent>
</Card>
{/* Language Settings */}
<Card>
<CardHeader>
<CardTitle>{t("Language")}</CardTitle>
<CardDescription>
{t("Choose your preferred language")}
</CardDescription>
</CardHeader>
<CardContent>
<LanguageSwitcher />
</CardContent>
</Card>
{/* Data Management */}
<Card>
<CardHeader>
<CardTitle>{t("Data Management")}</CardTitle>
<CardDescription>
{t("Export your notes for backup or import from a JSON file.")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Button
variant="outline"
onClick={exportData}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
{t("Export Data")}
</Button>
<Button
variant="outline"
onClick={triggerImport}
className="flex items-center gap-2"
>
<Upload className="h-4 w-4" />
{t("Import Data")}
</Button>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept=".json"
onChange={importData}
/>
</div>
</CardContent>
</Card>
</div>
);
}