diff --git a/backend/database/database.go b/backend/database/database.go index 2c91534..57beb4c 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -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')`, } diff --git a/backend/handlers/settings.go b/backend/handlers/settings.go index 2f7516e..b550426 100644 --- a/backend/handlers/settings.go +++ b/backend/handlers/settings.go @@ -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, } diff --git a/backend/main.go b/backend/main.go index 79cb467..e32ebe7 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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{ diff --git a/backend/workers/trash.go b/backend/workers/trash.go new file mode 100644 index 0000000..5eaa36f --- /dev/null +++ b/backend/workers/trash.go @@ -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") + } +} diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/src/components/FileExplorer.tsx b/frontend/src/components/FileExplorer.tsx index f5847e5..3ae5358 100644 --- a/frontend/src/components/FileExplorer.tsx +++ b/frontend/src/components/FileExplorer.tsx @@ -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 ( - + ); @@ -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 ( {/* === SIDEBAR === */} @@ -602,33 +660,21 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { ))} - - - 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 - - - {/* Logout */} + {/* Settings Footer */} 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)', + }} > - - Sign Out + + Settings @@ -674,30 +720,32 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { {/* Search Bar */} - - - 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 && ( - { setSearchQuery(''); setSearchResults(null); }} - className="absolute right-3 top-1/2 -translate-y-1/2" - > - - - )} - + {activeSection !== 'settings' && ( + + + 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 && ( + { setSearchQuery(''); setSearchResults(null); }} + className="absolute right-3 top-1/2 -translate-y-1/2" + > + + + )} + + )} {/* Action Buttons */} @@ -805,7 +853,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { {/* Dynamic Content Area */} {activeSection === 'settings' ? ( - + ) : activeSection === 'storage' ? ( ) : ( @@ -974,11 +1022,13 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { )} - {activeSection === 'files' && displayFiles.some(f => f.is_pinned) && ( - All Files - )} - - {displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned).map((file, idx) => ( + {groupedFiles.map((group, groupIdx) => ( + 0 || (activeSection === 'files' && displayFiles.some(f => f.is_pinned)) ? 'mt-8' : ''}> + {group.name && ( + {group.name} + )} + + {group.files.map((file, idx) => ( - ))} - + ))} + + + ))} ) : ( @@ -1118,14 +1170,19 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { )} - - - {activeSection === 'files' && displayFiles.some(f => f.is_pinned) ? 'All Files' : 'Name'} - Size - Modified - - - {displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned).map((file, idx) => ( + {groupedFiles.map((group, groupIdx) => ( + 0 || (activeSection === 'files' && displayFiles.some(f => f.is_pinned)) ? 'mt-8' : ''}> + {group.name && ( + {group.name} + )} + + + Name + Size + Modified + + + {group.files.map((file, idx) => ( - ))} - + ))} + + + ))} - )} + )} {/* Bulk Actions Bar */} diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index b09bf9c..c888ae3 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -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(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() { ) }, - { - id: 'versioning', - title: 'File Versioning', - icon: , - content: ( - - - Maximum Versions to Keep - - Older versions will be automatically deleted when a file is overwritten more times than this limit. Set to 0 to disable versioning. - - 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)' }} - /> - - - ) - }, + { id: 'trash', title: 'Trash Auto-Purge', @@ -322,7 +301,16 @@ export default function SettingsPage() { - + + + + Sign Out + +
- Older versions will be automatically deleted when a file is overwritten more times than this limit. Set to 0 to disable versioning. -