wowaweewa
This commit is contained in:
4
frontend/.prettierrc
Normal file
4
frontend/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true
|
||||
}
|
||||
21
frontend/package.json
Normal file
21
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
55
frontend/src/components/list-file-matches.svelte
Normal file
55
frontend/src/components/list-file-matches.svelte
Normal 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>
|
||||
41
frontend/src/components/untagged-controls.svelte
Normal file
41
frontend/src/components/untagged-controls.svelte
Normal 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>
|
||||
210
frontend/src/pages/untagged-collage.svelte
Normal file
210
frontend/src/pages/untagged-collage.svelte
Normal 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>
|
||||
70
frontend/src/services/untagged_api.ts
Normal file
70
frontend/src/services/untagged_api.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user