Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2f6b6d25e | |||
| 7a5a71471e | |||
| 38a45baad5 | |||
| c9ddcd0f60 | |||
| 95bc761a69 | |||
| 6acf273267 | |||
| 0822240fde | |||
| f5efbea053 | |||
| f653c26680 |
@@ -21,10 +21,10 @@ migrate_bp = Blueprint("migrate", __name__, url_prefix="/api/migrate")
|
|||||||
|
|
||||||
_VALID_KINDS = frozenset({
|
_VALID_KINDS = frozenset({
|
||||||
"backup", "gs_ingest", "ir_ingest", "tag_apply",
|
"backup", "gs_ingest", "ir_ingest", "tag_apply",
|
||||||
"ml_queue", "verify", "rollback",
|
"ml_queue", "verify", "rollback", "cleanup",
|
||||||
})
|
})
|
||||||
_INGEST_KINDS = frozenset({"gs_ingest", "ir_ingest"})
|
_INGEST_KINDS = frozenset({"gs_ingest", "ir_ingest"})
|
||||||
_APPLY_KINDS = frozenset({"gs_ingest", "ir_ingest", "tag_apply", "rollback"})
|
_APPLY_KINDS = frozenset({"gs_ingest", "ir_ingest", "tag_apply", "rollback", "cleanup"})
|
||||||
|
|
||||||
|
|
||||||
def _bad(error: str, *, status: int = 400, **extra):
|
def _bad(error: str, *, status: int = 400, **extra):
|
||||||
|
|||||||
@@ -141,11 +141,23 @@ async def system_stats():
|
|||||||
).all()
|
).all()
|
||||||
integrity_counts = {row[0]: row[1] for row in integrity_rows}
|
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 = (
|
active_batch_row = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(ImportBatch)
|
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())
|
.order_by(ImportBatch.started_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
|
|||||||
+24
-2
@@ -1,14 +1,36 @@
|
|||||||
"""Serves the built Vue SPA from frontend/dist/ with history-mode fallback."""
|
"""Serves the built Vue SPA from frontend/dist/ with history-mode fallback,
|
||||||
|
and the on-disk image library + thumbnails from /images.
|
||||||
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, send_from_directory
|
from quart import Blueprint, abort, send_from_directory
|
||||||
|
|
||||||
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
||||||
|
IMAGES_ROOT = Path("/images")
|
||||||
|
|
||||||
frontend_bp = Blueprint("frontend", __name__)
|
frontend_bp = Blueprint("frontend", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@frontend_bp.route("/images/<path:subpath>")
|
||||||
|
async def serve_image(subpath: str):
|
||||||
|
"""Serve a file from the /images volume (originals + thumbnails).
|
||||||
|
|
||||||
|
Without this route the SPA catch-all below would swallow image
|
||||||
|
requests and return index.html, leaving the browser to render the
|
||||||
|
aspect-ratio-shaped grey placeholder.
|
||||||
|
"""
|
||||||
|
target = (IMAGES_ROOT / subpath).resolve()
|
||||||
|
# Defend against path-traversal: refuse anything that escapes /images.
|
||||||
|
try:
|
||||||
|
target.relative_to(IMAGES_ROOT)
|
||||||
|
except ValueError:
|
||||||
|
abort(404)
|
||||||
|
if not target.is_file():
|
||||||
|
abort(404)
|
||||||
|
return await send_from_directory(IMAGES_ROOT, subpath)
|
||||||
|
|
||||||
|
|
||||||
@frontend_bp.route("/")
|
@frontend_bp.route("/")
|
||||||
@frontend_bp.route("/<path:subpath>")
|
@frontend_bp.route("/<path:subpath>")
|
||||||
async def serve_spa(subpath: str = ""):
|
async def serve_spa(subpath: str = ""):
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Targeted cleanup migrator: delete every image attributed to one Artist.
|
||||||
|
|
||||||
|
Built for the IR-migration rescue case where the filesystem scan derived
|
||||||
|
a bogus 'imagerepo' artist from a mismatched bind-mount layout. Every
|
||||||
|
image attributed to that artist (40k+ rows) needs to be removed — DB
|
||||||
|
rows, original files under `/images/<bucket>/...`, and thumbnails under
|
||||||
|
`/images/thumbs/...` — before the operator remounts and re-scans.
|
||||||
|
|
||||||
|
CASCADE handles image_tag, image_provenance, series_page, and
|
||||||
|
tag_suggestion_rejection child rows; import_task.result_image_id is
|
||||||
|
SET NULL by FK. We also delete ImportTask rows whose source_path starts
|
||||||
|
with the (still-existing) IR scan prefix so the next scan isn't fooled
|
||||||
|
by them.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ...models import Artist, ImageRecord, ImportBatch, ImportTask
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BATCH_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _zero_counts() -> dict:
|
||||||
|
return {
|
||||||
|
"rows_processed": 0, "rows_inserted": 0, "rows_skipped": 0,
|
||||||
|
"files_copied": 0, "bytes_copied": 0, "conflicts": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _thumb_path(images_root: Path, sha256_hex: str) -> tuple[Path, Path]:
|
||||||
|
"""Return both possible thumbnail paths (.jpg and .png). We try both
|
||||||
|
because the extension is chosen at generate-time based on the source
|
||||||
|
image's mode (alpha → .png, otherwise → .jpg)."""
|
||||||
|
bucket = sha256_hex[:3]
|
||||||
|
base = images_root / "thumbs" / bucket / sha256_hex
|
||||||
|
return base.with_suffix(".jpg"), base.with_suffix(".png")
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_file(path: Path) -> bool:
|
||||||
|
"""Best-effort unlink; True if the file was actually removed."""
|
||||||
|
try:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
return True
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("cleanup: failed to unlink %s: %s", path, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_artist_async(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
slug: str,
|
||||||
|
images_root: Path | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
source_path_prefix: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Delete every image attributed to the Artist with this slug,
|
||||||
|
along with the artist row itself and any associated import tasks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slug: artist.slug to target (e.g. 'imagerepo').
|
||||||
|
images_root: defaults to /images.
|
||||||
|
dry_run: skip filesystem + DB writes; still walk rows for counts.
|
||||||
|
source_path_prefix: if set, ImportTask rows whose source_path
|
||||||
|
starts with this string are deleted too (use the IR scan
|
||||||
|
mount prefix, e.g. '/import/imagerepo').
|
||||||
|
"""
|
||||||
|
root = images_root if images_root is not None else Path("/images")
|
||||||
|
|
||||||
|
artist = (await db.execute(
|
||||||
|
select(Artist).where(Artist.slug == slug)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if artist is None:
|
||||||
|
raise ValueError(f"no Artist with slug={slug!r}")
|
||||||
|
|
||||||
|
artist_id = artist.id
|
||||||
|
artist_name = artist.name
|
||||||
|
|
||||||
|
total_images = (await db.execute(
|
||||||
|
select(func.count(ImageRecord.id)).where(ImageRecord.artist_id == artist_id)
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
|
counts = _zero_counts()
|
||||||
|
files_deleted = 0
|
||||||
|
thumbs_deleted = 0
|
||||||
|
images_deleted = 0
|
||||||
|
|
||||||
|
# Batched delete loop. CASCADE handles image_tag, image_provenance,
|
||||||
|
# series_page, tag_suggestion_rejection. import_task.result_image_id
|
||||||
|
# is SET NULL by FK.
|
||||||
|
while True:
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(ImageRecord.id, ImageRecord.path, ImageRecord.sha256)
|
||||||
|
.where(ImageRecord.artist_id == artist_id)
|
||||||
|
.limit(_BATCH_SIZE)
|
||||||
|
)).all()
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
ids = [r.id for r in rows]
|
||||||
|
counts["rows_processed"] += len(ids)
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
for r in rows:
|
||||||
|
if r.path:
|
||||||
|
if _delete_file(Path(r.path)):
|
||||||
|
files_deleted += 1
|
||||||
|
if r.sha256:
|
||||||
|
jpg, png = _thumb_path(root, r.sha256)
|
||||||
|
if _delete_file(jpg):
|
||||||
|
thumbs_deleted += 1
|
||||||
|
if _delete_file(png):
|
||||||
|
thumbs_deleted += 1
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
delete(ImageRecord).where(ImageRecord.id.in_(ids))
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
images_deleted += len(ids)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
# Nothing was actually deleted from the DB; bail after one
|
||||||
|
# pass so we don't loop forever.
|
||||||
|
break
|
||||||
|
|
||||||
|
import_tasks_deleted = 0
|
||||||
|
if source_path_prefix and not dry_run:
|
||||||
|
# Delete ImportTask rows whose source_path is under the bad mount
|
||||||
|
# prefix. These are mostly orphaned now (result_image_id was set
|
||||||
|
# NULL by CASCADE) but their presence still blocks the
|
||||||
|
# idempotency check in scan_directory if the operator remounts
|
||||||
|
# the same prefix.
|
||||||
|
like_pattern = source_path_prefix.rstrip("/") + "/%"
|
||||||
|
result = await db.execute(
|
||||||
|
delete(ImportTask).where(ImportTask.source_path.like(like_pattern))
|
||||||
|
)
|
||||||
|
import_tasks_deleted = result.rowcount or 0
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Sweep ImportBatch rows that are now empty.
|
||||||
|
empty_batches_deleted = 0
|
||||||
|
if not dry_run:
|
||||||
|
empty_batch_ids = (await db.execute(
|
||||||
|
select(ImportBatch.id).where(
|
||||||
|
~select(ImportTask.id)
|
||||||
|
.where(ImportTask.batch_id == ImportBatch.id)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
)).scalars().all()
|
||||||
|
if empty_batch_ids:
|
||||||
|
result = await db.execute(
|
||||||
|
delete(ImportBatch).where(ImportBatch.id.in_(empty_batch_ids))
|
||||||
|
)
|
||||||
|
empty_batches_deleted = result.rowcount or 0
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Finally, the artist row.
|
||||||
|
if not dry_run:
|
||||||
|
await db.execute(delete(Artist).where(Artist.id == artist_id))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"counts": counts,
|
||||||
|
"artist": {"id": artist_id, "name": artist_name, "slug": slug},
|
||||||
|
"summary": {
|
||||||
|
"images_targeted": total_images,
|
||||||
|
"images_deleted": images_deleted,
|
||||||
|
"files_deleted": files_deleted,
|
||||||
|
"thumbs_deleted": thumbs_deleted,
|
||||||
|
"import_tasks_deleted": import_tasks_deleted,
|
||||||
|
"empty_batches_deleted": empty_batches_deleted,
|
||||||
|
"dry_run": dry_run,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ Dispatches to the right migrator based on `kind`. Updates MigrationRun
|
|||||||
row's status/counts/finished_at as it runs. Failures set status='error'
|
row's status/counts/finished_at as it runs. Failures set status='error'
|
||||||
with the error message preserved.
|
with the error message preserved.
|
||||||
|
|
||||||
kinds: backup, gs_ingest, ir_ingest, tag_apply, ml_queue, verify, rollback
|
kinds: backup, gs_ingest, ir_ingest, tag_apply, ml_queue, verify, rollback, cleanup
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ from ..config import get_config
|
|||||||
from ..models import MigrationRun
|
from ..models import MigrationRun
|
||||||
from ..services.credential_crypto import CredentialCrypto
|
from ..services.credential_crypto import CredentialCrypto
|
||||||
from ..services.migrators import backup as backup_mod
|
from ..services.migrators import backup as backup_mod
|
||||||
|
from ..services.migrators import cleanup as cleanup_mod
|
||||||
from ..services.migrators import gs_ingest, ir_ingest, ml_queue, tag_apply, verify
|
from ..services.migrators import gs_ingest, ir_ingest, ml_queue, tag_apply, verify
|
||||||
from ..services.migrators import rollback as rollback_mod
|
from ..services.migrators import rollback as rollback_mod
|
||||||
|
|
||||||
@@ -145,6 +146,26 @@ async def _run_async(run_id: int, kind: str, params: dict) -> dict:
|
|||||||
)
|
)
|
||||||
return {"checks": checks, "sample": sample}
|
return {"checks": checks, "sample": sample}
|
||||||
|
|
||||||
|
elif kind == "cleanup":
|
||||||
|
slug = params.get("slug")
|
||||||
|
if not slug:
|
||||||
|
raise ValueError("cleanup requires params.slug")
|
||||||
|
result = await cleanup_mod.cleanup_artist_async(
|
||||||
|
db, slug=slug, images_root=IMAGES_ROOT,
|
||||||
|
dry_run=params.get("dry_run", False),
|
||||||
|
source_path_prefix=params.get("source_path_prefix"),
|
||||||
|
)
|
||||||
|
await _update_run(
|
||||||
|
db, run_id, status="ok",
|
||||||
|
counts=result["counts"],
|
||||||
|
finished_at=datetime.now(UTC),
|
||||||
|
metadata_patch={
|
||||||
|
"artist": result["artist"],
|
||||||
|
"summary": result["summary"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
elif kind == "rollback":
|
elif kind == "rollback":
|
||||||
result = rollback_mod.rollback_to_pre_migration(
|
result = rollback_mod.rollback_to_pre_migration(
|
||||||
db_url=get_config().database_url_sync,
|
db_url=get_config().database_url_sync,
|
||||||
|
|||||||
@@ -95,6 +95,15 @@ def scan_directory(self, triggered_by: str = "manual",
|
|||||||
files_seen += 1
|
files_seen += 1
|
||||||
|
|
||||||
batch.total_files = files_seen
|
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()
|
session.commit()
|
||||||
|
|
||||||
# Now enqueue import_media_file for each pending task.
|
# Now enqueue import_media_file for each pending task.
|
||||||
|
|||||||
@@ -55,7 +55,13 @@ const health = computed(() => {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: flex;
|
/* Mirrored side columns (1fr / auto / 1fr) keep the link block dead-
|
||||||
|
centered no matter what the teleport-slot on the right is rendering
|
||||||
|
for the active view (Gallery: Select, Showcase: Shuffle, others: ∅).
|
||||||
|
With a plain flex layout the links shifted left as soon as actions
|
||||||
|
appeared. */
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
@@ -87,7 +93,6 @@ const health = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fc-links {
|
.fc-links {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -118,6 +123,7 @@ const health = computed(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
justify-self: start;
|
||||||
}
|
}
|
||||||
.fc-health {
|
.fc-health {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -129,5 +135,6 @@ const health = computed(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
justify-self: end;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
ref="canvasEl"
|
||||||
class="fc-canvas"
|
class="fc-canvas"
|
||||||
:class="{ 'fc-canvas--zoomed': panZoom.state.scale > 1 }"
|
:class="{ 'fc-canvas--zoomed': panZoom.state.scale > 1 }"
|
||||||
@wheel="panZoom.handlers.onWheel"
|
@wheel="panZoom.handlers.onWheel"
|
||||||
@pointerdown="panZoom.handlers.onPointerDown"
|
@pointerdown="panZoom.handlers.onPointerDown"
|
||||||
@pointermove="panZoom.handlers.onPointerMove"
|
@pointermove="panZoom.handlers.onPointerMove"
|
||||||
@pointerup="panZoom.handlers.onPointerUp"
|
@pointerup="panZoom.handlers.onPointerUp"
|
||||||
@click="panZoom.handlers.onClick"
|
@click="onCanvasClick"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
ref="imgEl"
|
||||||
:src="src" :alt="alt"
|
:src="src" :alt="alt"
|
||||||
:style="{
|
:style="{
|
||||||
transform: `translate(${panZoom.state.x}px, ${panZoom.state.y}px) scale(${panZoom.state.scale})`
|
transform: `translate(${panZoom.state.x}px, ${panZoom.state.y}px) scale(${panZoom.state.scale})`
|
||||||
@@ -19,21 +21,51 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { usePanZoom } from '../../composables/usePanZoom.js'
|
import { usePanZoom } from '../../composables/usePanZoom.js'
|
||||||
|
|
||||||
const props = defineProps({ src: String, alt: String })
|
const props = defineProps({ src: String, alt: String })
|
||||||
|
const emit = defineEmits(['close-request'])
|
||||||
const panZoom = usePanZoom()
|
const panZoom = usePanZoom()
|
||||||
|
|
||||||
|
const canvasEl = ref(null)
|
||||||
|
const imgEl = ref(null)
|
||||||
|
|
||||||
// Reset zoom when the src changes (prev/next nav).
|
// Reset zoom when the src changes (prev/next nav).
|
||||||
watch(() => props.src, () => panZoom.reset())
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-canvas {
|
.fc-canvas {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
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;
|
overflow: hidden;
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|||||||
@@ -39,9 +39,11 @@
|
|||||||
<template v-else-if="modal.current">
|
<template v-else-if="modal.current">
|
||||||
<ImageCanvas
|
<ImageCanvas
|
||||||
v-if="!isVideo" :src="modal.current.image_url" :alt="`Image ${modal.current.id}`"
|
v-if="!isVideo" :src="modal.current.image_url" :alt="`Image ${modal.current.id}`"
|
||||||
|
@close-request="$emit('close')"
|
||||||
/>
|
/>
|
||||||
<VideoCanvas
|
<VideoCanvas
|
||||||
v-else :src="modal.current.image_url" :mime="modal.current.mime"
|
v-else :src="modal.current.image_url" :mime="modal.current.mime"
|
||||||
|
@close-request="$emit('close')"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<v-alert v-else-if="modal.error" type="error" variant="tonal">
|
<v-alert v-else-if="modal.error" type="error" variant="tonal">
|
||||||
@@ -123,7 +125,11 @@ function isTextEntry(el) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-viewer {
|
.fc-viewer {
|
||||||
position: fixed; inset: 0; z-index: 2000;
|
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;
|
outline: none;
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -152,8 +158,12 @@ function isTextEntry(el) {
|
|||||||
flex: 1; display: flex; min-height: 0;
|
flex: 1; display: flex; min-height: 0;
|
||||||
}
|
}
|
||||||
.fc-viewer__media {
|
.fc-viewer__media {
|
||||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
/* Let the canvas fill us; the canvas does its own centering. Both
|
||||||
min-width: 0;
|
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 {
|
.fc-viewer__side {
|
||||||
width: 320px; flex-shrink: 0;
|
width: 320px; flex-shrink: 0;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fc-canvas fc-canvas--video">
|
<div class="fc-canvas fc-canvas--video" @click.self="$emit('close-request')">
|
||||||
<video
|
<video
|
||||||
v-if="playable" :src="src" controls playsinline preload="metadata"
|
v-if="playable" :src="src" controls playsinline preload="metadata"
|
||||||
class="fc-canvas__video"
|
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" />
|
<v-icon size="56" icon="mdi-video-off-outline" />
|
||||||
<h3 class="fc-canvas__unsupported-title">Format not browser-playable</h3>
|
<h3 class="fc-canvas__unsupported-title">Format not browser-playable</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
defineEmits(['close-request'])
|
||||||
const props = defineProps({ src: String, mime: String })
|
const props = defineProps({ src: String, mime: String })
|
||||||
|
|
||||||
const BROWSER_PLAYABLE = new Set([
|
const BROWSER_PLAYABLE = new Set([
|
||||||
@@ -34,7 +36,9 @@ const playable = computed(() => BROWSER_PLAYABLE.has(props.mime))
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-canvas {
|
.fc-canvas {
|
||||||
flex: 1;
|
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;
|
display: flex; align-items: center; justify-content: center;
|
||||||
}
|
}
|
||||||
.fc-canvas__video { max-width: 100%; max-height: 100%; }
|
.fc-canvas__video { max-width: 100%; max-height: 100%; }
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ function startPolling() {
|
|||||||
pollId = setInterval(() => {
|
pollId = setInterval(() => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
system.refreshStats()
|
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)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user