mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-02 11:18:57 +00:00
ENH - overhaul Photos media experience
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 19 KiB |
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -5,6 +5,7 @@ export type EasyShareKind =
|
||||
| 'document'
|
||||
| 'link'
|
||||
| 'location'
|
||||
| 'media'
|
||||
| 'note'
|
||||
| 'photo'
|
||||
| 'playlist'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user