From 9322c984fdcc4656cc520b1b78a8bf2700ff02a2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 13:02:24 -0400 Subject: [PATCH 1/8] feat(subs-hub): collapse /credentials + /downloads into /subscriptions hub with three GS-style subtabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the three top-level routes with a single `/subscriptions` parent owning the whole download-pipeline domain. Internal tab state via `?tab=` query param, mirroring ArtistView's pattern. TopNav auto-drops the two removed entries (route-driven via meta.title). Bookmark-safe redirects from `/credentials` and `/downloads` route into the appropriate subtab. **Subtab 1 — Subscriptions (default).** Carries over the existing artist-grouped expandable table; adds (a) status filter dropdown, (b) bulk-select column with Enable/Disable/Delete-all actions, (c) GS-style color-coded `PlatformChip` per distinct platform in the collapsed row. Reuses SourceRow, SourceHealthDot, SourceFormDialog, ArtistCreateDialog. **Subtab 2 — Downloads.** Full GS dashboard. Five colored stat chips up top (Queued/Running/Completed/Failed/Skipped, sourced from new `GET /api/downloads/stats?window_hours=`). Popover-style filter UI (Status/Source/FromDate/ToDate) with active-filter pills below. Maintenance menu wraps existing /api/import/retry-failed and /api/import/clear-stuck endpoints; Export-failed-logs item disabled with a "v2" tooltip. Per-row Retry preserved via existing DownloadEventRow. **Subtab 3 — Settings.** Four sections: ExtensionKeyBar (top), GS-style per-platform CredentialCard grid (md=6 v-row/v-col, dashed border if unset / accent border if set, expandable how-to panel), Downloader card (rate limit, validate_files), Schedule defaults card (default interval, event retention, failure warning threshold). The Downloader and Schedule sections were extracted out of components/settings/ImportFiltersForm.vue — SettingsView's Import tab now owns only image-import filters. **Backend:** new `GET /api/downloads/stats` returns {pending, running, ok, error, skipped} count grouped by status over the configurable window. Status keys stay raw from the ENUM; UI does the display-label mapping. Two integration tests pin the response shape + window_hours validation. **Util:** `frontend/src/utils/platformColor.js` — single source of truth for the six platforms' color + icon + label, mirroring GS's palette (patreon=red mdi-patreon, subscribestar=amber mdi-star, hentaifoundry=purple mdi-palette, discord=indigo mdi-discord, pixiv=blue mdi-alpha-p-box, deviantart=green mdi-deviantart). Unknown platform falls back to grey + mdi-web. Deferred (explicit non-goals): subscription import/export, "Trigger Due Now" scheduler-tick button (needs new backend endpoint), Export Failed Logs CSV dump. --- backend/app/api/downloads.py | 33 +- .../components/settings/ImportFiltersForm.vue | 70 +-- .../subscriptions/CredentialCard.vue | 148 +++++ .../subscriptions/DownloadStatChips.vue | 36 ++ .../subscriptions/DownloadsFilterPopover.vue | 120 ++++ .../components/subscriptions/DownloadsTab.vue | 123 +++++ .../subscriptions/MaintenanceMenu.vue | 62 +++ .../components/subscriptions/PlatformChip.vue | 25 + .../components/subscriptions/SettingsTab.vue | 196 +++++++ .../subscriptions/SubscriptionsTab.vue | 521 ++++++++++++++++++ frontend/src/router.js | 12 +- frontend/src/stores/downloads.js | 15 +- frontend/src/utils/platformColor.js | 43 ++ frontend/src/views/CredentialsView.vue | 79 --- frontend/src/views/DownloadsView.vue | 70 --- frontend/src/views/SubscriptionsView.vue | 439 ++------------- tests/test_api_downloads.py | 19 + 17 files changed, 1395 insertions(+), 616 deletions(-) create mode 100644 frontend/src/components/subscriptions/CredentialCard.vue create mode 100644 frontend/src/components/subscriptions/DownloadStatChips.vue create mode 100644 frontend/src/components/subscriptions/DownloadsFilterPopover.vue create mode 100644 frontend/src/components/subscriptions/DownloadsTab.vue create mode 100644 frontend/src/components/subscriptions/MaintenanceMenu.vue create mode 100644 frontend/src/components/subscriptions/PlatformChip.vue create mode 100644 frontend/src/components/subscriptions/SettingsTab.vue create mode 100644 frontend/src/components/subscriptions/SubscriptionsTab.vue create mode 100644 frontend/src/utils/platformColor.js delete mode 100644 frontend/src/views/CredentialsView.vue delete mode 100644 frontend/src/views/DownloadsView.vue diff --git a/backend/app/api/downloads.py b/backend/app/api/downloads.py index cac9015..ee28ff1 100644 --- a/backend/app/api/downloads.py +++ b/backend/app/api/downloads.py @@ -5,8 +5,10 @@ status/source/artist. Returns slim records. Detail view: full DownloadEvent including the metadata JSONB. """ +from datetime import datetime, timedelta, timezone + from quart import Blueprint, jsonify, request -from sqlalchemy import select +from sqlalchemy import func, select from ..extensions import get_session from ..models import Artist, DownloadEvent, Source @@ -95,6 +97,35 @@ async def list_downloads(): return jsonify([_list_record(e, s, a) for e, s, a in rows]) +@downloads_bp.route("/stats", methods=["GET"]) +async def downloads_stats(): + """Status-grouped count over download_event for the dashboard stat chips. + + `?window_hours=` (default 24) bounds by `started_at`. The full set of + statuses is always present in the response (zero for missing) so the + UI doesn't have to fill in defaults. + """ + try: + window_hours = int(request.args.get("window_hours", "24")) + except ValueError: + return jsonify({"error": "invalid_window_hours"}), 400 + if window_hours < 1 or window_hours > 24 * 365: + return jsonify({"error": "invalid_window_hours"}), 400 + + since = datetime.now(timezone.utc) - timedelta(hours=window_hours) + out = {"pending": 0, "running": 0, "ok": 0, "error": 0, "skipped": 0} + async with get_session() as session: + stmt = ( + select(DownloadEvent.status, func.count()) + .where(DownloadEvent.started_at >= since) + .group_by(DownloadEvent.status) + ) + for status, n in (await session.execute(stmt)).all(): + if status in out: + out[status] = int(n) + return jsonify(out) + + @downloads_bp.route("/", methods=["GET"]) async def get_download(event_id: int): async with get_session() as session: diff --git a/frontend/src/components/settings/ImportFiltersForm.vue b/frontend/src/components/settings/ImportFiltersForm.vue index 2628309..af06321 100644 --- a/frontend/src/components/settings/ImportFiltersForm.vue +++ b/frontend/src/components/settings/ImportFiltersForm.vue @@ -51,68 +51,6 @@ - -
Downloader (FC-3c)
- - - -
gallery-dl extractor.sleep. Higher = slower but safer.
-
- - - -
- - -
Download scheduling (FC-3d)
- - - -
- Used when a source has no per-source or per-artist override. - Default 28800 (8 hours). -
-
- - -
- Completed download events older than this are deleted nightly. - Default 90. -
-
- - -
- Source row badge turns red after this many consecutive - failures. Sources are never auto-disabled. Default 5. -
-
-
- {{ store.settingsError }} @@ -128,16 +66,14 @@ import { reactive, watch } from 'vue' import { useImportStore } from '../../stores/import.js' const store = useImportStore() +// Downloader + schedule-defaults fields moved to +// /subscriptions?tab=settings (operator decision 2026-05-27). This form +// now only owns image-import filters. const local = reactive({ min_width: 0, min_height: 0, skip_transparent: false, transparency_threshold: 0.9, skip_single_color: false, single_color_threshold: 0.95, phash_threshold: 10, - download_rate_limit_seconds: 3.0, - download_validate_files: true, - download_schedule_default_seconds: 28800, - download_event_retention_days: 90, - download_failure_warning_threshold: 5, }) watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true }) diff --git a/frontend/src/components/subscriptions/CredentialCard.vue b/frontend/src/components/subscriptions/CredentialCard.vue new file mode 100644 index 0000000..300b95e --- /dev/null +++ b/frontend/src/components/subscriptions/CredentialCard.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/frontend/src/components/subscriptions/DownloadStatChips.vue b/frontend/src/components/subscriptions/DownloadStatChips.vue new file mode 100644 index 0000000..ce5c975 --- /dev/null +++ b/frontend/src/components/subscriptions/DownloadStatChips.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/frontend/src/components/subscriptions/DownloadsFilterPopover.vue b/frontend/src/components/subscriptions/DownloadsFilterPopover.vue new file mode 100644 index 0000000..57b8253 --- /dev/null +++ b/frontend/src/components/subscriptions/DownloadsFilterPopover.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/src/components/subscriptions/DownloadsTab.vue b/frontend/src/components/subscriptions/DownloadsTab.vue new file mode 100644 index 0000000..7ced798 --- /dev/null +++ b/frontend/src/components/subscriptions/DownloadsTab.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/frontend/src/components/subscriptions/MaintenanceMenu.vue b/frontend/src/components/subscriptions/MaintenanceMenu.vue new file mode 100644 index 0000000..dc2d57e --- /dev/null +++ b/frontend/src/components/subscriptions/MaintenanceMenu.vue @@ -0,0 +1,62 @@ + + + diff --git a/frontend/src/components/subscriptions/PlatformChip.vue b/frontend/src/components/subscriptions/PlatformChip.vue new file mode 100644 index 0000000..e5b4586 --- /dev/null +++ b/frontend/src/components/subscriptions/PlatformChip.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/components/subscriptions/SettingsTab.vue b/frontend/src/components/subscriptions/SettingsTab.vue new file mode 100644 index 0000000..cc1be56 --- /dev/null +++ b/frontend/src/components/subscriptions/SettingsTab.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/frontend/src/components/subscriptions/SubscriptionsTab.vue b/frontend/src/components/subscriptions/SubscriptionsTab.vue new file mode 100644 index 0000000..96ac883 --- /dev/null +++ b/frontend/src/components/subscriptions/SubscriptionsTab.vue @@ -0,0 +1,521 @@ + + + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index b51f37f..f3b6cbe 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -7,8 +7,6 @@ import ArtistView from './views/ArtistView.vue' import SeriesManageView from './views/SeriesManageView.vue' import SeriesReaderView from './views/SeriesReaderView.vue' import SubscriptionsView from './views/SubscriptionsView.vue' -import CredentialsView from './views/CredentialsView.vue' -import DownloadsView from './views/DownloadsView.vue' import PostsView from './views/PostsView.vue' import ArtistsView from './views/ArtistsView.vue' @@ -34,10 +32,16 @@ const routes = [ { path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } }, // FC-3: subscription backbone + // /credentials and /downloads were folded into /subscriptions as subtabs + // 2026-05-27 (?tab=settings and ?tab=downloads). The hub view owns the + // whole download pipeline domain. { path: '/posts', name: 'posts', component: PostsView, meta: { title: 'Posts' } }, { path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions' } }, - { path: '/credentials', name: 'credentials', component: CredentialsView, meta: { title: 'Credentials' } }, - { path: '/downloads', name: 'downloads', component: DownloadsView, meta: { title: 'Downloads' } } + + // Bookmark/back-button safety net for the routes that got folded in + // (no meta.title — stay out of TopNav). + { path: '/credentials', redirect: '/subscriptions?tab=settings' }, + { path: '/downloads', redirect: '/subscriptions?tab=downloads' } ] // Browser uses HTML5 history; non-browser (Vitest/SSR) falls back to memory diff --git a/frontend/src/stores/downloads.js b/frontend/src/stores/downloads.js index d95f633..5dd76df 100644 --- a/frontend/src/stores/downloads.js +++ b/frontend/src/stores/downloads.js @@ -8,10 +8,14 @@ export const useDownloadsStore = defineStore('downloads', () => { const events = ref([]) const cursor = ref(null) const hasMore = ref(true) - const filter = ref({ status: null, source_id: null, artist_id: null }) + const filter = ref({ + status: null, source_id: null, artist_id: null, + from_date: null, to_date: null, + }) const selected = ref(null) const loading = ref(false) const error = ref(null) + const stats = ref({ pending: 0, running: 0, ok: 0, error: 0, skipped: 0 }) function _params(extra = {}) { const out = { limit: 50, ...extra } @@ -65,8 +69,13 @@ export const useDownloadsStore = defineStore('downloads', () => { selected.value = null } + async function loadStats(windowHours = 24) { + stats.value = await api.get('/api/downloads/stats', { params: { window_hours: windowHours } }) + return stats.value + } + return { - events, cursor, hasMore, filter, selected, loading, error, - loadFirst, loadMore, loadOne, applyFilter, closeDetail, + events, cursor, hasMore, filter, selected, loading, error, stats, + loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats, } }) diff --git a/frontend/src/utils/platformColor.js b/frontend/src/utils/platformColor.js new file mode 100644 index 0000000..dab041e --- /dev/null +++ b/frontend/src/utils/platformColor.js @@ -0,0 +1,43 @@ +// Single source of truth for platform → color + icon mapping. Used by +// PlatformChip and any other GS-style platform-tagged surface. The six +// platforms FC supports map 1:1 to the GS palette; unknown platforms fall +// back to grey + mdi-web. Operator-confirmed scope 2026-05-27. + +const ICONS = { + patreon: 'mdi-patreon', + subscribestar: 'mdi-star', + hentaifoundry: 'mdi-palette', + discord: 'mdi-discord', + pixiv: 'mdi-alpha-p-box', + deviantart: 'mdi-deviantart', +} + +const COLORS = { + patreon: 'red', + subscribestar: 'amber', + hentaifoundry: 'purple', + discord: 'indigo', + pixiv: 'blue', + deviantart: 'green', +} + +const LABELS = { + patreon: 'Patreon', + subscribestar: 'SubscribeStar', + hentaifoundry: 'HentaiFoundry', + discord: 'Discord', + pixiv: 'Pixiv', + deviantart: 'DeviantArt', +} + +export function platformIcon(platform) { + return ICONS[platform] || 'mdi-web' +} + +export function platformColor(platform) { + return COLORS[platform] || 'grey' +} + +export function platformLabel(platform) { + return LABELS[platform] || platform +} diff --git a/frontend/src/views/CredentialsView.vue b/frontend/src/views/CredentialsView.vue deleted file mode 100644 index 1be9334..0000000 --- a/frontend/src/views/CredentialsView.vue +++ /dev/null @@ -1,79 +0,0 @@ - - - diff --git a/frontend/src/views/DownloadsView.vue b/frontend/src/views/DownloadsView.vue deleted file mode 100644 index 4a2a612..0000000 --- a/frontend/src/views/DownloadsView.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - - - diff --git a/frontend/src/views/SubscriptionsView.vue b/frontend/src/views/SubscriptionsView.vue index 832aa84..67df3ab 100644 --- a/frontend/src/views/SubscriptionsView.vue +++ b/frontend/src/views/SubscriptionsView.vue @@ -1,416 +1,71 @@ diff --git a/tests/test_api_downloads.py b/tests/test_api_downloads.py index d64e649..253f1c5 100644 --- a/tests/test_api_downloads.py +++ b/tests/test_api_downloads.py @@ -107,3 +107,22 @@ async def test_detail_returns_full_metadata(client, seed): async def test_detail_404(client): resp = await client.get("/api/downloads/99999") assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_stats_returns_full_status_set(client, seed): + resp = await client.get("/api/downloads/stats") + assert resp.status_code == 200 + body = await resp.get_json() + assert set(body) == {"pending", "running", "ok", "error", "skipped"} + assert body["ok"] == 1 + assert body["error"] == 1 + assert body["pending"] == 0 + + +@pytest.mark.asyncio +async def test_stats_window_hours_rejects_out_of_range(client): + resp = await client.get("/api/downloads/stats?window_hours=0") + assert resp.status_code == 400 + resp = await client.get("/api/downloads/stats?window_hours=bogus") + assert resp.status_code == 400 From 1fd54897d8a34ce59877336e6def5ca99aa1e565 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 13:11:44 -0400 Subject: [PATCH 2/8] =?UTF-8?q?fix(api):=20ruff=20UP017=20=E2=80=94=20use?= =?UTF-8?q?=20datetime.UTC=20alias=20in=20/api/downloads/stats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/downloads.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/api/downloads.py b/backend/app/api/downloads.py index ee28ff1..e7a86c9 100644 --- a/backend/app/api/downloads.py +++ b/backend/app/api/downloads.py @@ -5,7 +5,7 @@ status/source/artist. Returns slim records. Detail view: full DownloadEvent including the metadata JSONB. """ -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from quart import Blueprint, jsonify, request from sqlalchemy import func, select @@ -112,7 +112,7 @@ async def downloads_stats(): if window_hours < 1 or window_hours > 24 * 365: return jsonify({"error": "invalid_window_hours"}), 400 - since = datetime.now(timezone.utc) - timedelta(hours=window_hours) + since = datetime.now(UTC) - timedelta(hours=window_hours) out = {"pending": 0, "running": 0, "ok": 0, "error": 0, "skipped": 0} async with get_session() as session: stmt = ( From b8ad17c68d27890329807adb5c13bd30c6b401b8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 13:25:58 -0400 Subject: [PATCH 3/8] fix(build): poll for ext- release in tag-push build-web (race fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cutting a release fires BOTH the push-to-main workflow AND the push-to-tag workflow in parallel. main-push runs sign-extension (AMO round-trip 1-5min) then publishes the ext- Forgejo release; tag-push skips sign-extension (gated to main) and races straight to build-web's Download XPI step. Tag-push lost every time — got 404 from releases/tags/ext- before main-push had finished signing. v26.05.27.0 hit this: tag-push build-web died on exit 22 because the ext-1.0.4 release wasn't published yet (it arrived ~4min later). Fix: wrap the release lookup in a 20-iteration sleep+retry loop, 30s between attempts (10min total upper bound, generous for AMO). main-push's signing eventually publishes the release; tag-push picks it up on a later poll. No more manual rerun of the failed job after every release cut. Banked the trap as reference_tag_push_main_push_race.md — same shape will recur any time a tag-push workflow consumes a main-push-produced artifact. --- .forgejo/workflows/build.yml | 51 +++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 407dc24..2bf6248 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -173,24 +173,51 @@ jobs: # same source code as the preceding main-push build but with an # immutable version tag — they need the XPI too, otherwise the # versioned image ships without the signed extension. + # + # Tag-push vs main-push race (operator-flagged 2026-05-27 after + # v26.05.27.0 hit it): a release cut fires BOTH workflows almost + # simultaneously. Main-push runs sign-extension (1-5min AMO round + # trip) before publishing the ext- release; tag-push + # skips sign-extension (gated to main) and races straight to + # this download step. Tag-push lost every time. Fix: poll the + # ext- release endpoint with a sleep+retry loop (30s + # for up to 10min total) before giving up. Main-push's signing + # eventually wins and tag-push picks the release up on a later + # iteration. if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') env: TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | set -eux VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') - # Look up the ext- release; extract the .xpi asset's - # browser_download_url (Forgejo's /releases/assets/ endpoint - # returns ASSET METADATA, not the binary blob — operator-flagged - # 2026-05-26: my prior code curl'd the metadata endpoint without - # -f and wrote the resulting 404-page-not-found text into - # fabledcurator-*.xpi, which Firefox then rejected as "corrupt"). - # browser_download_url is the canonical binary endpoint and is - # also publicly accessible (no token needed) but we pass the - # token anyway for symmetry with private-repo support. - curl -sf -H "Authorization: token $TOKEN" \ - "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" \ - -o release.json + # Poll for the ext- release. main-push's sign-extension + # step (AMO round-trip, 1-5min) needs to finish + upload before + # tag-push can fetch. 30s * 20 = up to 10min wait, then hard-fail. + for attempt in $(seq 1 20); do + STATUS=$(curl -s -o release.json -w "%{http_code}" \ + -H "Authorization: token $TOKEN" \ + "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000) + if [ "$STATUS" = "200" ]; then + echo "Found ext-$VERSION release on attempt $attempt" + break + fi + if [ "$attempt" = "20" ]; then + echo "ERROR: ext-$VERSION release not available after 10min of polling" + echo "Last HTTP status: $STATUS" + exit 1 + fi + echo "Attempt $attempt: ext-$VERSION not yet published (HTTP $STATUS); sleeping 30s" + sleep 30 + done + # Extract the .xpi asset's browser_download_url (Forgejo's + # /releases/assets/ endpoint returns ASSET METADATA, not + # the binary blob — operator-flagged 2026-05-26: my prior + # code curl'd the metadata endpoint without -f and wrote the + # resulting 404-page-not-found text into fabledcurator-*.xpi, + # which Firefox then rejected as "corrupt"). + # browser_download_url is the canonical binary endpoint and + # is also publicly accessible (no token needed) but we pass + # the token anyway for symmetry with private-repo support. DOWNLOAD_URL=$(python3 -c "import json; r=json.load(open('release.json')); xpis=[a for a in r.get('assets', []) if a.get('name','').endswith('.xpi')]; print(xpis[0]['browser_download_url'])") test -n "$DOWNLOAD_URL" echo "Downloading XPI from: $DOWNLOAD_URL" From 4d2c464045992d6635724a414fd32dbba6fc147b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 14:30:04 -0400 Subject: [PATCH 4/8] feat(post-card): absorb PostModal into PostCard with click-to-expand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostCard and PostModal competed for the same data and rendered redundant chrome (header twice, image grid twice, attachment list twice). The wider PostCard layout we shipped 2026-05-27 has enough real estate to be the canonical post surface, so collapse the two into one. Compact (default) state is unchanged: hero + 3-cell rail + truncated title + 3/5-line description + attachment count badge. Whole-card click expands in place. Expanded state shows: full title, mosaic of ALL post images via PostImageGrid (uncapped, lazy-loaded via getPostFull), full sanitized-HTML description with paragraph wrapping, attachments as downloadable pill links. Click the chevron in the header to collapse; mosaic image clicks open ImageViewer scoped to the post (modalStore's postImageIds path is preserved — only the comment changed). Per-card local state — no global modal store. Each PostCard owns its own expanded ref and lazy-loaded detail; collapsing a card discards neither (so re-expand is instant after the first fetch). Deleted: PostModal.vue, postModal.js store. Removed the App.vue mount. --- frontend/src/App.vue | 2 - frontend/src/components/posts/PostCard.vue | 254 ++++++++++++++++---- frontend/src/components/posts/PostModal.vue | 211 ---------------- frontend/src/stores/modal.js | 6 +- frontend/src/stores/postModal.js | 39 --- 5 files changed, 212 insertions(+), 300 deletions(-) delete mode 100644 frontend/src/components/posts/PostModal.vue delete mode 100644 frontend/src/stores/postModal.js diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 38a3ccf..5cfbdda 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,7 +4,6 @@ - @@ -14,7 +13,6 @@ import { onMounted, ref } from 'vue' import AppShell from './components/AppShell.vue' import AppSnackbar from './components/AppSnackbar.vue' import ImageViewer from './components/modal/ImageViewer.vue' -import PostModal from './components/posts/PostModal.vue' import { useModalStore } from './stores/modal.js' const modal = useModalStore() diff --git a/frontend/src/components/posts/PostCard.vue b/frontend/src/components/posts/PostCard.vue index c80a0ab..65194a0 100644 --- a/frontend/src/components/posts/PostCard.vue +++ b/frontend/src/components/posts/PostCard.vue @@ -1,8 +1,8 @@