Mulimage 2.0 #1

Merged
dtoro merged 64 commits from new into main 2026-05-21 22:48:55 +02:00
Showing only changes of commit 680fa90cbe - Show all commits

View File

@@ -14,6 +14,7 @@
ExternalLink,
File,
Folder,
HardDrive,
ImageIcon,
Loader2,
MapPin,
@@ -345,11 +346,19 @@
</div>
{/if}
<!-- Dimensions + file size -->
<!-- Dimensions -->
<div class="flex items-center gap-2">
<ImageIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
{dims} · {sizeStr}
{dims}
</span>
</div>
<!-- File size -->
<div class="flex items-center gap-2">
<HardDrive class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
{sizeStr}
</span>
</div>
@@ -386,82 +395,121 @@
></textarea>
</div>
<!-- Score + color label — two separate sections. Click a star/swatch to
set, click the active one to clear. Sits next to Keywords because
these are the per-photo culling marks the user reaches for in the
same workflow. Stored on the mule-sidecar; PhotoPrism's PUT can't
persist them. -->
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div>
<div class="flex items-center gap-0.5" role="group" aria-label="Rating">
{#each [1, 2, 3, 4, 5] as n (n)}
<button
type="button"
class="p-0.5 transition-colors disabled:opacity-50"
class:text-yellow-400={currentRating >= n}
class:text-muted-foreground={currentRating < n}
onclick={() => setRating(n)}
title={`Rate ${n}`}
aria-label={`Rate ${n}`}
>
<Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} />
</button>
{/each}
</div>
</div>
<!-- Tags — score, color label, keywords, and auto-labels grouped under
one collapsible section. Score + color are stored on the mule-
sidecar (PhotoPrism's PUT can't persist them); keywords live on
Details; auto-labels come from PhotoPrism's TF classifier and are
read-only. Open by default since these are the culling marks the
user reaches for first. -->
<details
class="rounded border border-border"
open={getMetadataSectionOpen('tags', true)}
ontoggle={(e) => setMetadataSection('tags', e.currentTarget.open)}
>
<summary
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
<span class="inline-flex items-center gap-1">
<Tag class="h-3 w-3" /> Tags
</span>
</summary>
<div class="space-y-2 p-2 pt-1">
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div>
<div class="flex items-center gap-0.5" role="group" aria-label="Rating">
{#each [1, 2, 3, 4, 5] as n (n)}
<button
type="button"
class="p-0.5 transition-colors disabled:opacity-50"
class:text-yellow-400={currentRating >= n}
class:text-muted-foreground={currentRating < n}
onclick={() => setRating(n)}
title={`Rate ${n}`}
aria-label={`Rate ${n}`}
>
<Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} />
</button>
{/each}
</div>
</div>
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div>
<div class="flex items-center gap-1" role="group" aria-label="Color label">
{#each COLOR_SWATCHES as c (c.key)}
<button
type="button"
class="h-4 w-4 rounded-full ring-2 transition-all {c.bg}"
class:ring-foreground={currentColor === c.key}
class:ring-transparent={currentColor !== c.key}
onclick={() => setColor(c.key)}
title={c.title}
aria-label={`Color ${c.key}`}
></button>
{/each}
</div>
</div>
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div>
<div class="flex items-center gap-1" role="group" aria-label="Color label">
{#each COLOR_SWATCHES as c (c.key)}
<button
type="button"
class="h-4 w-4 rounded-full ring-2 transition-all {c.bg}"
class:ring-foreground={currentColor === c.key}
class:ring-transparent={currentColor !== c.key}
onclick={() => setColor(c.key)}
title={c.title}
aria-label={`Color ${c.key}`}
></button>
{/each}
</div>
</div>
<!-- Keywords as chips -->
<div class="space-y-1">
<div class="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground">
<Tag class="h-3 w-3" /> Keywords
<!-- Keywords as chips -->
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Keywords</div>
<div class="flex flex-wrap gap-1">
{#each keywords as kw (kw)}
<span
class="inline-flex items-center gap-0.5 rounded-full border border-border bg-secondary px-1.5 py-0.5 text-[10px]"
>
{kw}
<button
class="text-muted-foreground hover:text-destructive"
onclick={() => removeKeyword(kw)}
aria-label={`Remove ${kw}`}
>
<X class="h-2.5 w-2.5" />
</button>
</span>
{/each}
<input
type="text"
placeholder="+ tag"
class="w-16 rounded border border-input bg-background px-1.5 py-0.5 text-[10px] shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={keywordDraft}
onblur={addKeyword}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addKeyword();
}
}}
/>
</div>
</div>
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-
only: editing labels requires re-indexing on PhotoPrism's
side. The dashed border + lower contrast distinguishes them
from the user-editable Keywords chips above. -->
{#if (photo.Labels ?? []).length > 0}
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">
Labels (auto)
</div>
<div class="flex flex-wrap gap-1">
{#each photo.Labels ?? [] as lbl (lbl.UID ?? lbl.Label?.Slug)}
{@const slug = lbl.Label?.Slug}
{@const name = lbl.Label?.Name ?? slug ?? '(unknown)'}
<a
href={slug ? `/?q=${encodeURIComponent(`label:${slug}`)}` : '#'}
class="inline-flex items-center rounded-full border border-dashed border-border bg-muted/40 px-1.5 py-0.5 text-[10px] text-muted-foreground hover:text-foreground"
title={`Source: ${lbl.Source ?? 'classifier'}`}
>
{name}
</a>
{/each}
</div>
</div>
{/if}
</div>
<div class="flex flex-wrap gap-1">
{#each keywords as kw (kw)}
<span
class="inline-flex items-center gap-0.5 rounded-full border border-border bg-secondary px-1.5 py-0.5 text-[10px]"
>
{kw}
<button
class="text-muted-foreground hover:text-destructive"
onclick={() => removeKeyword(kw)}
aria-label={`Remove ${kw}`}
>
<X class="h-2.5 w-2.5" />
</button>
</span>
{/each}
<input
type="text"
placeholder="+ tag"
class="w-16 rounded border border-input bg-background px-1.5 py-0.5 text-[10px] shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={keywordDraft}
onblur={addKeyword}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addKeyword();
}
}}
/>
</div>
</div>
</details>
<!-- Related strips (only the /review route opts in). The three
scopes match the three decisions the user usually makes here:
@@ -492,33 +540,6 @@
</div>
{/if}
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-only:
editing labels requires re-indexing on PhotoPrism's side. The
dashed border + lower contrast distinguishes them from the user-
editable Keywords chips above. -->
{#if (photo.Labels ?? []).length > 0}
<div class="space-y-1">
<div
class="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
<Tag class="h-3 w-3" /> Labels (auto)
</div>
<div class="flex flex-wrap gap-1">
{#each photo.Labels ?? [] as lbl (lbl.UID ?? lbl.Label?.Slug)}
{@const slug = lbl.Label?.Slug}
{@const name = lbl.Label?.Name ?? slug ?? '(unknown)'}
<a
href={slug ? `/?q=${encodeURIComponent(`label:${slug}`)}` : '#'}
class="inline-flex items-center rounded-full border border-dashed border-border bg-muted/40 px-1.5 py-0.5 text-[10px] text-muted-foreground hover:text-foreground"
title={`Source: ${lbl.Source ?? 'classifier'}`}
>
{name}
</a>
{/each}
</div>
</div>
{/if}
<!-- GPS detail. Static default (closed); user's expand/collapse
choice persists across photo switches via the view store.
Avoid data-driven defaults here — they make the `open` attr