mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
FIX - connect marketplace apps to phone media
This commit is contained in:
@@ -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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<T = unknown> = {
|
||||
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<T = unknown>(target: string): MediaSelectionResult<T> | 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
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<MediaType | null>(() => {
|
||||
const value = route.query.messageAttachment
|
||||
const value = route.query.mediaAttachment ?? route.query.messageAttachment
|
||||
return value === 'photo' || value === 'video' ? value : null
|
||||
})
|
||||
const mode = ref<MediaType>(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(() => {
|
||||
</div>
|
||||
|
||||
<header class="camera-topbar">
|
||||
<button
|
||||
v-if="requestedMessageMedia"
|
||||
class="camera-picker-back"
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.back')"
|
||||
@click="cancelMediaSelection"
|
||||
>
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<k-fab
|
||||
v-if="!requestedMessageMedia"
|
||||
component="button"
|
||||
type="button"
|
||||
class="camera-control"
|
||||
@@ -489,7 +504,7 @@ 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;
|
||||
|
||||
@@ -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<Tab>('discover')
|
||||
const screen = ref<Screen>('main')
|
||||
@@ -82,8 +102,7 @@ const feedback = ref('')
|
||||
const sellStep = ref(1)
|
||||
const submitting = ref(false)
|
||||
const selectedPhotoIds = ref<string[]>([])
|
||||
const photoSource = ref<'camera' | 'gallery' | null>(null)
|
||||
const cameraFlash = ref(false)
|
||||
const pickedPhotos = ref<SelectedPhoto[]>([])
|
||||
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<SellDraft>({
|
||||
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<ChatTimelineItem[]>(() => {
|
||||
}))
|
||||
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<void> {
|
||||
|
||||
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<void> {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const selection = messageMedia.consumeMany<MediaContext>('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 () => {
|
||||
</span>
|
||||
<h1>{{ phone.t(tab === 'discover' ? 'Apps.citymarkt.name' : `Apps.citymarkt.tabs.${tab}`) }}</h1>
|
||||
</div>
|
||||
<button
|
||||
<k-button
|
||||
component="button"
|
||||
clear
|
||||
rounded
|
||||
v-if="isAuthenticated"
|
||||
class="citymarkt__round"
|
||||
type="button"
|
||||
@@ -592,7 +649,7 @@ onMounted(async () => {
|
||||
>
|
||||
<Bell :size="18" />
|
||||
<i v-if="marketplace.counts.unread" />
|
||||
</button>
|
||||
</k-button>
|
||||
</header>
|
||||
|
||||
<section v-if="screen === 'main'" class="citymarkt__content">
|
||||
@@ -696,7 +753,13 @@ onMounted(async () => {
|
||||
</section>
|
||||
|
||||
<section v-else-if="screen === 'detail' && selectedListing" class="citymarkt__detail">
|
||||
<div class="citymarkt__top-actions"><button @click="screen = 'main'"><ArrowLeft :size="19" /></button><div><button v-if="!selectedListing.is_owner" @click="toggleFavorite"><Heart :size="19" :fill="selectedListing.is_favorite ? 'currentColor' : 'none'" /></button><button v-if="!selectedListing.is_owner" @click="screen = 'report'"><MoreHorizontal :size="20" /></button></div></div>
|
||||
<div class="citymarkt__top-actions">
|
||||
<k-button component="button" clear rounded @click="screen = 'main'"><ArrowLeft :size="19" /></k-button>
|
||||
<div>
|
||||
<k-button v-if="!selectedListing.is_owner" component="button" clear rounded @click="toggleFavorite"><Heart :size="19" :fill="selectedListing.is_favorite ? 'currentColor' : 'none'" /></k-button>
|
||||
<k-button v-if="!selectedListing.is_owner" component="button" clear rounded @click="screen = 'report'"><MoreHorizontal :size="20" /></k-button>
|
||||
</div>
|
||||
</div>
|
||||
<CityMarktGallery
|
||||
class="citymarkt__hero"
|
||||
:images="selectedListing.images"
|
||||
@@ -730,7 +793,7 @@ onMounted(async () => {
|
||||
</section>
|
||||
|
||||
<section v-else-if="screen === 'sell'" class="citymarkt__sell">
|
||||
<header><button @click="resetSell(); screen = 'main'; tab = 'discover'"><X :size="20" /></button><div><strong>{{ phone.t(editing ? 'Apps.citymarkt.editListing' : 'Apps.citymarkt.createListing') }}</strong><small>{{ phone.t('Apps.citymarkt.step', { current: String(sellStep), total: '4' }) }}</small></div><button :disabled="!canContinueSell || submitting" @click="sellStep < 4 ? sellStep++ : publish()">{{ sellStep < 4 ? phone.t('Apps.citymarkt.next') : phone.t(editing ? 'Apps.citymarkt.save' : 'Apps.citymarkt.publish') }}</button></header>
|
||||
<header><k-button component="button" clear rounded @click="resetSell(); screen = 'main'; tab = 'discover'"><X :size="20" /></k-button><div><strong>{{ phone.t(editing ? 'Apps.citymarkt.editListing' : 'Apps.citymarkt.createListing') }}</strong><small>{{ phone.t('Apps.citymarkt.step', { current: String(sellStep), total: '4' }) }}</small></div><k-button component="button" clear rounded :disabled="!canContinueSell || submitting" @click="sellStep < 4 ? sellStep++ : publish()">{{ sellStep < 4 ? phone.t('Apps.citymarkt.next') : phone.t(editing ? 'Apps.citymarkt.save' : 'Apps.citymarkt.publish') }}</k-button></header>
|
||||
<div class="citymarkt__progress"><i :style="{ width: `${sellStep * 25}%` }" /></div>
|
||||
<div class="citymarkt__sell-body">
|
||||
<template v-if="sellStep === 1">
|
||||
@@ -738,12 +801,12 @@ onMounted(async () => {
|
||||
<h2>{{ phone.t('Apps.citymarkt.addPhotos') }}</h2>
|
||||
<p>{{ phone.t('Apps.citymarkt.addPhotosBody') }}</p>
|
||||
<div class="citymarkt__photo-actions">
|
||||
<button type="button" @click="photoSource = 'gallery'">
|
||||
<button type="button" @click="openMediaApp('photos')">
|
||||
<span><Images :size="20" /></span>
|
||||
<strong>{{ phone.t('Apps.citymarkt.chooseGallery') }}</strong>
|
||||
<small>{{ phone.t('Apps.citymarkt.chooseGalleryBody') }}</small>
|
||||
</button>
|
||||
<button type="button" @click="photoSource = 'camera'">
|
||||
<button type="button" @click="openMediaApp('camera')">
|
||||
<span><Camera :size="20" /></span>
|
||||
<strong>{{ phone.t('Apps.citymarkt.takePhotos') }}</strong>
|
||||
<small>{{ phone.t('Apps.citymarkt.takePhotosBody') }}</small>
|
||||
@@ -798,31 +861,10 @@ onMounted(async () => {
|
||||
<template v-else><h2>{{ phone.t('Apps.citymarkt.preview') }}</h2><CityMarktGallery class="citymarkt__preview-image" :images="draftImages" :empty-title="phone.t('Apps.citymarkt.noPhoto')" :empty-body="phone.t('Apps.citymarkt.noPhotoBody')" :previous-label="phone.t('Apps.citymarkt.previousPhoto')" :next-label="phone.t('Apps.citymarkt.nextPhoto')" :photo-label="phone.t('Apps.citymarkt.photo')" /><strong class="citymarkt__preview-price">{{ formatPrice({ price: draft.price, price_type: draft.priceType }) }}</strong><h3>{{ draft.title }}</h3><p>{{ draft.description }}</p><small><MapPin :size="13" /> {{ label('districts', draft.district) }}</small></template>
|
||||
</div>
|
||||
<button v-if="sellStep > 1" class="citymarkt__previous" @click="sellStep--">{{ phone.t('Apps.citymarkt.previous') }}</button>
|
||||
<div v-if="photoSource" class="citymarkt__photo-source">
|
||||
<header>
|
||||
<div>
|
||||
<small>{{ phone.t('Apps.citymarkt.addPhotos') }}</small>
|
||||
<strong>{{ phone.t(photoSource === 'gallery' ? 'Apps.citymarkt.gallery' : 'Apps.citymarkt.camera') }}</strong>
|
||||
</div>
|
||||
<span>{{ selectedPhotoIds.length }} / 6</span>
|
||||
<button type="button" :aria-label="phone.t('Common.close')" @click="photoSource = null"><X :size="18" /></button>
|
||||
</header>
|
||||
<div v-if="photoSource === 'gallery'" class="citymarkt__photo-picker">
|
||||
<button v-for="photo in media.photos" :key="photo.id" type="button" :class="{ active: selectedPhotoIds.includes(photo.id) }" :style="{ background: photo.gradient }" @click="togglePhoto(photo.id)"><i>{{ selectedPhotoIds.indexOf(photo.id) + 1 }}</i></button>
|
||||
</div>
|
||||
<div v-else class="citymarkt__capture">
|
||||
<div class="citymarkt__viewfinder" :style="{ background: media.photos[0]?.gradient }">
|
||||
<i v-for="corner in ['tl', 'tr', 'bl', 'br']" :key="corner" :class="`corner-${corner}`" />
|
||||
<span class="citymarkt__camera-flash" :class="{ active: cameraFlash }" />
|
||||
</div>
|
||||
<p>{{ phone.t('Apps.citymarkt.cameraHint') }}</p>
|
||||
<button class="citymarkt__shutter" type="button" :aria-label="phone.t('Apps.citymarkt.takePhoto')" @click="captureMarketplacePhoto"><Camera :size="23" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="screen === 'chat' && selectedChat" class="citymarkt__chat">
|
||||
<header><button @click="screen = 'main'; tab = 'inbox'"><ArrowLeft :size="19" /></button><div><strong>{{ selectedChat.inquiry.seller_account_id === selectedChat.accountId ? selectedChat.inquiry.buyer_name : selectedChat.inquiry.seller_name }}</strong><small>{{ selectedChat.inquiry.title }}</small></div></header>
|
||||
<header><k-button component="button" clear rounded @click="screen = 'main'; tab = 'inbox'"><ArrowLeft :size="19" /></k-button><div><strong>{{ selectedChat.inquiry.seller_account_id === selectedChat.accountId ? selectedChat.inquiry.buyer_name : selectedChat.inquiry.seller_name }}</strong><small>{{ selectedChat.inquiry.title }}</small></div></header>
|
||||
<div class="citymarkt__chat-listing"><div><strong>{{ formatPrice(selectedChat.inquiry) }}</strong><span>{{ selectedChat.inquiry.title }}</span></div><i>{{ label('status', selectedChat.inquiry.status) }}</i></div>
|
||||
<div class="citymarkt__messages">
|
||||
<template v-for="item in chatTimeline" :key="item.key">
|
||||
@@ -851,7 +893,7 @@ onMounted(async () => {
|
||||
<small>{{ phone.t('Apps.citymarkt.offerFor') }}</small>
|
||||
<strong>{{ selectedChat.inquiry.title }}</strong>
|
||||
</div>
|
||||
<button type="button" @click="offerPanelOpen = false"><X :size="16" /></button>
|
||||
<k-button component="button" clear rounded type="button" @click="offerPanelOpen = false"><X :size="16" /></k-button>
|
||||
</header>
|
||||
<label>
|
||||
{{ phone.t('Apps.citymarkt.offerAmount') }}
|
||||
@@ -871,7 +913,7 @@ onMounted(async () => {
|
||||
</section>
|
||||
|
||||
<section v-else-if="screen === 'report' && selectedListing" class="citymarkt__report">
|
||||
<header><button @click="screen = 'detail'"><ArrowLeft :size="19" /></button><strong>{{ phone.t('Apps.citymarkt.reportListing') }}</strong></header>
|
||||
<header><k-button component="button" clear rounded @click="screen = 'detail'"><ArrowLeft :size="19" /></k-button><strong>{{ phone.t('Apps.citymarkt.reportListing') }}</strong></header>
|
||||
<div><h2>{{ phone.t('Apps.citymarkt.reportWhy') }}</h2><CityMarktSelect class="citymarkt__form-select" :model-value="reportReason" :options="reportReasonOptions" @change="selectReportReason" /><textarea v-model="reportDetails" maxlength="500" :placeholder="phone.t('Apps.citymarkt.reportDetails')" /><button @click="submitReport">{{ phone.t('Apps.citymarkt.sendReport') }}</button><button class="secondary" @click="blockSeller">{{ phone.t('Apps.citymarkt.blockSeller') }}</button></div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -41,9 +41,13 @@ const messageMedia = useMessageMediaStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const requestedMessageMedia = computed<GalleryFilter | null>(() => {
|
||||
const value = route.query.messageAttachment
|
||||
const value = route.query.mediaAttachment ?? route.query.messageAttachment
|
||||
return value === 'photo' || value === 'video' ? value : null
|
||||
})
|
||||
const multipleSelection = computed(
|
||||
() => requestedMessageMedia.value !== null && (messageMedia.request?.maxSelection ?? 1) > 1,
|
||||
)
|
||||
const selectedMediaIds = ref<number[]>([])
|
||||
const media = ref<PhoneMedia[]>([])
|
||||
const filter = ref<GalleryFilter>(requestedMessageMedia.value ?? 'all')
|
||||
const loading = ref(true)
|
||||
@@ -203,6 +207,14 @@ function observeMore(): void {
|
||||
|
||||
function openMedia(entry: PhoneMedia): void {
|
||||
if (requestedMessageMedia.value) {
|
||||
if (multipleSelection.value) {
|
||||
const index = selectedMediaIds.value.indexOf(entry.id)
|
||||
if (index >= 0) selectedMediaIds.value.splice(index, 1)
|
||||
else if (selectedMediaIds.value.length < (messageMedia.request?.maxSelection ?? 1)) {
|
||||
selectedMediaIds.value.push(entry.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
const returnPath = messageMedia.complete(entry)
|
||||
if (returnPath) void router.replace(returnPath)
|
||||
return
|
||||
@@ -214,6 +226,15 @@ function openMedia(entry: PhoneMedia): void {
|
||||
imagePan.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
function completeMultipleSelection(): void {
|
||||
const selectedMedia = selectedMediaIds.value.flatMap((id) => {
|
||||
const entry = media.value.find((item) => item.id === id)
|
||||
return entry ? [entry] : []
|
||||
})
|
||||
const returnPath = messageMedia.completeMany(selectedMedia)
|
||||
if (returnPath) void router.replace(returnPath)
|
||||
}
|
||||
|
||||
function cancelMessageSelection(): void {
|
||||
void router.replace(messageMedia.cancel())
|
||||
}
|
||||
@@ -352,6 +373,15 @@ onBeforeUnmount(() => {
|
||||
@click="cancelMessageSelection"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="multipleSelection" #right>
|
||||
<k-link
|
||||
component="button"
|
||||
:disabled="!selectedMediaIds.length"
|
||||
@click="completeMultipleSelection"
|
||||
>
|
||||
{{ phone.t('Common.done') }}
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
|
||||
<div class="gallery-content">
|
||||
@@ -375,6 +405,7 @@ onBeforeUnmount(() => {
|
||||
v-for="entry in media"
|
||||
:key="entry.id"
|
||||
class="gallery-tile"
|
||||
:class="{ 'gallery-tile--selected': selectedMediaIds.includes(entry.id) }"
|
||||
type="button"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
@@ -401,6 +432,12 @@ onBeforeUnmount(() => {
|
||||
<span v-if="entry.mediaType === 'video'" class="gallery-video-badge">
|
||||
<Play :size="16" fill="currentColor" />
|
||||
</span>
|
||||
<span
|
||||
v-if="multipleSelection && selectedMediaIds.includes(entry.id)"
|
||||
class="gallery-selection-badge"
|
||||
>
|
||||
{{ selectedMediaIds.indexOf(entry.id) + 1 }}
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
v-if="hasMore"
|
||||
@@ -594,6 +631,29 @@ onBeforeUnmount(() => {
|
||||
border: 0;
|
||||
background: #d1d1d6;
|
||||
}
|
||||
.gallery-tile--selected::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 3px solid #0a84ff;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gallery-selection-badge {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
background: #0a84ff;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 6px #0006;
|
||||
}
|
||||
.gallery-grid--fill .gallery-tile {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -18,24 +18,36 @@ import {
|
||||
UserRound,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import { kButton } from 'konsta/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
|
||||
import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useMediaStore } from '@/stores/media'
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { usePagesStore } from '@/stores/pages'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PagesCategory, PagesPost } from '@/types/pages'
|
||||
|
||||
type SelectedPhoto = { background: string; id: string }
|
||||
type ComposeDraft = {
|
||||
body: string
|
||||
category: Exclude<PagesCategory, 'citymarkt'>
|
||||
district: string
|
||||
images: string[]
|
||||
title: string
|
||||
}
|
||||
type MediaContext = { draft: ComposeDraft; photos: SelectedPhoto[] }
|
||||
|
||||
type Screen = 'main' | 'detail' | 'compose'
|
||||
type Tab = 'feed' | 'create' | 'profile'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const account = useAccountStore()
|
||||
const media = useMediaStore()
|
||||
const messageMedia = useMessageMediaStore()
|
||||
const pages = usePagesStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const screen = ref<Screen>('main')
|
||||
const tab = ref<Tab>('feed')
|
||||
@@ -46,9 +58,8 @@ const search = ref('')
|
||||
const category = ref<string>('all')
|
||||
const feedback = ref('')
|
||||
const reactionPending = ref(false)
|
||||
const photoSource = ref<'camera' | 'gallery' | null>(null)
|
||||
const cameraFlash = ref(false)
|
||||
const draft = ref({
|
||||
const pickedPhotos = ref<SelectedPhoto[]>([])
|
||||
const draft = ref<ComposeDraft>({
|
||||
body: '',
|
||||
category: 'recommendation' as Exclude<PagesCategory, 'citymarkt'>,
|
||||
district: 'los_santos',
|
||||
@@ -76,10 +87,10 @@ const displayedPosts = computed(() => tab.value === 'profile'
|
||||
: pages.items)
|
||||
const isAuthenticated = computed(() => Boolean(account.email))
|
||||
const selectedPhotos = computed(() => draft.value.images
|
||||
.map((id) => media.photos.find((photo) => photo.id === id))
|
||||
.map((id) => pickedPhotos.value.find((photo) => photo.id === id))
|
||||
.filter((photo) => photo !== undefined))
|
||||
const selectedImages = computed(() => selectedPhotos.value.map((photo, index) => ({
|
||||
gradient: photo.gradient,
|
||||
gradient: photo.background,
|
||||
media_id: photo.id,
|
||||
sort_order: index + 1,
|
||||
})))
|
||||
@@ -134,19 +145,29 @@ async function openPost(post: PagesPost): Promise<void> {
|
||||
|
||||
function togglePhoto(id: string): void {
|
||||
const index = draft.value.images.indexOf(id)
|
||||
if (index >= 0) draft.value.images.splice(index, 1)
|
||||
else if (draft.value.images.length < 6) draft.value.images.push(id)
|
||||
else showFeedback('Apps.localPages.photoLimit')
|
||||
if (index >= 0) {
|
||||
draft.value.images.splice(index, 1)
|
||||
pickedPhotos.value = pickedPhotos.value.filter((photo) => photo.id !== id)
|
||||
}
|
||||
}
|
||||
|
||||
function capturePhoto(): void {
|
||||
if (draft.value.images.length >= 6) {
|
||||
function openMediaApp(app: 'camera' | 'photos'): void {
|
||||
const remaining = 6 - draft.value.images.length
|
||||
if (remaining < 1) {
|
||||
showFeedback('Apps.localPages.photoLimit')
|
||||
return
|
||||
}
|
||||
cameraFlash.value = true
|
||||
draft.value.images.push(media.capture().id)
|
||||
window.setTimeout(() => (cameraFlash.value = false), 120)
|
||||
messageMedia.begin(
|
||||
'local-pages:compose',
|
||||
'photo',
|
||||
'/apps/local-pages?compose=1',
|
||||
app === 'photos' ? remaining : 1,
|
||||
{
|
||||
draft: { ...draft.value, images: [...draft.value.images] },
|
||||
photos: [...pickedPhotos.value],
|
||||
} satisfies MediaContext,
|
||||
)
|
||||
void router.push({ path: `/apps/${app}`, query: { mediaAttachment: 'photo' } })
|
||||
}
|
||||
|
||||
async function publish(): Promise<void> {
|
||||
@@ -166,7 +187,7 @@ async function publish(): Promise<void> {
|
||||
return
|
||||
}
|
||||
draft.value = { body: '', category: 'recommendation', district: 'los_santos', images: [], title: '' }
|
||||
photoSource.value = null
|
||||
pickedPhotos.value = []
|
||||
tab.value = 'feed'
|
||||
screen.value = 'main'
|
||||
showFeedback('Apps.localPages.published')
|
||||
@@ -225,7 +246,23 @@ function openCityMarktListing(): void {
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => void loadFeed())
|
||||
onMounted(() => {
|
||||
const selection = messageMedia.consumeMany<MediaContext>('local-pages:compose')
|
||||
if (selection) {
|
||||
if (selection.context) {
|
||||
draft.value = selection.context.draft
|
||||
pickedPhotos.value = selection.context.photos
|
||||
}
|
||||
for (const media of selection.media) {
|
||||
const id = String(media.id)
|
||||
if (draft.value.images.includes(id) || draft.value.images.length >= 6) continue
|
||||
draft.value.images.push(id)
|
||||
pickedPhotos.value.push({ background: `url(${JSON.stringify(media.url)})`, id })
|
||||
}
|
||||
}
|
||||
if (route.query.compose === '1') screen.value = 'compose'
|
||||
void loadFeed()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -282,7 +319,7 @@ onMounted(() => void loadFeed())
|
||||
</template>
|
||||
|
||||
<section v-else-if="screen === 'detail' && selected" class="pages__detail">
|
||||
<header><button @click="screen = 'main'"><ArrowLeft :size="20" /></button><strong>{{ phone.t('Apps.localPages.post') }}</strong><button v-if="selected.is_owner" class="danger" @click="removePost"><Trash2 :size="18" /></button><button v-else @click="react('save')"><Bookmark :size="18" :fill="selected.is_saved ? 'currentColor' : 'none'" /></button></header>
|
||||
<header><k-button component="button" clear rounded @click="screen = 'main'"><ArrowLeft :size="20" /></k-button><strong>{{ phone.t('Apps.localPages.post') }}</strong><k-button v-if="selected.is_owner" component="button" clear rounded class="danger" @click="removePost"><Trash2 :size="18" /></k-button><k-button v-else component="button" clear rounded @click="react('save')"><Bookmark :size="18" :fill="selected.is_saved ? 'currentColor' : 'none'" /></k-button></header>
|
||||
<div class="pages__detail-scroll">
|
||||
<div v-if="selected.images.length" class="pages__gallery" :style="{ background: selected.images[galleryIndex]?.gradient }"><button v-if="selected.images.length > 1" @click="moveGallery(-1)"><ChevronLeft /></button><button v-if="selected.images.length > 1" @click="moveGallery(1)"><ChevronRight /></button><span>{{ galleryIndex + 1 }} / {{ selected.images.length }}</span></div>
|
||||
<article><div class="pages__author"><span>{{ selected.author_name.charAt(0).toUpperCase() }}</span><div><strong>@{{ selected.author_name }}</strong><small>{{ relativeDate(selected.created_at) }}</small></div><i>{{ label('categories', selected.category) }}</i></div><h1>{{ selected.title }}</h1><p>{{ selected.body }}</p><div class="pages__location"><MapPin :size="17" /><div><small>{{ phone.t('Apps.localPages.location') }}</small><strong>{{ selected.district ? phone.t(`Apps.citymarkt.districts.${selected.district}`) : phone.t('Apps.localPages.allLosSantos') }}</strong></div></div><button v-if="selected.source_type === 'citymarkt'" class="pages__market-link" @click="openCityMarktListing"><Store :size="18" /><span><small>{{ phone.t('Apps.localPages.sharedFrom') }}</small><strong>{{ phone.t('Apps.localPages.openCityMarkt') }}</strong></span><b v-if="selected.citymarkt_price">${{ Number(selected.citymarkt_price).toLocaleString() }}</b></button></article>
|
||||
@@ -291,7 +328,7 @@ onMounted(() => void loadFeed())
|
||||
</section>
|
||||
|
||||
<section v-else class="pages__compose">
|
||||
<header><button @click="screen = 'main'"><X :size="20" /></button><div><small>{{ phone.t('Apps.localPages.newPost') }}</small><strong>{{ phone.t('Apps.localPages.shareWithCity') }}</strong></div><button :disabled="!canPublish" @click="publish"><Send :size="16" />{{ phone.t('Apps.localPages.publish') }}</button></header>
|
||||
<header><k-button component="button" clear rounded @click="screen = 'main'"><X :size="20" /></k-button><div><small>{{ phone.t('Apps.localPages.newPost') }}</small><strong>{{ phone.t('Apps.localPages.shareWithCity') }}</strong></div><k-button component="button" tonal rounded :disabled="!canPublish" @click="publish"><Send :size="16" />{{ phone.t('Apps.localPages.publish') }}</k-button></header>
|
||||
<div class="pages__compose-scroll">
|
||||
<label>{{ phone.t('Apps.localPages.title') }} <span :class="{ valid: draft.title.trim().length >= 5 }">{{ draft.title.trim().length }}/80 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '5' }) }}</span><input v-model="draft.title" maxlength="80" :placeholder="phone.t('Apps.localPages.titlePlaceholder')" /></label>
|
||||
<label>{{ phone.t('Apps.localPages.body') }} <span :class="{ valid: draft.body.trim().length >= 10 }">{{ draft.body.trim().length }}/1500 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '10' }) }}</span><textarea v-model="draft.body" maxlength="1500" :placeholder="phone.t('Apps.localPages.bodyPlaceholder')" /></label>
|
||||
@@ -301,12 +338,12 @@ onMounted(() => void loadFeed())
|
||||
<h2>{{ phone.t('Apps.citymarkt.addPhotos') }}</h2>
|
||||
<p>{{ phone.t('Apps.citymarkt.addPhotosBody') }}</p>
|
||||
<div class="pages__photo-actions">
|
||||
<button type="button" @click="photoSource = 'gallery'">
|
||||
<button type="button" @click="openMediaApp('photos')">
|
||||
<span><Images :size="20" /></span>
|
||||
<strong>{{ phone.t('Apps.citymarkt.chooseGallery') }}</strong>
|
||||
<small>{{ phone.t('Apps.citymarkt.chooseGalleryBody') }}</small>
|
||||
</button>
|
||||
<button type="button" @click="photoSource = 'camera'">
|
||||
<button type="button" @click="openMediaApp('camera')">
|
||||
<span><Camera :size="20" /></span>
|
||||
<strong>{{ phone.t('Apps.citymarkt.takePhotos') }}</strong>
|
||||
<small>{{ phone.t('Apps.citymarkt.takePhotosBody') }}</small>
|
||||
@@ -330,24 +367,6 @@ onMounted(() => void loadFeed())
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="photoSource" class="pages__photo-source">
|
||||
<header>
|
||||
<div><small>{{ phone.t('Apps.citymarkt.addPhotos') }}</small><strong>{{ phone.t(photoSource === 'gallery' ? 'Apps.citymarkt.gallery' : 'Apps.citymarkt.camera') }}</strong></div>
|
||||
<span>{{ draft.images.length }} / 6</span>
|
||||
<button type="button" :aria-label="phone.t('Common.close')" @click="photoSource = null"><X :size="18" /></button>
|
||||
</header>
|
||||
<div v-if="photoSource === 'gallery'" class="pages__photo-picker">
|
||||
<button v-for="photo in media.photos" :key="photo.id" type="button" :class="{ active: draft.images.includes(photo.id) }" :style="{ background: photo.gradient }" @click="togglePhoto(photo.id)"><i v-if="draft.images.includes(photo.id)">{{ draft.images.indexOf(photo.id) + 1 }}</i></button>
|
||||
</div>
|
||||
<div v-else class="pages__capture">
|
||||
<div class="pages__viewfinder" :style="{ background: media.photos[0]?.gradient }">
|
||||
<i v-for="corner in ['tl', 'tr', 'bl', 'br']" :key="corner" :class="`corner-${corner}`" />
|
||||
<span class="pages__camera-flash" :class="{ active: cameraFlash }" />
|
||||
</div>
|
||||
<p>{{ phone.t('Apps.citymarkt.cameraHint') }}</p>
|
||||
<button class="pages__shutter" type="button" :aria-label="phone.t('Apps.citymarkt.takePhoto')" @click="capturePhoto"><Camera :size="23" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Transition name="toast"><div v-if="feedback" class="pages__toast">{{ feedback }}</div></Transition>
|
||||
</main>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sky Phone</title>
|
||||
<script type="module" crossorigin src="./assets/sky-index-Jjqh_BPD.js"></script>
|
||||
<script type="module" crossorigin src="./assets/sky-index-DdYU6-mm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-Bhv3YOTT.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -436,7 +436,7 @@ local schema = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "listing_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "media_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "gradient", type = "VARCHAR(160) NOT NULL" },
|
||||
{ name = "gradient", type = "VARCHAR(2200) NOT NULL" },
|
||||
{ name = "sort_order", type = "TINYINT UNSIGNED NOT NULL" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
@@ -619,7 +619,7 @@ local schema = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "post_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "media_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "gradient", type = "VARCHAR(160) NOT NULL" },
|
||||
{ name = "gradient", type = "VARCHAR(2200) NOT NULL" },
|
||||
{ name = "sort_order", type = "TINYINT UNSIGNED NOT NULL" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
@@ -841,6 +841,14 @@ Bridge.Database.Query([[
|
||||
ALTER TABLE `sky_phone_darkchat_messages`
|
||||
MODIFY COLUMN `message_type` ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'system') NOT NULL DEFAULT 'text'
|
||||
]], {})
|
||||
Bridge.Database.Query([[
|
||||
ALTER TABLE `sky_phone_marketplace_images`
|
||||
MODIFY COLUMN `gradient` VARCHAR(2200) NOT NULL
|
||||
]], {})
|
||||
Bridge.Database.Query([[
|
||||
ALTER TABLE `sky_phone_pages_images`
|
||||
MODIFY COLUMN `gradient` VARCHAR(2200) NOT NULL
|
||||
]], {})
|
||||
Bridge.Database.EnsureIndex("sky_phone_devices", "uniq_sky_phone_devices_sim", "(`sim_id`)", { unique = true })
|
||||
Bridge.Database.Query("UPDATE `sky_phone_contacts` SET `contact_id` = `id` WHERE `contact_id` IS NULL", {})
|
||||
Bridge.Database.EnsureIndex("sky_phone_contacts", "uniq_sky_phone_contacts_account_contact", "(`account_id`, `contact_id`)", { unique = true })
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local categories = {}
|
||||
local districts = {}
|
||||
local photo_gradients = {}
|
||||
local item_conditions = { new = true, very_good = true, used = true, defective = true }
|
||||
local price_types = { fixed = true, negotiable = true, free = true }
|
||||
local report_reasons = { prohibited = true, fraud = true, spam = true, offensive = true, other = true }
|
||||
@@ -15,9 +14,6 @@ end
|
||||
for _, district in ipairs(Config.Marketplace.Districts) do
|
||||
districts[district] = true
|
||||
end
|
||||
for _, gradient in ipairs(Config.Marketplace.PhotoGradients) do
|
||||
photo_gradients[gradient] = true
|
||||
end
|
||||
|
||||
local function trim(value)
|
||||
if type(value) ~= "string" then
|
||||
@@ -96,7 +92,7 @@ local function load_images(listing_id)
|
||||
]], { listing_id })
|
||||
end
|
||||
|
||||
local function validate_images(source, imei, images)
|
||||
local function validate_images(source, images)
|
||||
if type(images) ~= "table" or #images > Config.Marketplace.MaxImages then
|
||||
return nil
|
||||
end
|
||||
@@ -104,30 +100,15 @@ local function validate_images(source, imei, images)
|
||||
return {}
|
||||
end
|
||||
|
||||
local owned_media = {
|
||||
["sunset-drive"] = Config.Marketplace.PhotoGradients[1],
|
||||
["ocean-air"] = Config.Marketplace.PhotoGradients[2],
|
||||
["city-lights"] = Config.Marketplace.PhotoGradients[3],
|
||||
["desert-road"] = Config.Marketplace.PhotoGradients[4],
|
||||
}
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `payload` FROM `sky_phone_device_data`
|
||||
WHERE `device_imei` = ? AND `namespace` = 'media'
|
||||
LIMIT 1
|
||||
]], { imei })
|
||||
local media = rows[1] and json.decode(rows[1].payload) or nil
|
||||
for _, capture in ipairs(type(media) == "table" and media.captures or {}) do
|
||||
if type(capture) == "table" and type(capture.id) == "string" and photo_gradients[capture.gradient] then
|
||||
owned_media[capture.id] = capture.gradient
|
||||
end
|
||||
end
|
||||
|
||||
local normalized = {}
|
||||
local seen = {}
|
||||
for index, image in ipairs(images) do
|
||||
local media_id = type(image) == "table" and image.id or nil
|
||||
local gradient = media_id and owned_media[media_id] or nil
|
||||
if type(media_id) ~= "string" or #media_id > 64 or not gradient or seen[media_id] then
|
||||
local numeric_id = tonumber(media_id)
|
||||
local normalized_id = numeric_id and tostring(math.floor(numeric_id)) or nil
|
||||
if not numeric_id or numeric_id < 1 or numeric_id ~= math.floor(numeric_id)
|
||||
or seen[normalized_id]
|
||||
then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected unowned marketplace image from source %s.",
|
||||
@@ -135,8 +116,17 @@ local function validate_images(source, imei, images)
|
||||
)
|
||||
return nil
|
||||
end
|
||||
seen[media_id] = true
|
||||
normalized[index] = { id = media_id, gradient = gradient }
|
||||
local url = SkyPhoneMedia.ResolveOwnedMedia(source, normalized_id, "photo")
|
||||
if not url then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected unowned marketplace image from source %s.",
|
||||
tostring(source)
|
||||
)
|
||||
return nil
|
||||
end
|
||||
seen[normalized_id] = true
|
||||
normalized[index] = { id = normalized_id, gradient = ("url(%s)"):format(json.encode(url)) }
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
@@ -168,7 +158,7 @@ local function validate_listing(source, account, data)
|
||||
return nil, "phone_unavailable"
|
||||
end
|
||||
|
||||
local images = validate_images(source, account.imei, data.images)
|
||||
local images = validate_images(source, data.images)
|
||||
if not images then
|
||||
return nil, "invalid_images"
|
||||
end
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local categories = {}
|
||||
local districts = {}
|
||||
local photo_gradients = {}
|
||||
for _, value in ipairs(Config.LocalPages.Categories) do categories[value] = true end
|
||||
for _, value in ipairs(Config.Marketplace.Districts) do districts[value] = true end
|
||||
for _, value in ipairs(Config.Marketplace.PhotoGradients) do photo_gradients[value] = true end
|
||||
|
||||
local function trim(value)
|
||||
if type(value) ~= "string" then return nil end
|
||||
@@ -33,39 +31,29 @@ local function load_images(post_id)
|
||||
]], { post_id })
|
||||
end
|
||||
|
||||
local function validate_images(source, imei, images)
|
||||
local function validate_images(source, images)
|
||||
if type(images) ~= "table" or #images > Config.LocalPages.MaxImages then return nil end
|
||||
if #images == 0 then return {} end
|
||||
|
||||
local owned_media = {
|
||||
["sunset-drive"] = Config.Marketplace.PhotoGradients[1],
|
||||
["ocean-air"] = Config.Marketplace.PhotoGradients[2],
|
||||
["city-lights"] = Config.Marketplace.PhotoGradients[3],
|
||||
["desert-road"] = Config.Marketplace.PhotoGradients[4],
|
||||
}
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `payload` FROM `sky_phone_device_data`
|
||||
WHERE `device_imei` = ? AND `namespace` = 'media'
|
||||
LIMIT 1
|
||||
]], { imei })
|
||||
local media = rows[1] and json.decode(rows[1].payload) or nil
|
||||
for _, capture in ipairs(type(media) == "table" and media.captures or {}) do
|
||||
if type(capture) == "table" and type(capture.id) == "string" and photo_gradients[capture.gradient] then
|
||||
owned_media[capture.id] = capture.gradient
|
||||
end
|
||||
end
|
||||
|
||||
local normalized = {}
|
||||
local seen = {}
|
||||
for index, image in ipairs(images) do
|
||||
local media_id = type(image) == "table" and image.id or nil
|
||||
local gradient = media_id and owned_media[media_id] or nil
|
||||
if type(media_id) ~= "string" or #media_id > 64 or not gradient or seen[media_id] then
|
||||
local numeric_id = tonumber(media_id)
|
||||
local normalized_id = numeric_id and tostring(math.floor(numeric_id)) or nil
|
||||
if not numeric_id or numeric_id < 1 or numeric_id ~= math.floor(numeric_id)
|
||||
or seen[normalized_id]
|
||||
then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected unowned Local Pages image from source %s.", tostring(source))
|
||||
return nil
|
||||
end
|
||||
seen[media_id] = true
|
||||
normalized[index] = { id = media_id, gradient = gradient }
|
||||
local url = SkyPhoneMedia.ResolveOwnedMedia(source, normalized_id, "photo")
|
||||
if not url then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected unowned Local Pages image from source %s.", tostring(source))
|
||||
return nil
|
||||
end
|
||||
seen[normalized_id] = true
|
||||
normalized[index] = { id = normalized_id, gradient = ("url(%s)"):format(json.encode(url)) }
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
@@ -189,7 +177,7 @@ Bridge.Callbacks.Register("sky_phone:pages:create", function(source, data)
|
||||
then
|
||||
return { success = false, error = "invalid_post" }
|
||||
end
|
||||
local images = validate_images(source, account.imei, data.images)
|
||||
local images = validate_images(source, data.images)
|
||||
if not images then return { success = false, error = "invalid_images" } end
|
||||
local id = new_id()
|
||||
local statements = {{
|
||||
|
||||
Reference in New Issue
Block a user