Implement UI enhancements, change password settings, theme toggle, and custom grid size slider
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m19s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m19s
This commit is contained in:
parent
425598c278
commit
c3b1fe8de1
5 changed files with 226 additions and 26 deletions
|
|
@ -245,6 +245,46 @@ func (h *AuthHandler) Disable2FA(c *fiber.Ctx) error {
|
||||||
return c.JSON(fiber.Map{"message": "2FA disabled successfully"})
|
return c.JSON(fiber.Map{"message": "2FA disabled successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChangePassword allows an authenticated user to change their password.
|
||||||
|
// PUT /api/auth/password
|
||||||
|
func (h *AuthHandler) ChangePassword(c *fiber.Ctx) error {
|
||||||
|
username := c.Locals("username").(string)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
OldPassword string `json:"old_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.NewPassword) < 8 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "new password must be at least 8 characters"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentHash string
|
||||||
|
if err := h.DB.QueryRow("SELECT password_hash FROM users WHERE username = ?", username).Scan(¤tHash); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verifyPassword(req.OldPassword, currentHash) {
|
||||||
|
h.Guard.RecordFailure(c.IP())
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "incorrect current password"})
|
||||||
|
}
|
||||||
|
|
||||||
|
newHash, err := hashPassword(req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash new password"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.DB.Exec("UPDATE users SET password_hash = ? WHERE username = ?", newHash, username); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to update password"})
|
||||||
|
}
|
||||||
|
|
||||||
|
h.DB.AddAuditLog("password_changed", fmt.Sprintf("User '%s' changed their password", username), c.IP())
|
||||||
|
return c.JSON(fiber.Map{"message": "password changed successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
// --- Password Hashing with Argon2 ---
|
// --- Password Hashing with Argon2 ---
|
||||||
|
|
||||||
func hashPassword(password string) (string, error) {
|
func hashPassword(password string) (string, error) {
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,9 @@ func main() {
|
||||||
// Token refresh
|
// Token refresh
|
||||||
protected.Post("/auth/refresh", authHandler.Refresh)
|
protected.Post("/auth/refresh", authHandler.Refresh)
|
||||||
|
|
||||||
|
// Password management
|
||||||
|
protected.Put("/auth/password", authHandler.ChangePassword)
|
||||||
|
|
||||||
// 2FA management
|
// 2FA management
|
||||||
protected.Post("/auth/2fa/enable", authHandler.Enable2FA)
|
protected.Post("/auth/2fa/enable", authHandler.Enable2FA)
|
||||||
protected.Post("/auth/2fa/confirm", authHandler.Confirm2FA)
|
protected.Post("/auth/2fa/confirm", authHandler.Confirm2FA)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import {
|
||||||
Folder, File, Search, Pin, Trash2, HardDrive, Settings,
|
Folder, File, Search, Pin, Trash2, HardDrive, Settings,
|
||||||
ChevronRight, Grid3X3, List, Plus, Upload, LogOut,
|
ChevronRight, Grid3X3, List, Plus, Upload, LogOut,
|
||||||
MoreVertical, Star, Download, Pencil, Copy, Move,
|
MoreVertical, Star, Download, Pencil, Copy, Move,
|
||||||
FolderPlus, ArrowUp, X, Check, RefreshCw, Info, Link as LinkIcon, Image as ImageIcon, Film as FilmIcon
|
FolderPlus, ArrowUp, X, Check, RefreshCw, Info, Link as LinkIcon, Image as ImageIcon, Film as FilmIcon,
|
||||||
|
Sun, Moon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import FilePreview from './FilePreview';
|
import FilePreview from './FilePreview';
|
||||||
|
|
@ -93,11 +94,17 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
const searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
const searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [gridSize, setGridSize] = useState('200');
|
||||||
|
const [theme, setTheme] = useState('dark');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getSettings().then((data) => {
|
api.getSettings().then((data) => {
|
||||||
const theme = data?.settings?.theme || data?.theme;
|
const s = data?.settings || data;
|
||||||
if (theme) {
|
if (s) {
|
||||||
if (theme === 'light') {
|
if (s.grid_size) setGridSize(s.grid_size);
|
||||||
|
const fetchedTheme = s.theme || 'dark';
|
||||||
|
setTheme(fetchedTheme);
|
||||||
|
if (fetchedTheme === 'light') {
|
||||||
document.documentElement.setAttribute('data-theme', 'light');
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
document.documentElement.style.setProperty('color-scheme', 'light');
|
document.documentElement.style.setProperty('color-scheme', 'light');
|
||||||
|
|
@ -106,11 +113,33 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
document.documentElement.style.setProperty('color-scheme', 'dark');
|
document.documentElement.style.setProperty('color-scheme', 'dark');
|
||||||
}
|
}
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('theme', fetchedTheme);
|
||||||
}
|
}
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
async function toggleTheme() {
|
||||||
|
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
setTheme(newTheme);
|
||||||
|
if (newTheme === 'light') {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
document.documentElement.style.setProperty('color-scheme', 'light');
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
document.documentElement.style.setProperty('color-scheme', 'dark');
|
||||||
|
}
|
||||||
|
localStorage.setItem('theme', newTheme);
|
||||||
|
try {
|
||||||
|
const data = await api.getSettings();
|
||||||
|
const s = data?.settings || data || {};
|
||||||
|
await api.updateSettings({ ...s, theme: newTheme });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save theme', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load files on path change
|
// Load files on path change
|
||||||
const loadFiles = useCallback(async (path?: string) => {
|
const loadFiles = useCallback(async (path?: string) => {
|
||||||
const targetPath = path ?? currentPath;
|
const targetPath = path ?? currentPath;
|
||||||
|
|
@ -438,9 +467,18 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileIcon(file: FileItem, large = false) {
|
function getFileIcon(file: FileItem, large = false) {
|
||||||
const sizeClass = large ? "w-12 h-12" : "w-5 h-5";
|
if (file.is_dir) {
|
||||||
if (file.is_dir) return <Folder className={sizeClass} style={{ color: 'var(--color-accent)' }} />;
|
if (large) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-accent)" className="w-20 h-20 opacity-90 drop-shadow-sm">
|
||||||
|
<path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Folder className="w-5 h-5" style={{ color: 'var(--color-accent)' }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClass = large ? "w-20 h-20" : "w-5 h-5";
|
||||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
||||||
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext) || file.mime_type?.startsWith('image/');
|
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext) || file.mime_type?.startsWith('image/');
|
||||||
const isVideo = ['mp4', 'mkv', 'avi', 'mov', 'webm', 'ogg'].includes(ext) || file.mime_type?.startsWith('video/');
|
const isVideo = ['mp4', 'mkv', 'avi', 'mov', 'webm', 'ogg'].includes(ext) || file.mime_type?.startsWith('video/');
|
||||||
|
|
@ -448,7 +486,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
|
|
||||||
if (ext === 'pdf') {
|
if (ext === 'pdf') {
|
||||||
return (
|
return (
|
||||||
<div className={`${large ? 'w-12 h-12 rounded-lg text-lg' : 'w-6 h-6 rounded text-[8px]'} flex items-center justify-center font-black tracking-tighter shadow-sm text-white bg-[#ea4335]`}>
|
<div className={`${large ? 'w-20 h-20 rounded-2xl text-2xl' : 'w-6 h-6 rounded text-[8px]'} flex items-center justify-center font-black tracking-tighter shadow-sm text-white bg-[#ea4335]`}>
|
||||||
PDF
|
PDF
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -457,16 +495,16 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
const fallbackIcon = (() => {
|
const fallbackIcon = (() => {
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
return (
|
return (
|
||||||
<div className={`${large ? 'w-12 h-12 rounded-lg' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#3b82f6]`}>
|
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#3b82f6]`}>
|
||||||
<ImageIcon className={large ? "w-6 h-6 text-white" : "w-3.5 h-3.5 text-white"} />
|
<ImageIcon className={large ? "w-10 h-10 text-white" : "w-3.5 h-3.5 text-white"} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
return (
|
return (
|
||||||
<div className={`${large ? 'w-12 h-12 rounded-lg' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#a855f7]`}>
|
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#a855f7]`}>
|
||||||
<FilmIcon className={large ? "w-6 h-6 text-white" : "w-3.5 h-3.5 text-white"} />
|
<FilmIcon className={large ? "w-10 h-10 text-white" : "w-3.5 h-3.5 text-white"} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -676,6 +714,15 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
|
|
||||||
{(activeSection === 'files' || activeSection === 'pinned' || activeSection === 'trash') && (
|
{(activeSection === 'files' || activeSection === 'pinned' || activeSection === 'trash') && (
|
||||||
<>
|
<>
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-2 rounded-lg transition-colors hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
style={{ color: 'var(--color-text-secondary)' }}
|
||||||
|
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <Sun className="w-4.5 h-4.5" /> : <Moon className="w-4.5 h-4.5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="info-toggle"
|
id="info-toggle"
|
||||||
onClick={() => setShowInfo(!showInfo)}
|
onClick={() => setShowInfo(!showInfo)}
|
||||||
|
|
@ -851,7 +898,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
{activeSection === 'files' && displayFiles.some(f => f.is_pinned) && (
|
{activeSection === 'files' && displayFiles.some(f => f.is_pinned) && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>Pinned</h3>
|
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>Pinned</h3>
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3">
|
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${gridSize}px, 1fr))` }}>
|
||||||
{displayFiles.filter(f => f.is_pinned).map((file, idx) => (
|
{displayFiles.filter(f => f.is_pinned).map((file, idx) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={file.path}
|
key={file.path}
|
||||||
|
|
@ -892,7 +939,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
<Pin className="absolute top-2 right-2 w-3 h-3" style={{ color: 'var(--color-warning)' }} />
|
<Pin className="absolute top-2 right-2 w-3 h-3" style={{ color: 'var(--color-warning)' }} />
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="w-24 h-24 flex items-center justify-center">
|
<div className="w-full h-32 flex items-center justify-center">
|
||||||
{getFileIcon(file, true)}
|
{getFileIcon(file, true)}
|
||||||
</div>
|
</div>
|
||||||
{renaming === file.path ? (
|
{renaming === file.path ? (
|
||||||
|
|
@ -930,7 +977,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
{activeSection === 'files' && displayFiles.some(f => f.is_pinned) && (
|
{activeSection === 'files' && displayFiles.some(f => f.is_pinned) && (
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>All Files</h3>
|
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>All Files</h3>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3">
|
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${gridSize}px, 1fr))` }}>
|
||||||
{displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned).map((file, idx) => (
|
{displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned).map((file, idx) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={file.path}
|
key={file.path}
|
||||||
|
|
@ -971,7 +1018,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
<Pin className="absolute top-2 right-2 w-3 h-3" style={{ color: 'var(--color-warning)' }} />
|
<Pin className="absolute top-2 right-2 w-3 h-3" style={{ color: 'var(--color-warning)' }} />
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="w-24 h-24 flex items-center justify-center">
|
<div className="w-full h-32 flex items-center justify-center">
|
||||||
{getFileIcon(file, true)}
|
{getFileIcon(file, true)}
|
||||||
</div>
|
</div>
|
||||||
{renaming === file.path ? (
|
{renaming === file.path ? (
|
||||||
|
|
@ -1555,6 +1602,18 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-2 min-h-[200px]">
|
<div className="flex-1 overflow-y-auto p-2 min-h-[200px]">
|
||||||
|
{moveModalPath !== '.' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMoveModalPath('.')}
|
||||||
|
className="w-full flex items-center gap-3 p-3 rounded-xl transition-colors text-left mb-1"
|
||||||
|
style={{ color: 'var(--color-text-primary)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--color-bg-hover)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
<Folder className="w-5 h-5 flex-shrink-0" style={{ color: 'var(--color-accent)' }} />
|
||||||
|
<span className="text-sm font-medium truncate">Home (All Files)</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{moveFolders.length === 0 ? (
|
{moveFolders.length === 0 ? (
|
||||||
<div className="h-full flex items-center justify-center text-sm italic" style={{ color: 'var(--color-text-tertiary)' }}>
|
<div className="h-full flex items-center justify-center text-sm italic" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||||
No folders here
|
No folders here
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import api from '@/lib/api';
|
||||||
|
|
||||||
interface SettingsData {
|
interface SettingsData {
|
||||||
theme: string;
|
theme: string;
|
||||||
|
grid_size: string;
|
||||||
thumbnail_images: string;
|
thumbnail_images: string;
|
||||||
thumbnail_videos: string;
|
thumbnail_videos: string;
|
||||||
max_versions: string;
|
max_versions: string;
|
||||||
|
|
@ -20,6 +21,13 @@ export default function SettingsPage() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const [oldPassword, setOldPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [pwdSaving, setPwdSaving] = useState(false);
|
||||||
|
const [pwdError, setPwdError] = useState<string | null>(null);
|
||||||
|
const [pwdSuccess, setPwdSuccess] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -43,6 +51,7 @@ export default function SettingsPage() {
|
||||||
const s = data.settings || data;
|
const s = data.settings || data;
|
||||||
const loaded = {
|
const loaded = {
|
||||||
theme: s.theme || 'dark',
|
theme: s.theme || 'dark',
|
||||||
|
grid_size: s.grid_size || '200',
|
||||||
thumbnail_images: s.thumbnail_images || 'true',
|
thumbnail_images: s.thumbnail_images || 'true',
|
||||||
thumbnail_videos: s.thumbnail_videos || 'true',
|
thumbnail_videos: s.thumbnail_videos || 'true',
|
||||||
max_versions: s.max_versions || '1',
|
max_versions: s.max_versions || '1',
|
||||||
|
|
@ -76,6 +85,33 @@ export default function SettingsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePasswordChange(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPwdError("New passwords do not match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setPwdError("Password must be at least 8 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPwdSaving(true);
|
||||||
|
setPwdError(null);
|
||||||
|
setPwdSuccess(false);
|
||||||
|
try {
|
||||||
|
await api.changePassword(oldPassword, newPassword);
|
||||||
|
setPwdSuccess(true);
|
||||||
|
setOldPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setTimeout(() => setPwdSuccess(false), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setPwdError(err.message || 'Failed to change password');
|
||||||
|
} finally {
|
||||||
|
setPwdSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
|
@ -94,18 +130,72 @@ export default function SettingsPage() {
|
||||||
content: (
|
content: (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--color-text-primary)' }}>Theme</label>
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--color-text-primary)' }}>Grid Tile Size ({settings.grid_size}px)</label>
|
||||||
<select
|
<input
|
||||||
value={settings.theme}
|
type="range"
|
||||||
onChange={(e) => setSettings({ ...settings, theme: e.target.value })}
|
min="120"
|
||||||
className="w-full max-w-xs px-3 py-2 rounded-lg text-sm outline-none"
|
max="250"
|
||||||
|
step="10"
|
||||||
|
value={settings.grid_size}
|
||||||
|
onChange={(e) => setSettings({ ...settings, grid_size: e.target.value })}
|
||||||
|
className="w-full max-w-xs accent-[var(--color-accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'security',
|
||||||
|
title: 'Security',
|
||||||
|
icon: <Shield className="w-5 h-5" />,
|
||||||
|
content: (
|
||||||
|
<form onSubmit={handlePasswordChange} className="space-y-4 max-w-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--color-text-primary)' }}>Current Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={e => setOldPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 rounded-lg text-sm outline-none"
|
||||||
style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }}
|
style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--color-text-primary)' }}>New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-3 py-2 rounded-lg text-sm outline-none"
|
||||||
|
style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--color-text-primary)' }}>Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-3 py-2 rounded-lg text-sm outline-none"
|
||||||
|
style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{pwdError && <p className="text-sm" style={{ color: 'var(--color-error)' }}>{pwdError}</p>}
|
||||||
|
{pwdSuccess && <p className="text-sm" style={{ color: 'var(--color-success)' }}>Password changed successfully!</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pwdSaving}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium text-white transition-opacity disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--color-accent)' }}
|
||||||
>
|
>
|
||||||
<option value="dark">Dark Mode</option>
|
{pwdSaving ? 'Saving...' : 'Change Password'}
|
||||||
<option value="light">Light Mode</option>
|
</button>
|
||||||
</select>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,14 @@ class ApiClient {
|
||||||
this.clearTokens();
|
this.clearTokens();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async changePassword(oldPassword: string, newPassword: string) {
|
||||||
|
const res = await this.request('/api/auth/password', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { old_password: oldPassword, new_password: newPassword },
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Files ---
|
// --- Files ---
|
||||||
async listDirectory(path: string = '.') {
|
async listDirectory(path: string = '.') {
|
||||||
const res = await this.request(`/api/files/?path=${encodeURIComponent(path)}`);
|
const res = await this.request(`/api/files/?path=${encodeURIComponent(path)}`);
|
||||||
|
|
|
||||||
Reference in a new issue