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);
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')`,
}

View file

@ -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,
}

View file

@ -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
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';
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,12 +660,14 @@ 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)' }} />
</nav>
{/* Settings Footer */}
<div className="px-3 py-4 border-t" style={{ borderColor: '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"
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={{
backgroundColor: activeSection === 'settings' ? 'var(--color-accent-subtle)' : 'transparent',
color: activeSection === 'settings' ? 'var(--color-accent)' : 'var(--color-text-secondary)',
@ -617,20 +677,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
Settings
</button>
</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>
{/* === MAIN CONTENT === */}
@ -674,6 +720,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</div>
{/* Search Bar */}
{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
@ -698,6 +745,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</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>
{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))` }}>
{displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned).map((file, idx) => (
{group.files.map((file, idx) => (
<motion.div
key={file.path}
draggable
@ -1050,6 +1100,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
))}
</div>
</div>
))}
</div>
</div>
) : (
/* List View */
@ -1118,14 +1170,19 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
)}
<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="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>Modified</span>
<span></span>
</div>
{displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned).map((file, idx) => (
{group.files.map((file, idx) => (
<motion.div
key={file.path}
draggable
@ -1182,6 +1239,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
))}
</div>
</div>
))}
</div>
</div>
)}
</div>

View file

@ -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}

View file

@ -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"
]
}