feat: implement trash auto-purge, settings layout update, larger folder icons, and legacy version cleanup
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m18s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m18s
This commit is contained in:
parent
c3b1fe8de1
commit
71ba029fbc
8 changed files with 256 additions and 103 deletions
|
|
@ -139,11 +139,9 @@ func (db *DB) migrate() error {
|
|||
INSERT INTO files_fts(rowid, name, path) VALUES (new.id, new.name, new.path);
|
||||
END`,
|
||||
|
||||
// Default settings
|
||||
`INSERT OR IGNORE INTO settings (key, value) VALUES ('theme', 'dark')`,
|
||||
`INSERT OR IGNORE INTO settings (key, value) VALUES ('thumbnail_images', 'true')`,
|
||||
`INSERT OR IGNORE INTO settings (key, value) VALUES ('thumbnail_videos', 'true')`,
|
||||
`INSERT OR IGNORE INTO settings (key, value) VALUES ('max_versions', '5')`,
|
||||
`INSERT OR IGNORE INTO settings (key, value) VALUES ('trash_auto_purge_days', '30')`,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ func (h *SettingsHandler) UpdateSettings(c *fiber.Ctx) error {
|
|||
"theme": true,
|
||||
"thumbnail_images": true,
|
||||
"thumbnail_videos": true,
|
||||
"max_versions": true,
|
||||
"trash_auto_purge_days": true,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ func main() {
|
|||
workers.InitTaskManager(cfg)
|
||||
workers.InitThumbnailManager(cfg, db)
|
||||
workers.StartBackupWorker(cfg, db)
|
||||
workers.StartTrashWorker(cfg, db)
|
||||
|
||||
// Create Fiber app
|
||||
app := fiber.New(fiber.Config{
|
||||
|
|
|
|||
85
backend/workers/trash.go
Normal file
85
backend/workers/trash.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package workers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
)
|
||||
|
||||
// StartTrashWorker runs a periodic task to permanently delete old items from the trash
|
||||
func StartTrashWorker(cfg *config.Config, db *database.DB) {
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
runTrashPurge(cfg, db)
|
||||
}
|
||||
}()
|
||||
|
||||
// Also run one purge 5 minutes after startup
|
||||
time.AfterFunc(5*time.Minute, func() {
|
||||
runTrashPurge(cfg, db)
|
||||
})
|
||||
}
|
||||
|
||||
func runTrashPurge(cfg *config.Config, db *database.DB) {
|
||||
purgeDaysStr, err := db.GetSetting("trash_auto_purge_days")
|
||||
if err != nil {
|
||||
purgeDaysStr = "30" // fallback
|
||||
}
|
||||
|
||||
purgeDays, err := strconv.Atoi(purgeDaysStr)
|
||||
if err != nil {
|
||||
purgeDays = 30
|
||||
}
|
||||
|
||||
if purgeDays <= 0 {
|
||||
return // 0 means disabled
|
||||
}
|
||||
|
||||
log.Printf("🗑️ Running trash auto-purge (items older than %d days)...", purgeDays)
|
||||
|
||||
rows, err := db.Query(
|
||||
"SELECT id, path FROM files WHERE is_trashed = 1 AND trashed_at <= date('now', '-' || ? || ' days')",
|
||||
purgeDays,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("❌ Failed to query old trash items: %v", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var deletedCount int
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var path string
|
||||
if err := rows.Scan(&id, &path); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
trashPath := filepath.Join(cfg.TrashDir, filepath.FromSlash(path))
|
||||
|
||||
// Remove physical file/directory
|
||||
if err := os.RemoveAll(trashPath); err != nil {
|
||||
log.Printf("❌ Failed to delete physical trash file %s: %v", trashPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove from DB
|
||||
if _, err := db.Exec("DELETE FROM files WHERE id = ?", id); err != nil {
|
||||
log.Printf("❌ Failed to delete DB record for %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
|
||||
deletedCount++
|
||||
}
|
||||
|
||||
if deletedCount > 0 {
|
||||
log.Printf("✅ Trash auto-purge completed: deleted %d old items.", deletedCount)
|
||||
db.AddAuditLog("trash_auto_purge", strconv.Itoa(deletedCount)+" items permanently deleted", "system")
|
||||
}
|
||||
}
|
||||
6
frontend/next-env.d.ts
vendored
Normal file
6
frontend/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Folder, File, Search, Pin, Trash2, HardDrive, Settings,
|
||||
|
|
@ -25,6 +25,7 @@ interface FileItem {
|
|||
is_pinned: boolean;
|
||||
is_trashed: boolean;
|
||||
mod_time: string;
|
||||
trashed_at?: string;
|
||||
checksum?: string;
|
||||
}
|
||||
|
||||
|
|
@ -96,12 +97,14 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
|
||||
const [gridSize, setGridSize] = useState('200');
|
||||
const [theme, setTheme] = useState('dark');
|
||||
const [trashAutoPurgeDays, setTrashAutoPurgeDays] = useState(30);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then((data) => {
|
||||
const s = data?.settings || data;
|
||||
if (s) {
|
||||
if (s.grid_size) setGridSize(s.grid_size);
|
||||
if (s.trash_auto_purge_days) setTrashAutoPurgeDays(parseInt(s.trash_auto_purge_days, 10));
|
||||
const fetchedTheme = s.theme || 'dark';
|
||||
setTheme(fetchedTheme);
|
||||
if (fetchedTheme === 'light') {
|
||||
|
|
@ -228,6 +231,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
setFiles(data.items?.map((item: any) => ({
|
||||
...item,
|
||||
is_trashed: true,
|
||||
trashed_at: item.trashed_at,
|
||||
})) || []);
|
||||
} catch {
|
||||
setFiles([]);
|
||||
|
|
@ -470,7 +474,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
if (file.is_dir) {
|
||||
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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-accent)" className="w-28 h-28 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>
|
||||
);
|
||||
|
|
@ -554,6 +558,60 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
|
||||
const groupedFiles = useMemo(() => {
|
||||
const baseFiles = displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned);
|
||||
if (activeSection !== 'trash' || trashAutoPurgeDays <= 0) {
|
||||
const defaultName = activeSection === 'files' && displayFiles.some(f => f.is_pinned) ? 'All Files' : '';
|
||||
return [{ name: defaultName, files: baseFiles }];
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
|
||||
const groupsMap: { [key: string]: FileItem[] } = {};
|
||||
const groupSortValue: { [key: string]: number } = {};
|
||||
|
||||
baseFiles.forEach(f => {
|
||||
if (!f.trashed_at) {
|
||||
if (!groupsMap['Unknown']) groupsMap['Unknown'] = [];
|
||||
groupsMap['Unknown'].push(f);
|
||||
groupSortValue['Unknown'] = 999;
|
||||
return;
|
||||
}
|
||||
|
||||
let tStr = f.trashed_at;
|
||||
if (tStr.includes(' ')) tStr = tStr.replace(' ', 'T');
|
||||
const trashedDate = new Date(tStr);
|
||||
if (isNaN(trashedDate.getTime())) {
|
||||
if (!groupsMap['Unknown']) groupsMap['Unknown'] = [];
|
||||
groupsMap['Unknown'].push(f);
|
||||
groupSortValue['Unknown'] = 999;
|
||||
return;
|
||||
}
|
||||
|
||||
const trashedDay = new Date(trashedDate.getFullYear(), trashedDate.getMonth(), trashedDate.getDate()).getTime();
|
||||
|
||||
if (trashedDay === today) {
|
||||
if (!groupsMap['Deleted Today']) groupsMap['Deleted Today'] = [];
|
||||
groupsMap['Deleted Today'].push(f);
|
||||
groupSortValue['Deleted Today'] = -1;
|
||||
} else {
|
||||
const expirationDate = trashedDate.getTime() + (trashAutoPurgeDays * 24 * 60 * 60 * 1000);
|
||||
const daysRemaining = Math.ceil((expirationDate - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
let groupName = `Deletes in ${daysRemaining} days`;
|
||||
if (daysRemaining === 1) groupName = `Deletes in 1 day`;
|
||||
else if (daysRemaining <= 0) groupName = `Pending Deletion`;
|
||||
|
||||
if (!groupsMap[groupName]) groupsMap[groupName] = [];
|
||||
groupsMap[groupName].push(f);
|
||||
groupSortValue[groupName] = daysRemaining;
|
||||
}
|
||||
});
|
||||
|
||||
const sortedGroupNames = Object.keys(groupsMap).sort((a, b) => groupSortValue[a] - groupSortValue[b]);
|
||||
return sortedGroupNames.map(name => ({ name, files: groupsMap[name] }));
|
||||
}, [displayFiles, activeSection, trashAutoPurgeDays]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||
{/* === SIDEBAR === */}
|
||||
|
|
@ -602,33 +660,21 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
</button>
|
||||
))}
|
||||
|
||||
<div className="pt-4">
|
||||
<div className="h-px mx-2 mb-4" style={{ backgroundColor: 'var(--color-border-subtle)' }} />
|
||||
<button
|
||||
id="nav-settings"
|
||||
onClick={() => handleSectionChange('settings')}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150"
|
||||
style={{
|
||||
backgroundColor: activeSection === 'settings' ? 'var(--color-accent-subtle)' : 'transparent',
|
||||
color: activeSection === 'settings' ? 'var(--color-accent)' : 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<Settings className="w-4.5 h-4.5" />
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Logout */}
|
||||
{/* Settings Footer */}
|
||||
<div className="px-3 py-4 border-t" style={{ borderColor: 'var(--color-border-subtle)' }}>
|
||||
<button
|
||||
id="logout-button"
|
||||
onClick={onLogout}
|
||||
id="nav-settings"
|
||||
onClick={() => handleSectionChange('settings')}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150 hover:opacity-80"
|
||||
style={{ color: 'var(--color-danger)' }}
|
||||
style={{
|
||||
backgroundColor: activeSection === 'settings' ? 'var(--color-accent-subtle)' : 'transparent',
|
||||
color: activeSection === 'settings' ? 'var(--color-accent)' : 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<LogOut className="w-4.5 h-4.5" />
|
||||
Sign Out
|
||||
<Settings className="w-4.5 h-4.5" />
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -674,30 +720,32 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
<input
|
||||
id="search-input"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder="Search files..."
|
||||
className="w-full pl-9 pr-4 py-2 rounded-lg text-sm outline-none transition-all"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-tertiary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => { setSearchQuery(''); setSearchResults(null); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{activeSection !== 'settings' && (
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
<input
|
||||
id="search-input"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder="Search files..."
|
||||
className="w-full pl-9 pr-4 py-2 rounded-lg text-sm outline-none transition-all"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-tertiary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => { setSearchQuery(''); setSearchResults(null); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -805,7 +853,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
|
||||
{/* Dynamic Content Area */}
|
||||
{activeSection === 'settings' ? (
|
||||
<SettingsPage />
|
||||
<SettingsPage onLogout={onLogout} />
|
||||
) : activeSection === 'storage' ? (
|
||||
<StorageDashboard />
|
||||
) : (
|
||||
|
|
@ -974,11 +1022,13 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
)}
|
||||
|
||||
<div>
|
||||
{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>
|
||||
)}
|
||||
<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) => (
|
||||
{groupedFiles.map((group, groupIdx) => (
|
||||
<div key={group.name} className={groupIdx > 0 || (activeSection === 'files' && displayFiles.some(f => f.is_pinned)) ? 'mt-8' : ''}>
|
||||
{group.name && (
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>{group.name}</h3>
|
||||
)}
|
||||
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${gridSize}px, 1fr))` }}>
|
||||
{group.files.map((file, idx) => (
|
||||
<motion.div
|
||||
key={file.path}
|
||||
draggable
|
||||
|
|
@ -1047,8 +1097,10 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -1118,14 +1170,19 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
)}
|
||||
|
||||
<div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="grid grid-cols-[1fr_100px_140px_40px] gap-4 px-4 py-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
<span>{activeSection === 'files' && displayFiles.some(f => f.is_pinned) ? 'All Files' : 'Name'}</span>
|
||||
<span>Size</span>
|
||||
<span>Modified</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned).map((file, idx) => (
|
||||
{groupedFiles.map((group, groupIdx) => (
|
||||
<div key={group.name} className={groupIdx > 0 || (activeSection === 'files' && displayFiles.some(f => f.is_pinned)) ? 'mt-8' : ''}>
|
||||
{group.name && (
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider mb-2 px-4" style={{ color: 'var(--color-text-tertiary)' }}>{group.name}</h3>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
<div className="grid grid-cols-[1fr_100px_140px_40px] gap-4 px-4 py-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
<span>Name</span>
|
||||
<span>Size</span>
|
||||
<span>Modified</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{group.files.map((file, idx) => (
|
||||
<motion.div
|
||||
key={file.path}
|
||||
draggable
|
||||
|
|
@ -1179,11 +1236,13 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
<MoreVertical className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Bar */}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Settings, Shield, Image as ImageIcon, History, Trash2, Save, Loader2 } from 'lucide-react';
|
||||
import { Settings, Shield, Image as ImageIcon, History, Trash2, Save, Loader2, LogOut } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface SettingsData {
|
||||
|
|
@ -10,11 +10,14 @@ interface SettingsData {
|
|||
grid_size: string;
|
||||
thumbnail_images: string;
|
||||
thumbnail_videos: string;
|
||||
max_versions: string;
|
||||
trash_auto_purge_days: string;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
interface SettingsPageProps {
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export default function SettingsPage({ onLogout }: SettingsPageProps) {
|
||||
const [settings, setSettings] = useState<SettingsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
|
@ -54,7 +57,6 @@ export default function SettingsPage() {
|
|||
grid_size: s.grid_size || '200',
|
||||
thumbnail_images: s.thumbnail_images || 'true',
|
||||
thumbnail_videos: s.thumbnail_videos || 'true',
|
||||
max_versions: s.max_versions || '1',
|
||||
trash_auto_purge_days: s.trash_auto_purge_days || '14',
|
||||
};
|
||||
setSettings(loaded);
|
||||
|
|
@ -227,30 +229,7 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'versioning',
|
||||
title: 'File Versioning',
|
||||
icon: <History className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--color-text-primary)' }}>Maximum Versions to Keep</label>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Older versions will be automatically deleted when a file is overwritten more times than this limit. Set to 0 to disable versioning.
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={settings.max_versions}
|
||||
onChange={(e) => setSettings({ ...settings, max_versions: e.target.value })}
|
||||
className="w-full max-w-xs 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>
|
||||
)
|
||||
},
|
||||
|
||||
{
|
||||
id: 'trash',
|
||||
title: 'Trash Auto-Purge',
|
||||
|
|
@ -322,7 +301,16 @@ export default function SettingsPage() {
|
|||
|
||||
|
||||
|
||||
<div className="mt-8 flex justify-end">
|
||||
<div className="mt-8 flex justify-between items-center">
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-medium transition-all hover:opacity-80"
|
||||
style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', color: 'var(--color-danger)' }}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign Out
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
|
|
@ -13,11 +17,24 @@
|
|||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"next-env.d.ts",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue