ENH - overhaul Photos media experience

This commit is contained in:
smx.pusha
2026-08-14 10:46:36 +02:00
parent 6cfdd44551
commit 3fc0ebb08f
18 changed files with 1651 additions and 267 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+1
View File
@@ -6,6 +6,7 @@ import type { PhoneMedia } from '@/types/media'
const photo: PhoneMedia = {
createdAt: 1_786_035_600,
favorite: false,
id: 17,
mediaType: 'photo',
url: 'https://media.example/photo.jpg',
+62 -26
View File
@@ -362,6 +362,7 @@ const defaultLocales: LocaleTree = {
document: 'Document',
link: 'Link',
location: 'Location',
media: 'Media',
note: 'Note',
photo: 'Photo',
playlist: 'Playlist',
@@ -489,7 +490,7 @@ const defaultLocales: LocaleTree = {
category: 'Category',
status: 'Publication Status',
cover: 'Cover Image',
chooseCover: 'Choose from Gallery',
chooseCover: 'Choose from Photos',
changeCover: 'Change Cover',
removeCover: 'Remove Cover',
coverAlt: 'Selected article cover',
@@ -561,7 +562,7 @@ const defaultLocales: LocaleTree = {
login: 'Log in',
register: 'Register',
ifruitEmail: 'iFruit address',
gallery: 'Gallery',
gallery: 'Photos',
camera: 'Camera',
backToLogin: 'Back to CrewLink login',
authErrors: {
@@ -783,9 +784,9 @@ const defaultLocales: LocaleTree = {
photo: 'Profile photo',
profilePhotos: 'Profile photos',
profilePhotosBody:
'Choose up to six photos from Gallery. Your first photo is shown first.',
'Choose up to six photos from Photos. Your first photo is shown first.',
addPhotos: 'Add photos',
choosePhotos: 'Choose from Gallery',
choosePhotos: 'Choose from Photos',
primaryPhoto: 'Main',
removePhoto: 'Remove photo {number}',
yourName: 'Your name',
@@ -904,7 +905,7 @@ const defaultLocales: LocaleTree = {
errors: {
invalid_profile: 'Check your name, age and profile text.',
invalid_profile_photos:
'Choose up to six photos from your own Gallery.',
'Choose up to six photos from your own Photos library.',
request_failed: 'Flare could not save those changes. Try again.',
invalid_target: 'This profile is no longer available.',
invalid_choice: 'That swipe could not be saved.',
@@ -985,8 +986,8 @@ const defaultLocales: LocaleTree = {
story: 'Story',
choosePhotos: 'Choose photos',
choosePhoto: 'Choose photo',
choosePhotosHint: 'Select up to five photos from Gallery',
chooseStoryHint: 'Select one photo from Gallery',
choosePhotosHint: 'Select up to five photos from Photos',
chooseStoryHint: 'Select one photo from Photos',
selectedPhotos: '{count} selected',
changePhotos: 'Change selection',
caption: 'Caption',
@@ -1231,7 +1232,7 @@ const defaultLocales: LocaleTree = {
composerAudience: 'Everyone can join the conversation',
mediaTitle: 'Add to your feather',
mediaBody: 'Share up to four photos from your phone or take a new one.',
chooseGallery: 'Gallery',
chooseGallery: 'Photos',
chooseGalleryBody: 'Choose one or more saved photos',
takePhoto: 'Camera',
takePhotoBody: 'Take a new photo now',
@@ -1359,7 +1360,7 @@ const defaultLocales: LocaleTree = {
createBody:
'Choose a clip, set the cover and sound, then decide who can watch it.',
chooseVideo: 'Choose a video',
chooseVideoHint: 'Select one from Gallery',
chooseVideoHint: 'Select one from Photos',
changeVideo: 'Change',
caption: 'Caption',
captionPlaceholder: 'Write a caption...',
@@ -1435,7 +1436,7 @@ const defaultLocales: LocaleTree = {
accountType: 'Account type',
profilePhoto: 'Profile photo',
changePhoto: 'Change photo',
chooseFromGallery: 'Gallery',
chooseFromGallery: 'Photos',
takePhoto: 'Camera',
removePhoto: 'Remove photo',
noConnections: 'No profiles to show',
@@ -1797,7 +1798,7 @@ const defaultLocales: LocaleTree = {
phoneNumber: 'Phone Number',
officialContact: 'Official company contact',
choosePhoto: 'Choose Contact Photo',
chooseGallery: 'Gallery',
chooseGallery: 'Photos',
takePhoto: 'Camera',
removePhoto: 'Remove Photo',
call: 'Call',
@@ -2348,7 +2349,7 @@ const defaultLocales: LocaleTree = {
editProfile: 'Edit Profile',
editProfileBody: 'Change your public name and profile photo',
profilePhoto: 'Profile photo',
gallery: 'Gallery',
gallery: 'Photos',
camera: 'Camera',
profileDetails: 'Profile details',
profileName: 'Name',
@@ -2854,13 +2855,13 @@ const defaultLocales: LocaleTree = {
publish: 'Publish',
addPhotos: 'Add photos',
addPhotosBody:
'Choose up to six photos from your gallery or take new ones. Photos are optional and the first becomes the cover.',
chooseGallery: 'Choose from gallery',
'Choose up to six photos from Photos or take new ones. Photos are optional and the first becomes the cover.',
chooseGallery: 'Choose from Photos',
chooseGalleryBody: 'Use photos saved on this phone.',
takePhotos: 'Take photos',
takePhotosBody: 'Take several new photos for this listing.',
selectedPhotos: 'Selected photos',
gallery: 'Gallery',
gallery: 'Photos',
camera: 'Camera',
takePhoto: 'Take photo',
cameraHint:
@@ -3006,7 +3007,7 @@ const defaultLocales: LocaleTree = {
profileBio: 'Bio',
profileBioPlaceholder: 'Tell the city a little about yourself...',
profilePhoto: 'Profile photo',
profilePhotoHint: 'Choose a photo from your Gallery or take a new one.',
profilePhotoHint: 'Choose a photo from Photos or take a new one.',
removeProfilePhoto: 'Remove photo',
profileSave: 'Save profile',
profileCancel: 'Cancel',
@@ -3031,7 +3032,7 @@ const defaultLocales: LocaleTree = {
photos: 'Photos',
optional: 'optional',
camera: 'Camera',
gallery: 'Gallery',
gallery: 'Photos',
photoLimit: 'You can add up to six photos.',
cityMarktShare: 'Share to Local Pages',
cityMarktShared: 'Shared to Local Pages.',
@@ -3176,11 +3177,11 @@ const defaultLocales: LocaleTree = {
returnHelp: 'Space to return',
uploading: '{count} uploading',
saving: 'Saving video...',
openGallery: 'Open Gallery',
openGallery: 'Open Photos',
takePhoto: 'Take photo',
startRecording: 'Start recording',
stopRecording: 'Stop recording',
saved: 'Saved to Gallery.',
saved: 'Saved to Photos.',
zoom: 'Set camera zoom to {zoom}',
errors: {
cancelled: 'Capture cancelled.',
@@ -3607,15 +3608,50 @@ const defaultLocales: LocaleTree = {
},
},
photos: {
name: 'Gallery',
count: '{count} items',
loading: 'Loading Gallery...',
name: 'Photos',
library: 'Library',
counts: {
all: '{count} Objects',
allOne: '{count} Object',
favorite: '{count} Favorite',
favorites: '{count} Favorites',
photo: '{count} Photo',
photos: '{count} Photos',
video: '{count} Video',
videos: '{count} Videos',
},
sorting: {
action: 'Sort Photos',
title: 'Sort Order',
show: 'Show',
allItems: 'All Items',
favorites: 'Favorites',
newestFirst: 'Newest First',
oldestFirst: 'Oldest First',
},
selection: {
action: 'Select',
selected: '{count} Selected',
share: 'Share Selected',
shareTitle: '{count} Media Items',
shareCopy: '{count} photos and videos shared from Photos.',
delete: 'Delete Selected',
deleteTitle: 'Delete Selected Media?',
deleteBody: 'The selected photos and videos will be permanently deleted.',
deleted: '{count} media items deleted.',
limit: 'You can select up to 50 media items.',
},
loading: 'Loading Photos...',
emptyTitle: 'No Photos or Videos',
emptyBody: 'Captures from Camera will appear here.',
photo: 'Photo',
video: 'Video',
photoAlt: 'Gallery photo',
videoAlt: 'Gallery video',
photoAlt: 'Photo library image',
videoAlt: 'Photo library video',
today: 'Today',
yesterday: 'Yesterday',
addFavorite: 'Add to Favorites',
removeFavorite: 'Remove from Favorites',
delete: 'Delete media',
deleteTitle: 'Delete Media?',
deleteBody: 'This photo or video will be permanently deleted.',
@@ -3650,7 +3686,7 @@ const defaultLocales: LocaleTree = {
invalid_media_type: 'The media type is invalid.',
invalid_upload: 'The upload could not be verified.',
invalid_upload_token: 'The upload session is no longer valid.',
missing_config: 'Gallery uploads are not configured.',
missing_config: 'Photos uploads are not configured.',
import_media_not_allowed: 'This media item is not allowed.',
import_media_too_large: 'This media item is too large.',
import_media_unavailable: 'This media item is no longer available.',
@@ -3667,7 +3703,7 @@ const defaultLocales: LocaleTree = {
'Another media operation is already in progress.',
owner_changed: 'The active phone account changed.',
rate_limited: 'Too many media actions. Try again shortly.',
request_failed: 'The Gallery request failed.',
request_failed: 'The Photos request failed.',
request_timeout: 'The media service timed out.',
unsupported: 'This media format is not supported.',
upload_failed: 'The media upload failed.',
+1
View File
@@ -5,6 +5,7 @@ export type EasyShareKind =
| 'document'
| 'link'
| 'location'
| 'media'
| 'note'
| 'photo'
| 'playlist'
+25
View File
@@ -3,11 +3,29 @@ export type GalleryFilter = 'all' | MediaType
export type PhoneMedia = {
createdAt: number
favorite: boolean
id: number
mediaType: MediaType
thumbnailUrl?: string
url: string
}
export type FavoriteResult = {
favorite: boolean
id: number
}
export type GalleryCounts = {
all: number
favoritePhotos: number
favorites: number
favoriteVideos: number
photos: number
videos: number
}
export type GallerySortOrder = 'newest' | 'oldest'
export type MediaImportSource = {
id: string
label: string
@@ -75,3 +93,10 @@ export type DeleteResult = {
id?: number
success: boolean
}
export type DeleteManyResult = {
correlationId: string
deletedIds?: number[]
error?: string
success: boolean
}
+29 -4
View File
@@ -1,17 +1,20 @@
import { describe, expect, it } from 'vitest'
import {
bottomRightGridPosition,
filterMedia,
formatMediaSize,
formatRecordingDuration,
hasNextMediaPage,
mediaErrorKey,
mergeMedia,
orderMedia,
orderMediaOldestFirst,
} from './media'
const media = [
{ createdAt: 10, id: 1, mediaType: 'photo' as const, url: 'photo' },
{ createdAt: 20, id: 2, mediaType: 'video' as const, url: 'video' },
{ createdAt: 10, favorite: false, id: 1, mediaType: 'photo' as const, url: 'photo' },
{ createdAt: 20, favorite: false, id: 2, mediaType: 'video' as const, url: 'video' },
]
describe('media utilities', () => {
@@ -24,8 +27,8 @@ describe('media utilities', () => {
it('merges pages without duplicates and keeps newest first', () => {
expect(
mergeMedia(media, [
{ createdAt: 30, id: 1, mediaType: 'photo', url: 'updated' },
{ createdAt: 25, id: 3, mediaType: 'photo', url: 'new' },
{ createdAt: 30, favorite: true, id: 1, mediaType: 'photo', url: 'updated' },
{ createdAt: 25, favorite: false, id: 3, mediaType: 'photo', url: 'new' },
]).map((entry) => [entry.id, entry.url]),
).toEqual([
[1, 'updated'],
@@ -34,6 +37,28 @@ describe('media utilities', () => {
])
})
it('orders the photo grid from oldest to newest', () => {
expect(orderMediaOldestFirst(media).map((entry) => entry.id)).toEqual([
1, 2,
])
})
it('orders the photo grid in the selected direction', () => {
expect(orderMedia(media, 'oldest').map((entry) => entry.id)).toEqual([1, 2])
expect(orderMedia(media, 'newest').map((entry) => entry.id)).toEqual([2, 1])
})
it('fills the gallery from bottom-right to top-left', () => {
expect(bottomRightGridPosition(0, 1)).toEqual({ column: 3, row: 1 })
expect(bottomRightGridPosition(0, 3)).toEqual({ column: 3, row: 1 })
expect(bottomRightGridPosition(1, 3)).toEqual({ column: 2, row: 1 })
expect(bottomRightGridPosition(2, 3)).toEqual({ column: 1, row: 1 })
expect(bottomRightGridPosition(0, 4)).toEqual({ column: 3, row: 2 })
expect(bottomRightGridPosition(3, 4)).toEqual({ column: 3, row: 1 })
expect(bottomRightGridPosition(4, 5)).toEqual({ column: 2, row: 1 })
expect(bottomRightGridPosition(10, 11)).toEqual({ column: 2, row: 1 })
})
it('loads another gallery page only after a full 30-item batch', () => {
expect(hasNextMediaPage(30)).toBe(true)
expect(hasNextMediaPage(29)).toBe(false)
+37 -1
View File
@@ -1,4 +1,9 @@
import type { GalleryFilter, MediaType, PhoneMedia } from '@/types/media'
import type {
GalleryFilter,
GallerySortOrder,
MediaType,
PhoneMedia,
} from '@/types/media'
export const MEDIA_PAGE_SIZE = 30
@@ -26,6 +31,37 @@ export function mergeMedia(
)
}
export function orderMediaOldestFirst(media: PhoneMedia[]): PhoneMedia[] {
return [...media].sort(
(left, right) => left.createdAt - right.createdAt || left.id - right.id,
)
}
export function orderMedia(
media: PhoneMedia[],
sortOrder: GallerySortOrder,
): PhoneMedia[] {
return sortOrder === 'oldest'
? orderMediaOldestFirst(media)
: [...media].sort(
(left, right) =>
right.createdAt - left.createdAt || right.id - left.id,
)
}
export function bottomRightGridPosition(
itemIndex: number,
itemCount: number,
columnCount = 3,
): { column: number; row: number } {
const count = Math.max(1, itemCount)
const index = Math.max(0, Math.min(itemIndex, count - 1))
return {
column: columnCount - (index % columnCount),
row: Math.ceil(count / columnCount) - Math.floor(index / columnCount),
}
}
export function hasNextMediaPage(pageLength: number): boolean {
return pageLength === MEDIA_PAGE_SIZE
}
@@ -0,0 +1,89 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./GalleryApp.vue', import.meta.url), 'utf8')
const headerActions = source.slice(
source.indexOf('<template #right>'),
source.indexOf('</template>', source.indexOf('<template #right>')),
)
describe('GalleryApp import action', () => {
it('uses the photo viewer toolbar button design', () => {
expect(headerActions).toContain('<SkyToolbarPane')
expect(headerActions).toContain('gallery-header-tool--icon')
expect(headerActions).toContain('<SkyButton')
expect(headerActions).toContain('clear')
expect(headerActions).toContain(
'<Download :size="21" aria-hidden="true" />',
)
expect(headerActions).toContain(
':aria-label="phone.t(\'Apps.photos.import.action\')"',
)
expect(headerActions).toContain(
':title="phone.t(\'Apps.photos.import.action\')"',
)
expect(headerActions).not.toContain(
"{{ phone.t('Apps.photos.import.action') }}",
)
expect(headerActions).not.toContain('tonal')
})
it('renders an iPhone-style large library header with filtered counts', () => {
expect(source).toContain('<SkyNavbar')
expect(source).toContain('variant="large"')
expect(source).toContain(':title="phone.t(\'Apps.photos.library\')"')
expect(source).toContain(':subtitle="countText"')
expect(source).toContain("nuiCall<GalleryCounts>('gallery:counts')")
expect(source).toContain("`Apps.photos.counts.${translationKey}`")
expect(source).toContain('gridColumnStart: bottomRightGridPosition(')
expect(source).toContain('gridRowStart: bottomRightGridPosition(')
expect(source).toContain('var(--sky-navbar-large-title-height) - 30px')
})
it('opens an accessible sort menu from the large header', () => {
expect(headerActions).toContain('<ListFilter')
expect(headerActions).toContain('sortMenuOpened = true')
expect(source).toContain('<SkyActionSheet')
expect(source).toContain("selectSortOrder('newest')")
expect(source).toContain("selectSortOrder('oldest')")
expect(source).toContain('orderMedia(media.value, sortOrder.value)')
})
it('supports selecting, sharing, and deleting multiple media items', () => {
expect(headerActions).toContain("phone.t('Apps.photos.selection.action')")
expect(headerActions).toContain('enterSelectionMode')
expect(source).toContain('v-if="selectionMode"')
expect(source).toContain('selectedCountText')
expect(source).toContain('shareSelection')
expect(source).toContain("kind: 'media'")
expect(source).toContain("nuiCall('gallery:delete-many'")
expect(source).toContain('media:deleteManyResult')
})
it('shows capture time and iPhone-style actions in the media viewer', () => {
expect(source).toContain(':title="selectedCaptureDay"')
expect(source).toContain(':subtitle="selectedCaptureTime"')
expect(source).not.toContain("'Apps.photos.photo'\n : 'Apps.photos.video'")
expect(source).toContain("nuiCall<FavoriteResult>('gallery:favorite'")
expect(source).toContain('<Heart')
expect(source).toContain('gallery-detail-toolbar')
expect(source).toContain('color: var(--sky-text)')
expect(source).toContain('shareSelected')
expect(source).toContain('deleteDialogOpened = true')
})
it('uses complete local media fixtures when no browser API port is set', () => {
expect(source).toContain("developmentParameters?.has('apiPort')")
expect(source).toContain('isDevelopment && !developmentApiEnabled')
expect(source).toContain('all: developmentMedia.length')
expect(source).toContain('developmentMedia.filter(')
expect(source).toContain("mockGalleryImage(\n 'Flower Video'")
expect(source).toContain("mockGalleryImage(\n 'Sintel Video'")
expect(source).toContain("mockGalleryImage(\n 'Bunny Video'")
expect(source).toContain('const additionalPhotos = [')
expect(source).toContain("['Campfire', '#c2410c', '#431407', '#fef08a']")
expect(source).toContain('id: 13 + index')
expect(source).not.toContain('picsum.photos')
})
})
File diff suppressed because it is too large Load Diff
+165 -10
View File
@@ -2472,38 +2472,140 @@ let recentCalls = [
status: 'missed',
},
]
function mockGalleryImage(title, sky, landscape, accent) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 1200"><defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop stop-color="${sky}"/><stop offset="1" stop-color="${accent}"/></linearGradient><linearGradient id="land" x1="0" y1="0" x2="1" y2="1"><stop stop-color="${landscape}"/><stop offset="1" stop-color="#101114"/></linearGradient></defs><rect width="900" height="1200" fill="url(#sky)"/><circle cx="690" cy="260" r="105" fill="#fff" opacity=".72"/><path d="M0 690 210 440 390 650 585 360 900 720V1200H0Z" fill="${landscape}" opacity=".84"/><path d="M0 790 230 620 410 765 650 525 900 770V1200H0Z" fill="url(#land)"/><path d="M360 1200 475 690 560 690 690 1200Z" fill="${accent}" opacity=".48"/><text x="54" y="1100" fill="#fff" font-family="system-ui,sans-serif" font-size="62" font-weight="700">${title}</text><text x="57" y="1160" fill="#fff" opacity=".72" font-family="system-ui,sans-serif" font-size="30">Sky Phone test media</text></svg>`
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`
}
let mockMedia = [
{
createdAt: Date.now() - 60_000,
createdAt: Date.now() - 4 * 60_000,
favorite: false,
id: 1,
mediaType: 'photo',
url: 'https://picsum.photos/seed/sky-phone-1/600/800',
url: mockGalleryImage('City Night', '#172554', '#111827', '#7c3aed'),
},
{
createdAt: Date.now() - 120_000,
createdAt: Date.now() - 18 * 60_000,
favorite: true,
id: 2,
mediaType: 'video',
thumbnailUrl: mockGalleryImage('Flower Video', '#7f1d1d', '#365314', '#fb7185'),
url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
},
{
createdAt: Date.now() - 180_000,
createdAt: Date.now() - 47 * 60_000,
favorite: true,
id: 3,
mediaType: 'photo',
url: 'https://picsum.photos/seed/sky-phone-3/800/600',
url: mockGalleryImage('Beach Drive', '#0369a1', '#164e63', '#fbbf24'),
},
{
createdAt: Date.now() - 240_000,
createdAt: Date.now() - 2 * 60 * 60_000,
favorite: false,
id: 4,
mediaType: 'photo',
url: 'https://picsum.photos/seed/sky-phone-4/800/600',
url: mockGalleryImage('Mountain Road', '#475569', '#334155', '#e2e8f0'),
},
{
createdAt: Date.now() - 300_000,
createdAt: Date.now() - 5 * 60 * 60_000,
favorite: false,
id: 5,
mediaType: 'photo',
url: 'https://picsum.photos/seed/sky-phone-5/800/600',
url: mockGalleryImage('Downtown', '#312e81', '#1e293b', '#f472b6'),
},
{
createdAt: Date.now() - 8 * 60 * 60_000,
favorite: false,
id: 6,
mediaType: 'video',
thumbnailUrl: mockGalleryImage('Sintel Video', '#9a3412', '#431407', '#fdba74'),
url: 'https://media.w3.org/2010/05/sintel/trailer.mp4',
},
{
createdAt: Date.now() - 12 * 60 * 60_000,
favorite: false,
id: 7,
mediaType: 'photo',
url: mockGalleryImage('Palm Sunset', '#c2410c', '#422006', '#facc15'),
},
{
createdAt: Date.now() - 26 * 60 * 60_000,
favorite: true,
id: 8,
mediaType: 'photo',
url: mockGalleryImage('Sports Car', '#1f2937', '#111827', '#ef4444'),
},
{
createdAt: Date.now() - 31 * 60 * 60_000,
favorite: false,
id: 9,
mediaType: 'photo',
url: mockGalleryImage('Boardwalk', '#0e7490', '#713f12', '#67e8f9'),
},
{
createdAt: Date.now() - 46 * 60 * 60_000,
favorite: false,
id: 10,
mediaType: 'video',
thumbnailUrl: mockGalleryImage('Bunny Video', '#166534', '#14532d', '#86efac'),
url: 'https://www.w3schools.com/html/mov_bbb.mp4',
},
{
createdAt: Date.now() - 3 * 86_400_000,
favorite: false,
id: 11,
mediaType: 'photo',
url: mockGalleryImage('Vinewood', '#1d4ed8', '#166534', '#f8fafc'),
},
{
createdAt: Date.now() - 5 * 86_400_000,
favorite: true,
id: 12,
mediaType: 'photo',
url: mockGalleryImage('Friends', '#7e22ce', '#4c1d95', '#f0abfc'),
},
]
mockMedia.push(
...[
['Airport Lights', '#172554', '#1e3a8a', '#38bdf8'],
['Desert Route', '#fb923c', '#7c2d12', '#fde047'],
['Harbor Morning', '#0e7490', '#164e63', '#a5f3fc'],
['Forest Trail', '#166534', '#14532d', '#bef264'],
['Neon Alley', '#581c87', '#1e1b4b', '#f472b6'],
['Lake House', '#0369a1', '#3f6212', '#fef08a'],
['Snow Pass', '#94a3b8', '#334155', '#f8fafc'],
['Night Drive', '#111827', '#312e81', '#22d3ee'],
['Canyon View', '#b45309', '#78350f', '#fdba74'],
['Green Hills', '#15803d', '#365314', '#86efac'],
['Purple Sky', '#6b21a8', '#312e81', '#e879f9'],
['Ocean Road', '#0284c7', '#0f766e', '#67e8f9'],
['City Park', '#4d7c0f', '#14532d', '#facc15'],
['Sunrise Pier', '#ea580c', '#7c2d12', '#fef3c7'],
['Rainy Street', '#334155', '#0f172a', '#60a5fa'],
['Golden Fields', '#ca8a04', '#713f12', '#fef08a'],
['Metro Station', '#1f2937', '#374151', '#f43f5e'],
['Island Bay', '#0891b2', '#115e59', '#f0fdfa'],
['Cliff Road', '#7c3aed', '#3f3f46', '#c4b5fd'],
['Old Town', '#9a3412', '#451a03', '#fed7aa'],
['Racing Night', '#991b1b', '#111827', '#fb7185'],
['Quiet Beach', '#0ea5e9', '#155e75', '#fde68a'],
['Hilltop', '#65a30d', '#3f6212', '#d9f99d'],
['Downtown Rain', '#3730a3', '#1e293b', '#93c5fd'],
['Coastal Sunset', '#be123c', '#7c2d12', '#fbbf24'],
['Country Road', '#854d0e', '#365314', '#fde047'],
['Blue Mountains', '#1d4ed8', '#334155', '#bfdbfe'],
['Palm Beach', '#0d9488', '#166534', '#fcd34d'],
['Skyline', '#4338ca', '#111827', '#a78bfa'],
['Campfire', '#c2410c', '#431407', '#fef08a'],
].map(([title, sky, landscape, accent], index) => ({
createdAt: Date.now() - (6 + index) * 86_400_000,
favorite: index % 7 === 0,
id: 13 + index,
mediaType: 'photo',
url: mockGalleryImage(title, sky, landscape, accent),
})),
)
const weazelNewsCategoryIds = ['official', 'events', 'jobs', 'news', 'business']
let weazelNewsSequence = 8
let weazelNewsArticles = [
@@ -8265,6 +8367,7 @@ app.post('/api/:endpoint', (request, response) => {
const fallbackVideo = mockMedia.find((item) => item.mediaType === 'video')
const media = {
createdAt: Date.now(),
favorite: false,
id,
mediaType,
url:
@@ -8353,6 +8456,24 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: media })
return
}
if (endpoint === 'gallery:counts') {
response.json({
success: true,
data: {
all: mockMedia.length,
favoritePhotos: mockMedia.filter(
(item) => item.mediaType === 'photo' && item.favorite,
).length,
favorites: mockMedia.filter((item) => item.favorite).length,
favoriteVideos: mockMedia.filter(
(item) => item.mediaType === 'video' && item.favorite,
).length,
photos: mockMedia.filter((item) => item.mediaType === 'photo').length,
videos: mockMedia.filter((item) => item.mediaType === 'video').length,
},
})
return
}
if (endpoint === 'gallery:list') {
if (request.body.mockState === 'error') {
response.json({ success: false, error: 'service_unavailable' })
@@ -8362,9 +8483,12 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: [] })
return
}
const filtered = request.body.mediaType
const typeFiltered = request.body.mediaType
? mockMedia.filter((item) => item.mediaType === request.body.mediaType)
: mockMedia
const filtered = request.body.favoriteOnly
? typeFiltered.filter((item) => item.favorite)
: typeFiltered
const offset = Number(request.body.offset) || 0
const limit = Number(request.body.limit) || 30
response.json({
@@ -8387,6 +8511,37 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true })
return
}
if (endpoint === 'gallery:favorite') {
const item = mockMedia.find((entry) => entry.id === Number(request.body.id))
if (!item || typeof request.body.favorite !== 'boolean') {
response.json({ success: false, error: 'media_not_found' })
return
}
item.favorite = request.body.favorite
response.json({
success: true,
data: { favorite: item.favorite, id: item.id },
})
return
}
if (endpoint === 'gallery:delete-many') {
const ids = Array.isArray(request.body.ids)
? request.body.ids.map(Number).filter(Number.isInteger)
: []
const deletedIds = mockMedia
.filter((item) => ids.includes(item.id))
.map((item) => item.id)
mockMedia = mockMedia.filter((item) => !deletedIds.includes(item.id))
response.json({
success: true,
data: {
correlationId: request.body.correlationId,
deletedIds,
success: true,
},
})
return
}
if (endpoint === 'account:login' || endpoint === 'account:register') {
authenticated = true
linkedAccount = {
+22
View File
@@ -31,6 +31,7 @@ const browserDataRequests = [
['fliptok:discover', { limit: 20 }],
['fliptok:activities', {}],
['gallery:list', {}],
['gallery:counts', {}],
['garage:vehicles', {}],
['garage:valet-state', {}],
['housing:overview', {}],
@@ -83,6 +84,27 @@ async function expectSuccess(baseUrl, endpoint, body = {}, data = false) {
}
async function verifyStatefulActions(baseUrl) {
let gallery = await expectSuccess(baseUrl, 'gallery:list', {}, true)
assert(gallery.length >= 10, 'gallery:list did not include enough test media')
assert(
gallery.filter((item) => item.mediaType === 'video').length >= 3,
'gallery:list did not include enough test videos',
)
assert(
gallery
.filter((item) => item.mediaType === 'video')
.every((item) => typeof item.thumbnailUrl === 'string'),
'gallery:list returned a test video without a thumbnail',
)
await expectSuccess(
baseUrl,
'gallery:favorite',
{ favorite: true, id: gallery[0].id },
true,
)
gallery = await expectSuccess(baseUrl, 'gallery:list', {}, true)
assert.equal(gallery.find((item) => item.id === gallery[0].id)?.favorite, true)
const memoBootstrap = await expectSuccess(
baseUrl,
'development:bootstrap',