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') }}
+
+
{
{
![]()
+
+
+
+
+
+
+
+
+
- {{ detailImages.length }}
+ {{ detailImageIndex + 1 }} /
+ {{ detailImages.length }}