From 2c0e8bb24663b1e7086d6d16aec2d2d8efe1c6e8 Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:55:45 +0200 Subject: [PATCH 01/34] FIX - require phone item to open device --- README.md | 6 +++--- sky_phone/config/config.lua | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ec2fe8d..428972a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # sky_phone -Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `sky_phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly. +Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly. An iFruit account is optional. Unlinked devices retain local settings, alarms, media, apps, notes, contacts, and recent calls. Linking from Mail or Settings moves local data into an empty cloud account; an existing cloud dataset wins over local contacts and recents. Signing out keeps an editable local snapshot without deleting cloud data. @@ -8,7 +8,7 @@ An iFruit account is optional. Unlinked devices retain local settings, alarms, m - ESX Legacy (`es_extended`), Qbox (`qbx_core`), or QBCore (`qb-core`). The bridge selects a running supported framework when `Config.Bridge.Framework` is set to `"auto"`. - A supported inventory: `ox_inventory`, `qb-inventory`, `lj-inventory`, `qs-inventory`, `codem-inventory`, `core_inventory`, `mf-inventory`, or `smx-inventory`. The bridge auto-detects a running provider and normalizes metadata, slots, counts, item mutations, and usable-item callbacks. `mf-inventory` and `smx-inventory` require ESX. Because SMX stores standard ESX items as stacks, its adapter persists one active Phone/SIM metadata record per player and item type in ESX player metadata. -- A non-stackable inventory item named `sky_phone`. +- A non-stackable inventory item named `phone`. - Two unique, non-stackable inventory items named `sky_phone_sim_registered` and `sky_phone_sim_anonymous`. Their metadata is initialized automatically on first use, so shops and crafting recipes add plain items without supplying a number. - `oxmysql` with MySQL/MariaDB. - `pma-voice` when `Config.Calls.VoiceProvider` is set to `"pma"`. @@ -49,7 +49,7 @@ For `ox_inventory`, configure all three items with `stack = false` and `consume Example `ox_inventory/data/items.lua` entries: ```lua -["sky_phone"] = { +["phone"] = { label = "iFruit Phone", weight = 200, stack = false, diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 7e7d448..9562150 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -15,7 +15,7 @@ Config.Command = "phone" Config.Phone = { Item = "phone", - DevelopmentCommand = true, + DevelopmentCommand = false, DeviceName = "iFruit Phone", } From 2ed4c78f72d1b6916280e85e8d533b18f42c577d Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:56:04 +0200 Subject: [PATCH 02/34] FIX - complete phone app categories --- frontend/src/config/apps.test.ts | 2 +- frontend/src/config/apps.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index b72dd9c..c725db5 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -112,7 +112,7 @@ describe('app registry', () => { PHONE_APPS.filter((app) => app.category === 'social').map( (app) => app.id, ), - ).toEqual(['local-pages', 'phone', 'darkchat', 'mail']) + ).toEqual(['local-pages', 'phone', 'darkchat', 'banking', 'mail']) expect( PHONE_APPS.filter((app) => app.dockOrder !== null) .sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0)) diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index a93ae6a..fec59f1 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -156,6 +156,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/banking', }, { + category: 'social', component: markRaw( defineAsyncComponent(() => import('@/views/apps/MailApp.vue')), ), From d9c8fbbdbde16b1ed3a9888b4b1b3a34698911ef Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:56:25 +0200 Subject: [PATCH 03/34] FIX - connect marketplace media workflows --- frontend/src/stores/messageMedia.test.ts | 27 ++++ frontend/src/stores/messageMedia.ts | 42 +++++- frontend/src/types/marketplace.ts | 17 +-- frontend/src/views/apps/CameraApp.vue | 30 ++++- frontend/src/views/apps/CityMarktApp.vue | 154 ++++++++++++++-------- frontend/src/views/apps/GalleryApp.vue | 62 ++++++++- frontend/src/views/apps/LocalPagesApp.vue | 99 ++++++++------ sky_phone/source/server/db_migrate.lua | 12 +- sky_phone/source/server/marketplace.lua | 46 +++---- sky_phone/source/server/pages.lua | 40 ++---- 10 files changed, 363 insertions(+), 166 deletions(-) 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') }}