diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 40dfb89..fb991b8 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -594,6 +594,9 @@ const defaultLocales: LocaleTree = { search: 'Search Weazel News', clearSearch: 'Clear article search', articleImages: 'Article photos', + previousPhoto: 'Show the previous article photo', + nextPhoto: 'Show the next article photo', + showPhoto: 'Show article photo {current} of {total}', coverPreview: 'Article cover preview', removeCover: 'Remove the selected cover image', removePhoto: 'Remove this article photo', diff --git a/frontend/src/stores/weazel-news.test.ts b/frontend/src/stores/weazel-news.test.ts index 2f61a9b..5874b4b 100644 --- a/frontend/src/stores/weazel-news.test.ts +++ b/frontend/src/stores/weazel-news.test.ts @@ -115,6 +115,37 @@ describe('Weazel News store', () => { expect(store.publicHasMore).toBe(false) }) + it('normalizes the public response to newest first without changing images', async () => { + const older = { + ...article, + id: 'older-article', + publishedAt: (article.publishedAt ?? 0) - 1_000, + } + const newer = { + ...article, + id: 'newer-article', + images: [...article.images].reverse(), + publishedAt: (article.publishedAt ?? 0) + 1_000, + } + mockNuiCall.mockResolvedValueOnce({ + data: { hasMore: false, items: [older, newer] }, + success: true, + }) + const store = useWeazelNewsStore() + + expect(await store.loadPublic()).toBe(true) + expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:list', { + category: null, + offset: 0, + search: '', + }) + expect(store.publicItems.map((item) => item.id)).toEqual([ + 'newer-article', + 'older-article', + ]) + expect(store.publicItems[0]?.images).toEqual(newer.images) + }) + it('replaces stale search results with a successful empty response', async () => { mockNuiCall.mockResolvedValueOnce({ data: { hasMore: false, items: [] }, diff --git a/frontend/src/stores/weazel-news.ts b/frontend/src/stores/weazel-news.ts index 303a8d9..7b2dec3 100644 --- a/frontend/src/stores/weazel-news.ts +++ b/frontend/src/stores/weazel-news.ts @@ -38,6 +38,16 @@ function mergeArticles( return [...merged.values()] } +function sortNewestFirst( + items: WeazelNewsArticleSummary[], +): WeazelNewsArticleSummary[] { + return [...items].sort( + (left, right) => + (right.publishedAt ?? right.updatedAt ?? right.createdAt) - + (left.publishedAt ?? left.updatedAt ?? left.createdAt), + ) +} + export const useWeazelNewsStore = defineStore('weazel-news', { state: () => ({ context: null as WeazelNewsContext | null, @@ -106,9 +116,11 @@ export const useWeazelNewsStore = defineStore('weazel-news', { this.error = this.publicError return false } - this.publicItems = append - ? mergeArticles(this.publicItems, response.data.items) - : response.data.items + this.publicItems = sortNewestFirst( + append + ? mergeArticles(this.publicItems, response.data.items) + : response.data.items, + ) this.publicHasMore = response.data.hasMore this.publicError = '' this.error = '' @@ -202,10 +214,10 @@ export const useWeazelNewsStore = defineStore('weazel-news', { ...this.managedItems.filter((item) => item.id !== article.id), ] if (article.status === 'published') { - this.publicItems = [ + this.publicItems = sortNewestFirst([ article, ...this.publicItems.filter((item) => item.id !== article.id), - ] + ]) } this.error = '' return article @@ -240,13 +252,10 @@ export const useWeazelNewsStore = defineStore('weazel-news', { this.managedItems = replaceArticle(this.managedItems, canonical) this.publicItems = canonical.status === 'published' - ? [ + ? sortNewestFirst([ canonical, ...this.publicItems.filter((item) => item.id !== canonical.id), - ].sort( - (left, right) => - (right.publishedAt ?? 0) - (left.publishedAt ?? 0), - ) + ]) : this.publicItems.filter((item) => item.id !== canonical.id) this.error = '' return canonical diff --git a/frontend/src/views/apps/weazel-news-app.contract.test.ts b/frontend/src/views/apps/weazel-news-app.contract.test.ts index cf3cd75..592f2d8 100644 --- a/frontend/src/views/apps/weazel-news-app.contract.test.ts +++ b/frontend/src/views/apps/weazel-news-app.contract.test.ts @@ -132,10 +132,36 @@ describe('Weazel News article detail contract', () => { /@click\s*=\s*["']\s*editArticle\s*\(\s*selectedArticle\s*\)\s*["']/, ) }) + + it('provides explicit controls and touch gestures for multiple article images', () => { + const paginationButtonRule = + source.match(/\.weazel-detail-pagination button\s*\{[^}]*\}/s)?.[0] ?? '' + + expect(detailSource).toContain(':key="activeDetailImage.id"') + expect(detailSource).toContain('role="group"') + expect(detailSource).toContain('@click="showPreviousDetailImage"') + expect(detailSource).toContain('@click="showNextDetailImage"') + expect(detailSource).toContain('@click="selectDetailImage(index)"') + expect(detailSource).toContain( + '@keydown.left.prevent="showPreviousDetailImage"', + ) + expect(detailSource).toContain( + '@keydown.right.prevent="showNextDetailImage"', + ) + expect(detailSource).toContain( + '@touchstart.passive="beginDetailImageSwipe"', + ) + expect(detailSource).toContain('@touchend.passive="finishDetailImageSwipe"') + expect(detailSource).toContain("t('accessibility.previousPhoto')") + expect(detailSource).toContain("t('accessibility.nextPhoto')") + expect(paginationButtonRule).toContain('width: var(--sky-touch-target)') + expect(paginationButtonRule).toContain('height: var(--sky-touch-target)') + }) }) describe('Weazel News empty search contract', () => { it('uses the shared compact empty state when no article matches', () => { + const clearButtonRule = styleRule('.weazel-search-clear') const emptyStateTag = searchSource.match( /<(?:SkyEmptyState|sky-empty-state)\b[^>]*>/is, )?.[0] @@ -143,24 +169,64 @@ describe('Weazel News empty search contract', () => { expect(searchSource).not.toBe('') expect(emptyStateTag).toBeDefined() expect(emptyStateTag).toMatch(/\bcompact\b/i) - expect(emptyStateTag).toMatch(/!\s*news\.publicItems\.length/) + expect(emptyStateTag).toMatch( + /searchSubmitted\s*&&\s*!\s*news\.publicItems\.length/, + ) expect(emptyStateTag).toContain("t('search.emptyTitle')") expect(emptyStateTag).toContain("t('search.emptyBody')") + expect(clearButtonRule).toContain('width: var(--sky-touch-target)') + expect(clearButtonRule).toContain('height: var(--sky-touch-target)') + }) + + it('loads and lists the latest articles before a query is submitted', () => { + expect(source).toContain("await news.loadPublic({ search: '' })") + expect(source).toContain("if (tab !== 'search') cancelQueuedSearch()") + expect(source).toContain("if (activeTab.value !== 'search') return") + expect(searchSource).toContain('
') + expect(searchSource).toMatch( + /v-for\s*=\s*["']article\s+in\s+news\.publicItems["']/, + ) + expect(searchSource).not.toContain( + 'v-else-if="searchSubmitted" class="weazel-card-list"', + ) }) }) 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), + it('uses checked central SkyDropdown menus for category and status', () => { + const categoryTrigger = openingTags( + 'SkyListItem', + 'sky-list-item', + composerSource, + ).find((tag) => /weazel-news-category-trigger/.test(tag)) + const statusTrigger = openingTags( + 'SkyListItem', + 'sky-list-item', + composerSource, + ).find((tag) => /weazel-news-status-trigger/.test(tag)) + const dropdowns = openingTags('SkyDropdown', 'sky-dropdown', composerSource) + const categoryDropdown = dropdowns.find((tag) => + /categoryDropdownItems/.test(tag), + ) + const statusDropdown = dropdowns.find((tag) => + /statusDropdownItems/.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'") + expect(categoryTrigger).toBeDefined() + expect(categoryTrigger).toContain("'aria-expanded': categoryDropdownOpened") + expect(categoryTrigger).toContain("'aria-haspopup': 'menu'") + expect(statusTrigger).toBeDefined() + expect(statusTrigger).toContain("'aria-expanded': statusDropdownOpened") + expect(statusTrigger).toContain("'aria-haspopup': 'menu'") + expect(categoryDropdown).toBeDefined() + expect(categoryDropdown).toContain(':opened="categoryDropdownOpened"') + expect(statusDropdown).toBeDefined() + expect(statusDropdown).toContain(':opened="statusDropdownOpened"') + expect(source).toContain('checked: draft.value.category === category') + expect(source).toContain("checked: draft.value.status === 'draft'") + expect(composerSource).not.toMatch( + /<(?:SkyField|sky-field)\b[^>]*\btype\s*=\s*["']select["']/i, + ) }) it('keeps Photos and Camera directly visible in the composer', () => { diff --git a/frontend/src/views/apps/weazel-news-app.vue b/frontend/src/views/apps/weazel-news-app.vue index dac85bc..a60ff8f 100644 --- a/frontend/src/views/apps/weazel-news-app.vue +++ b/frontend/src/views/apps/weazel-news-app.vue @@ -5,6 +5,7 @@ import { SkyCard, SkyDialog, SkyDialogButton, + SkyDropdown, SkyEmptyState, SkyField, SkyIcon, @@ -25,6 +26,8 @@ import { Building2, Camera, CalendarDays, + ChevronDown, + ChevronLeft, ChevronRight, FileText, House, @@ -57,7 +60,6 @@ import { WEAZEL_NEWS_CATEGORY_IDS } from '@/types/weazel-news' type MainTab = 'home' | 'categories' | 'search' | 'editorial' type Screen = 'main' | 'detail' | 'composer' -type ComposerOption = { label: string; value: string } type SelectedImage = { id: number; url: string } type ComposerContext = { article: WeazelNewsArticle | null @@ -83,6 +85,11 @@ const searchPending = ref(false) const editorialStatus = ref('all') const editingArticle = ref(null) const selectedImages = ref([]) +const detailImageIndex = ref(0) +const categoryDropdownOpened = ref(false) +const categoryDropdownTarget = ref(null) +const statusDropdownOpened = ref(false) +const statusDropdownTarget = ref(null) const deleteTarget = ref(null) const deleteDialogOpened = ref(false) const toastOpened = ref(false) @@ -119,15 +126,24 @@ const activeTabIndex = computed(() => activeTab.value, ), ) -const categoryOptions = computed(() => +const categoryDropdownItems = computed(() => WEAZEL_NEWS_CATEGORY_IDS.map((category) => ({ + checked: draft.value.category === category, + id: category, label: categoryLabel(category), - value: category, })), ) -const statusOptions = computed(() => [ - { label: t('composer.statusDraft'), value: 'draft' }, - { label: t('composer.statusPublished'), value: 'published' }, +const statusDropdownItems = computed(() => [ + { + checked: draft.value.status === 'draft', + id: 'draft', + label: t('composer.statusDraft'), + }, + { + checked: draft.value.status === 'published', + id: 'published', + label: t('composer.statusPublished'), + }, ]) const detailImages = computed(() => { const article = selectedArticle.value @@ -142,6 +158,10 @@ const detailImages = computed(() => { ? [{ id: article.imageMediaId, url: article.imageUrl }] : [] }) +const activeDetailImage = computed( + () => + detailImages.value[detailImageIndex.value] ?? detailImages.value[0] ?? null, +) const knownErrors = new Set([ 'feature_disabled', 'invalid_article', @@ -177,7 +197,7 @@ const currentSurfaceLoading = computed(() => { } if (activeTab.value === 'categories') return news.contextLoading if (activeTab.value === 'search') { - return searchSubmitted.value && (searchPending.value || news.publicLoading) + return searchPending.value || news.publicLoading } return news.publicLoading }) @@ -236,14 +256,26 @@ async function loadHome(): Promise { await news.loadPublic({ category: selectedCategory.value }) } +function cancelQueuedSearch(): void { + if (searchTimer !== undefined) { + window.clearTimeout(searchTimer) + searchTimer = undefined + } + searchPending.value = false +} + async function selectTab(tab: MainTab): Promise { + if (tab !== 'search') cancelQueuedSearch() activeTab.value = tab screen.value = 'main' news.error = '' if (tab === 'home') await loadHome() if (tab === 'categories') await news.loadContext() - if (tab === 'search' && searchSubmitted.value) await submitSearch() + if (tab === 'search') { + if (searchSubmitted.value) await submitSearch() + else await loadLatestSearch() + } if (tab === 'editorial') await loadEditorial() } @@ -268,7 +300,7 @@ async function submitSearch(): Promise { } submittedSearchQuery.value = searchQuery.value.trim() if (!submittedSearchQuery.value) { - clearSearch() + await clearSearch() return } searchSubmitted.value = true @@ -280,7 +312,7 @@ function queueSearch(event: Event): void { searchQuery.value = eventValue(event) if (searchTimer !== undefined) window.clearTimeout(searchTimer) if (!searchQuery.value.trim()) { - clearSearch() + void clearSearch() return } submittedSearchQuery.value = searchQuery.value.trim() @@ -288,6 +320,7 @@ function queueSearch(event: Event): void { searchPending.value = true searchTimer = window.setTimeout(() => { searchTimer = undefined + if (activeTab.value !== 'search') return void submitSearch() }, 250) } @@ -300,7 +333,16 @@ async function loadMoreSearch(): Promise { }) } -function clearSearch(): void { +async function loadLatestSearch(): Promise { + await news.loadPublic({ search: '' }) +} + +async function retrySearch(): Promise { + if (searchSubmitted.value) await submitSearch() + else await loadLatestSearch() +} + +async function clearSearch(): Promise { if (searchTimer !== undefined) { window.clearTimeout(searchTimer) searchTimer = undefined @@ -311,6 +353,7 @@ function clearSearch(): void { searchPending.value = false news.publicError = '' news.error = '' + await loadLatestSearch() } async function openArticle( @@ -321,12 +364,14 @@ async function openArticle( showToast(errorKey(news.detailError)) return } + detailImageIndex.value = 0 detailManaged.value = managed screen.value = 'detail' } function closeDetail(): void { screen.value = 'main' + detailImageIndex.value = 0 news.selected = null if (activeTab.value === 'editorial' && news.context?.canManage) { void news.loadManaged(editorialStatus.value) @@ -337,6 +382,7 @@ function createArticle(): void { if (!news.context?.canManage) return editingArticle.value = null selectedImages.value = [] + closeComposerDropdowns() draft.value = emptyDraft() screen.value = 'composer' } @@ -344,6 +390,7 @@ function createArticle(): void { function editArticle(article: WeazelNewsArticle): void { if (!news.context?.canManage) return editingArticle.value = article + closeComposerDropdowns() selectedImages.value = article.images?.length ? article.images.map((image) => ({ id: image.mediaId, @@ -363,9 +410,29 @@ function editArticle(article: WeazelNewsArticle): void { } function closeComposer(): void { + closeComposerDropdowns() screen.value = editingArticle.value ? 'detail' : 'main' } +function closeComposerDropdowns(): void { + categoryDropdownOpened.value = false + statusDropdownOpened.value = false +} + +function toggleCategoryDropdown(event: MouseEvent): void { + if (!(event.currentTarget instanceof HTMLElement)) return + categoryDropdownTarget.value = event.currentTarget + statusDropdownOpened.value = false + categoryDropdownOpened.value = !categoryDropdownOpened.value +} + +function toggleStatusDropdown(event: MouseEvent): void { + if (!(event.currentTarget instanceof HTMLElement)) return + statusDropdownTarget.value = event.currentTarget + categoryDropdownOpened.value = false + statusDropdownOpened.value = !statusDropdownOpened.value +} + function updateCategory(value: string): void { if (WEAZEL_NEWS_CATEGORY_IDS.includes(value as WeazelNewsCategoryId)) { draft.value.category = value as WeazelNewsCategoryId @@ -378,6 +445,53 @@ function updateStatus(value: string): void { } } +function selectCategoryDropdownItem(id: string): void { + updateCategory(id) + closeComposerDropdowns() +} + +function selectStatusDropdownItem(id: string): void { + updateStatus(id) + closeComposerDropdowns() +} + +function selectDetailImage(index: number): void { + if (index < 0 || index >= detailImages.value.length) return + detailImageIndex.value = index +} + +function showPreviousDetailImage(): void { + const count = detailImages.value.length + if (count < 2) return + detailImageIndex.value = (detailImageIndex.value - 1 + count) % count +} + +function showNextDetailImage(): void { + const count = detailImages.value.length + if (count < 2) return + detailImageIndex.value = (detailImageIndex.value + 1) % count +} + +let detailSwipeStartX: number | null = null + +function beginDetailImageSwipe(event: TouchEvent): void { + detailSwipeStartX = event.changedTouches.item(0)?.clientX ?? null +} + +function finishDetailImageSwipe(event: TouchEvent): void { + const endX = event.changedTouches.item(0)?.clientX + if (detailSwipeStartX === null || endX === undefined) return + const distance = endX - detailSwipeStartX + detailSwipeStartX = null + if (Math.abs(distance) < 44) return + if (distance < 0) showNextDetailImage() + else showPreviousDetailImage() +} + +function cancelDetailImageSwipe(): void { + detailSwipeStartX = null +} + function syncImageMediaIds(): void { draft.value.imageMediaIds = selectedImages.value.map((image) => image.id) } @@ -462,6 +576,7 @@ async function saveArticle( editingArticle.value = article news.selected = article + detailImageIndex.value = 0 detailManaged.value = true screen.value = 'detail' showToast(wasEditing ? 'feedback.updated' : 'feedback.created') @@ -541,6 +656,8 @@ async function initialize(): Promise { onMounted(() => void initialize()) onBeforeUnmount(() => { + categoryDropdownTarget.value = null + statusDropdownTarget.value = null if (toastTimer !== undefined) window.clearTimeout(toastTimer) if (searchTimer !== undefined) window.clearTimeout(searchTimer) }) @@ -769,7 +886,7 @@ onBeforeUnmount(() => { {{ t('states.errorTitle') }} {{ contextualError }} - {{ + {{ t('retry') }}
@@ -781,7 +898,15 @@ onBeforeUnmount(() => { > -
+
+ + {{ t('states.emptyTitle') }} + {{ t('states.emptyBody') }} +
+
{
{
@@ -1210,23 +1385,55 @@ onBeforeUnmount(() => { :value="draft.body" @input="draft.body = eventValue($event)" /> - - + + + + link + link-component="button" + :chevron="false" + :title="t('composer.status')" + :aria-label=" + t('accessibility.status', { + status: statusLabel(draft.status), + }) + " + :link-props="{ + id: 'weazel-news-status-trigger', + 'aria-controls': 'weazel-news-status-dropdown', + 'aria-expanded': statusDropdownOpened, + 'aria-haspopup': 'menu', + type: 'button', + }" + @click="toggleStatusDropdown" + > + +
@@ -1262,6 +1469,35 @@ onBeforeUnmount(() => { + + + { position: absolute; z-index: 2; top: 50%; - right: 14px; - width: 30px; - height: 30px; + right: 7px; + width: var(--sky-touch-target); + min-width: var(--sky-touch-target); + height: var(--sky-touch-target); + min-height: var(--sky-touch-target); display: grid; place-items: center; transform: translateY(-50%); border: 0; - border-radius: 999px; - background: var(--weazel-line); + padding: 0; + background: transparent; color: var(--weazel-muted); } +.weazel-search-clear::before { + position: absolute; + width: 30px; + height: 30px; + border-radius: var(--sky-radius-pill); + background: var(--weazel-line); + content: ''; +} + +.weazel-search-clear:focus-visible { + outline: 2px solid var(--sky-app-accent, #007aff); + outline-offset: -2px; +} + +.weazel-search-clear > svg { + position: relative; +} + .weazel-access-content { display: grid; grid-template-columns: auto minmax(0, 1fr); @@ -1831,15 +2087,14 @@ onBeforeUnmount(() => { position: relative; width: 100%; height: 240px; - display: flex; - overflow-x: auto; - overflow-y: hidden; - scroll-snap-type: x mandatory; - scrollbar-width: none; + overflow: hidden; + touch-action: pan-y; } -.weazel-detail-gallery::-webkit-scrollbar { - display: none; +.weazel-detail-gallery:focus-visible, +.weazel-detail-pagination button:focus-visible { + outline: 2px solid var(--sky-app-accent, #007aff); + outline-offset: -2px; } .weazel-detail-cover { @@ -1847,9 +2102,62 @@ onBeforeUnmount(() => { max-width: none; height: 240px; display: block; - flex: 0 0 100%; object-fit: cover; - scroll-snap-align: start; +} + +.weazel-detail-gallery-control { + position: absolute; + z-index: 2; + top: 50%; + width: var(--sky-touch-target) !important; + height: var(--sky-touch-target) !important; + min-width: var(--sky-touch-target) !important; + min-height: var(--sky-touch-target) !important; + transform: translateY(-50%); + background: rgb(0 0 0 / 58%) !important; + color: #fff !important; +} + +.weazel-detail-gallery-control.is-previous { + left: var(--sky-space-2); +} + +.weazel-detail-gallery-control.is-next { + right: var(--sky-space-2); +} + +.weazel-detail-pagination { + position: absolute; + z-index: 2; + right: 50%; + bottom: 0; + display: flex; + transform: translateX(50%); +} + +.weazel-detail-pagination button { + width: var(--sky-touch-target); + min-width: var(--sky-touch-target); + height: var(--sky-touch-target); + min-height: var(--sky-touch-target); + display: grid; + place-items: center; + border: 0; + padding: 0; + background: transparent; +} + +.weazel-detail-pagination span { + width: 6px; + height: 6px; + border-radius: var(--sky-radius-pill); + background: rgb(255 255 255 / 55%); + box-shadow: 0 1px 3px rgb(0 0 0 / 45%); +} + +.weazel-detail-pagination button.is-active span { + width: 16px; + background: #fff; } .weazel-detail-masthead { @@ -1859,8 +2167,9 @@ onBeforeUnmount(() => { .weazel-detail-count { position: absolute; + z-index: 2; right: var(--sky-page-gutter); - bottom: var(--sky-space-3); + top: var(--sky-space-3); padding: 4px 9px; display: inline-flex; align-items: center; @@ -2099,6 +2408,13 @@ onBeforeUnmount(() => { resize: vertical; } +.weazel-composer-select-value { + display: inline-flex; + align-items: center; + gap: var(--sky-space-1); + color: var(--weazel-muted); +} + .weazel-photo-source-actions { padding: 0 var(--sky-space-3) var(--sky-space-3); display: grid; diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 2c25548..23bdfc9 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -344,6 +344,9 @@ Locales["en"] = { search = "Search Weazel News", clearSearch = "Clear article search", articleImages = "Article photos", + previousPhoto = "Show the previous article photo", + nextPhoto = "Show the next article photo", + showPhoto = "Show article photo {current} of {total}", coverPreview = "Article cover preview", removeCover = "Remove the selected cover image", removePhoto = "Remove this article photo",