diff --git a/frontend/src/stores/messageMedia.test.ts b/frontend/src/stores/messageMedia.test.ts index d972bd5..e792ca1 100644 --- a/frontend/src/stores/messageMedia.test.ts +++ b/frontend/src/stores/messageMedia.test.ts @@ -31,4 +31,31 @@ describe('message media handoff', () => { expect(store.request).toMatchObject({ mediaType: 'video', target: '4205550196' }) expect(store.cancel()).toBe('/apps/messages') }) + + it('returns multiple photos and the requesting app context', () => { + const store = useMessageMediaStore() + const secondPhoto = { ...photo, id: 18 } + store.begin('citymarkt:sell', 'photo', '/apps/citymarkt?sell=1', 2, { + title: 'Draft listing', + }) + + expect(store.completeMany([photo, secondPhoto])).toBe('/apps/citymarkt?sell=1') + expect(store.consumeMany<{ title: string }>('citymarkt:sell')).toEqual({ + context: { title: 'Draft listing' }, + media: [photo, secondPhoto], + }) + }) + + it('preserves the requesting app context when selection is cancelled', () => { + const store = useMessageMediaStore() + store.begin('local-pages:compose', 'photo', '/apps/local-pages?compose=1', 6, { + title: 'Draft post', + }) + + expect(store.cancel()).toBe('/apps/local-pages?compose=1') + expect(store.consumeMany<{ title: string }>('local-pages:compose')).toEqual({ + context: { title: 'Draft post' }, + media: [], + }) + }) }) diff --git a/frontend/src/stores/messageMedia.ts b/frontend/src/stores/messageMedia.ts index 083a461..4b3607d 100644 --- a/frontend/src/stores/messageMedia.ts +++ b/frontend/src/stores/messageMedia.ts @@ -3,13 +3,20 @@ import { defineStore } from 'pinia' import type { MediaType, PhoneMedia } from '@/types/media' type MessageMediaRequest = { + context?: unknown + maxSelection: number mediaType: MediaType returnPath: string target: string } type MessageMediaResult = MessageMediaRequest & { - media: PhoneMedia + media: PhoneMedia[] +} + +export type MediaSelectionResult = { + context?: T + media: PhoneMedia[] } export const useMessageMediaStore = defineStore('message-media', { @@ -22,17 +29,37 @@ export const useMessageMediaStore = defineStore('message-media', { target: string, mediaType: MediaType, returnPath = '/apps/messages', + maxSelection = 1, + context?: unknown, ): void { - this.request = { mediaType, returnPath, target } + this.request = { + context, + maxSelection: Math.max(1, Math.floor(maxSelection)), + mediaType, + returnPath, + target, + } this.result = null }, cancel(): string { const returnPath = this.request?.returnPath ?? '/apps/messages' + if (this.request) this.result = { ...this.request, media: [] } this.request = null return returnPath }, complete(media: PhoneMedia): string | null { if (!this.request || this.request.mediaType !== media.mediaType) return null + return this.completeMany([media]) + }, + completeMany(media: PhoneMedia[]): string | null { + if ( + !this.request || + media.length < 1 || + media.length > this.request.maxSelection || + media.some((entry) => entry.mediaType !== this.request?.mediaType) + ) { + return null + } const returnPath = this.request.returnPath this.result = { ...this.request, media } this.request = null @@ -40,9 +67,18 @@ export const useMessageMediaStore = defineStore('message-media', { }, consume(target: string): PhoneMedia | null { if (!this.result || this.result.target !== target) return null - const media = this.result.media + const media = this.result.media[0] ?? null this.result = null return media }, + consumeMany(target: string): MediaSelectionResult | null { + if (!this.result || this.result.target !== target) return null + const result = { + context: this.result.context as T | undefined, + media: [...this.result.media], + } + this.result = null + return result + }, }, }) diff --git a/frontend/src/types/marketplace.ts b/frontend/src/types/marketplace.ts index 7634c1a..eecc668 100644 --- a/frontend/src/types/marketplace.ts +++ b/frontend/src/types/marketplace.ts @@ -23,9 +23,9 @@ export type MarketplaceImage = { export type MarketplaceListingSummary = { category: MarketplaceCategory - created_at: string + created_at: DatabaseDateValue district: string | null - expires_at: string + expires_at: DatabaseDateValue id: string image: string | null is_favorite: boolean | number @@ -35,7 +35,7 @@ export type MarketplaceListingSummary = { seller_name: string status: MarketplaceStatus title: string - updated_at: string + updated_at: DatabaseDateValue } export type MarketplaceListing = MarketplaceListingSummary & { @@ -46,7 +46,7 @@ export type MarketplaceListing = MarketplaceListingSummary & { reserved_account_id: number | null revision: number seller_active: number - seller_since: string + seller_since: DatabaseDateValue show_phone: boolean | number } @@ -75,12 +75,12 @@ export type MarketplaceInquirySummary = { status: MarketplaceStatus title: string unread: number - updated_at: string + updated_at: DatabaseDateValue } export type MarketplaceMessage = { body: string - created_at: string + created_at: DatabaseDateValue id: number read_at: string | null sender_account_id: number @@ -88,13 +88,13 @@ export type MarketplaceMessage = { export type MarketplaceOffer = { amount: number | string - created_at: string + created_at: DatabaseDateValue id: number proposer_account_id: number read_at: string | null response_read_at: string | null status: MarketplaceOfferStatus - updated_at: string + updated_at: DatabaseDateValue } export type MarketplaceInquiry = { @@ -124,3 +124,4 @@ export type MarketplaceChat = { } export type MarketplaceCounts = { active: number; unread: number } +import type { DatabaseDateValue } from '@/utils/date' diff --git a/frontend/src/views/apps/CameraApp.vue b/frontend/src/views/apps/CameraApp.vue index eae808e..efd2b98 100644 --- a/frontend/src/views/apps/CameraApp.vue +++ b/frontend/src/views/apps/CameraApp.vue @@ -7,6 +7,7 @@ import { kSegmentedButton, } from 'konsta/vue' import { + ArrowLeft, Images, RefreshCw, RotateCcwSquare, @@ -38,7 +39,7 @@ const messageMedia = useMessageMediaStore() const route = useRoute() const router = useRouter() const requestedMessageMedia = computed(() => { - const value = route.query.messageAttachment + const value = route.query.mediaAttachment ?? route.query.messageAttachment return value === 'photo' || value === 'video' ? value : null }) const mode = ref(requestedMessageMedia.value ?? 'photo') @@ -205,6 +206,10 @@ function capture(): void { void requestPhoto() } +function cancelMediaSelection(): void { + void router.replace(messageMedia.cancel()) +} + function setMode(nextMode: MediaType): void { if (recording.value || savingVideo.value) return mode.value = nextMode @@ -413,7 +418,17 @@ onBeforeUnmount(() => {
+ { router.push({ path: '/apps/photos', query: requestedMessageMedia - ? { messageAttachment: requestedMessageMedia } + ? { mediaAttachment: requestedMessageMedia } : undefined, }) " @@ -671,6 +686,17 @@ onBeforeUnmount(() => { .camera-control { --color-primary: transparent; } +.camera-picker-back { + width: 44px; + height: 44px; + border: 0; + border-radius: 50%; + display: grid; + place-items: center; + background: #1c1c1ecc; + color: #fff; + backdrop-filter: blur(16px); +} .camera-control svg { width: 21px; height: 21px; diff --git a/frontend/src/views/apps/CityMarktApp.vue b/frontend/src/views/apps/CityMarktApp.vue index 52c54a0..37fc2ae 100644 --- a/frontend/src/views/apps/CityMarktApp.vue +++ b/frontend/src/views/apps/CityMarktApp.vue @@ -29,17 +29,19 @@ import { Wrench, X, } from 'lucide-vue-next' +import { kButton } from 'konsta/vue' import { computed, onMounted, ref } from 'vue' -import { useRoute } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue' import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue' import CityMarktOfferCard from '@/components/citymarkt/CityMarktOfferCard.vue' import { useAccountStore } from '@/stores/account' import { useMarketplaceStore } from '@/stores/marketplace' -import { useMediaStore } from '@/stores/media' +import { useMessageMediaStore } from '@/stores/messageMedia' import { usePagesStore } from '@/stores/pages' import { usePhoneStore } from '@/stores/phone' +import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date' import type { MarketplaceCategory, MarketplaceChat, @@ -55,14 +57,32 @@ import type { type Tab = 'discover' | 'search' | 'sell' | 'inbox' | 'profile' type Screen = 'main' | 'detail' | 'sell' | 'chat' | 'report' type ChatTimelineItem = - | { createdAt: string; key: string; kind: 'message'; value: MarketplaceMessage } - | { createdAt: string; isCounter: boolean; key: string; kind: 'offer'; value: MarketplaceOffer } + | { createdAt: DatabaseDateValue; key: string; kind: 'message'; value: MarketplaceMessage } + | { createdAt: DatabaseDateValue; isCounter: boolean; key: string; kind: 'offer'; value: MarketplaceOffer } +type SelectedPhoto = { background: string; id: string } +type SellDraft = { + category: MarketplaceCategory + condition: MarketplaceCondition + description: string + district: string + price: string + priceType: MarketplacePriceType + showPhone: boolean + title: string +} +type MediaContext = { + draft: SellDraft + editing: { id: string; revision: number } | null + photos: SelectedPhoto[] + sellStep: number +} const phone = usePhoneStore() const route = useRoute() +const router = useRouter() const account = useAccountStore() const marketplace = useMarketplaceStore() -const media = useMediaStore() +const messageMedia = useMessageMediaStore() const pages = usePagesStore() const tab = ref('discover') const screen = ref('main') @@ -82,8 +102,7 @@ const feedback = ref('') const sellStep = ref(1) const submitting = ref(false) const selectedPhotoIds = ref([]) -const photoSource = ref<'camera' | 'gallery' | null>(null) -const cameraFlash = ref(false) +const pickedPhotos = ref([]) const reportReason = ref('spam') const reportDetails = ref('') const editing = ref<{ id: string; revision: number } | null>(null) @@ -91,7 +110,7 @@ const listingTextLimits = { description: { maximum: 2000, minimum: 20 }, title: { maximum: 70, minimum: 5 }, } as const -const draft = ref({ +const draft = ref({ category: 'vehicles' as MarketplaceCategory, condition: 'used' as MarketplaceCondition, description: '', @@ -175,9 +194,9 @@ const displayItems = computed(() => { }) const draftImages = computed(() => selectedPhotoIds.value.flatMap((id, index) => { - const photo = media.photos.find((item) => item.id === id) + const photo = pickedPhotos.value.find((item) => item.id === id) return photo - ? [{ gradient: photo.gradient, media_id: photo.id, sort_order: index + 1 }] + ? [{ gradient: photo.background, media_id: photo.id, sort_order: index + 1 }] : [] }), ) @@ -198,8 +217,8 @@ const chatTimeline = computed(() => { })) return [...messages, ...offers].sort( (left, right) => - new Date(left.createdAt.replace(' ', 'T')).getTime() - - new Date(right.createdAt.replace(' ', 'T')).getTime(), + parseDatabaseDate(left.createdAt).getTime() - + parseDatabaseDate(right.createdAt).getTime(), ) }) const canMakeOffer = computed(() => { @@ -243,16 +262,16 @@ function formatPrice(item: { price: number | string | null; price_type: Marketpl : phone.t('Apps.citymarkt.money', { price: formatted }) } -function relativeDate(value: string): string { - const timestamp = new Date(value.replace(' ', 'T')).getTime() +function relativeDate(value: DatabaseDateValue): string { + const timestamp = parseDatabaseDate(value).getTime() const hours = Math.max(1, Math.floor((Date.now() - timestamp) / 3_600_000)) if (hours < 24) return phone.t('Apps.citymarkt.hoursAgo', { count: String(hours) }) return phone.t('Apps.citymarkt.daysAgo', { count: String(Math.floor(hours / 24)) }) } -function messageTime(value: string): string { +function messageTime(value: DatabaseDateValue): string { return new Intl.DateTimeFormat(phone.lang, { hour: '2-digit', minute: '2-digit' }).format( - new Date(value.replace(' ', 'T')), + parseDatabaseDate(value), ) } @@ -351,27 +370,38 @@ async function toggleFavorite(): Promise { function togglePhoto(id: string): void { const index = selectedPhotoIds.value.indexOf(id) - if (index >= 0) selectedPhotoIds.value.splice(index, 1) - else if (selectedPhotoIds.value.length < 6) selectedPhotoIds.value.push(id) - else setFeedback('Apps.citymarkt.photoLimit') + if (index >= 0) { + selectedPhotoIds.value.splice(index, 1) + pickedPhotos.value = pickedPhotos.value.filter((photo) => photo.id !== id) + } } -function captureMarketplacePhoto(): void { - if (selectedPhotoIds.value.length >= 6) { +function openMediaApp(app: 'camera' | 'photos'): void { + const remaining = 6 - selectedPhotoIds.value.length + if (remaining < 1) { setFeedback('Apps.citymarkt.photoLimit') return } - cameraFlash.value = true - const photo = media.capture() - selectedPhotoIds.value.push(photo.id) - window.setTimeout(() => (cameraFlash.value = false), 120) + messageMedia.begin( + 'citymarkt:sell', + 'photo', + '/apps/citymarkt?sell=1', + app === 'photos' ? remaining : 1, + { + draft: { ...draft.value }, + editing: editing.value ? { ...editing.value } : null, + photos: [...pickedPhotos.value], + sellStep: sellStep.value, + } satisfies MediaContext, + ) + void router.push({ path: `/apps/${app}`, query: { mediaAttachment: 'photo' } }) } function resetSell(): void { editing.value = null sellStep.value = 1 selectedPhotoIds.value = [] - photoSource.value = null + pickedPhotos.value = [] draft.value = { category: 'vehicles', condition: 'used', @@ -388,6 +418,10 @@ function editListing(): void { if (!selectedListing.value) return editing.value = { id: selectedListing.value.id, revision: selectedListing.value.revision } selectedPhotoIds.value = selectedListing.value.images.map((image) => image.media_id) + pickedPhotos.value = selectedListing.value.images.map((image) => ({ + background: image.gradient, + id: image.media_id, + })) draft.value = { category: selectedListing.value.category, condition: selectedListing.value.item_condition, @@ -565,6 +599,26 @@ async function blockSeller(): Promise { } onMounted(async () => { + const selection = messageMedia.consumeMany('citymarkt:sell') + if (selection) { + if (selection.context) { + draft.value = selection.context.draft + editing.value = selection.context.editing + pickedPhotos.value = selection.context.photos + sellStep.value = selection.context.sellStep + selectedPhotoIds.value = selection.context.photos.map((photo) => photo.id) + } + for (const media of selection.media) { + const id = String(media.id) + if (selectedPhotoIds.value.includes(id) || selectedPhotoIds.value.length >= 6) continue + selectedPhotoIds.value.push(id) + pickedPhotos.value.push({ background: `url(${JSON.stringify(media.url)})`, id }) + } + } + if (route.query.sell === '1') { + tab.value = 'sell' + screen.value = 'sell' + } await loadFeed() if (isAuthenticated.value) await marketplace.loadCounts() if (typeof route.query.listingId === 'string') { @@ -583,7 +637,10 @@ onMounted(async () => {

{{ phone.t(tab === 'discover' ? 'Apps.citymarkt.name' : `Apps.citymarkt.tabs.${tab}`) }}

- +
@@ -696,7 +753,13 @@ onMounted(async () => {
-
+
+ +
+ + +
+
{
-
{{ phone.t(editing ? 'Apps.citymarkt.editListing' : 'Apps.citymarkt.createListing') }}{{ phone.t('Apps.citymarkt.step', { current: String(sellStep), total: '4' }) }}
+
{{ phone.t(editing ? 'Apps.citymarkt.editListing' : 'Apps.citymarkt.createListing') }}{{ phone.t('Apps.citymarkt.step', { current: String(sellStep), total: '4' }) }}
{{ sellStep < 4 ? phone.t('Apps.citymarkt.next') : phone.t(editing ? 'Apps.citymarkt.save' : 'Apps.citymarkt.publish') }}