From a25508c89efd4fb354607a1325a9f0394421244a Mon Sep 17 00:00:00 2001 From: Dominik Date: Sun, 16 Aug 2026 17:38:02 +0200 Subject: [PATCH] ENH - refine Weazel News experience --- frontend/src/stores/phone.ts | 11 + frontend/src/stores/weazel-news.test.ts | 27 +- frontend/src/types/weazel-news.ts | 9 +- .../apps/weazel-news-app.contract.test.ts | 196 +++ frontend/src/views/apps/weazel-news-app.vue | 1277 ++++++++--------- frontend/testserver/index.cjs | 81 +- frontend/testserver/smoke.cjs | 86 +- sky_phone/config/locales/en.lua | 10 + sky_phone/config/weazel_news.lua | 1 + sky_phone/source/server/db_migrate.lua | 25 + sky_phone/source/server/weazel_news.lua | 293 +++- sky_phone/sql/install.sql | 12 + 12 files changed, 1222 insertions(+), 806 deletions(-) create mode 100644 frontend/src/views/apps/weazel-news-app.contract.test.ts diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 135de68..5f9eb3f 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -551,6 +551,15 @@ const defaultLocales: LocaleTree = { category: 'Category', status: 'Publication Status', cover: 'Cover Image', + photos: 'Article Photos', + photoCount: '{count} of {maximum} photos', + addPhotos: 'Add Photos', + choosePhotos: 'Choose from Photos', + takePhoto: 'Take Photo', + photoSourceHint: + 'Choose several photos from your library or take a new one.', + primaryPhoto: 'Cover', + makePrimary: 'Use as Cover', chooseCover: 'Choose from Photos', changeCover: 'Change Cover', removeCover: 'Remove Cover', @@ -584,8 +593,10 @@ const defaultLocales: LocaleTree = { deleteArticle: 'Delete {title}', search: 'Search Weazel News', clearSearch: 'Clear article search', + articleImages: 'Article photos', coverPreview: 'Article cover preview', removeCover: 'Remove the selected cover image', + removePhoto: 'Remove this article photo', status: 'Article status: {status}', }, errors: { diff --git a/frontend/src/stores/weazel-news.test.ts b/frontend/src/stores/weazel-news.test.ts index 7eb50f6..2f61a9b 100644 --- a/frontend/src/stores/weazel-news.test.ts +++ b/frontend/src/stores/weazel-news.test.ts @@ -21,6 +21,16 @@ const article: WeazelNewsArticle = { id: '7d28b252-0532-4869-9512-e313e34d2fb8', imageMediaId: 12, imageUrl: 'https://media.invalid/weazel/article.webp', + images: [ + { + mediaId: 12, + url: 'https://media.invalid/weazel/article.webp', + }, + { + mediaId: 13, + url: 'https://media.invalid/weazel/article-2.webp', + }, + ], publishedAt: 1_754_000_100, revision: 4, status: 'published', @@ -31,7 +41,7 @@ const article: WeazelNewsArticle = { const draft: WeazelNewsArticleDraft = { body: 'Updated body.', category: 'business', - imageMediaId: null, + imageMediaIds: [33, 34], status: 'draft', title: 'Updated headline', } @@ -52,6 +62,7 @@ describe('Weazel News store', () => { ], jobGradeLabel: 'Editor', jobLabel: 'Weazel News', + maximumImages: 6, }, success: true, }) @@ -61,6 +72,7 @@ describe('Weazel News store', () => { expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:context') expect(store.context?.canManage).toBe(true) expect(store.context?.categories[0]).toEqual({ count: 3, id: 'news' }) + expect(store.context?.maximumImages).toBe(6) }) it('loads and deduplicates appended public pages', async () => { @@ -103,6 +115,19 @@ describe('Weazel News store', () => { expect(store.publicHasMore).toBe(false) }) + it('replaces stale search results with a successful empty response', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { hasMore: false, items: [] }, + success: true, + }) + const store = useWeazelNewsStore() + store.publicItems = [article] + + expect(await store.loadPublic({ search: 'missing headline' })).toBe(true) + expect(store.publicItems).toEqual([]) + expect(store.publicHasMore).toBe(false) + }) + it('loads paged management results including drafts', async () => { mockNuiCall.mockResolvedValueOnce({ data: { hasMore: false, items: [{ ...article, status: 'draft' }] }, diff --git a/frontend/src/types/weazel-news.ts b/frontend/src/types/weazel-news.ts index 6784bfb..e1dec43 100644 --- a/frontend/src/types/weazel-news.ts +++ b/frontend/src/types/weazel-news.ts @@ -20,6 +20,11 @@ export type WeazelNewsCategorySummary = { id: WeazelNewsCategoryId } +export type WeazelNewsArticleImage = { + mediaId: number + url: string +} + export type WeazelNewsArticle = { authorName: string body: string @@ -29,6 +34,7 @@ export type WeazelNewsArticle = { id: string imageMediaId?: number | null imageUrl?: string | null + images: WeazelNewsArticleImage[] publishedAt?: number | null revision: number status: WeazelNewsArticleStatus @@ -41,7 +47,7 @@ export type WeazelNewsArticleSummary = Omit export type WeazelNewsArticleDraft = { body: string category: WeazelNewsCategoryId - imageMediaId: number | null + imageMediaIds: number[] status: WeazelNewsArticleStatus title: string } @@ -51,6 +57,7 @@ export type WeazelNewsContext = { categories: WeazelNewsCategorySummary[] jobGradeLabel?: string jobLabel?: string + maximumImages: number } export type WeazelNewsListResponse = { diff --git a/frontend/src/views/apps/weazel-news-app.contract.test.ts b/frontend/src/views/apps/weazel-news-app.contract.test.ts new file mode 100644 index 0000000..0c5a597 --- /dev/null +++ b/frontend/src/views/apps/weazel-news-app.contract.test.ts @@ -0,0 +1,196 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync( + new URL('./weazel-news-app.vue', import.meta.url), + 'utf8', +) + +function sectionBetween(start: RegExp, end: RegExp): string { + const startMatch = start.exec(source) + if (!startMatch || startMatch.index === undefined) return '' + + const tail = source.slice(startMatch.index + startMatch[0].length) + const endMatch = end.exec(tail) + return endMatch?.index === undefined + ? source.slice(startMatch.index) + : source.slice( + startMatch.index, + startMatch.index + startMatch[0].length + endMatch.index, + ) +} + +function openingTags( + pascalName: string, + kebabName: string, + value = source, +): string[] { + const pattern = new RegExp(`<(?:${pascalName}|${kebabName})\\b[^>]*>`, 'gis') + return value.match(pattern) ?? [] +} + +function styleRule(selector: string): string { + const start = source.indexOf(selector) + if (start < 0) return '' + const end = source.indexOf('}', start) + return end < 0 ? source.slice(start) : source.slice(start, end + 1) +} + +const detailSource = sectionBetween( + /]*>/i, + /]*>/i, +) +const composerSource = sectionBetween( + /]*>/i, + /<(?:SkyActionSheet|sky-action-sheet|SkySheet|sky-sheet)\b/i, +) +const searchSource = sectionBetween( + /]*>/i, + /class=["']weazel-editorial-heading["']/i, +) + +describe('Weazel News central navigation contract', () => { + it('uses the full Sky pill navigation and a tabbar-aware scroll owner', () => { + const navigationSource = + source.match( + /<(?:SkyPillNavigation|sky-pill-navigation)\b[\s\S]*?<\/(?:SkyPillNavigation|sky-pill-navigation)>/i, + )?.[0] ?? '' + const pillTags = openingTags( + 'SkyPillNavigation', + 'sky-pill-navigation', + navigationSource, + ) + const segmentedTags = openingTags( + 'SkySegmented', + 'sky-segmented', + navigationSource, + ) + const segmentedButtonTags = openingTags( + 'SkySegmentedButton', + 'sky-segmented-button', + navigationSource, + ) + const scrollTags = openingTags('SkyScrollArea', 'sky-scroll-area') + + expect(pillTags).toHaveLength(1) + expect(pillTags[0]).toMatch(/\blayout\s*=\s*["']full["']/i) + expect(segmentedTags.length).toBeGreaterThanOrEqual(1) + expect(segmentedButtonTags).toHaveLength(4) + expect( + scrollTags.some((tag) => /\bwith-(?:tabbar|tab-bar)\b/i.test(tag)), + ).toBe(true) + + expect(source).not.toMatch(/<(?:SkyTabBar|sky-tab-bar)\b/i) + expect(source).not.toMatch(/\bSkyTabBar\b/) + expect(source).not.toMatch(/\.weazel-tabbar(?:\b|__)/) + }) +}) + +describe('Weazel News article detail contract', () => { + it('uses the centered back action owned by SkyNavbar', () => { + const navbarTag = detailSource.match( + /<(?:SkyNavbar|sky-navbar)\b[^>]*>/is, + )?.[0] + + expect(detailSource).not.toBe('') + expect(navbarTag).toBeDefined() + expect(navbarTag).toMatch(/\bshow-(?:back|back-button)\b/i) + expect(navbarTag).toMatch(/\bback-appearance\s*=\s*["']surface["']/i) + expect(navbarTag).toMatch(/@back\s*=\s*["']closeDetail["']/) + expect(detailSource).not.toMatch( + /<(?:SkyNavbarBackLink|sky-navbar-back-link)\b/i, + ) + }) + + it('keeps the lead image full bleed while aligning article copy to the page gutter', () => { + const detailScrollTag = detailSource.match( + /<(?:SkyScrollArea|sky-scroll-area)\b(?=[^>]*\bweazel-detail-scroll\b)[^>]*>/is, + )?.[0] + const coverRule = styleRule('.weazel-detail-cover') + const copyRule = styleRule('.weazel-detail-copy') + + expect(detailScrollTag).toBeDefined() + expect(detailScrollTag).toMatch(/\bas\s*=\s*["']article["']/i) + expect(detailScrollTag).not.toMatch(/\bpadded\b/i) + expect(coverRule).toMatch(/\bwidth\s*:\s*100%\s*;/i) + expect(coverRule).not.toMatch(/calc\s*\(\s*100%/i) + expect(coverRule).not.toMatch(/margin[^;]*-\d/i) + expect(copyRule).toContain('var(--sky-page-gutter)') + }) + + it('offers one canonical edit action to article writers', () => { + const editActions = + detailSource.match( + /@click\s*=\s*["']\s*editArticle\s*\(\s*selectedArticle\s*\)\s*["']/g, + ) ?? [] + const navbarEnd = detailSource.search(/<\/(?:SkyNavbar|sky-navbar)>/i) + const navbarSource = navbarEnd < 0 ? '' : detailSource.slice(0, navbarEnd) + + expect(editActions).toHaveLength(1) + expect(navbarSource).toMatch( + /@click\s*=\s*["']\s*editArticle\s*\(\s*selectedArticle\s*\)\s*["']/, + ) + }) +}) + +describe('Weazel News empty search contract', () => { + it('uses the shared compact empty state when no article matches', () => { + const emptyStateTag = searchSource.match( + /<(?:SkyEmptyState|sky-empty-state)\b[^>]*>/is, + )?.[0] + + expect(searchSource).not.toBe('') + expect(emptyStateTag).toBeDefined() + expect(emptyStateTag).toMatch(/\bcompact\b/i) + expect(emptyStateTag).toMatch(/!\s*news\.publicItems\.length/) + expect(emptyStateTag).toContain("t('search.emptyTitle')") + expect(emptyStateTag).toContain("t('search.emptyBody')") + }) +}) + +describe('Weazel News article composer contract', () => { + it('uses the central SkyField select for the article category', () => { + const categorySelect = openingTags('SkyField', 'sky-field').find( + (tag) => + /\btype\s*=\s*["']select["']/i.test(tag) && /draft\.category/.test(tag), + ) + + expect(categorySelect).toBeDefined() + expect(categorySelect).toMatch(/:options\s*=/i) + expect(categorySelect).toMatch(/\bdropdown\b/i) + expect(categorySelect).toContain("t('composer.category')") + expect(composerSource).not.toContain("composerChoice = 'category'") + }) + + it('offers Photos and Camera through the shared action sheet', () => { + const actionSheet = source.match( + /<(?:SkyActionSheet|sky-action-sheet)\b[\s\S]*?<\/(?:SkyActionSheet|sky-action-sheet)>/i, + )?.[0] + + expect(actionSheet).toBeDefined() + expect(actionSheet).toMatch( + /<(?:SkyActionButton|sky-action-button)\b[\s\S]*?(?:photos|gallery)/i, + ) + expect(actionSheet).toMatch( + /<(?:SkyActionButton|sky-action-button)\b[\s\S]*?camera/i, + ) + expect(actionSheet).toMatch(/@backdropclick\s*=/i) + expect(actionSheet).toMatch(/@escape\s*=/i) + }) + + it('round-trips and previews multiple article images', () => { + expect(source).toContain('imageMediaIds') + expect(source).toContain('maximumImages') + expect(source).toMatch(/messageMedia\.begin\s*\(/) + expect(source).toMatch(/messageMedia\.consumeMany(?:<[^>]+>)?\s*\(/) + expect(source).toMatch(/selection\?*\.media/) + expect(source).not.toMatch(/selection\?*\.media\s*\[\s*0\s*\]/) + expect(composerSource).toMatch( + /v-for\s*=\s*["'][^"']+\s+in\s+[^"']*(?:image|media)[^"']*["']/i, + ) + expect(source).toMatch( + /['"]camera['"]\s*\|\s*['"]photos['"]|['"]photos['"]\s*\|\s*['"]camera['"]/, + ) + }) +}) diff --git a/frontend/src/views/apps/weazel-news-app.vue b/frontend/src/views/apps/weazel-news-app.vue index e566b79..2e6e74a 100644 --- a/frontend/src/views/apps/weazel-news-app.vue +++ b/frontend/src/views/apps/weazel-news-app.vue @@ -1,37 +1,39 @@ @@ -478,7 +579,7 @@ onBeforeUnmount(() => { -
+ -
+ - - - + - - - - + + {{ t('tabs.home') }} + + + - - - - + + {{ t('tabs.categories') }} + + + - - - - + + {{ t('tabs.search') }} + + + - - - - - + + + {{ t('tabs.editorial') }} + + + + - - - + + {{ t('composer.addPhotos') }} + + + {{ t('composer.choosePhotos') }} + + + + + {{ t('composer.takePhoto') }} + + + + + + {{ phone.t('Common.cancel') }} + + + { } .weazel-navbar { - --sky-safe-area-top: 46px; - position: absolute; - z-index: 10; - top: 0; - right: 0; - left: 0; color: var(--weazel-text); } @@ -1339,19 +1394,11 @@ onBeforeUnmount(() => { .weazel-scroll, .weazel-detail-scroll, .weazel-composer-scroll { - position: absolute; - inset: 0; - overflow-x: hidden; - overflow-y: auto; - padding: 100px 14px 86px; - padding: 12.4cqh 1.7cqh 10.6cqh; scrollbar-width: none; } -.weazel-detail-scroll, -.weazel-composer-scroll { - padding-bottom: 32px; - padding-bottom: 4cqh; +.weazel-detail-scroll { + padding-bottom: calc(var(--sky-safe-area-bottom) + var(--sky-space-6)); } .weazel-scroll::-webkit-scrollbar, @@ -1803,99 +1850,73 @@ onBeforeUnmount(() => { color: #34c759; } -.weazel-tabbar { - position: absolute !important; - z-index: 20; - right: 9px; - right: 1.1cqh; - bottom: 25px; - bottom: 3.1cqh; - left: 9px; - left: 1.1cqh; - width: auto !important; - padding: 0 !important; - color: var(--weazel-text); - overflow: hidden; -} - -.weazel-tabbar :deep(> div:first-child) { - height: 100% !important; -} - -.weazel-tabbar :deep(.weazel-tabbar__inner) { - width: 100% !important; - height: 50px !important; - height: 6.2cqh !important; - max-width: none !important; - gap: 0 !important; - padding: 0 3px !important; - padding: 0 0.37cqh !important; -} - -.weazel-tabbar :deep(.weazel-tabbar__pane) { - width: 100% !important; - max-width: none !important; - display: grid !important; - grid-template-columns: repeat(4, minmax(0, 1fr)); - align-items: stretch; - gap: 0; -} - -.weazel-tabbar :deep(.weazel-tabbar__link) { - width: 100% !important; - min-width: 0 !important; - max-width: none !important; - justify-content: center; - overflow: hidden; - padding: 0 !important; -} - -.weazel-tabbar :deep(.weazel-tabbar__link > span) { +.weazel-navigation__item { min-width: 0; - gap: 1px; - gap: 0.12cqh; - padding: 3px 0 !important; - padding: 0.37cqh 0 !important; + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + gap: 2px; + font-size: 10px; + line-height: 1.1; } -.weazel-tabbar :deep(.weazel-tabbar__link.sky-link) { - padding-right: 0 !important; - padding-left: 0 !important; -} - -.weazel-tabbar :deep(.sky-tab-button__icon), -.weazel-tabbar :deep(.sky-icon) { - width: 22px !important; - width: 2.72cqh !important; - height: 22px !important; - height: 2.72cqh !important; -} - -.weazel-tabbar :deep(.sky-tab-button__label) { - width: 100%; +.weazel-navigation__item > span:last-child { + max-width: 100%; overflow: hidden; - font-size: 9.5px; - font-size: 1.18cqh; - line-height: 1.15; - text-align: center; text-overflow: ellipsis; white-space: nowrap; } -.weazel-tabbar :deep(.text-primary) { - color: #d71920 !important; +.weazel-detail-gallery { + position: relative; + width: 100%; + height: 240px; + display: flex; + overflow-x: auto; + overflow-y: hidden; + scroll-snap-type: x mandatory; + scrollbar-width: none; } -.weazel-detail-cover, -.weazel-detail-masthead { - width: calc(100% + 30px); +.weazel-detail-gallery::-webkit-scrollbar { + display: none; +} + +.weazel-detail-cover { + width: 100%; + max-width: none; height: 240px; - margin: 0 -15px; + display: block; + flex: 0 0 100%; object-fit: cover; + scroll-snap-align: start; +} + +.weazel-detail-masthead { + width: 100%; + height: 240px; +} + +.weazel-detail-count { + position: absolute; + right: var(--sky-page-gutter); + bottom: var(--sky-space-3); + padding: 4px 9px; + display: inline-flex; + align-items: center; + gap: 4px; + border-radius: var(--sky-radius-pill); + background: rgb(0 0 0 / 58%); + color: #fff; + font-size: 11px; + font-weight: 700; + pointer-events: none; } .weazel-detail-copy { - padding: 22px 4px 10px; + padding: 22px calc(var(--sky-page-gutter) + var(--sky-safe-area-right)) 10px; + padding-left: calc(var(--sky-page-gutter) + var(--sky-safe-area-left)); } .weazel-detail-copy h1 { @@ -1945,15 +1966,18 @@ onBeforeUnmount(() => { white-space: pre-wrap; } -.weazel-detail-actions, -.weazel-composer-actions, -.weazel-cover-actions { +.weazel-detail-actions { display: flex; gap: 10px; } -.weazel-detail-actions > *, -.weazel-composer-actions > * { +.weazel-detail-actions { + padding-right: calc(var(--sky-page-gutter) + var(--sky-safe-area-right)); + padding-left: calc(var(--sky-page-gutter) + var(--sky-safe-area-left)); +} + +.weazel-detail-actions > * { + width: 100%; flex: 1; } @@ -1961,347 +1985,166 @@ onBeforeUnmount(() => { color: #ff453a !important; } -.weazel-composer-cover { +.weazel-composer-actions { + width: 100%; + display: flex; + flex-direction: column; + gap: var(--sky-space-2); + margin: var(--sky-space-4) 0 0; +} + +.weazel-composer-actions > * { + width: 100%; +} + +.weazel-composer-media { + box-sizing: border-box; + width: 100%; + margin-bottom: var(--sky-space-4); overflow: hidden; - margin-bottom: 14px; border: 1px solid var(--weazel-line); - border-radius: 20px; + border-radius: var(--sky-radius-card); background: var(--weazel-surface); } -.weazel-composer-cover > img, -.weazel-composer-cover > div:first-child { +.weazel-composer-media__header { + min-height: var(--sky-touch-target); + padding: var(--sky-space-2) var(--sky-space-3); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sky-space-2); + border-bottom: 1px solid var(--weazel-line); +} + +.weazel-composer-media__header > div { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.weazel-composer-media__header strong { + color: var(--weazel-text); + font-size: 14px; +} + +.weazel-composer-media__header span { + color: var(--weazel-muted); + font-size: 11px; +} + +.weazel-image-grid { + padding: var(--sky-space-2); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sky-space-2); +} + +.weazel-image-preview { + position: relative; + min-width: 0; + aspect-ratio: 1; + overflow: hidden; + border-radius: var(--sky-radius-control); + background: var(--weazel-surface-strong); +} + +.weazel-image-preview.is-primary { + aspect-ratio: 16 / 9; + grid-column: 1 / -1; +} + +.weazel-image-preview img { width: 100%; - height: 190px; + height: 100%; + display: block; object-fit: cover; } -.weazel-composer-cover > div:first-child { +.weazel-image-primary { + position: absolute; + top: var(--sky-space-2); + left: var(--sky-space-2); + padding: 4px 8px; + border-radius: var(--sky-radius-pill); + background: rgb(0 0 0 / 64%); + color: #fff; + font-size: 10px; + font-weight: 700; +} + +.weazel-image-actions { + position: absolute; + right: var(--sky-space-1); + bottom: var(--sky-space-1); + left: var(--sky-space-1); + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--sky-space-1); +} + +.weazel-image-actions > :first-child:not(:last-child) { + min-width: 0; + flex: 1; +} + +.weazel-image-actions :deep(.sky-button) { + min-height: 34px; + border-color: rgb(255 255 255 / 14%); + background: rgb(17 17 17 / 78%); + color: #fff; + backdrop-filter: blur(12px); +} + +.weazel-image-empty { + width: 100%; + min-height: 184px; + padding: var(--sky-space-5); display: flex; align-items: center; justify-content: center; flex-direction: column; - gap: 8px; + gap: var(--sky-space-2); + border: 0; + background: transparent; color: var(--weazel-muted); + font: inherit; + cursor: pointer; } -.weazel-cover-actions { - padding: 10px; +.weazel-image-empty strong { + color: var(--weazel-text); + font-size: 15px; } -.weazel-cover-actions > * { - height: 36px !important; - height: 4.45cqh !important; - min-width: 0; - flex: 1 1 0; - padding-block: 0 !important; -} - -.weazel-cover-picker { - gap: 6px; - gap: 0.74cqh; - padding-inline: 10px !important; - padding-inline: 1.24cqh !important; - font-size: 12px !important; - font-size: 1.48cqh !important; - line-height: 1.15; -} - -.weazel-cover-picker :deep(svg) { - width: 16px; - width: 1.98cqh; - height: 16px; - height: 1.98cqh; - flex: none; -} - -.weazel-cover-picker__label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.weazel-cover-remove { - padding-inline: 8px !important; - padding-inline: 1cqh !important; - font-size: 12px !important; - font-size: 1.48cqh !important; +.weazel-image-empty span { + max-width: 250px; + font-size: 12px; + line-height: 1.4; + text-align: center; } .weazel-composer-list { - margin: 0 !important; - overflow: visible; - background: transparent !important; + margin: 0 0 var(--sky-space-4) !important; + overflow: hidden; + background: var(--weazel-surface) !important; } .weazel-composer-list :deep(.weazel-composer-field) { - margin-block: 13px !important; - margin-block: 1.61cqh !important; + margin-block: 0 !important; } -.weazel-composer-list :deep(.weazel-composer-field > .relative) { - margin-inline: 0 !important; - padding-inline-start: 0 !important; - border-radius: 9px !important; - border-radius: 1.11cqh !important; +.weazel-composer-list :deep(.sky-field__textarea) { + min-height: 180px; + resize: vertical; } -.weazel-composer-list :deep(.weazel-composer-field > .relative > .w-full) { - padding-block: 0 !important; - padding-inline-end: 0 !important; -} - -.weazel-composer-list - :deep(.weazel-composer-field > .relative > .w-full > .relative) { - margin-top: -7px !important; - margin-top: -0.87cqh !important; - margin-bottom: -7px !important; - margin-bottom: -0.87cqh !important; -} - -.weazel-composer-list :deep(.weazel-composer-field .text-xs) { - margin-top: -11px !important; - margin-top: -1.36cqh !important; - font-size: 10px !important; - font-size: 1.24cqh !important; - line-height: 1.25; -} - -.weazel-composer-list :deep(.weazel-composer-field .text-xs > div) { - top: -1px !important; - top: -0.12cqh !important; - margin: -1px !important; - margin: -0.12cqh !important; - padding: 2px 4px !important; - padding: 0.25cqh 0.5cqh !important; -} - -.weazel-composer-list :deep(.weazel-composer-input) { - height: 44px !important; - height: 5.45cqh !important; - padding-inline: 2px !important; - padding-inline: 0.25cqh !important; - color: var(--weazel-text); - font-size: 13px !important; - font-size: 1.61cqh !important; - line-height: 1.35; -} - -.weazel-composer-list :deep(.weazel-body-input) { - height: 210px !important; - height: 26cqh !important; - min-height: 210px !important; - min-height: 26cqh !important; - padding-block: 8px !important; - padding-block: 1cqh !important; - resize: none; -} - -.weazel-choice-trigger { - margin-block: 13px; - margin-block: 1.61cqh; -} - -.weazel-composer-list :deep(.weazel-choice-trigger__content) { - min-height: 44px; - min-height: 5.45cqh; - padding-inline: 10px !important; - padding-inline: 1.24cqh !important; - border: 1px solid var(--weazel-line); - border-radius: 9px; - border-radius: 1.11cqh; - background: var(--weazel-surface); -} - -.weazel-composer-list :deep(.weazel-choice-trigger__inner) { - min-width: 0; - display: flex; +.weazel-photo-source { + display: inline-flex; align-items: center; - justify-content: space-between; - gap: 8px; - gap: 1cqh; - padding: 0 !important; -} - -.weazel-choice-trigger__copy { - min-width: 0; - display: flex; - flex: 1; - flex-direction: column; - gap: 2px; - gap: 0.25cqh; - text-align: left; -} - -.weazel-choice-trigger__copy small { - color: var(--weazel-muted); - font-size: 10px; - font-size: 1.24cqh; - line-height: 1.15; -} - -.weazel-choice-trigger__copy strong { - overflow: hidden; - color: var(--weazel-text); - font-size: 13px; - font-size: 1.61cqh; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; -} - -.weazel-choice-trigger__icon { - width: 16px; - width: 1.98cqh; - height: 16px; - height: 1.98cqh; - flex: none; - color: var(--weazel-muted); - transition: transform 0.18s ease; -} - -.weazel-choice-trigger__icon.is-open { - transform: rotate(180deg); -} - -.weazel-choice-sheet { - right: 0 !important; - left: 0 !important; - width: 100% !important; - border-radius: 18px 18px 0 0 !important; - border-radius: 2.23cqh 2.23cqh 0 0 !important; - background: var(--weazel-surface) !important; - color: var(--weazel-text); -} - -.weazel-choice-sheet__content { - max-height: 436px; - max-height: 54cqh; - overflow-y: auto; - padding: 6px 10px 20px; - padding: 0.74cqh 1.24cqh 2.48cqh; - border-radius: 18px 18px 0 0; - border-radius: 2.23cqh 2.23cqh 0 0; - background: var(--weazel-surface); - color: var(--weazel-text); - scrollbar-width: none; -} - -.weazel-choice-sheet__content::-webkit-scrollbar { - display: none; -} - -.weazel-choice-sheet__handle { - width: 34px; - width: 4.2cqh; - height: 4px; - height: 0.5cqh; - margin: 0 auto 6px; - margin: 0 auto 0.74cqh; - border-radius: 999px; - background: var(--weazel-line); -} - -.weazel-choice-sheet__header { - min-height: 36px; - min-height: 4.45cqh; - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - gap: 1cqh; - padding-inline: 4px; - padding-inline: 0.5cqh; -} - -.weazel-choice-sheet__header h2 { - margin: 0; - font-family: Georgia, 'Times New Roman', serif; - font-size: 16px; - font-size: 1.98cqh; - line-height: 1.15; -} - -.weazel-choice-sheet__close { - width: 30px !important; - width: 3.71cqh !important; - height: 30px !important; - height: 3.71cqh !important; - min-width: 30px !important; - min-width: 3.71cqh !important; - padding: 0 !important; - border-radius: 999px; - background: var(--weazel-surface-strong); - color: var(--weazel-muted); -} - -.weazel-choice-sheet__close :deep(svg) { - width: 15px; - width: 1.86cqh; - height: 15px; - height: 1.86cqh; -} - -.weazel-choice-options { - margin: 5px 0 0 !important; - margin: 0.62cqh 0 0 !important; -} - -.weazel-choice-options :deep(.weazel-choice-option__content) { - min-height: 40px; - min-height: 4.95cqh; - padding-inline: 8px !important; - padding-inline: 1cqh !important; - border-radius: 8px; - border-radius: 1cqh; -} - -.weazel-choice-options :deep(.weazel-choice-option__inner) { - min-width: 0; - padding: 0 !important; -} - -.weazel-choice-options :deep(.weazel-choice-option__title) { - min-height: 40px !important; - min-height: 4.95cqh !important; - font-size: 13px !important; - font-size: 1.61cqh !important; - line-height: 1.2; -} - -.weazel-choice-options :deep(.weazel-choice-option__title > div:first-child) { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.weazel-choice-options :deep(.weazel-choice-option__title > div:nth-child(2)) { - gap: 2px; - gap: 0.25cqh; - padding-inline-start: 3px; - padding-inline-start: 0.37cqh; -} - -.weazel-choice-options :deep(.weazel-choice-option__title svg) { - width: 16px; - width: 1.98cqh; - height: 16px; - height: 1.98cqh; - color: #d71920; -} - -.weazel-composer-actions { - margin: 16px 0 5px; -} - -.weazel-composer-actions > * { - height: 44px !important; - height: 5.45cqh !important; - padding: 0 8px !important; - padding: 0 1cqh !important; - font-size: 14px !important; - font-size: 1.73cqh !important; - line-height: 1.15; + justify-content: center; + gap: var(--sky-space-2); } diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 8512cb2..7dae5c0 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -2800,6 +2800,7 @@ mockMedia.push( })), ) const weazelNewsCategoryIds = ['official', 'events', 'jobs', 'news', 'business'] +const weazelNewsMaxImages = 6 let weazelNewsSequence = 8 let weazelNewsArticles = [ { @@ -2809,8 +2810,7 @@ let weazelNewsArticles = [ excerpt: 'Temporary navigation restrictions are in effect around the southern harbor while crews inspect the main shipping channel.', category: 'official', - imageUrl: 'https://picsum.photos/seed/weazel-harbor/1200/760', - imageMediaId: null, + images: weazelNewsImages([15, 13, 20]), authorName: 'Avery Brooks', createdAt: Date.now() - 35 * 60 * 1000, updatedAt: Date.now() - 28 * 60 * 1000, @@ -2825,8 +2825,7 @@ let weazelNewsArticles = [ excerpt: 'Food stands, live performers, and classic cars are coming to Vinewood Boulevard this weekend.', category: 'events', - imageUrl: 'https://picsum.photos/seed/weazel-vinewood/1200/760', - imageMediaId: null, + images: weazelNewsImages([11]), authorName: 'Maya Chen', createdAt: Date.now() - 2 * 60 * 60 * 1000, updatedAt: Date.now() - 2 * 60 * 60 * 1000, @@ -2841,8 +2840,7 @@ let weazelNewsArticles = [ excerpt: 'City departments are recruiting new staff across emergency response, transport, and public administration.', category: 'jobs', - imageUrl: null, - imageMediaId: null, + images: [], authorName: 'Jordan Hayes', createdAt: Date.now() - 4 * 60 * 60 * 1000, updatedAt: Date.now() - 3 * 60 * 60 * 1000, @@ -2857,8 +2855,7 @@ let weazelNewsArticles = [ excerpt: 'Every lane through Del Perro has reopened after crews cleared an earlier road obstruction.', category: 'news', - imageUrl: 'https://picsum.photos/seed/weazel-del-perro/1200/760', - imageMediaId: null, + images: weazelNewsImages([9]), authorName: 'Avery Brooks', createdAt: Date.now() - 7 * 60 * 60 * 1000, updatedAt: Date.now() - 6 * 60 * 60 * 1000, @@ -2873,8 +2870,7 @@ let weazelNewsArticles = [ excerpt: 'Independent downtown retailers are seeing stronger evening trade during a trial of extended opening hours.', category: 'business', - imageUrl: 'https://picsum.photos/seed/weazel-downtown/1200/760', - imageMediaId: null, + images: weazelNewsImages([5]), authorName: 'Maya Chen', createdAt: Date.now() - 26 * 60 * 60 * 1000, updatedAt: Date.now() - 25 * 60 * 60 * 1000, @@ -2889,8 +2885,7 @@ let weazelNewsArticles = [ excerpt: 'Local racing teams are preparing vehicles and reviewing safety procedures for the next sanctioned season.', category: 'events', - imageUrl: 'https://picsum.photos/seed/sky-phone-3/800/600', - imageMediaId: 3, + images: weazelNewsImages([3, 7]), authorName: 'Jordan Hayes', createdAt: Date.now() - 55 * 60 * 1000, updatedAt: Date.now() - 12 * 60 * 1000, @@ -2905,8 +2900,7 @@ let weazelNewsArticles = [ excerpt: 'The editorial desk is collecting confirmed service notices and transport updates for Monday morning.', category: 'official', - imageUrl: null, - imageMediaId: null, + images: [], authorName: 'Jordan Hayes', createdAt: Date.now() - 18 * 60 * 1000, updatedAt: Date.now() - 8 * 60 * 1000, @@ -2914,7 +2908,7 @@ let weazelNewsArticles = [ status: 'draft', revision: 2, }, -] +].map(syncWeazelNewsArticleImages) function weazelNewsExcerpt(body) { const normalized = body.replace(/\s+/g, ' ').trim() @@ -2931,6 +2925,24 @@ function weazelNewsImageUrl(imageMediaId) { return media?.url ?? null } +function weazelNewsImages(imageMediaIds) { + return imageMediaIds.map((mediaId) => ({ + mediaId, + url: weazelNewsImageUrl(mediaId), + })) +} + +function syncWeazelNewsArticleImages(article) { + const images = Array.isArray(article.images) ? article.images : [] + const firstImage = images[0] ?? null + return { + ...article, + imageMediaId: firstImage?.mediaId ?? null, + imageUrl: firstImage?.url ?? null, + images, + } +} + function validateWeazelNewsDraft(data) { const title = typeof data.title === 'string' ? data.title.trim() : '' const body = typeof data.body === 'string' ? data.body.trim() : '' @@ -2948,24 +2960,45 @@ function validateWeazelNewsDraft(data) { return { error: status === 'draft' ? 'invalid_draft' : 'invalid_publish' } } - let imageMediaId = null - if (data.imageMediaId !== null && data.imageMediaId !== undefined) { - imageMediaId = Number(data.imageMediaId) + const requestedImageMediaIds = + data.imageMediaIds === undefined + ? data.imageMediaId === null || data.imageMediaId === undefined + ? [] + : [data.imageMediaId] + : data.imageMediaIds + if ( + !Array.isArray(requestedImageMediaIds) || + requestedImageMediaIds.length > weazelNewsMaxImages + ) { + return { error: 'invalid_attachment' } + } + + const imageMediaIds = [] + const seenImageMediaIds = new Set() + for (const requestedImageMediaId of requestedImageMediaIds) { + const imageMediaId = Number(requestedImageMediaId) if ( !Number.isSafeInteger(imageMediaId) || + seenImageMediaIds.has(imageMediaId) || !weazelNewsImageUrl(imageMediaId) ) { return { error: 'invalid_attachment' } } + seenImageMediaIds.add(imageMediaId) + imageMediaIds.push(imageMediaId) } + const images = weazelNewsImages(imageMediaIds) + const firstImage = images[0] ?? null + return { article: { body, category: data.category, excerpt: weazelNewsExcerpt(body), - imageMediaId, - imageUrl: weazelNewsImageUrl(imageMediaId), + imageMediaId: firstImage?.mediaId ?? null, + imageUrl: firstImage?.url ?? null, + images, status, title, }, @@ -4822,6 +4855,7 @@ app.post('/api/:endpoint', (request, response) => { ...(canManageWeazelNews ? { jobGradeLabel: 'Senior Reporter', jobLabel: 'Weazel News' } : {}), + maximumImages: weazelNewsMaxImages, }, }) return @@ -7730,9 +7764,7 @@ app.post('/api/:endpoint', (request, response) => { const property = mockHousingOverview.properties.find( (item) => item.id === request.body.propertyId, ) - const existingNames = new Set( - (property?.keys ?? []).map((key) => key.name), - ) + const existingNames = new Set((property?.keys ?? []).map((key) => key.name)) response.json({ success: true, data: { @@ -7774,8 +7806,7 @@ app.post('/api/:endpoint', (request, response) => { if (request.body.action === 'revoke_key') { property.keys = (property.keys ?? []).filter( (key) => - key.identifier !== request.body.identifier || - key.revocable === false, + key.identifier !== request.body.identifier || key.revocable === false, ) } response.json({ success: true, data: { accepted: true } }) diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs index 8b17496..c0b5247 100644 --- a/frontend/testserver/smoke.cjs +++ b/frontend/testserver/smoke.cjs @@ -65,6 +65,8 @@ const browserDataRequests = [ ['skyride:history', {}], ['skyride:get-player-coords', {}], ['weather:get', {}], + ['weazel-news:context', {}], + ['weazel-news:list', { category: null, offset: 0, search: '' }], ] async function post(baseUrl, endpoint, body = {}) { @@ -104,7 +106,89 @@ async function verifyStatefulActions(baseUrl) { true, ) gallery = await expectSuccess(baseUrl, 'gallery:list', {}, true) - assert.equal(gallery.find((item) => item.id === gallery[0].id)?.favorite, true) + assert.equal( + gallery.find((item) => item.id === gallery[0].id)?.favorite, + true, + ) + + const articlePhotos = gallery + .filter((item) => item.mediaType === 'photo') + .slice(0, 7) + assert.equal( + articlePhotos.length, + 7, + 'gallery:list did not include enough photos for Weazel News', + ) + const weazelContext = await expectSuccess( + baseUrl, + 'weazel-news:context', + {}, + true, + ) + assert.equal(weazelContext.maximumImages, 6) + const createdArticleResponse = await expectSuccess( + baseUrl, + 'weazel-news:create', + { + body: 'Created by the browser mock smoke test with several photos.', + category: 'news', + imageMediaIds: articlePhotos.slice(0, 3).map((item) => item.id), + status: 'published', + title: 'Browser test Weazel article', + }, + true, + ) + const createdArticle = createdArticleResponse.article + assert.deepEqual( + createdArticle.images.map((image) => image.mediaId), + articlePhotos.slice(0, 3).map((item) => item.id), + 'weazel-news:create did not preserve image order', + ) + assert.equal(createdArticle.imageMediaId, articlePhotos[0].id) + + const updatedArticleResponse = await expectSuccess( + baseUrl, + 'weazel-news:update', + { + body: createdArticle.body, + category: 'business', + id: createdArticle.id, + imageMediaIds: [articlePhotos[2].id, articlePhotos[0].id], + revision: createdArticle.revision, + status: 'draft', + title: 'Updated browser test Weazel article', + }, + true, + ) + const updatedArticle = updatedArticleResponse.article + assert.deepEqual( + updatedArticle.images.map((image) => image.mediaId), + [articlePhotos[2].id, articlePhotos[0].id], + 'weazel-news:update did not preserve the reordered images', + ) + assert.equal(updatedArticle.imageMediaId, articlePhotos[2].id) + + const loadedArticleResponse = await expectSuccess( + baseUrl, + 'weazel-news:get', + { id: updatedArticle.id, manage: true }, + true, + ) + assert.deepEqual(loadedArticleResponse.article.images, updatedArticle.images) + + const tooManyImages = await post(baseUrl, 'weazel-news:create', { + body: 'This article must be rejected because it has too many photos.', + category: 'news', + imageMediaIds: articlePhotos.map((item) => item.id), + status: 'draft', + title: 'Invalid Weazel article', + }) + assert.equal(tooManyImages.success, false) + assert.equal(tooManyImages.error, 'invalid_attachment') + await expectSuccess(baseUrl, 'weazel-news:delete', { + id: updatedArticle.id, + revision: updatedArticle.revision, + }) const memoBootstrap = await expectSuccess( baseUrl, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 0eca1cf..df39654 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -302,6 +302,14 @@ Locales["en"] = { category = "Category", status = "Publication Status", cover = "Cover Image", + photos = "Article Photos", + photoCount = "{count} of {maximum} photos", + addPhotos = "Add Photos", + choosePhotos = "Choose from Photos", + takePhoto = "Take Photo", + photoSourceHint = "Choose several photos from your library or take a new one.", + primaryPhoto = "Cover", + makePrimary = "Use as Cover", chooseCover = "Choose from Photos", changeCover = "Change Cover", removeCover = "Remove Cover", @@ -335,8 +343,10 @@ Locales["en"] = { deleteArticle = "Delete {title}", search = "Search Weazel News", clearSearch = "Clear article search", + articleImages = "Article photos", coverPreview = "Article cover preview", removeCover = "Remove the selected cover image", + removePhoto = "Remove this article photo", status = "Article status: {status}", }, errors = { diff --git a/sky_phone/config/weazel_news.lua b/sky_phone/config/weazel_news.lua index af0ecc0..868e8a2 100644 --- a/sky_phone/config/weazel_news.lua +++ b/sky_phone/config/weazel_news.lua @@ -2,6 +2,7 @@ Config.WeazelNews = { Enabled = true, PageSize = 20, MaximumOffset = 10000, + MaximumImages = 6, SearchMaxLength = 80, DraftTitleMinLength = 1, DraftBodyMinLength = 1, diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 2305a49..2db600b 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -2663,9 +2663,34 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_weazel_article_media", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "article_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "media_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "position", type = "TINYINT UNSIGNED NOT NULL" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_weazel_article_position", columns = "(`article_id`, `position`)" }, + { name = "uniq_sky_phone_weazel_article_media", columns = "(`article_id`, `media_id`)" }, + }, + foreignKeys = { + { column = "article_id", references = "`sky_phone_weazel_articles` (`id`) ON DELETE CASCADE" }, + { column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, } Bridge.Database.Migrate("sky_phone", schema) +Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_weazel_article_media` (`article_id`, `media_id`, `position`) + SELECT `id`, `image_media_id`, 1 + FROM `sky_phone_weazel_articles` + WHERE `image_media_id` IS NOT NULL +]], {}) Bridge.Database.Query("DELETE FROM `sky_phone_feather_notifications` WHERE `kind` = 'repost'", {}) Bridge.Database.Query("DELETE FROM `sky_phone_feather_reactions` WHERE `kind` = 'repost'", {}) Bridge.Database.Query([[ diff --git a/sky_phone/source/server/weazel_news.lua b/sky_phone/source/server/weazel_news.lua index a70f797..bfde62e 100644 --- a/sky_phone/source/server/weazel_news.lua +++ b/sky_phone/source/server/weazel_news.lua @@ -17,6 +17,7 @@ if type(config.Enabled) ~= "boolean" then end require_integer_config("PageSize", 1, 100) require_integer_config("MaximumOffset", 0, 1000000) +require_integer_config("MaximumImages", 1, 6) require_integer_config("SearchMaxLength", 1, 256) require_integer_config("DraftTitleMinLength", 1, 160) require_integer_config("DraftBodyMinLength", 1, 12000) @@ -219,6 +220,7 @@ local function article_dto(row) category = row.category, imageUrl = row.image_url, imageMediaId = row.image_media_id and tonumber(row.image_media_id) or nil, + images = {}, authorName = row.author_name, createdAt = created_at * 1000, updatedAt = updated_at * 1000, @@ -228,6 +230,54 @@ local function article_dto(row) } end +local function attach_article_images(articles) + if #articles < 1 then + return articles + end + + local placeholders = {} + local article_ids = {} + local articles_by_id = {} + for index, article in ipairs(articles) do + placeholders[index] = "?" + article_ids[index] = article.id + article.images = {} + articles_by_id[article.id] = article + end + + local rows = Bridge.Database.Query(([[ + SELECT relation.`article_id`, relation.`media_id`, relation.`position`, media.`url` + FROM `sky_phone_weazel_article_media` relation + JOIN `sky_phone_media` media + ON media.`id` = relation.`media_id` AND media.`media_type` = 'photo' + WHERE relation.`article_id` IN (%s) + ORDER BY relation.`article_id`, relation.`position`, relation.`id` + ]]):format(table.concat(placeholders, ", ")), article_ids) + for _, row in ipairs(rows) do + local article = articles_by_id[row.article_id] + local media_id = tonumber(row.media_id) + if article and media_id and type(row.url) == "string" then + article.images[#article.images + 1] = { + mediaId = media_id, + url = row.url, + } + end + end + + for _, article in ipairs(articles) do + if #article.images < 1 and article.imageMediaId and article.imageUrl then + article.images[1] = { + mediaId = article.imageMediaId, + url = article.imageUrl, + } + end + local cover = article.images[1] + article.imageMediaId = cover and cover.mediaId or nil + article.imageUrl = cover and cover.url or nil + end + return articles +end + local function load_article(id, include_drafts) local visibility = include_drafts and "" or " AND article.`status` = 'published'" local rows = Bridge.Database.Query(([[ @@ -237,7 +287,8 @@ local function load_article(id, include_drafts) WHERE article.`id` = ? AND article.`deleted_at` IS NULL%s LIMIT 1 ]]):format(article_detail_columns, visibility), { id }) - return article_dto(rows[1]) + local article = article_dto(rows[1]) + return article and attach_article_images({ article })[1] or nil end local function query_articles(where_clause, parameters, order_clause, offset) @@ -264,10 +315,42 @@ local function query_articles(where_clause, parameters, order_clause, offset) for _, row in ipairs(rows) do articles[#articles + 1] = article_dto(row) end - return articles, has_more + return attach_article_images(articles), has_more end -local function validate_article(source, data, retained_media_id) +local function validate_article_media(source, data, retained_media_ids) + local values = data.imageMediaIds + if values == nil then + values = data.imageMediaId ~= nil and { data.imageMediaId } or {} + end + if type(values) ~= "table" or #values > config.MaximumImages then + return nil + end + for key in pairs(values) do + if type(key) ~= "number" or key < 1 or key > #values or key ~= math.floor(key) then + return nil + end + end + + local media_ids = {} + local seen = {} + for index, value in ipairs(values) do + local media_id = valid_integer(value, 1, 9007199254740991) + if not media_id or seen[media_id] then + return nil + end + if not retained_media_ids[media_id] + and not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "photo") + then + return nil + end + seen[media_id] = true + media_ids[index] = media_id + end + return media_ids +end + +local function validate_article(source, data, retained_media_ids) if type(data) ~= "table" then return nil, "invalid_article" end @@ -287,29 +370,40 @@ local function validate_article(source, data, retained_media_id) return nil, status == "draft" and "invalid_draft" or "invalid_publish" end - local media_id - if data.imageMediaId ~= nil then - media_id = valid_integer(data.imageMediaId, 1, 9007199254740991) - if not media_id then - return nil, "invalid_attachment" - end - if media_id ~= retained_media_id then - local url = SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "photo") - if not url then - return nil, "invalid_attachment" - end - end + local media_ids = validate_article_media(source, data, retained_media_ids or {}) + if not media_ids then + return nil, "invalid_attachment" end return { title = title, body = body, excerpt = make_excerpt(body), category = data.category, - image_media_id = media_id, + image_media_id = media_ids[1], + image_media_ids = media_ids, status = status, } end +local function article_matches(article, expected, revision) + if not article or article.revision ~= revision + or article.title ~= expected.title + or article.body ~= expected.body + or article.excerpt ~= expected.excerpt + or article.category ~= expected.category + or article.status ~= expected.status + or #article.images ~= #expected.image_media_ids + then + return false + end + for index, media_id in ipairs(expected.image_media_ids) do + if article.images[index].mediaId ~= media_id then + return false + end + end + return true +end + Bridge.Callbacks.Register("sky_phone:weazel-news:context", function(source) local _, error_response = require_phone(source, "read", config.RateLimits.Read) if error_response then @@ -340,6 +434,7 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:context", function(source) jobLabel = access and access.job_label or nil, jobGradeLabel = access and access.grade_label or nil, categories = category_context, + maximumImages = config.MaximumImages, }, } end) @@ -466,24 +561,39 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:create", function(source, data) if not valid_uuid(id) then error("[sky_phone] Database did not generate a Weazel News article id.") end - Bridge.Database.Query([[ - INSERT INTO `sky_phone_weazel_articles` - (`id`, `title`, `body`, `excerpt`, `category`, `image_media_id`, `author_identifier`, - `author_name`, `updated_by_identifier`, `status`, `published_at`) - VALUES (?, ?, ?, ?, ?, NULLIF(?, 0), ?, ?, ?, ?, IF(? = 'published', CURRENT_TIMESTAMP, NULL)) - ]], { - id, - article.title, - article.body, - article.excerpt, - article.category, - article.image_media_id or 0, - actor.identifier, - actor.name, - actor.identifier, - article.status, - article.status, - }) + local statements = {{ + query = [[ + INSERT INTO `sky_phone_weazel_articles` + (`id`, `title`, `body`, `excerpt`, `category`, `image_media_id`, `author_identifier`, + `author_name`, `updated_by_identifier`, `status`, `published_at`) + VALUES (?, ?, ?, ?, ?, NULLIF(?, 0), ?, ?, ?, ?, IF(? = 'published', CURRENT_TIMESTAMP, NULL)) + ]], + params = { + id, + article.title, + article.body, + article.excerpt, + article.category, + article.image_media_id or 0, + actor.identifier, + actor.name, + actor.identifier, + article.status, + article.status, + }, + }} + for position, media_id in ipairs(article.image_media_ids) do + statements[#statements + 1] = { + query = [[ + INSERT INTO `sky_phone_weazel_article_media` (`article_id`, `media_id`, `position`) + VALUES (?, ?, ?) + ]], + params = { id, media_id, position }, + } + end + if not Bridge.Database.Transaction(statements) then + return { success = false, error = "request_failed" } + end local created = load_article(id, true) if not created then error(("[sky_phone] Could not reload created Weazel News article '%s'."):format(id)) @@ -517,8 +627,23 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:update", function(source, data) if tonumber(current.revision) ~= revision then return { success = false, error = "revision_conflict" } end - local retained_media_id = current.image_media_id and tonumber(current.image_media_id) or nil - local article, validation_error = validate_article(source, data, retained_media_id) + local retained_media_ids = {} + local retained_cover_id = current.image_media_id and tonumber(current.image_media_id) or nil + if retained_cover_id then + retained_media_ids[retained_cover_id] = true + end + local retained_rows = Bridge.Database.Query([[ + SELECT `media_id` + FROM `sky_phone_weazel_article_media` + WHERE `article_id` = ? + ]], { data.id }) + for _, row in ipairs(retained_rows) do + local media_id = tonumber(row.media_id) + if media_id then + retained_media_ids[media_id] = true + end + end + local article, validation_error = validate_article(source, data, retained_media_ids) if not article then return { success = false, error = validation_error } end @@ -526,34 +651,80 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:update", function(source, data) if not actor then return { success = false, error = "request_failed" } end - local result = Bridge.Database.Query([[ - UPDATE `sky_phone_weazel_articles` - SET `title` = ?, `body` = ?, `excerpt` = ?, `category` = ?, `image_media_id` = NULLIF(?, 0), - `published_at` = CASE - WHEN ? = 'draft' THEN NULL - WHEN `status` = 'draft' THEN CURRENT_TIMESTAMP - ELSE `published_at` - END, - `status` = ?, `updated_by_identifier` = ?, `revision` = `revision` + 1 - WHERE `id` = ? AND `revision` = ? AND `deleted_at` IS NULL - ]], { - article.title, - article.body, - article.excerpt, - article.category, - article.image_media_id or 0, - article.status, - article.status, - actor.identifier, - data.id, - revision, - }) - if affected_rows(result) ~= 1 then - return { success = false, error = "revision_conflict" } + local mutation_rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) + local mutation_id = mutation_rows[1] and mutation_rows[1].id + if not valid_uuid(mutation_id) then + error("[sky_phone] Database did not generate a Weazel News mutation id.") + end + local mutation_token = "weazel:" .. mutation_id + local next_revision = revision + 1 + local statements = { + { + query = [[ + UPDATE `sky_phone_weazel_articles` + SET `title` = ?, `body` = ?, `excerpt` = ?, `category` = ?, + `image_media_id` = NULLIF(?, 0), + `published_at` = CASE + WHEN ? = 'draft' THEN NULL + WHEN `status` = 'draft' THEN CURRENT_TIMESTAMP + ELSE `published_at` + END, + `status` = ?, `updated_by_identifier` = ?, `revision` = `revision` + 1 + WHERE `id` = ? AND `revision` = ? AND `deleted_at` IS NULL + ]], + params = { + article.title, + article.body, + article.excerpt, + article.category, + article.image_media_id or 0, + article.status, + article.status, + mutation_token, + data.id, + revision, + }, + }, + { + query = [[ + DELETE relation + FROM `sky_phone_weazel_article_media` relation + JOIN `sky_phone_weazel_articles` article ON article.`id` = relation.`article_id` + WHERE article.`id` = ? AND article.`revision` = ? + AND article.`updated_by_identifier` = ? + ]], + params = { data.id, next_revision, mutation_token }, + }, + } + for position, media_id in ipairs(article.image_media_ids) do + statements[#statements + 1] = { + query = [[ + INSERT INTO `sky_phone_weazel_article_media` (`article_id`, `media_id`, `position`) + SELECT article.`id`, ?, ? + FROM `sky_phone_weazel_articles` article + WHERE article.`id` = ? AND article.`revision` = ? + AND article.`updated_by_identifier` = ? AND article.`deleted_at` IS NULL + ]], + params = { media_id, position, data.id, next_revision, mutation_token }, + } + end + statements[#statements + 1] = { + query = [[ + UPDATE `sky_phone_weazel_articles` + SET `updated_by_identifier` = ? + WHERE `id` = ? AND `revision` = ? AND `updated_by_identifier` = ? + ]], + params = { actor.identifier, data.id, next_revision, mutation_token }, + } + if not Bridge.Database.Transaction(statements) then + return { success = false, error = "request_failed" } end local updated = load_article(data.id, true) if not updated then - error(("[sky_phone] Could not reload updated Weazel News article '%s'."):format(data.id)) + return { success = false, error = "not_found" } + end + if not article_matches(updated, article, next_revision) then + return { success = false, error = "revision_conflict" } end return { success = true, data = { article = updated } } end) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 96ca343..f15348a 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -1244,3 +1244,15 @@ CREATE TABLE IF NOT EXISTS `sky_phone_weazel_articles` ( KEY `idx_sky_phone_weazel_media` (`image_media_id`), FOREIGN KEY (`image_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_weazel_article_media` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `article_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `media_id` BIGINT UNSIGNED NOT NULL, + `position` TINYINT UNSIGNED NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_weazel_article_position` (`article_id`,`position`), + UNIQUE KEY `uniq_sky_phone_weazel_article_media` (`article_id`,`media_id`), + FOREIGN KEY (`article_id`) REFERENCES `sky_phone_weazel_articles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;