feat: add filesystem permissions check and improved batch deletion error handling
All checks were successful
Docker Build / build (push) Successful in 15s
All checks were successful
Docker Build / build (push) Successful in 15s
This commit is contained in:
parent
62428c313b
commit
85f7a18883
2 changed files with 170 additions and 96 deletions
|
|
@ -1139,62 +1139,60 @@ def api_recovery_start():
|
||||||
@app.route("/api/recovery/delete-batch", methods=["POST"])
|
@app.route("/api/recovery/delete-batch", methods=["POST"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def api_recovery_delete_batch():
|
def api_recovery_delete_batch():
|
||||||
data = request.get_json()
|
data = request.json
|
||||||
filepaths = data.get('filepaths', [])
|
paths = data.get("filepaths", [])
|
||||||
destruct_mode = data.get('destruct_mode', False)
|
destruct = data.get("destruct_mode", False)
|
||||||
|
|
||||||
if not filepaths:
|
success_count = 0
|
||||||
return jsonify({"error": "No filepaths provided"}), 400
|
fail_count = 0
|
||||||
|
errors = []
|
||||||
results = []
|
|
||||||
# Refresh metadata to ensure we have latest paths for destruct mode
|
|
||||||
video_map = fetch_all_metadata() if destruct_mode else {}
|
|
||||||
|
|
||||||
for filepath in filepaths:
|
# Refresh metadata for destruct mode
|
||||||
p = Path(filepath)
|
video_map = fetch_all_metadata() if destruct else {}
|
||||||
if not p.exists():
|
|
||||||
results.append({"path": filepath, "success": False, "error": "File not found"})
|
for path in paths:
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
vid_id = extract_id_from_filename(p.name)
|
# 1. Destruct Source if enabled
|
||||||
|
if destruct:
|
||||||
# DESTRUCT MODE: Delete source too
|
source_deleted = False
|
||||||
if destruct_mode and vid_id:
|
for vid_id, meta in video_map.items():
|
||||||
meta = video_map.get(vid_id)
|
if meta.get('path') == path or meta.get('filesystem_path') == path:
|
||||||
if meta and meta.get('filesystem_path'):
|
source_path = meta.get('filesystem_path')
|
||||||
source_path = Path(meta['filesystem_path'])
|
if source_path and os.path.exists(source_path):
|
||||||
if source_path.exists():
|
os.remove(source_path)
|
||||||
source_path.unlink()
|
log(f"☢️ [DESTRUCT] Deleted source: {source_path}")
|
||||||
log(f" [DESTRUCT] Deleted source: {source_path}")
|
source_deleted = True
|
||||||
|
break
|
||||||
# Also check lost_media table
|
if not source_deleted:
|
||||||
with get_db() as conn:
|
log(f"⚠️ [DESTRUCT] Source not found for: {path}")
|
||||||
conn.execute("DELETE FROM lost_media WHERE video_id = ?", (vid_id,))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# DELETE TARGET (Symlink/Resource)
|
# 2. Delete Target
|
||||||
p.unlink()
|
p = Path(path)
|
||||||
|
if p.exists():
|
||||||
# Clean up empty parent folder if it's a video folder
|
if p.is_dir():
|
||||||
parent = p.parent
|
shutil.rmtree(p)
|
||||||
if parent not in [TARGET_DIR, HIDDEN_DIR, SOURCE_DIR] and parent.name != "source":
|
else:
|
||||||
try:
|
p.unlink()
|
||||||
if not any(parent.iterdir()):
|
|
||||||
|
# 3. Cleanup empty parent
|
||||||
|
parent = p.parent
|
||||||
|
if parent != Path(TARGET_DIR) and parent != Path(HIDDEN_DIR):
|
||||||
|
if parent.exists() and not any(parent.iterdir()):
|
||||||
parent.rmdir()
|
parent.rmdir()
|
||||||
log(f" [CLEANUP] Removed empty dir: {parent}")
|
log(f"🧹 [CLEANUP] Removed empty folder: {parent}")
|
||||||
except:
|
|
||||||
pass
|
success_count += 1
|
||||||
|
|
||||||
results.append({"path": filepath, "success": True})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results.append({"path": filepath, "success": False, "error": str(e)})
|
err_msg = str(e)
|
||||||
log(f"❌ Failed to delete {filepath}: {e}")
|
log(f"❌ Failed to delete {path}: {err_msg}")
|
||||||
|
fail_count += 1
|
||||||
|
if err_msg not in errors:
|
||||||
|
errors.append(err_msg)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"results": results,
|
"success_count": success_count,
|
||||||
"success_count": len([r for r in results if r["success"]]),
|
"fail_count": fail_count,
|
||||||
"fail_count": len([r for r in results if not r["success"]])
|
"errors": errors[:5]
|
||||||
})
|
})
|
||||||
|
|
||||||
@app.route("/api/recovery/delete", methods=["POST"])
|
@app.route("/api/recovery/delete", methods=["POST"])
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
let results: any = { unindexed: [], rescue: [], redundant: [], lost: [] };
|
let results: any = { unindexed: [], rescue: [], redundant: [], lost: [] };
|
||||||
let pollInterval: ReturnType<typeof setInterval>;
|
let pollInterval: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
// New State for Destruct Mode & Multi-selection
|
// State for Destruct Mode & Multi-selection
|
||||||
let destructMode = false;
|
let destructMode = false;
|
||||||
let selectedPaths = new Set<string>();
|
let selectedPaths = new Set<string>();
|
||||||
|
|
||||||
|
|
@ -19,6 +19,22 @@
|
||||||
let loadingChannels = false;
|
let loadingChannels = false;
|
||||||
let searchingVideos = false;
|
let searchingVideos = false;
|
||||||
|
|
||||||
|
// Permissions state
|
||||||
|
let permissions: any = null;
|
||||||
|
let loadingPermissions = false;
|
||||||
|
|
||||||
|
async function checkPermissions() {
|
||||||
|
loadingPermissions = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/system/check-permissions");
|
||||||
|
permissions = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to check permissions", e);
|
||||||
|
} finally {
|
||||||
|
loadingPermissions = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function startScan() {
|
async function startScan() {
|
||||||
scanning = true;
|
scanning = true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -111,7 +127,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(msg)) return;
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
if (destructMode && !confirm("FINAL WARNING: This is IRREVERSIBLE. Delete source file now?")) return;
|
if (destructMode && !confirm("FINAL WARNING: This is IRREVERSIBLE. Delete source file now?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -128,7 +143,10 @@
|
||||||
alert("Deleted.");
|
alert("Deleted.");
|
||||||
if (activeTab === "channels") fetchChannelVideos(selectedChannel);
|
if (activeTab === "channels") fetchChannelVideos(selectedChannel);
|
||||||
else startScan();
|
else startScan();
|
||||||
} else alert("Error deleting file.");
|
} else {
|
||||||
|
const err = d.errors?.[0] || "Unknown error";
|
||||||
|
alert(`Error deleting file: ${err}`);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e);
|
alert(e);
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +173,14 @@
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
alert(`Batch complete. Success: ${d.success_count}, Failed: ${d.fail_count}`);
|
|
||||||
|
if (d.fail_count > 0) {
|
||||||
|
const firstErr = d.errors?.[0] || "Check backend logs.";
|
||||||
|
alert(`Batch partially failed.\nSuccess: ${d.success_count}\nFailed: ${d.fail_count}\n\nFirst error: ${firstErr}`);
|
||||||
|
} else {
|
||||||
|
alert(`Batch complete. Deleted ${d.success_count} items.`);
|
||||||
|
}
|
||||||
|
|
||||||
selectedPaths = new Set();
|
selectedPaths = new Set();
|
||||||
if (activeTab === "channels") fetchChannelVideos(selectedChannel);
|
if (activeTab === "channels") fetchChannelVideos(selectedChannel);
|
||||||
else startScan();
|
else startScan();
|
||||||
|
|
@ -170,7 +195,7 @@
|
||||||
} else {
|
} else {
|
||||||
selectedPaths.add(path);
|
selectedPaths.add(path);
|
||||||
}
|
}
|
||||||
selectedPaths = selectedPaths; // Trigger Svelte reactivity
|
selectedPaths = selectedPaths;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAll() {
|
function toggleAll() {
|
||||||
|
|
@ -184,8 +209,12 @@
|
||||||
|
|
||||||
$: allSelected = (activeTab === "channels" ? channelVideos : results[activeTab])?.length > 0 && selectedPaths.size === (activeTab === "channels" ? channelVideos : results[activeTab])?.length;
|
$: allSelected = (activeTab === "channels" ? channelVideos : results[activeTab])?.length > 0 && selectedPaths.size === (activeTab === "channels" ? channelVideos : results[activeTab])?.length;
|
||||||
|
|
||||||
|
$: sourceRO = permissions?.source?.writeable === false;
|
||||||
|
$: targetRO = permissions?.target?.writeable === false;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
fetchChannels();
|
fetchChannels();
|
||||||
|
checkPermissions();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -193,11 +222,11 @@
|
||||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-cyber-card border border-neon-cyan/30 rounded-xl w-full max-w-5xl h-[80vh] flex flex-col shadow-[0_0_50px_rgba(0,243,255,0.1)]"
|
class="bg-cyber-card border border-neon-cyan/30 rounded-xl w-full max-w-5xl h-[80vh] flex flex-col shadow-[0_0_50px_rgba(0,243,255,0.1)] overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div
|
<div
|
||||||
class="p-5 border-b border-gray-800 flex justify-between items-center"
|
class="p-5 border-b border-gray-800 flex justify-between items-center bg-gray-900/40"
|
||||||
>
|
>
|
||||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
||||||
<i class="bi bi-bandaid text-neon-pink"></i> Advanced Recovery
|
<i class="bi bi-bandaid text-neon-pink"></i> Advanced Recovery
|
||||||
|
|
@ -207,16 +236,36 @@
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
on:click={() => dispatch("close")}
|
on:click={() => dispatch("close")}
|
||||||
class="text-gray-500 hover:text-white"
|
class="text-gray-500 hover:text-white transition-colors"
|
||||||
aria-label="Close"><i class="bi bi-x-lg"></i></button
|
aria-label="Close"><i class="bi bi-x-lg"></i></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Permission Warnings -->
|
||||||
|
{#if sourceRO || targetRO}
|
||||||
|
<div class="bg-red-900/30 border-b border-red-500/50 p-3 flex items-center gap-3 animate-in fade-in slide-in-from-top duration-300">
|
||||||
|
<i class="bi bi-exclamation-octagon-fill text-red-500 text-lg"></i>
|
||||||
|
<div class="text-xs text-red-200">
|
||||||
|
<span class="font-bold uppercase">Filesystem Alert:</span>
|
||||||
|
{#if sourceRO && targetRO}
|
||||||
|
Both Source AND Target directories are currently <span class="text-white underline">READ-ONLY</span>. Deletion and reorganization will fail.
|
||||||
|
{:else if sourceRO}
|
||||||
|
Source archive is <span class="text-white underline">READ-ONLY</span>. Destruct Mode will fail.
|
||||||
|
{:else}
|
||||||
|
Target library is <span class="text-white underline">READ-ONLY</span>. Symlink cleanup will fail.
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button on:click={checkPermissions} class="ml-auto text-[10px] bg-red-500/20 hover:bg-red-500/40 px-2 py-1 rounded border border-red-500/30 transition-colors">
|
||||||
|
Retry Check
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Controls -->
|
<!-- Controls -->
|
||||||
<div class="p-4 bg-gray-900/50 flex flex-wrap gap-4 justify-between items-center">
|
<div class="p-4 bg-gray-900/50 flex flex-wrap gap-4 justify-between items-center">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
class="btn-primary px-6 py-2 rounded font-bold text-black bg-neon-cyan hover:bg-white transition-colors disabled:opacity-50"
|
class="btn-primary px-6 py-2 rounded font-bold text-black bg-neon-cyan hover:bg-white transition-all transform active:scale-95 disabled:opacity-50"
|
||||||
on:click={startScan}
|
on:click={startScan}
|
||||||
disabled={scanning}
|
disabled={scanning}
|
||||||
>
|
>
|
||||||
|
|
@ -225,8 +274,9 @@
|
||||||
|
|
||||||
{#if selectedPaths.size > 0}
|
{#if selectedPaths.size > 0}
|
||||||
<button
|
<button
|
||||||
class="px-6 py-2 rounded font-bold text-white bg-red-600 hover:bg-red-500 transition-colors flex items-center gap-2"
|
class="px-6 py-2 rounded font-bold text-white bg-red-600 hover:bg-red-500 transition-all transform active:scale-95 flex items-center gap-2 shadow-[0_0_15px_rgba(220,38,38,0.4)]"
|
||||||
on:click={deleteSelected}
|
on:click={deleteSelected}
|
||||||
|
disabled={targetRO || (destructMode && sourceRO)}
|
||||||
>
|
>
|
||||||
<i class="bi bi-trash"></i> Delete Selected ({selectedPaths.size})
|
<i class="bi bi-trash"></i> Delete Selected ({selectedPaths.size})
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -238,29 +288,31 @@
|
||||||
<label class="relative inline-flex items-center cursor-pointer group">
|
<label class="relative inline-flex items-center cursor-pointer group">
|
||||||
<input type="checkbox" bind:checked={destructMode} class="sr-only peer">
|
<input type="checkbox" bind:checked={destructMode} class="sr-only peer">
|
||||||
<div class="w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-red-600"></div>
|
<div class="w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-red-600"></div>
|
||||||
<span class="ml-3 text-sm font-medium text-gray-300 group-hover:text-white transition-colors">
|
<span class="ml-3 text-sm font-medium text-gray-300 group-hover:text-white transition-colors flex items-center gap-2">
|
||||||
Destruct Mode
|
Destruct Mode
|
||||||
<i class="bi bi-exclamation-triangle-fill text-red-500 ml-1 {destructMode ? 'visible' : 'opacity-0'}"></i>
|
{#if destructMode}
|
||||||
|
<i class="bi bi-exclamation-triangle-fill text-red-500 animate-pulse"></i>
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="text-xs text-mono text-gray-500">Status: <span class="text-neon-cyan">{status}</span></div>
|
<div class="text-xs text-mono text-gray-500">Status: <span class="text-neon-cyan underline decoration-dotted">{status}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="flex border-b border-gray-800 px-4">
|
<div class="flex border-b border-gray-800 px-4 bg-gray-900/20">
|
||||||
{#each ["unindexed", "rescue", "redundant", "lost", "channels"] as tab}
|
{#each ["unindexed", "rescue", "redundant", "lost", "channels"] as tab}
|
||||||
<button
|
<button
|
||||||
class="px-4 py-3 text-sm font-semibold capitalize border-b-2 transition-colors flex items-center gap-2
|
class="px-4 py-3 text-sm font-semibold capitalize border-b-2 transition-all flex items-center gap-2
|
||||||
{activeTab === tab
|
{activeTab === tab
|
||||||
? 'border-neon-cyan text-neon-cyan'
|
? 'border-neon-cyan text-neon-cyan bg-neon-cyan/5'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-300'}"
|
: 'border-transparent text-gray-500 hover:text-gray-300 hover:bg-white/5'}"
|
||||||
on:click={() => { activeTab = tab as any; selectedPaths = new Set(); }}
|
on:click={() => { activeTab = tab as any; selectedPaths = new Set(); }}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
{#if tab !== 'channels'}
|
{#if tab !== 'channels'}
|
||||||
<span class="bg-gray-800 text-xs px-1.5 rounded-full"
|
<span class="bg-gray-800 text-[10px] px-1.5 py-0.5 rounded-full min-w-[1.2rem] text-center"
|
||||||
>{results[tab]?.length || 0}</span
|
>{results[tab]?.length || 0}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -275,9 +327,9 @@
|
||||||
<!-- Channel Selector -->
|
<!-- Channel Selector -->
|
||||||
<div class="flex gap-4 items-end">
|
<div class="flex gap-4 items-end">
|
||||||
<div class="flex-grow">
|
<div class="flex-grow">
|
||||||
<label class="block text-xs text-gray-500 mb-1">Select Channel</label>
|
<label class="block text-[10px] text-gray-500 mb-1 uppercase tracking-widest">Select Channel (Indexed Only)</label>
|
||||||
<select
|
<select
|
||||||
class="w-full bg-gray-900 border border-gray-700 rounded p-2 text-white outline-none focus:border-neon-cyan"
|
class="w-full bg-gray-900 border border-gray-700 rounded p-2 text-white outline-none focus:border-neon-cyan transition-colors"
|
||||||
value={selectedChannel}
|
value={selectedChannel}
|
||||||
on:change={(e) => fetchChannelVideos(e.currentTarget.value)}
|
on:change={(e) => fetchChannelVideos(e.currentTarget.value)}
|
||||||
>
|
>
|
||||||
|
|
@ -288,22 +340,24 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn-secondary px-4 py-2 rounded bg-gray-800 text-white hover:bg-gray-700"
|
class="p-2.5 rounded bg-gray-800 text-white hover:bg-gray-700 transition-colors border border-gray-700"
|
||||||
on:click={fetchChannels}
|
on:click={fetchChannels}
|
||||||
|
title="Refresh Channel List"
|
||||||
>
|
>
|
||||||
<i class="bi bi-arrow-clockwise"></i>
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video Table -->
|
<!-- Video Table -->
|
||||||
<div class="flex-grow overflow-y-auto border border-gray-800 rounded">
|
<div class="flex-grow overflow-y-auto border border-gray-800 rounded bg-gray-900/20">
|
||||||
{#if searchingVideos}
|
{#if searchingVideos}
|
||||||
<div class="flex items-center justify-center h-full text-neon-cyan animate-pulse">
|
<div class="flex flex-col items-center justify-center h-full text-neon-cyan gap-4">
|
||||||
Fetching videos...
|
<div class="w-8 h-8 border-2 border-neon-cyan border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<span class="text-xs font-mono animate-pulse">QUERYING TA DATABASE...</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if channelVideos.length > 0}
|
{:else if channelVideos.length > 0}
|
||||||
<table class="w-full text-left text-xs text-gray-300 font-mono border-collapse">
|
<table class="w-full text-left text-[11px] text-gray-300 font-mono border-collapse">
|
||||||
<thead class="sticky top-0 bg-cyber-card z-10 shadow-sm">
|
<thead class="sticky top-0 bg-gray-900 z-10">
|
||||||
<tr class="text-gray-500 border-b border-gray-800">
|
<tr class="text-gray-500 border-b border-gray-800">
|
||||||
<th class="p-3 w-10">
|
<th class="p-3 w-10">
|
||||||
<input
|
<input
|
||||||
|
|
@ -333,10 +387,11 @@
|
||||||
<td class="p-3 truncate max-w-[400px]" title={item.title}>{item.title}</td>
|
<td class="p-3 truncate max-w-[400px]" title={item.title}>{item.title}</td>
|
||||||
<td class="p-3 text-right">
|
<td class="p-3 text-right">
|
||||||
<button
|
<button
|
||||||
class="text-red-500 hover:underline flex items-center gap-1 ml-auto"
|
class="text-red-500 hover:text-red-400 font-bold transition-colors disabled:opacity-30"
|
||||||
on:click={() => deleteFile(item.path)}
|
on:click={() => deleteFile(item.path)}
|
||||||
|
disabled={targetRO || (destructMode && sourceRO)}
|
||||||
>
|
>
|
||||||
<i class="bi bi-trash"></i> Delete
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -344,9 +399,9 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col items-center justify-center h-full text-gray-600 gap-2">
|
<div class="flex flex-col items-center justify-center h-full text-gray-700 gap-3">
|
||||||
<i class="bi bi-search text-3xl opacity-20"></i>
|
<i class="bi bi-broadcast text-4xl opacity-10"></i>
|
||||||
Select a channel to view its videos.
|
<span class="text-xs uppercase tracking-widest font-bold">No channel selected</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -356,11 +411,11 @@
|
||||||
class="flex flex-col items-center justify-center h-full text-neon-cyan gap-4"
|
class="flex flex-col items-center justify-center h-full text-neon-cyan gap-4"
|
||||||
>
|
>
|
||||||
<div class="w-12 h-12 border-4 border-neon-cyan border-t-transparent rounded-full animate-spin"></div>
|
<div class="w-12 h-12 border-4 border-neon-cyan border-t-transparent rounded-full animate-spin"></div>
|
||||||
<div class="animate-pulse font-mono">SYSTEM WIDE SCAN IN PROGRESS...</div>
|
<div class="animate-pulse font-mono tracking-widest">SYSTEM WIDE SCAN IN PROGRESS...</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<table class="w-full text-left text-xs text-gray-300 font-mono border-collapse">
|
<table class="w-full text-left text-[11px] text-gray-300 font-mono border-collapse">
|
||||||
<thead class="sticky top-0 bg-cyber-card z-10 shadow-sm">
|
<thead class="sticky top-0 bg-gray-900 z-10">
|
||||||
<tr class="text-gray-500 border-b border-gray-800">
|
<tr class="text-gray-500 border-b border-gray-800">
|
||||||
<th class="p-3 w-10">
|
<th class="p-3 w-10">
|
||||||
<input
|
<input
|
||||||
|
|
@ -370,9 +425,9 @@
|
||||||
class="accent-neon-cyan"
|
class="accent-neon-cyan"
|
||||||
>
|
>
|
||||||
</th>
|
</th>
|
||||||
<th class="p-3">Video ID</th>
|
<th class="p-3">ID</th>
|
||||||
<th class="p-3">Filename / Path</th>
|
<th class="p-3">Filename / Target Path</th>
|
||||||
<th class="p-3">Size/Info</th>
|
<th class="p-3">Info</th>
|
||||||
<th class="p-3 text-right">Action</th>
|
<th class="p-3 text-right">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -408,33 +463,35 @@
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
{#if activeTab === "unindexed"}
|
{#if activeTab === "unindexed"}
|
||||||
<button
|
<button
|
||||||
class="text-neon-green hover:underline flex items-center gap-1"
|
class="text-neon-green hover:underline flex items-center gap-1 transition-all hover:scale-105"
|
||||||
on:click={() =>
|
on:click={() =>
|
||||||
recoverFile(item.path)}
|
recoverFile(item.path)}
|
||||||
><i class="bi bi-plus-circle"></i> Recover</button
|
><i class="bi bi-plus-circle"></i> RECOVER</button
|
||||||
>
|
>
|
||||||
{:else if activeTab === "redundant"}
|
{:else if activeTab === "redundant"}
|
||||||
<button
|
<button
|
||||||
class="text-red-500 hover:underline flex items-center gap-1"
|
class="text-red-500 hover:underline font-bold transition-all hover:scale-105 disabled:opacity-30"
|
||||||
on:click={() =>
|
on:click={() =>
|
||||||
deleteFile(item.path)}
|
deleteFile(item.path)}
|
||||||
><i class="bi bi-trash"></i> Delete</button
|
disabled={targetRO || (destructMode && sourceRO)}
|
||||||
|
><i class="bi bi-trash"></i> DELETE</button
|
||||||
>
|
>
|
||||||
{:else if activeTab === "lost"}
|
{:else if activeTab === "lost"}
|
||||||
<button
|
<button
|
||||||
class="text-neon-yellow hover:underline flex items-center gap-1"
|
class="text-neon-yellow hover:underline flex items-center gap-1"
|
||||||
><i class="bi bi-lightning-fill"></i> Force</button
|
><i class="bi bi-lightning-fill"></i> FORCE</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="text-red-500 hover:underline flex items-center gap-1"
|
class="text-red-500 hover:underline font-bold disabled:opacity-30"
|
||||||
on:click={() =>
|
on:click={() =>
|
||||||
deleteFile(item.path)}
|
deleteFile(item.path)}
|
||||||
><i class="bi bi-trash"></i> Delete</button
|
disabled={targetRO || (destructMode && sourceRO)}
|
||||||
|
><i class="bi bi-trash"></i> DELETE</button
|
||||||
>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
class="text-neon-pink hover:underline flex items-center gap-1"
|
class="text-neon-pink hover:underline flex items-center gap-1"
|
||||||
><i class="bi bi-rescue-ambulance"></i> Rescue</button
|
><i class="bi bi-rescue-ambulance"></i> RESCUE</button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -445,10 +502,10 @@
|
||||||
<tr
|
<tr
|
||||||
><td
|
><td
|
||||||
colspan="5"
|
colspan="5"
|
||||||
class="p-20 text-center text-gray-600 bg-gray-900/20"
|
class="p-20 text-center text-gray-600 bg-gray-900/10"
|
||||||
>
|
>
|
||||||
<i class="bi bi-cloud-check text-4xl mb-4 block opacity-20"></i>
|
<i class="bi bi-shield-lock text-4xl mb-4 block opacity-10"></i>
|
||||||
Nothing detected in this category.
|
NO ANOMALIES DETECTED IN THIS SECTOR.
|
||||||
</td
|
</td
|
||||||
></tr
|
></tr
|
||||||
>
|
>
|
||||||
|
|
@ -464,4 +521,23 @@
|
||||||
.accent-neon-cyan {
|
.accent-neon-cyan {
|
||||||
accent-color: #00f3ff;
|
accent-color: #00f3ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-cyber-card {
|
||||||
|
background: linear-gradient(135deg, #0f0f13 0%, #050505 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar for better Cyberpunk feel */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(15, 15, 15, 0.5);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #00f3ff22;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #00f3ff66;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue