From f940395a46a4f9626eb8e7d522d6e453bf6cb92e Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:05:44 +0200 Subject: [PATCH] ADD - build Local Pages city feed --- .../src/assets/img/app-icons/local-pages.webp | Bin 0 -> 4592 bytes frontend/src/config/apps.ts | 15 + frontend/src/stores/pages.test.ts | 33 ++ frontend/src/stores/pages.ts | 63 ++++ frontend/src/stores/phone.ts | 17 ++ frontend/src/types/apps.ts | 1 + frontend/src/types/pages.ts | 47 +++ frontend/src/utils/preferences.ts | 1 + frontend/src/views/apps/CityMarktApp.vue | 18 ++ frontend/src/views/apps/LocalPagesApp.vue | 260 ++++++++++++++++ frontend/testserver/index.cjs | 86 ++++++ sky_phone/config/config.lua | 11 + sky_phone/config/locales/en.lua | 17 ++ sky_phone/fxmanifest.lua | 1 + sky_phone/source/client/main.lua | 7 + sky_phone/source/html/index.html | 2 +- sky_phone/source/server/db_migrate.lua | 69 +++++ sky_phone/source/server/pages.lua | 285 ++++++++++++++++++ 18 files changed, 932 insertions(+), 1 deletion(-) create mode 100644 frontend/src/assets/img/app-icons/local-pages.webp create mode 100644 frontend/src/stores/pages.test.ts create mode 100644 frontend/src/stores/pages.ts create mode 100644 frontend/src/types/pages.ts create mode 100644 frontend/src/views/apps/LocalPagesApp.vue create mode 100644 sky_phone/source/server/pages.lua diff --git a/frontend/src/assets/img/app-icons/local-pages.webp b/frontend/src/assets/img/app-icons/local-pages.webp new file mode 100644 index 0000000000000000000000000000000000000000..5037723af810f7551a5332460c20994cf9732b8b GIT binary patch literal 4592 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 a&bK)M6ksPA-a1~+;p1Jx-I*kvfB*n?F)rBv literal 0 HcmV?d00001 diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 6369dc0..83f3b25 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -18,6 +18,7 @@ import { CloudSun, Wind, Tag, + MapPinHouse, } from 'lucide-vue-next' import { defineAsyncComponent, markRaw } from 'vue' @@ -40,9 +41,23 @@ import skyFlappyIcon from '@/assets/img/app-icons/sky-flappy.webp' import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp' import weatherIcon from '@/assets/img/app-icons/weather.webp' import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp' +import localPagesIcon from '@/assets/img/app-icons/local-pages.webp' import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps' export const PHONE_APPS: PhoneAppDefinition[] = [ + { + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/LocalPagesApp.vue')), + ), + dockOrder: null, + gridOrder: 19, + icon: markRaw(MapPinHouse), + iconClass: 'app-icon--local-pages', + iconImage: localPagesIcon, + id: 'local-pages', + labelKey: 'Apps.localPages.name', + route: '/apps/local-pages', + }, { component: markRaw( defineAsyncComponent(() => import('@/views/apps/PhoneApp.vue')), diff --git a/frontend/src/stores/pages.test.ts b/frontend/src/stores/pages.test.ts new file mode 100644 index 0000000..e2d027c --- /dev/null +++ b/frontend/src/stores/pages.test.ts @@ -0,0 +1,33 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { usePagesStore } from '@/stores/pages' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) +const mockNuiCall = vi.mocked(nuiCall) + +describe('Local Pages store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('shares only the CityMarkt listing id with the server', async () => { + mockNuiCall.mockResolvedValueOnce({ data: { id: 'post-id' }, success: true }) + const response = await usePagesStore().shareCityMarkt('listing-id') + expect(response.success).toBe(true) + expect(mockNuiCall).toHaveBeenCalledWith('pages:share-citymarkt', { + listingId: 'listing-id', + }) + }) + + it('updates a successful like locally', async () => { + mockNuiCall.mockResolvedValueOnce({ success: true }) + const pages = usePagesStore() + pages.items = [{ id: 'post-id', is_liked: false, like_count: 2 } as never] + await pages.react('post-id', 'like', true) + expect(pages.items[0]?.is_liked).toBe(true) + expect(pages.items[0]?.like_count).toBe(3) + }) +}) diff --git a/frontend/src/stores/pages.ts b/frontend/src/stores/pages.ts new file mode 100644 index 0000000..a5e492d --- /dev/null +++ b/frontend/src/stores/pages.ts @@ -0,0 +1,63 @@ +import { defineStore } from 'pinia' + +import type { PagesPage, PagesPost, PagesPostDraft } from '@/types/pages' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +export const usePagesStore = defineStore('pages', { + state: () => ({ + items: [] as PagesPost[], + ownItems: [] as PagesPost[], + savedItems: [] as PagesPost[], + isLoading: false, + }), + actions: { + async load(filters: Record = {}): Promise { + this.isLoading = true + const response = await nuiCall('pages:list', filters) + this.isLoading = false + if (response.success && response.data) this.items = response.data.items + return response.success + }, + async loadProfile(): Promise { + const [own, saved] = await Promise.all([ + nuiCall('pages:list-own'), + nuiCall('pages:list', { saved: true }), + ]) + if (own.success && own.data) this.ownItems = own.data.items + if (saved.success && saved.data) this.savedItems = saved.data.items + return own.success && saved.success + }, + get(id: string): Promise> { + return nuiCall('pages:get', { id }) + }, + async create(draft: PagesPostDraft): Promise> { + const response = await nuiCall<{ id: string }>('pages:create', draft) + if (response.success) await Promise.all([this.load(), this.loadProfile()]) + return response + }, + async shareCityMarkt(listingId: string): Promise> { + return nuiCall<{ id: string }>('pages:share-citymarkt', { listingId }) + }, + async react(id: string, kind: 'like' | 'save', active: boolean): Promise { + const response = await nuiCall('pages:react', { active, id, kind }) + if (response.success) { + for (const item of [...this.items, ...this.ownItems, ...this.savedItems]) { + if (item.id !== id) continue + if (kind === 'like') { + item.like_count = Math.max(0, item.like_count + (active ? 1 : -1)) + item.is_liked = active + } else item.is_saved = active + } + } + return response.success + }, + async remove(id: string): Promise { + const response = await nuiCall('pages:delete', { id }) + if (response.success) { + this.items = this.items.filter((item) => item.id !== id) + this.ownItems = this.ownItems.filter((item) => item.id !== id) + } + return response.success + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 4d56526..a438d2d 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -414,6 +414,23 @@ const defaultLocales: LocaleTree = { default: 'The CityMarkt request failed.', }, }, + localPages: { + name: 'Local Pages', eyebrow: 'Your city. Your stories.', cityPulse: 'Live from Los Santos', + heroTitle: 'What is happening nearby?', heroBody: 'Places, people, tips and local discoveries from the community.', + searchPlaceholder: 'Search posts and places', search: 'Search', allCategories: 'All categories', + allLosSantos: 'Los Santos', hoursAgo: '{count}h ago', daysAgo: '{count}d ago', + categories: { recommendation: 'Recommended', wanted: 'Wanted', service: 'Service', event: 'Event', place: 'Place', community: 'Community', citymarkt: 'CityMarkt' }, + discover: 'Discover', create: 'Post', profile: 'Profile', post: 'Post', posts: 'posts', + noPosts: 'Nothing here yet', noPostsBody: 'Be the first to share something with the city.', noPhoto: 'No photo attached', + signInTitle: 'Your Local Pages profile', signInBody: 'Sign in to iFruit in Settings to publish and save posts.', + localCreator: 'Local creator', myPosts: 'My posts', saved: 'Saved', save: 'Save', likes: 'likes', + location: 'Location', sharedFrom: 'Shared from CityMarkt', openCityMarkt: 'Open CityMarkt listing', + newPost: 'New local post', shareWithCity: 'Share with the city', publish: 'Publish', published: 'Your post is live.', deleted: 'Post deleted.', + title: 'Title', body: 'Your story', category: 'Category', titlePlaceholder: 'What should people know?', bodyPlaceholder: 'Add details, a recommendation or directions...', + photos: 'Photos', optional: 'optional', camera: 'Camera', gallery: 'Gallery', photoLimit: 'You can add up to six photos.', + cityMarktShare: 'Share to Local Pages', cityMarktShared: 'Shared to Local Pages.', cityMarktShareHint: 'One CityMarkt share per day', + errors: { invalid_post: 'Add a title and a little more detail.', invalid_images: 'Choose valid photos from this phone.', invalid_request: 'This action is not valid.', post_not_found: 'This post is no longer available.', citymarkt_not_found: 'This CityMarkt listing is unavailable.', citymarkt_daily_limit: 'You already shared a CityMarkt listing today.', citymarkt_already_shared: 'This listing was already shared.', not_authenticated: 'Sign in to iFruit first.', rate_limited: 'Too many requests. Try again shortly.', request_failed: 'The post could not be saved.', default: 'Local Pages is temporarily unavailable.' }, + }, map: { name: 'Map', controls: 'Map controls', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 56b2874..0c692a6 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -20,6 +20,7 @@ export type PhoneAppId = | 'sky-flappy' | 'neon-drop' | 'citymarkt' + | 'local-pages' export type AppLaunchOrigin = { borderRadius: number diff --git a/frontend/src/types/pages.ts b/frontend/src/types/pages.ts new file mode 100644 index 0000000..40f82c2 --- /dev/null +++ b/frontend/src/types/pages.ts @@ -0,0 +1,47 @@ +export type PagesCategory = + | 'recommendation' + | 'wanted' + | 'service' + | 'event' + | 'place' + | 'community' + | 'citymarkt' + +export type PagesImage = { + gradient: string + media_id: string + sort_order: number +} + +export type PagesPost = { + author_name: string + body: string + category: PagesCategory + citymarkt_listing_id: string | null + citymarkt_price: number | string | null + created_at: string + district: string | null + id: string + image: string | null + images: PagesImage[] + is_liked: boolean | number + is_owner: boolean | number + is_saved: boolean | number + like_count: number + source_type: 'personal' | 'citymarkt' + title: string +} + +export type PagesPostDraft = { + body: string + category: Exclude + district: string + images: Array<{ id: string }> + title: string +} + +export type PagesPage = { + hasMore: boolean + items: PagesPost[] + offset: number +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 7127855..3f7424b 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -58,6 +58,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< 'sky-flappy': { enabled: true, sounds: true }, 'neon-drop': { enabled: true, sounds: true }, citymarkt: { enabled: true, sounds: true }, + 'local-pages': { enabled: true, sounds: true }, camera: { enabled: true, sounds: true }, clock: { enabled: true, sounds: true }, weather: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/CityMarktApp.vue b/frontend/src/views/apps/CityMarktApp.vue index ff0b436..6eb346d 100644 --- a/frontend/src/views/apps/CityMarktApp.vue +++ b/frontend/src/views/apps/CityMarktApp.vue @@ -22,6 +22,7 @@ import { MoreHorizontal, Search, Send, + Share2, Shirt, Tag, UserRound, @@ -36,6 +37,7 @@ import CityMarktOfferCard from '@/components/citymarkt/CityMarktOfferCard.vue' import { useAccountStore } from '@/stores/account' import { useMarketplaceStore } from '@/stores/marketplace' import { useMediaStore } from '@/stores/media' +import { usePagesStore } from '@/stores/pages' import { usePhoneStore } from '@/stores/phone' import type { MarketplaceCategory, @@ -59,6 +61,7 @@ const phone = usePhoneStore() const account = useAccountStore() const marketplace = useMarketplaceStore() const media = useMediaStore() +const pages = usePagesStore() const tab = ref('discover') const screen = ref('main') const selectedListing = ref(null) @@ -258,6 +261,14 @@ function setFeedback(key: string): void { }, 2600) } +async function shareToLocalPages(): Promise { + if (!selectedListing.value) return + const response = await pages.shareCityMarkt(selectedListing.value.id) + setFeedback(response.success + ? 'Apps.localPages.cityMarktShared' + : `Apps.localPages.errors.${response.error ?? 'default'}`) +} + async function loadFeed(): Promise { await marketplace.load({ category: category.value, @@ -695,6 +706,12 @@ onMounted(async () => {
{{ selectedListing.seller_name.charAt(0).toUpperCase() }}
{{ selectedListing.seller_name }}{{ selectedListing.seller_active }} {{ phone.t('Apps.citymarkt.activeListings') }}

{{ phone.t('Apps.citymarkt.phone') }}: {{ selectedListing.phone_number }}