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

This commit is contained in:
Elijah 2026-05-22 18:41:16 -07:00
parent c3b1fe8de1
commit 71ba029fbc
8 changed files with 256 additions and 103 deletions

View file

@ -139,11 +139,9 @@ func (db *DB) migrate() error {
INSERT INTO files_fts(rowid, name, path) VALUES (new.id, new.name, new.path); INSERT INTO files_fts(rowid, name, path) VALUES (new.id, new.name, new.path);
END`, END`,
// Default settings
`INSERT OR IGNORE INTO settings (key, value) VALUES ('theme', 'dark')`, `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_images', 'true')`,
`INSERT OR IGNORE INTO settings (key, value) VALUES ('thumbnail_videos', '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')`, `INSERT OR IGNORE INTO settings (key, value) VALUES ('trash_auto_purge_days', '30')`,
} }

View file

@ -42,7 +42,6 @@ func (h *SettingsHandler) UpdateSettings(c *fiber.Ctx) error {
"theme": true, "theme": true,
"thumbnail_images": true, "thumbnail_images": true,
"thumbnail_videos": true, "thumbnail_videos": true,
"max_versions": true,
"trash_auto_purge_days": true, "trash_auto_purge_days": true,
} }

View file

@ -73,6 +73,7 @@ func main() {
workers.InitTaskManager(cfg) workers.InitTaskManager(cfg)
workers.InitThumbnailManager(cfg, db) workers.InitThumbnailManager(cfg, db)
workers.StartBackupWorker(cfg, db) workers.StartBackupWorker(cfg, db)
workers.StartTrashWorker(cfg, db)
// Create Fiber app // Create Fiber app
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{

85
backend/workers/trash.go Normal file
View 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
View 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.

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Folder, File, Search, Pin, Trash2, HardDrive, Settings, Folder, File, Search, Pin, Trash2, HardDrive, Settings,
@ -25,6 +25,7 @@ interface FileItem {
is_pinned: boolean; is_pinned: boolean;
is_trashed: boolean; is_trashed: boolean;
mod_time: string; mod_time: string;
trashed_at?: string;
checksum?: string; checksum?: string;
} }
@ -96,12 +97,14 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
const [gridSize, setGridSize] = useState('200'); const [gridSize, setGridSize] = useState('200');
const [theme, setTheme] = useState('dark'); const [theme, setTheme] = useState('dark');
const [trashAutoPurgeDays, setTrashAutoPurgeDays] = useState(30);
useEffect(() => { useEffect(() => {
api.getSettings().then((data) => { api.getSettings().then((data) => {
const s = data?.settings || data; const s = data?.settings || data;
if (s) { if (s) {
if (s.grid_size) setGridSize(s.grid_size); 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'; const fetchedTheme = s.theme || 'dark';
setTheme(fetchedTheme); setTheme(fetchedTheme);
if (fetchedTheme === 'light') { if (fetchedTheme === 'light') {
@ -228,6 +231,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
setFiles(data.items?.map((item: any) => ({ setFiles(data.items?.map((item: any) => ({
...item, ...item,
is_trashed: true, is_trashed: true,
trashed_at: item.trashed_at,
})) || []); })) || []);
} catch { } catch {
setFiles([]); setFiles([]);
@ -470,7 +474,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
if (file.is_dir) { if (file.is_dir) {
if (large) { if (large) {
return ( 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" /> <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> </svg>
); );
@ -554,6 +558,60 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
return sortAsc ? cmp : -cmp; 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 ( return (
<div className="flex h-screen" style={{ backgroundColor: 'var(--color-bg-primary)' }}> <div className="flex h-screen" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
{/* === SIDEBAR === */} {/* === SIDEBAR === */}
@ -602,12 +660,14 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</button> </button>
))} ))}
<div className="pt-4"> </nav>
<div className="h-px mx-2 mb-4" style={{ backgroundColor: 'var(--color-border-subtle)' }} />
{/* Settings Footer */}
<div className="px-3 py-4 border-t" style={{ borderColor: 'var(--color-border-subtle)' }}>
<button <button
id="nav-settings" id="nav-settings"
onClick={() => handleSectionChange('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" 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={{ style={{
backgroundColor: activeSection === 'settings' ? 'var(--color-accent-subtle)' : 'transparent', backgroundColor: activeSection === 'settings' ? 'var(--color-accent-subtle)' : 'transparent',
color: activeSection === 'settings' ? 'var(--color-accent)' : 'var(--color-text-secondary)', color: activeSection === 'settings' ? 'var(--color-accent)' : 'var(--color-text-secondary)',
@ -617,20 +677,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
Settings Settings
</button> </button>
</div> </div>
</nav>
{/* Logout */}
<div className="px-3 py-4 border-t" style={{ borderColor: 'var(--color-border-subtle)' }}>
<button
id="logout-button"
onClick={onLogout}
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)' }}
>
<LogOut className="w-4.5 h-4.5" />
Sign Out
</button>
</div>
</aside> </aside>
{/* === MAIN CONTENT === */} {/* === MAIN CONTENT === */}
@ -674,6 +720,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</div> </div>
{/* Search Bar */} {/* Search Bar */}
{activeSection !== 'settings' && (
<div className="relative w-72"> <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)' }} /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
<input <input
@ -698,6 +745,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</button> </button>
)} )}
</div> </div>
)}
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -805,7 +853,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
{/* Dynamic Content Area */} {/* Dynamic Content Area */}
{activeSection === 'settings' ? ( {activeSection === 'settings' ? (
<SettingsPage /> <SettingsPage onLogout={onLogout} />
) : activeSection === 'storage' ? ( ) : activeSection === 'storage' ? (
<StorageDashboard /> <StorageDashboard />
) : ( ) : (
@ -974,11 +1022,13 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
)} )}
<div> <div>
{activeSection === 'files' && displayFiles.some(f => f.is_pinned) && ( {groupedFiles.map((group, groupIdx) => (
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>All Files</h3> <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))` }}> <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) => ( {group.files.map((file, idx) => (
<motion.div <motion.div
key={file.path} key={file.path}
draggable draggable
@ -1050,6 +1100,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
))} ))}
</div> </div>
</div> </div>
))}
</div>
</div> </div>
) : ( ) : (
/* List View */ /* List View */
@ -1118,14 +1170,19 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
)} )}
<div> <div>
{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="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)' }}> <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>Name</span>
<span>Size</span> <span>Size</span>
<span>Modified</span> <span>Modified</span>
<span></span> <span></span>
</div> </div>
{displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned).map((file, idx) => ( {group.files.map((file, idx) => (
<motion.div <motion.div
key={file.path} key={file.path}
draggable draggable
@ -1182,6 +1239,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
))} ))}
</div> </div>
</div> </div>
))}
</div>
</div> </div>
)} )}
</div> </div>

View file

@ -2,7 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; 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'; import api from '@/lib/api';
interface SettingsData { interface SettingsData {
@ -10,11 +10,14 @@ interface SettingsData {
grid_size: string; grid_size: string;
thumbnail_images: string; thumbnail_images: string;
thumbnail_videos: string; thumbnail_videos: string;
max_versions: string;
trash_auto_purge_days: 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 [settings, setSettings] = useState<SettingsData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -54,7 +57,6 @@ export default function SettingsPage() {
grid_size: s.grid_size || '200', 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',
trash_auto_purge_days: s.trash_auto_purge_days || '14', trash_auto_purge_days: s.trash_auto_purge_days || '14',
}; };
setSettings(loaded); setSettings(loaded);
@ -227,30 +229,7 @@ export default function SettingsPage() {
</div> </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', id: 'trash',
title: 'Trash Auto-Purge', 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 <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}

View file

@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@ -13,11 +17,24 @@
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "incremental": true,
"plugins": [{ "name": "next" }], "plugins": [
{
"name": "next"
}
],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
"./src/*"
]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "include": [
"exclude": ["node_modules"] "**/*.ts",
"**/*.tsx",
"next-env.d.ts",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }