From ec44c653fec9d4bdb11131d56acaf1193194818b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 22 May 2026 22:54:19 -0400 Subject: [PATCH 1/7] fc5: strip SQLAlchemy +psycopg/+asyncpg driver suffix when passing URL to pg_dump/psql Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/migrators/backup.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/app/services/migrators/backup.py b/backend/app/services/migrators/backup.py index 88040fd..4d3cb96 100644 --- a/backend/app/services/migrators/backup.py +++ b/backend/app/services/migrators/backup.py @@ -16,6 +16,19 @@ from typing import Any _BACKUPS_DIRNAME = "_backups" +def _libpq_url(sa_url: str) -> str: + """Strip SQLAlchemy driver suffix so pg_dump/psql accept the URL. + + SQLAlchemy uses URLs like `postgresql+psycopg://...` or + `postgresql+asyncpg://...`. libpq tools (pg_dump, psql) only know + the plain `postgresql://` scheme. + """ + for driver in ("postgresql+psycopg", "postgresql+asyncpg", "postgresql+psycopg2"): + if sa_url.startswith(driver + "://"): + return "postgresql://" + sa_url[len(driver) + 3:] + return sa_url + + def _backups_dir(images_root: Path | None = None) -> Path: # Overridable for tests via monkeypatch. root = images_root if images_root is not None else Path("/images") @@ -49,7 +62,7 @@ def create_backup( manifest_path = out_dir / f"fc_{ts}.json" _run_subprocess( - ["pg_dump", "--no-owner", "--no-acl", "-f", str(sql_path), db_url], + ["pg_dump", "--no-owner", "--no-acl", "-f", str(sql_path), _libpq_url(db_url)], _test_ts=ts, ) _run_subprocess( @@ -104,7 +117,7 @@ def restore_backup( tar_path = Path(manifest["tar_path"]) _run_subprocess( - ["psql", "-d", db_url, "-f", str(sql_path)], + ["psql", "-d", _libpq_url(db_url), "-f", str(sql_path)], ) # Wipe everything in images_root EXCEPT _backups/ (we'd delete the backup From 6f68bf5fa75c24ae7846d5e2d15b397e69243f58 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 22 May 2026 23:18:22 -0400 Subject: [PATCH 2/7] feat(ui): bump thumbnail sizes 1.5x, make Settings fluid, add subscription_count stat Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/settings.py | 6 +++++- frontend/src/components/gallery/GalleryGrid.vue | 6 +++--- .../src/components/settings/SystemStatsCards.vue | 14 ++++++++------ frontend/src/composables/usePolyMasonry.js | 5 +++-- frontend/src/views/SettingsView.vue | 2 +- tests/test_api_settings.py | 2 +- 6 files changed, 21 insertions(+), 14 deletions(-) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 6ad49b8..15c52e9 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -6,7 +6,7 @@ from quart import Blueprint, jsonify, request from sqlalchemy import func, select from ..extensions import get_session -from ..models import AppSetting, ImageRecord, ImportBatch, ImportSettings, ImportTask, Tag +from ..models import AppSetting, Artist, ImageRecord, ImportBatch, ImportSettings, ImportTask, Tag settings_bp = Blueprint("settings", __name__, url_prefix="/api") @@ -118,6 +118,9 @@ async def system_stats(): storage_bytes = ( (await session.execute(select(func.coalesce(func.sum(ImageRecord.size_bytes), 0)))).scalar_one() ) + subscription_count = (await session.execute( + select(func.count(Artist.id)).where(Artist.is_subscription.is_(True)) + )).scalar_one() # Task counts grouped by status status_rows = ( @@ -163,6 +166,7 @@ async def system_stats(): "total_images": total_images, "total_tags": total_tags, "storage_bytes": storage_bytes, + "subscription_count": int(subscription_count), "tasks": { "pending": status_counts.get("pending", 0), "queued": status_counts.get("queued", 0), diff --git a/frontend/src/components/gallery/GalleryGrid.vue b/frontend/src/components/gallery/GalleryGrid.vue index 111cf61..8552c01 100644 --- a/frontend/src/components/gallery/GalleryGrid.vue +++ b/frontend/src/components/gallery/GalleryGrid.vue @@ -86,7 +86,7 @@ function imagesForGroup(group) { } .fc-gallery-grid__items { display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 8px; } .fc-gallery-grid__sentinel { @@ -101,7 +101,7 @@ function imagesForGroup(group) { } .fc-gallery-grid__skeleton { display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 8px; } .fc-gallery-grid__skeleton-item { @@ -124,7 +124,7 @@ function imagesForGroup(group) { @media (max-width: 600px) { .fc-gallery-grid__items, .fc-gallery-grid__skeleton { - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 4px; } } diff --git a/frontend/src/components/settings/SystemStatsCards.vue b/frontend/src/components/settings/SystemStatsCards.vue index 92d9ba7..fda022f 100644 --- a/frontend/src/components/settings/SystemStatsCards.vue +++ b/frontend/src/components/settings/SystemStatsCards.vue @@ -1,6 +1,6 @@ @@ -35,7 +31,6 @@ import GalleryGrid from '../components/gallery/GalleryGrid.vue' import TimelineSidebar from '../components/gallery/TimelineSidebar.vue' import EmptyState from '../components/gallery/EmptyState.vue' import PostInfoHeader from '../components/gallery/PostInfoHeader.vue' -import ImageViewer from '../components/modal/ImageViewer.vue' import BulkEditorPanel from '../components/gallery/BulkEditorPanel.vue' import { useGallerySelectionStore } from '../stores/gallerySelection.js' diff --git a/frontend/src/views/ShowcaseView.vue b/frontend/src/views/ShowcaseView.vue index 7a4fa64..df97f7c 100644 --- a/frontend/src/views/ShowcaseView.vue +++ b/frontend/src/views/ShowcaseView.vue @@ -26,16 +26,16 @@