feat: add Cloudinary audit admin UI

- Create audit page at /admin/media/audit with 2x2 summary grid
- Add interactive table with file selection and batch operations
- Implement delete functionality with confirmation modal
- Add chevron-left icon for navigation
- Style with consistent admin UI patterns
- Include loading states and error handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-06-16 16:56:39 +01:00
parent 1f04a96dad
commit 655a8a05a5
2 changed files with 676 additions and 0 deletions

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 218 B

View file

@ -0,0 +1,673 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import AdminHeader from '$lib/components/admin/AdminHeader.svelte'
import Button from '$lib/components/admin/Button.svelte'
import Modal from '$lib/components/admin/Modal.svelte'
import { formatBytes } from '$lib/utils/format'
import { CheckCircle, Trash2, AlertCircle, RefreshCw } from 'lucide-svelte'
import ChevronLeft from '$icons/chevron-left.svg'
interface AuditSummary {
totalCloudinaryFiles: number
totalDatabaseReferences: number
orphanedFilesCount: number
orphanedFilesSize: number
orphanedFilesSizeFormatted: string
missingReferencesCount: number
}
interface OrphanedFile {
publicId: string
url: string
folder: string
format: string
size: number
sizeFormatted: string
dimensions: { width: number; height: number } | null
createdAt: string
}
let loading = true
let deleting = false
let auditData: {
summary: AuditSummary
orphanedFiles: OrphanedFile[]
missingReferences: string[]
} | null = null
let error: string | null = null
let selectedFiles = new Set<string>()
let showDeleteModal = false
let deleteResults: { succeeded: number; failed: string[] } | null = null
$: allSelected = auditData && selectedFiles.size === auditData.orphanedFiles.length
$: hasSelection = selectedFiles.size > 0
$: selectedSize =
auditData?.orphanedFiles
.filter((f) => selectedFiles.has(f.publicId))
.reduce((sum, f) => sum + f.size, 0) || 0
onMount(() => {
runAudit()
})
async function runAudit() {
loading = true
error = null
selectedFiles.clear()
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
error = 'Not authenticated'
loading = false
return
}
const response = await fetch('/api/admin/cloudinary-audit', {
headers: {
Authorization: `Basic ${auth}`
}
})
if (!response.ok) {
throw new Error('Failed to fetch audit data')
}
auditData = await response.json()
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred'
} finally {
loading = false
}
}
function toggleSelectAll() {
if (allSelected) {
selectedFiles.clear()
} else {
selectedFiles = new Set(auditData?.orphanedFiles.map((f) => f.publicId) || [])
}
}
function toggleFile(publicId: string) {
if (selectedFiles.has(publicId)) {
selectedFiles.delete(publicId)
} else {
selectedFiles.add(publicId)
}
selectedFiles = selectedFiles // Trigger reactivity
}
async function deleteSelected(dryRun = true) {
if (!hasSelection || deleting) return
if (!dryRun) {
showDeleteModal = false
}
deleting = true
deleteResults = null
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
error = 'Not authenticated'
deleting = false
return
}
const response = await fetch('/api/admin/cloudinary-audit', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`
},
body: JSON.stringify({
publicIds: Array.from(selectedFiles),
dryRun
})
})
if (!response.ok) {
throw new Error('Failed to delete files')
}
const result = await response.json()
if (!dryRun && result.results) {
deleteResults = result.results
// Refresh audit after successful deletion
setTimeout(() => {
runAudit()
}, 2000)
}
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred'
} finally {
deleting = false
}
}
function formatDate(dateString: string) {
const date = new Date(dateString)
// Format: 01/05/24
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const year = date.getFullYear().toString().slice(-2)
return `${month}/${day}/${year}`
}
</script>
<AdminPage>
<header slot="header">
<div class="header-left">
<button class="btn-icon" onclick={() => goto('/admin/media')}>
<ChevronLeft />
</button>
<h1>Cloudinary Audit</h1>
</div>
<div class="header-actions">
<Button
variant="secondary"
onclick={runAudit}
disabled={loading}
icon={RefreshCw}
iconPosition="left"
>
{loading ? 'Running Audit...' : 'Run Audit'}
</Button>
</div>
</header>
{#if loading}
<div class="loading">
<div class="spinner"></div>
<p>Analyzing Cloudinary storage...</p>
</div>
{:else if error}
<div class="error">
<AlertCircle size={24} />
<p>{error}</p>
<Button variant="secondary" size="small" onclick={runAudit}>Try Again</Button>
</div>
{:else if auditData}
<!-- Summary Cards -->
<div class="summary-grid">
<div class="summary-card">
<div class="card-content">
<h3>Total Files</h3>
<p class="value">{auditData.summary.totalCloudinaryFiles.toLocaleString()}</p>
<p class="label">in Cloudinary</p>
</div>
</div>
<div class="summary-card">
<div class="card-content">
<h3>Database References</h3>
<p class="value">{auditData.summary.totalDatabaseReferences.toLocaleString()}</p>
<p class="label">tracked files</p>
</div>
</div>
<div class="summary-card warning">
<div class="card-content">
<h3>Orphaned Files</h3>
<p class="value">{auditData.summary.orphanedFilesCount.toLocaleString()}</p>
<p class="label">{auditData.summary.orphanedFilesSizeFormatted} wasted</p>
</div>
</div>
<div class="summary-card {auditData.summary.missingReferencesCount > 0 ? 'error' : ''}">
<div class="card-content">
<h3>Missing Files</h3>
<p class="value">{auditData.summary.missingReferencesCount.toLocaleString()}</p>
<p class="label">broken references</p>
</div>
</div>
</div>
{#if auditData.orphanedFiles.length > 0}
<!-- Actions Bar -->
<div class="actions-bar">
<div class="selection-info">
{#if hasSelection}
<span>{selectedFiles.size} files selected ({formatBytes(selectedSize)})</span>
{:else}
<span>{auditData.orphanedFiles.length} orphaned files found</span>
{/if}
</div>
<div class="actions">
<Button variant="text" size="small" onclick={toggleSelectAll}>
{allSelected ? 'Deselect All' : 'Select All'}
</Button>
<Button
variant="danger"
size="small"
onclick={() => (showDeleteModal = true)}
disabled={!hasSelection || deleting}
icon={Trash2}
iconPosition="left"
>
Delete Selected
</Button>
</div>
</div>
<!-- Files Table -->
<div class="files-table">
<table>
<thead>
<tr>
<th class="checkbox">
<input type="checkbox" checked={allSelected} onchange={toggleSelectAll} />
</th>
<th>Preview</th>
<th>File Path</th>
<th>Size</th>
<th>Dimensions</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{#each auditData.orphanedFiles as file}
<tr class:selected={selectedFiles.has(file.publicId)}>
<td class="checkbox">
<input
type="checkbox"
checked={selectedFiles.has(file.publicId)}
onchange={() => toggleFile(file.publicId)}
/>
</td>
<td class="preview">
{#if file.format === 'svg'}
<div class="svg-preview">.svg</div>
{:else}
<img src={file.url} alt={file.publicId} />
{/if}
</td>
<td class="file-path">
<span class="folder">{file.folder}/</span>
<span class="filename">{file.publicId.split('/').pop()}</span>
</td>
<td class="size">{file.sizeFormatted}</td>
<td class="dimensions">
{#if file.dimensions}
{file.dimensions.width}×{file.dimensions.height}
{:else}
{/if}
</td>
<td class="date">{formatDate(file.createdAt)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<!-- No orphaned files -->
<div class="empty-state">
<CheckCircle size={48} />
<h2>All Clean!</h2>
<p>No orphaned files found. Your Cloudinary storage is in sync with your database.</p>
</div>
{/if}
{#if deleteResults}
<div class="delete-results">
<h3>Deletion Complete</h3>
<p>✓ Successfully deleted {deleteResults.succeeded} files</p>
{#if deleteResults.failed.length > 0}
<p>✗ Failed to delete {deleteResults.failed.length} files</p>
{/if}
</div>
{/if}
{/if}
</AdminPage>
<!-- Delete Confirmation Modal -->
<Modal bind:open={showDeleteModal} title="Delete Orphaned Files">
<div class="delete-confirmation">
<p>Are you sure you want to delete {selectedFiles.size} orphaned files?</p>
<p class="size-info">This will free up {formatBytes(selectedSize)} of storage.</p>
<p class="warning">⚠️ This action cannot be undone.</p>
</div>
<div slot="actions">
<Button variant="secondary" onclick={() => (showDeleteModal = false)}>Cancel</Button>
<Button variant="danger" onclick={() => deleteSelected(false)} disabled={deleting}>
{deleting ? 'Deleting...' : 'Delete Files'}
</Button>
</div>
</Modal>
<style lang="scss">
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0;
margin: 0;
width: 100%;
}
.header-left {
display: flex;
align-items: center;
gap: $unit-2x;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: $grey-10;
}
}
.header-actions {
display: flex;
align-items: center;
gap: $unit-2x;
}
.btn-icon {
width: 40px;
height: 40px;
border: none;
background: none;
color: $grey-40;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s ease;
:global(svg) {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
&:hover {
background: $grey-90;
color: $grey-10;
}
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 1rem;
.spinner {
width: 40px;
height: 40px;
border: 3px solid $grey-80;
border-top-color: $red-60;
border-radius: 50%;
animation: spin 1s linear infinite;
}
p {
color: $grey-30;
}
}
.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 1rem;
color: $red-60;
p {
font-size: 1.1rem;
}
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
margin-bottom: 2rem;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.summary-card {
background: $grey-95;
border-radius: 8px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 140px;
.card-content {
padding: 1.5rem;
text-align: center;
width: 100%;
}
h3 {
font-size: 0.875rem;
font-weight: 500;
color: $grey-30;
margin: 0 0 0.75rem 0;
letter-spacing: 0.01em;
text-transform: uppercase;
}
.value {
font-size: 2.5rem;
font-weight: 600;
color: $grey-10;
margin: 0 0 0.5rem 0;
line-height: 1;
display: block;
}
.label {
font-size: 0.875rem;
color: $grey-40;
margin: 0;
line-height: 1.2;
display: block;
}
&.warning {
background: rgba($yellow-60, 0.1);
.value {
color: $yellow-60;
}
}
&.error {
background: rgba($red-60, 0.1);
.value {
color: $red-60;
}
}
}
.actions-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: $grey-95;
border-radius: 8px;
margin-bottom: 1rem;
.selection-info {
color: $grey-30;
font-size: 0.875rem;
}
.actions {
display: flex;
gap: 0.5rem;
}
}
.files-table {
background: white;
border: 1px solid $grey-90;
border-radius: 8px;
overflow: hidden;
table {
width: 100%;
border-collapse: collapse;
th {
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: $grey-30;
background: $grey-95;
border-bottom: 1px solid $grey-90;
&.checkbox {
width: 40px;
}
}
td {
padding: 0.75rem 1rem;
border-bottom: 1px solid $grey-95;
vertical-align: middle;
&.checkbox {
width: 40px;
vertical-align: middle;
}
&.preview {
width: 60px;
vertical-align: middle;
img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
display: block;
}
.svg-preview {
width: 40px;
height: 40px;
background: $grey-90;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: $grey-40;
}
}
&.file-path {
.folder {
color: $grey-40;
}
}
&.size {
color: $grey-30;
font-size: 0.875rem;
}
&.dimensions {
color: $grey-30;
font-size: 0.875rem;
}
&.date {
color: $grey-30;
font-size: 0.875rem;
vertical-align: middle;
white-space: nowrap;
}
}
tr.selected {
background: rgba($red-60, 0.05);
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
text-align: center;
color: $blue-60;
h2 {
margin: 1rem 0 0.5rem;
color: $grey-10;
}
p {
color: $grey-30;
max-width: 400px;
}
}
.delete-confirmation {
padding: 1rem 0;
p {
margin: 0.5rem 0;
}
.size-info {
color: $grey-30;
font-size: 0.875rem;
}
.warning {
color: $yellow-60;
font-weight: 500;
margin-top: 1rem;
}
}
.delete-results {
margin-top: 1rem;
padding: 1rem;
background: rgba($blue-60, 0.1);
border-radius: 8px;
text-align: center;
h3 {
margin: 0 0 0.5rem;
color: $blue-60;
}
p {
margin: 0.25rem 0;
color: $grey-30;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>