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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
63
k-notes-frontend/src/hooks/use-api-url.ts
Normal file
63
k-notes-frontend/src/hooks/use-api-url.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
53
k-notes-frontend/src/hooks/use-data-management.ts
Normal file
53
k-notes-frontend/src/hooks/use-data-management.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
125
k-notes-frontend/src/pages/settings.tsx
Normal file
125
k-notes-frontend/src/pages/settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user