wowaweewa

This commit is contained in:
Danilo Reyes
2026-02-07 06:15:34 -06:00
parent 070a3633d8
commit 4337a8847c
29 changed files with 1699 additions and 38 deletions

4
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"semi": true
}

21
frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "archive-curator-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"eslint": "^9.0.0",
"prettier": "^3.0.0",
"svelte": "^5.0.0",
"vite": "^5.0.0"
}
}

View File

@@ -0,0 +1,55 @@
<script lang="ts">
export let matches: string[] = [];
export let selected: string[] = [];
function toggle(match: string) {
if (selected.includes(match)) {
selected = selected.filter((value) => value !== match);
} else {
selected = [...selected, match];
}
}
</script>
<div class="matches">
<h3>List-file matches</h3>
{#if matches.length === 0}
<p>No matches detected.</p>
{:else}
<ul>
{#each matches as match}
<li>
<label>
<input
type="checkbox"
checked={selected.includes(match)}
on:change={() => toggle(match)}
/>
<span>{match}</span>
</label>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.matches {
border: 1px solid #c2b8a3;
padding: 1rem;
border-radius: 12px;
background: #f7f2e9;
}
ul {
list-style: none;
padding: 0;
margin: 0.5rem 0 0;
display: grid;
gap: 0.5rem;
}
label {
display: flex;
gap: 0.5rem;
align-items: center;
}
</style>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
export let onResample: () => void;
export let onKeep: () => void;
export let onPreviewDelete: () => void;
export let onConfirmDelete: () => void;
export let confirmEnabled = false;
export let busy = false;
</script>
<div class="controls">
<button on:click={onResample} disabled={busy}>Resample</button>
<button on:click={onKeep} disabled={busy}>Keep</button>
<button on:click={onPreviewDelete} disabled={busy}>Preview delete</button>
<button class="danger" on:click={onConfirmDelete} disabled={!confirmEnabled || busy}>
Confirm delete
</button>
</div>
<style>
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
button {
padding: 0.6rem 1rem;
border-radius: 999px;
border: 1px solid #1e1e1e;
background: #f1d6b8;
font-weight: 600;
}
button.danger {
background: #d96c4f;
color: #fff;
border-color: #7f3422;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,210 @@
<script lang="ts">
import { onMount } from 'svelte';
import ListFileMatches from '../components/list-file-matches.svelte';
import UntaggedControls from '../components/untagged-controls.svelte';
import {
confirmDelete,
fetchNextUntagged,
previewDelete,
resampleCollage,
keepDirectory,
type DeletePreview,
type UntaggedCollage,
} from '../services/untagged_api';
let collage: UntaggedCollage | null = null;
let preview: DeletePreview | null = null;
let selectedMatches: string[] = [];
let statusMessage = '';
let busy = false;
async function loadNext() {
busy = true;
statusMessage = '';
preview = null;
selectedMatches = [];
try {
collage = await fetchNextUntagged();
} catch (err) {
statusMessage = 'No untagged directories available.';
} finally {
busy = false;
}
}
async function handleResample() {
if (!collage) return;
busy = true;
try {
collage = await resampleCollage(collage.directory_id);
} finally {
busy = false;
}
}
async function handleKeep() {
if (!collage) return;
busy = true;
statusMessage = '';
try {
await keepDirectory(collage.directory_id);
statusMessage = 'Directory moved to kept.';
await loadNext();
} catch (err) {
statusMessage = 'Keep failed.';
} finally {
busy = false;
}
}
async function handlePreviewDelete() {
if (!collage) return;
busy = true;
statusMessage = '';
try {
preview = await previewDelete(collage.directory_id);
selectedMatches = preview.list_file_changes_preview;
} catch (err) {
statusMessage = 'Preview failed.';
} finally {
busy = false;
}
}
async function handleConfirmDelete() {
if (!collage || !preview) return;
busy = true;
statusMessage = '';
try {
await confirmDelete(collage.directory_id, {
preview_id: preview.preview_id,
remove_from_list_file: selectedMatches.length > 0,
selected_matches: selectedMatches,
});
statusMessage = 'Delete staged.';
await loadNext();
} catch (err) {
statusMessage = 'Delete failed.';
} finally {
busy = false;
}
}
onMount(loadNext);
</script>
<section class="page">
<header>
<h1>Untagged Collage Review</h1>
<p>Curate directories quickly, with staged deletes and list-file previews.</p>
</header>
{#if collage}
<div class="summary">
<h2>{collage.directory_name}</h2>
<div class="meta">
<span>{collage.file_count} files</span>
<span>{Math.round(collage.total_size_bytes / (1024 * 1024))} MB</span>
</div>
</div>
<div class="grid">
{#each collage.samples as item}
<div class="tile">
<div class="badge">{item.media_type}</div>
<div class="path">{item.relative_path}</div>
</div>
{/each}
</div>
<UntaggedControls
{busy}
onResample={handleResample}
onKeep={handleKeep}
onPreviewDelete={handlePreviewDelete}
onConfirmDelete={handleConfirmDelete}
confirmEnabled={Boolean(preview)}
/>
{#if preview}
<ListFileMatches
matches={preview.list_file_changes_preview}
bind:selected={selectedMatches}
/>
{/if}
{:else}
<p class="empty">{statusMessage}</p>
{/if}
{#if statusMessage && collage}
<p class="status">{statusMessage}</p>
{/if}
</section>
<style>
:global(body) {
font-family: 'Space Grotesk', 'Fira Sans', sans-serif;
margin: 0;
background: radial-gradient(circle at top left, #f8efe1, #f4dfc8 55%, #e6c39b 100%);
color: #1b130b;
}
.page {
padding: 2rem;
max-width: 1100px;
margin: 0 auto;
}
header h1 {
font-size: 2.4rem;
margin-bottom: 0.2rem;
}
header p {
margin-top: 0;
color: #5b4634;
}
.summary {
margin: 1.5rem 0;
display: flex;
justify-content: space-between;
align-items: baseline;
border-bottom: 1px solid #bba486;
padding-bottom: 0.5rem;
}
.meta {
display: flex;
gap: 1rem;
font-weight: 600;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.tile {
background: #fff8ee;
border-radius: 16px;
padding: 0.8rem;
min-height: 120px;
box-shadow: 0 8px 24px rgba(73, 45, 22, 0.15);
}
.badge {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8d5b3c;
}
.path {
font-size: 0.85rem;
margin-top: 0.6rem;
color: #3b2a1d;
word-break: break-word;
}
.status {
margin-top: 1rem;
font-weight: 600;
}
.empty {
font-style: italic;
padding: 2rem 0;
}
</style>

View File

@@ -0,0 +1,70 @@
export type MediaItem = {
id: string;
user_directory_id: string;
relative_path: string;
size_bytes: number;
media_type: string;
};
export type UntaggedCollage = {
directory_id: string;
directory_name: string;
total_size_bytes: number;
file_count: number;
samples: MediaItem[];
};
export type DeletePreview = {
preview_id: string;
target_paths: string[];
list_file_changes_preview: string[];
can_proceed: boolean;
read_only_mode: boolean;
};
export type DeleteConfirm = {
preview_id: string;
remove_from_list_file: boolean;
selected_matches?: string[];
};
export type DecisionResult = {
outcome: string;
audit_entry_id: string;
};
const API_BASE = import.meta.env.VITE_API_BASE ?? '';
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return (await response.json()) as T;
}
export function fetchNextUntagged(): Promise<UntaggedCollage> {
return request('/directories/untagged/next');
}
export function resampleCollage(directoryId: string): Promise<UntaggedCollage> {
return request(`/directories/untagged/${directoryId}/resample`, { method: 'POST' });
}
export function keepDirectory(directoryId: string): Promise<DecisionResult> {
return request(`/directories/untagged/${directoryId}/keep`, { method: 'POST' });
}
export function previewDelete(directoryId: string): Promise<DeletePreview> {
return request(`/directories/untagged/${directoryId}/preview-delete`, { method: 'POST' });
}
export function confirmDelete(directoryId: string, payload: DeleteConfirm): Promise<DecisionResult> {
return request(`/directories/untagged/${directoryId}/confirm-delete`, {
method: 'POST',
body: JSON.stringify(payload),
});
}