From 42c33e44f9b08a867fa1bbb59946826be9592fb3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:47:18 -0400 Subject: [PATCH 01/18] =?UTF-8?q?feat(post-api):=20get=5Fpost=20returns=20?= =?UTF-8?q?uncapped=20thumbnails=20=E2=80=94=20PostModal=20masonry=20needs?= =?UTF-8?q?=20full=20image=20list;=20feed=20query=20unchanged=20(still=20c?= =?UTF-8?q?apped=20at=206=20for=20previews).=20=5Fthumbnails=5Ffor=20gains?= =?UTF-8?q?=20a=20`limit`=20kwarg;=20get=5Fpost=20passes=20limit=3DNone.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/post_feed_service.py | 34 ++++++++++------ tests/test_api_posts.py | 47 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/backend/app/services/post_feed_service.py b/backend/app/services/post_feed_service.py index d137188..974427e 100644 --- a/backend/app/services/post_feed_service.py +++ b/backend/app/services/post_feed_service.py @@ -109,7 +109,10 @@ class PostFeedService: if row is None: return None post, artist, source = row - thumbs_map = await self._thumbnails_for([post.id]) + # Detail endpoint returns the FULL image list for PostModal's + # masonry grid — feed query still caps at THUMBNAIL_LIMIT via + # the default arg. + thumbs_map = await self._thumbnails_for([post.id], limit=None) atts_map = await self._attachments_for([post.id]) item = self._to_dict(post, artist, source, thumbs_map, atts_map) item["description_full"] = html_to_plain(post.description) @@ -117,15 +120,21 @@ class PostFeedService: # --- composition helpers --------------------------------------------- - async def _thumbnails_for(self, post_ids: list[int]) -> dict[int, dict]: - """post_id -> {"thumbs": [...up to 6], "more": int}. + async def _thumbnails_for( + self, post_ids: list[int], *, limit: int | None = THUMBNAIL_LIMIT, + ) -> dict[int, dict]: + """post_id -> {"thumbs": [...up to limit], "more": int}. - Selects THUMBNAIL_LIMIT+1 images per post via window function so we - can detect overflow in a single query. + Selects up to `limit` images per post via window function so we + can detect overflow in a single query. Pass `limit=None` to + return ALL thumbnails per post (used by `get_post` for PostModal's + masonry grid; the feed pass keeps the default cap so payloads + stay small). """ if not post_ids: return {} - # Rank images within each post and fetch only the top THUMBNAIL_LIMIT+1. + # Rank images within each post; cap at `limit` rows per post when + # limit is set, return all when limit is None. ranked = ( select( ImageRecord.id, @@ -143,12 +152,13 @@ class PostFeedService: .where(ImageRecord.primary_post_id.in_(post_ids)) .subquery() ) - rows = (await self.session.execute( - select( - ranked.c.id, ranked.c.primary_post_id, - ranked.c.sha256, ranked.c.mime, ranked.c.total, - ).where(ranked.c.rn <= THUMBNAIL_LIMIT) - )).all() + stmt = select( + ranked.c.id, ranked.c.primary_post_id, + ranked.c.sha256, ranked.c.mime, ranked.c.total, + ) + if limit is not None: + stmt = stmt.where(ranked.c.rn <= limit) + rows = (await self.session.execute(stmt)).all() out: dict[int, dict] = {pid: {"thumbs": [], "more": 0} for pid in post_ids} for img_id, pid, sha, mime, total in rows: diff --git a/tests/test_api_posts.py b/tests/test_api_posts.py index a820ad8..997bfa7 100644 --- a/tests/test_api_posts.py +++ b/tests/test_api_posts.py @@ -117,3 +117,50 @@ async def test_detail_404_for_unknown(client): assert resp.status_code == 404 body = await resp.get_json() assert body["error"] == "not_found" + + +@pytest.mark.asyncio +async def test_detail_returns_uncapped_thumbnails(client, db): + """Feed query caps thumbnails at 6 for previews; detail endpoint + returns the full list so PostModal can render the masonry grid.""" + from backend.app.models import ImageRecord + + a = Artist(name="yuki-api", slug="yuki-api") + db.add(a) + await db.flush() + s = Source( + artist_id=a.id, platform="patreon", + url="https://patreon.com/cw/yuki-api", enabled=True, + ) + db.add(s) + await db.flush() + p = Post( + source_id=s.id, external_post_id="DETAIL10", + post_title="big post", description="

body

", + ) + db.add(p) + await db.flush() + # Seed 10 ImageRecord rows linked to this post via primary_post_id. + for i in range(10): + sha = f"y{i:x}".ljust(64, "0")[:64] + rec = ImageRecord( + path=f"/images/test-yuki-{i}.jpg", + sha256=sha, + size_bytes=1, + mime="image/jpeg", + width=64, + height=64, + origin="downloaded", + integrity_status="unknown", + primary_post_id=p.id, + artist_id=a.id, + ) + db.add(rec) + await db.commit() + + resp = await client.get(f"/api/posts/{p.id}") + assert resp.status_code == 200 + body = await resp.get_json() + # Detail returns ALL 10 thumbnails (feed would return 6 + thumbnails_more). + assert len(body["thumbnails"]) == 10 + assert body["description_full"] == "body" From 07344e084371a3c50ad9233207517f0317e9bfc8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:47:36 -0400 Subject: [PATCH 02/18] =?UTF-8?q?feat(util):=20htmlSanitize=20=E2=80=94=20?= =?UTF-8?q?whitelist-based=20DOM=20scrubber=20for=20PostModal's=20descript?= =?UTF-8?q?ion=20v-html=20(Patreon=20ships=20HTML;=20sanitize=20before=20r?= =?UTF-8?q?ender)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/utils/htmlSanitize.js | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 frontend/src/utils/htmlSanitize.js diff --git a/frontend/src/utils/htmlSanitize.js b/frontend/src/utils/htmlSanitize.js new file mode 100644 index 0000000..7978fca --- /dev/null +++ b/frontend/src/utils/htmlSanitize.js @@ -0,0 +1,61 @@ +// Whitelist-based HTML sanitizer for rendering third-party post +// descriptions (e.g. Patreon) via v-html. Strips script/style/iframe +// + event handlers + dangerous hrefs. Tag whitelist covers what +// Patreon, SubscribeStar, and similar platforms ship in normal posts. +// +// Not a substitute for server-side sanitization in higher-stakes +// contexts. This is for FC's single-operator homelab posture where +// the content source is the operator's own subscriptions. + +const ALLOWED_TAGS = new Set([ + 'a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', + 'ol', 'p', 'pre', 's', 'span', 'strong', 'sub', 'sup', 'u', 'ul', +]) + +const ALLOWED_ATTRS = { + a: new Set(['href', 'title', 'rel', 'target']), + img: new Set(['src', 'alt', 'title', 'width', 'height']), + // any tag → these always allowed + '*': new Set(['class']), +} + +const SAFE_URL_RE = /^(https?:|mailto:|#|\/)/i + +export function sanitizeHtml (html) { + if (typeof html !== 'string' || !html) return '' + const doc = new DOMParser().parseFromString(html, 'text/html') + _scrubNode(doc.body) + return doc.body.innerHTML +} + +function _scrubNode (node) { + const children = Array.from(node.children) + for (const child of children) { + const tag = child.tagName.toLowerCase() + if (!ALLOWED_TAGS.has(tag)) { + // Strip the tag but keep its text content as a fallback. + const text = document.createTextNode(child.textContent || '') + child.replaceWith(text) + continue + } + const allowed = new Set([ + ...(ALLOWED_ATTRS[tag] || []), + ...(ALLOWED_ATTRS['*'] || []), + ]) + for (const attr of Array.from(child.attributes)) { + const name = attr.name.toLowerCase() + if (name.startsWith('on') || !allowed.has(name)) { + child.removeAttribute(attr.name) + continue + } + if ((name === 'href' || name === 'src') && !SAFE_URL_RE.test(attr.value)) { + child.removeAttribute(attr.name) + } + } + if (tag === 'a' && child.getAttribute('target') === '_blank') { + child.setAttribute('rel', 'noopener noreferrer') + } + _scrubNode(child) + } +} From b8d89b9f2acb08e7be835c56fe90e004856f4dec Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:48:30 -0400 Subject: [PATCH 03/18] =?UTF-8?q?feat(modal-store):=20post-scoped=20cycle?= =?UTF-8?q?=20=E2=80=94=20open(id,=20{=20postImageIds=20})=20pins=20prev/n?= =?UTF-8?q?ext=20to=20the=20array;=20canPrev/canNext=20+=20goPrev/goNext?= =?UTF-8?q?=20check=20the=20array=20index=20instead=20of=20current.value.n?= =?UTF-8?q?eighbors=20when=20set.=20Gallery-context=20callers=20unchanged?= =?UTF-8?q?=20(default=20args=20clear=20scope)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/stores/modal.js | 87 +++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 21 deletions(-) diff --git a/frontend/src/stores/modal.js b/frontend/src/stores/modal.js index 1998c97..0f8665f 100644 --- a/frontend/src/stores/modal.js +++ b/frontend/src/stores/modal.js @@ -6,14 +6,31 @@ export const useModalStore = defineStore('modal', () => { const api = useApi() const currentImageId = ref(null) - const current = ref(null) // full image detail from API + const current = ref(null) const loading = ref(false) const error = ref(null) - async function open(id) { + // Post-scoped cycle. When set, prev/next cycles within this array + // (used by PostModal's PostImageGrid clicks). When null, prev/next + // falls back to current.value.neighbors (the gallery-store-driven + // /api/gallery/image/ neighbors). + const postImageIds = ref(null) + const postImageIndex = ref(0) + + async function open (id, opts = {}) { currentImageId.value = id loading.value = true error.value = null + // Update post-scoped state if caller passed it; otherwise clear so + // the next open() from gallery context uses neighbors mode. + if (opts.postImageIds != null) { + postImageIds.value = opts.postImageIds + postImageIndex.value = opts.postImageIds.indexOf(id) + if (postImageIndex.value < 0) postImageIndex.value = 0 + } else if (opts.clearPostScope !== false && postImageIds.value != null) { + postImageIds.value = null + postImageIndex.value = 0 + } try { current.value = await api.get(`/api/gallery/image/${id}`) } catch (e) { @@ -24,41 +41,58 @@ export const useModalStore = defineStore('modal', () => { } } - async function close() { + async function close () { currentImageId.value = null current.value = null error.value = null + postImageIds.value = null + postImageIndex.value = 0 } - async function goPrev() { - if (current.value && current.value.neighbors.prev_id) { + async function goPrev () { + if (postImageIds.value != null) { + if (postImageIndex.value > 0) { + const newIdx = postImageIndex.value - 1 + const newId = postImageIds.value[newIdx] + postImageIndex.value = newIdx + await open(newId, { postImageIds: postImageIds.value }) + } + return + } + if (current.value && current.value.neighbors?.prev_id) { await open(current.value.neighbors.prev_id) } } - async function goNext() { - if (current.value && current.value.neighbors.next_id) { + + async function goNext () { + if (postImageIds.value != null) { + if (postImageIndex.value < postImageIds.value.length - 1) { + const newIdx = postImageIndex.value + 1 + const newId = postImageIds.value[newIdx] + postImageIndex.value = newIdx + await open(newId, { postImageIds: postImageIds.value }) + } + return + } + if (current.value && current.value.neighbors?.next_id) { await open(current.value.neighbors.next_id) } } - async function reloadTags() { + async function reloadTags () { if (!currentImageId.value) return const tags = await api.get(`/api/images/${currentImageId.value}/tags`) if (current.value) current.value.tags = tags } - async function removeTag(tagId) { + async function removeTag (tagId) { if (!currentImageId.value) return - // Optimistic UI: remove locally first, restore on error. const prev = current.value.tags current.value.tags = current.value.tags.filter(t => t.id !== tagId) try { - // FC-2b: removal also records a per-image rejection (suggestions/dismiss - // is the rejection-recording endpoint) so the allowlist maintenance - // task won't re-apply this tag to this image. await api.delete(`/api/images/${currentImageId.value}/tags/${tagId}`) await api.post(`/api/images/${currentImageId.value}/suggestions/dismiss`, { - body: { tag_id: tagId } + body: { tag_id: tagId }, }) } catch (e) { current.value.tags = prev @@ -67,25 +101,36 @@ export const useModalStore = defineStore('modal', () => { } } - async function addExistingTag(tagId) { + async function addExistingTag (tagId) { if (!currentImageId.value) return await api.post(`/api/images/${currentImageId.value}/tags`, { - body: { tag_id: tagId, source: 'manual' } + body: { tag_id: tagId, source: 'manual' }, }) await reloadTags() } - async function createAndAdd({ name, kind, fandom_id = null }) { + async function createAndAdd ({ name, kind, fandom_id = null }) { const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } }) await addExistingTag(tag.id) } const isOpen = computed(() => currentImageId.value !== null) - const canPrev = computed(() => current.value?.neighbors?.prev_id != null) - const canNext = computed(() => current.value?.neighbors?.next_id != null) + const canPrev = computed(() => { + if (postImageIds.value != null) return postImageIndex.value > 0 + return current.value?.neighbors?.prev_id != null + }) + const canNext = computed(() => { + if (postImageIds.value != null) { + return postImageIndex.value < (postImageIds.value.length - 1) + } + return current.value?.neighbors?.next_id != null + }) return { - currentImageId, current, loading, error, isOpen, canPrev, canNext, - open, close, goPrev, goNext, reloadTags, removeTag, addExistingTag, createAndAdd + currentImageId, current, loading, error, + postImageIds, postImageIndex, + isOpen, canPrev, canNext, + open, close, goPrev, goNext, + reloadTags, removeTag, addExistingTag, createAndAdd, } }) From 90c176b195c164064749fab4643b6323937c847f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:48:49 -0400 Subject: [PATCH 04/18] =?UTF-8?q?feat(postmodal-store):=20Pinia=20store=20?= =?UTF-8?q?driving=20the=20app-level=20PostModal=20=E2=80=94=20open(post)?= =?UTF-8?q?=20fetches=20full=20detail=20via=20posts=20store;=20close()=20c?= =?UTF-8?q?lears?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/stores/postModal.js | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 frontend/src/stores/postModal.js diff --git a/frontend/src/stores/postModal.js b/frontend/src/stores/postModal.js new file mode 100644 index 0000000..64fd1c9 --- /dev/null +++ b/frontend/src/stores/postModal.js @@ -0,0 +1,39 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { usePostsStore } from './posts.js' + +export const usePostModalStore = defineStore('postModal', () => { + const postsStore = usePostsStore() + + // Feed-shape on open; upgraded to detail shape (full description + + // uncapped thumbnails) once getPostFull resolves. + const currentPost = ref(null) + const detailLoaded = ref(false) + const error = ref(null) + const isOpen = computed(() => currentPost.value !== null) + + async function open (post) { + currentPost.value = post + detailLoaded.value = false + error.value = null + try { + const detail = await postsStore.getPostFull(post.id) + if (currentPost.value?.id === post.id) { + currentPost.value = detail + detailLoaded.value = true + } + } catch (e) { + // Keep feed-shape data; PostModal can still render title + truncated + // description + the up-to-6 feed thumbnails. Just surface the error. + error.value = e.message + } + } + + function close () { + currentPost.value = null + detailLoaded.value = false + error.value = null + } + + return { currentPost, detailLoaded, error, isOpen, open, close } +}) From 965a953b2ee7f5ca23702b4128f3693c04c56cb8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:49:02 -0400 Subject: [PATCH 05/18] =?UTF-8?q?feat(post-card):=20PostEmptyThumbs=20?= =?UTF-8?q?=E2=80=94=20dashed-border=20placeholder=20shown=20in=20PostCard?= =?UTF-8?q?'s=20hero=20slot=20when=20post=20has=20zero=20linked=20images?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/posts/PostEmptyThumbs.vue | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 frontend/src/components/posts/PostEmptyThumbs.vue diff --git a/frontend/src/components/posts/PostEmptyThumbs.vue b/frontend/src/components/posts/PostEmptyThumbs.vue new file mode 100644 index 0000000..9927424 --- /dev/null +++ b/frontend/src/components/posts/PostEmptyThumbs.vue @@ -0,0 +1,35 @@ + + + + + From a5cb684d3411a3ee9df5514237956b8de0b7f7a3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:49:20 -0400 Subject: [PATCH 06/18] =?UTF-8?q?feat(post-modal):=20PostImageGrid=20?= =?UTF-8?q?=E2=80=94=20fixed-cell=20grid=20(auto-fill=20220px+,=204:3=20as?= =?UTF-8?q?pect-cover)=20inside=20PostModal;=20click=20opens=20ImageViewer?= =?UTF-8?q?=20scoped=20to=20the=20post's=20images=20via=20modalStore.open(?= =?UTF-8?q?id,=20{=20postImageIds=20})?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/posts/PostImageGrid.vue | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 frontend/src/components/posts/PostImageGrid.vue diff --git a/frontend/src/components/posts/PostImageGrid.vue b/frontend/src/components/posts/PostImageGrid.vue new file mode 100644 index 0000000..c77620f --- /dev/null +++ b/frontend/src/components/posts/PostImageGrid.vue @@ -0,0 +1,63 @@ + + + + + From 2f16699971cf68961a5346eb97c1f76cd34254da Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:49:59 -0400 Subject: [PATCH 07/18] =?UTF-8?q?feat(post-modal):=20PostModal=20=E2=80=94?= =?UTF-8?q?=20full=20Patreon-style=20v-dialog=20(header=20+=20image=20grid?= =?UTF-8?q?=20+=20sanitized=20body=20+=20attachments);=20reads=20from=20us?= =?UTF-8?q?ePostModalStore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/posts/PostModal.vue | 211 ++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 frontend/src/components/posts/PostModal.vue diff --git a/frontend/src/components/posts/PostModal.vue b/frontend/src/components/posts/PostModal.vue new file mode 100644 index 0000000..3c25ccb --- /dev/null +++ b/frontend/src/components/posts/PostModal.vue @@ -0,0 +1,211 @@ + + + + + From 243e5362259d22c775d69acba8fe80fe0639142f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:50:34 -0400 Subject: [PATCH 08/18] =?UTF-8?q?feat(app):=20mount=20PostModal=20at=20app?= =?UTF-8?q?=20root=20next=20to=20ImageViewer=20=E2=80=94=20single=20instan?= =?UTF-8?q?ce=20driven=20by=20usePostModalStore=20so=20PostCard=20can=20op?= =?UTF-8?q?en=20from=20anywhere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/App.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5cfbdda..38a3ccf 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,6 +4,7 @@ + @@ -13,6 +14,7 @@ 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() From 6df74683b370f9fbad6bc9716cce995ae58fe2d7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:51:10 -0400 Subject: [PATCH 09/18] =?UTF-8?q?feat(post-card):=20responsive=20redesign?= =?UTF-8?q?=20=E2=80=94=20container-query=20split=20(stack=20<800px=20/=20?= =?UTF-8?q?side-by-side=20=E2=89=A5800px),=20hero=20+=20thumb=20rail,=20+N?= =?UTF-8?q?=20overflow=20chip,=20line-clamp=20body=20(3=20narrow=20/=205?= =?UTF-8?q?=20wide),=20title/desc=20fallbacks=20for=20sparse=20data,=20cli?= =?UTF-8?q?ck=E2=86=92postModal.open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/posts/PostCard.vue | 294 ++++++++++++--------- 1 file changed, 172 insertions(+), 122 deletions(-) diff --git a/frontend/src/components/posts/PostCard.vue b/frontend/src/components/posts/PostCard.vue index 68b537e..c80a0ab 100644 --- a/frontend/src/components/posts/PostCard.vue +++ b/frontend/src/components/posts/PostCard.vue @@ -1,10 +1,17 @@ @@ -118,13 +110,25 @@ function formatBytes(n) { .fc-post-card { padding: 1rem; margin-bottom: 1rem; + cursor: pointer; + container-type: inline-size; + transition: border-color 0.15s ease; } +.fc-post-card:hover { + border-color: rgb(var(--v-theme-accent)); +} +.fc-post-card:focus-visible { + outline: 2px solid rgb(var(--v-theme-accent)); + outline-offset: 2px; +} + .fc-post-card__head { display: flex; align-items: center; gap: 0.6rem; - font-size: 0.85rem; + font-size: 0.8rem; color: rgb(var(--v-theme-on-surface-variant)); + margin-bottom: 12px; } .fc-post-card__artist { color: rgb(var(--v-theme-on-surface)); @@ -133,72 +137,118 @@ function formatBytes(n) { } .fc-post-card__artist:hover { color: rgb(var(--v-theme-accent)); } .fc-post-card__date { white-space: nowrap; } -.fc-post-card__title { - font-size: 1.05rem; - margin: 0.6rem 0 0.4rem; -} -.fc-post-card__desc { - white-space: pre-wrap; - font-size: 0.92rem; - color: rgb(var(--v-theme-on-surface)); - margin-bottom: 0.6rem; -} -.fc-post-card__more { - background: none; - border: none; - color: rgb(var(--v-theme-accent)); - cursor: pointer; - padding: 0 0.25rem; - font-size: inherit; -} -.fc-post-card__thumbs { + +.fc-post-card__body { display: flex; - flex-wrap: wrap; - gap: 0.4rem; - align-items: center; - margin: 0.5rem 0; + flex-direction: column; + gap: 16px; } -.fc-post-card__thumb { line-height: 0; } -.fc-post-card__thumb-img { - border-radius: 4px; +@container (min-width: 800px) { + .fc-post-card__body { + flex-direction: row; + gap: 24px; + } + .fc-post-card__media { + flex: 0 0 50%; + } + .fc-post-card__text { + flex: 1 1 0; + min-width: 0; + } +} + +.fc-post-card__hero { + width: 100%; + aspect-ratio: 16 / 10; overflow: hidden; + border-radius: 6px; } -.fc-post-card__more-thumbs { - display: inline-flex; +.fc-post-card__hero img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.fc-post-card__rail { + display: flex; + gap: 6px; + margin-top: 6px; +} +.fc-post-card__rail-cell { + width: 80px; + height: 80px; + overflow: hidden; + border-radius: 4px; +} +.fc-post-card__rail-cell img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.fc-post-card__rail-more { + width: 80px; + height: 80px; + display: flex; align-items: center; justify-content: center; - width: 96px; - height: 96px; border: 1px dashed rgb(var(--v-theme-on-surface-variant)); border-radius: 4px; color: rgb(var(--v-theme-on-surface-variant)); - text-decoration: none; font-size: 0.85rem; } -.fc-post-card__more-thumbs:hover { - color: rgb(var(--v-theme-accent)); - border-color: rgb(var(--v-theme-accent)); + +.fc-post-card__title { + font-family: 'Fraunces', Georgia, serif; + font-size: 18px; + font-weight: 500; + margin: 0 0 8px 0; + color: rgb(var(--v-theme-on-surface)); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } -.fc-post-card__attachments { - display: flex; - flex-wrap: wrap; - gap: 0.4rem; - margin-top: 0.6rem; +.fc-post-card__title--missing { + font-style: italic; + color: rgb(var(--v-theme-on-surface-variant)); } -.fc-post-card__att { +@container (min-width: 800px) { + .fc-post-card__title { + font-size: 20px; + -webkit-line-clamp: 1; + white-space: nowrap; + text-overflow: ellipsis; + } +} + +.fc-post-card__desc { + font-size: 0.9rem; + line-height: 1.5; + color: rgb(var(--v-theme-on-surface)); + margin: 0 0 12px 0; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} +.fc-post-card__desc--missing { + font-style: italic; + color: rgb(var(--v-theme-on-surface-variant)); +} +@container (min-width: 800px) { + .fc-post-card__desc { + -webkit-line-clamp: 5; + } +} + +.fc-post-card__atts { display: inline-flex; align-items: center; - gap: 0.35rem; - padding: 0.2rem 0.5rem; - border: 1px solid rgb(var(--v-theme-on-surface-variant)); - border-radius: 999px; - color: rgb(var(--v-theme-on-surface)); - text-decoration: none; - font-size: 0.82rem; + gap: 6px; + font-size: 0.85rem; + color: rgb(var(--v-theme-on-surface-variant)); } -.fc-post-card__att:hover { - color: rgb(var(--v-theme-accent)); - border-color: rgb(var(--v-theme-accent)); -} -.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); } +.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); } From 0316f92e8bda60dc9b8779fc2e0a9b87e5985d55 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:51:21 -0400 Subject: [PATCH 10/18] =?UTF-8?q?feat(artist-posts-tab):=20bump=20max-widt?= =?UTF-8?q?h=20900=20=E2=86=92=201600=20so=20the=20new=20wide-layout=20Pos?= =?UTF-8?q?tCard=20has=20room=20and=20the=20artist=20Posts=20feed=20doesn'?= =?UTF-8?q?t=20leave=20most=20of=20an=20ultra-wide=20screen=20empty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/artist/ArtistPostsTab.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/artist/ArtistPostsTab.vue b/frontend/src/components/artist/ArtistPostsTab.vue index b575531..d628f62 100644 --- a/frontend/src/components/artist/ArtistPostsTab.vue +++ b/frontend/src/components/artist/ArtistPostsTab.vue @@ -67,7 +67,7 @@ onUnmounted(() => {