Compare commits

...

8 Commits

Author SHA1 Message Date
bvandeusen 885dcf64f3 Merge pull request 'v26.05.24.0: TopNav re-fix (flex 1 1 0 side cells)' (#6) from dev into main 2026-05-23 22:49:29 -04:00
bvandeusen 05b398c352 fix(ui): TopNav side cells use flex: 1 1 0 (equal weight) so middle link block stays centered regardless of action-slot content
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:24:41 -04:00
bvandeusen f2f6b6d25e Merge pull request 'v26.05.23.3: dogfood UX polish + accurate active-batch stats' (#5) from dev into main 2026-05-23 22:05:59 -04:00
bvandeusen 7a5a71471e fix(scan): auto-finalize empty scans + system_stats picks running batch with actual work
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:25:57 -04:00
bvandeusen 38a45baad5 feat(ui): click outside the image in the viewer modal closes it (parity with IR)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:24:06 -04:00
bvandeusen c9ddcd0f60 fix(ui): viewer modal — haze background through to the image, fit-to-view at zoom 1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:21:02 -04:00
bvandeusen 95bc761a69 fix(ui): TopNav uses 1fr/auto/1fr grid so Gallery/Showcase action buttons don't shift the centered links
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:16:37 -04:00
bvandeusen 6acf273267 fix(ui): poll import task list during active batch so Recent Import Tasks doesn't sit stale
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:12:17 -04:00
7 changed files with 100 additions and 15 deletions
+14 -2
View File
@@ -141,11 +141,23 @@ async def system_stats():
).all()
integrity_counts = {row[0]: row[1] for row in integrity_rows}
# Active batch (most recent running)
# Active batch = running batch that still has outstanding work.
# Plain "most recent running" picks a freshly-created scan that
# enqueued zero new files and hides the older batch that's
# actually being processed; the EXISTS clause filters those
# empty batches out.
active_batch_row = (
await session.execute(
select(ImportBatch)
.where(ImportBatch.status == "running")
.where(
ImportBatch.status == "running",
select(ImportTask.id)
.where(
ImportTask.batch_id == ImportBatch.id,
ImportTask.status.in_(["pending", "queued", "processing"]),
)
.exists(),
)
.order_by(ImportBatch.started_at.desc())
.limit(1)
)
+9
View File
@@ -95,6 +95,15 @@ def scan_directory(self, triggered_by: str = "manual",
files_seen += 1
batch.total_files = files_seen
# If the walk enqueued nothing (every file was already on a
# non-failed ImportTask from a prior scan), there's no
# import_media_file message that would ever flip this batch to
# 'complete' — finalize it now so the active-batch query in
# /api/system/stats doesn't get stuck reporting all-zero
# counters from an empty scan.
if files_seen == 0:
batch.status = "complete"
batch.finished_at = datetime.now(UTC)
session.commit()
# Now enqueue import_media_file for each pending task.
+16 -3
View File
@@ -55,6 +55,15 @@ const health = computed(() => {
position: sticky;
top: 0;
z-index: 1000;
/* Both side cells use `flex: 1 1 0` — equal flex weight, basis 0 —
so they grow/shrink at the same rate regardless of which one has
content (brand vs. teleport-slot action button). The middle cell
is `flex: 0 0 auto` (content width), and because the side cells
are symmetric, the middle stays geometrically centered. Earlier
attempts: `flex: 1` defaults to basis 0%, which made the centered
links shift when actions appeared; `grid-template-columns: 1fr
auto 1fr` actually means `minmax(auto, 1fr)` so a wide brand
pushed the link block off-center on narrow viewports. */
display: flex;
align-items: center;
gap: 1rem;
@@ -87,7 +96,7 @@ const health = computed(() => {
}
.fc-links {
flex: 1;
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
justify-content: center;
@@ -114,10 +123,12 @@ const health = computed(() => {
}
.fc-nav-left {
flex: 1 1 0;
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
flex-shrink: 0;
}
.fc-health {
display: flex;
@@ -125,9 +136,11 @@ const health = computed(() => {
flex-shrink: 0;
}
.fc-nav-actions {
flex: 1 1 0;
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
flex-shrink: 0;
}
</style>
+35 -3
View File
@@ -1,14 +1,16 @@
<template>
<div
ref="canvasEl"
class="fc-canvas"
:class="{ 'fc-canvas--zoomed': panZoom.state.scale > 1 }"
@wheel="panZoom.handlers.onWheel"
@pointerdown="panZoom.handlers.onPointerDown"
@pointermove="panZoom.handlers.onPointerMove"
@pointerup="panZoom.handlers.onPointerUp"
@click="panZoom.handlers.onClick"
@click="onCanvasClick"
>
<img
ref="imgEl"
:src="src" :alt="alt"
:style="{
transform: `translate(${panZoom.state.x}px, ${panZoom.state.y}px) scale(${panZoom.state.scale})`
@@ -19,21 +21,51 @@
</template>
<script setup>
import { watch } from 'vue'
import { ref, watch } from 'vue'
import { usePanZoom } from '../../composables/usePanZoom.js'
const props = defineProps({ src: String, alt: String })
const emit = defineEmits(['close-request'])
const panZoom = usePanZoom()
const canvasEl = ref(null)
const imgEl = ref(null)
// Reset zoom when the src changes (prev/next nav).
watch(() => props.src, () => panZoom.reset())
// Click on the image → toggle zoom (IR/expected behavior). Click on
// the haze area around the image → request close. We use the image's
// own bounding rect rather than @click.self because the <img> sets
// pointer-events: none for the pan-drag pathway, so the click always
// targets the canvas div regardless of where the cursor was.
function onCanvasClick(ev) {
if (panZoom.state.scale > 1) {
// When zoomed, any click resets zoom — preserve old behavior.
panZoom.handlers.onClick(ev)
return
}
const img = imgEl.value
if (!img) return
const r = img.getBoundingClientRect()
const inside =
ev.clientX >= r.left && ev.clientX <= r.right &&
ev.clientY >= r.top && ev.clientY <= r.bottom
if (inside) {
panZoom.handlers.onClick(ev)
} else {
emit('close-request')
}
}
</script>
<style scoped>
.fc-canvas {
flex: 1;
position: relative;
background: rgb(var(--v-theme-background));
/* Transparent so the viewer's haze shows through to the image area
instead of a solid panel sitting on top of it. */
background: transparent;
overflow: hidden;
cursor: zoom-in;
user-select: none;
+13 -3
View File
@@ -39,9 +39,11 @@
<template v-else-if="modal.current">
<ImageCanvas
v-if="!isVideo" :src="modal.current.image_url" :alt="`Image ${modal.current.id}`"
@close-request="$emit('close')"
/>
<VideoCanvas
v-else :src="modal.current.image_url" :mime="modal.current.mime"
@close-request="$emit('close')"
/>
</template>
<v-alert v-else-if="modal.error" type="error" variant="tonal">
@@ -123,7 +125,11 @@ function isTextEntry(el) {
<style scoped>
.fc-viewer {
position: fixed; inset: 0; z-index: 2000;
background: rgba(20, 23, 26, 0.96);
/* Obsidian haze (#14171A = 20,23,26) — same palette as TopNav,
mid-opacity + blur so the page behind shows through faintly. */
background: rgba(20, 23, 26, 0.65);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
outline: none;
display: flex; flex-direction: column;
}
@@ -152,8 +158,12 @@ function isTextEntry(el) {
flex: 1; display: flex; min-height: 0;
}
.fc-viewer__media {
flex: 1; display: flex; align-items: center; justify-content: center;
min-width: 0;
/* Let the canvas fill us; the canvas does its own centering. Both
min-* are needed so this child can shrink inside its flex parents
(without them, max-height/max-width on the <img> have nothing to
bound against and the image overflows the viewport). */
flex: 1; display: flex;
min-width: 0; min-height: 0;
}
.fc-viewer__side {
width: 320px; flex-shrink: 0;
@@ -1,10 +1,11 @@
<template>
<div class="fc-canvas fc-canvas--video">
<div class="fc-canvas fc-canvas--video" @click.self="$emit('close-request')">
<video
v-if="playable" :src="src" controls playsinline preload="metadata"
class="fc-canvas__video"
@click.stop
/>
<div v-else class="fc-canvas__unsupported">
<div v-else class="fc-canvas__unsupported" @click.stop>
<v-icon size="56" icon="mdi-video-off-outline" />
<h3 class="fc-canvas__unsupported-title">Format not browser-playable</h3>
<p>
@@ -23,6 +24,7 @@
<script setup>
import { computed } from 'vue'
defineEmits(['close-request'])
const props = defineProps({ src: String, mime: String })
const BROWSER_PLAYABLE = new Set([
@@ -34,7 +36,9 @@ const playable = computed(() => BROWSER_PLAYABLE.has(props.mime))
<style scoped>
.fc-canvas {
flex: 1;
background: rgb(var(--v-theme-background));
/* Transparent so the viewer's haze shows through (parallels
ImageCanvas both sit inside ImageViewer's blurred backdrop). */
background: transparent;
display: flex; align-items: center; justify-content: center;
}
.fc-canvas__video { max-width: 100%; max-height: 100%; }
+6 -1
View File
@@ -57,7 +57,12 @@ function startPolling() {
pollId = setInterval(() => {
if (!document.hidden) {
system.refreshStats()
if (tab.value === 'import') importStore.refreshStatus()
if (tab.value === 'import') {
importStore.refreshStatus()
// Refresh the task list while a batch is in flight so the UI
// doesn't sit stale next to a ticking imported/skipped counter.
if (importStore.activeBatch) importStore.loadTasks(true)
}
}
}, 5000)
}