ADD - build Local Pages city feed

This commit is contained in:
smx.pusha
2026-08-06 14:05:44 +02:00
parent 646e36f87a
commit f940395a46
18 changed files with 932 additions and 1 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+15
View File
@@ -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')),
+33
View File
@@ -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)
})
})
+63
View File
@@ -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<string, unknown> = {}): Promise<boolean> {
this.isLoading = true
const response = await nuiCall<PagesPage>('pages:list', filters)
this.isLoading = false
if (response.success && response.data) this.items = response.data.items
return response.success
},
async loadProfile(): Promise<boolean> {
const [own, saved] = await Promise.all([
nuiCall<PagesPage>('pages:list-own'),
nuiCall<PagesPage>('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<NuiResponse<PagesPost>> {
return nuiCall<PagesPost>('pages:get', { id })
},
async create(draft: PagesPostDraft): Promise<NuiResponse<{ id: string }>> {
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<NuiResponse<{ id: string }>> {
return nuiCall<{ id: string }>('pages:share-citymarkt', { listingId })
},
async react(id: string, kind: 'like' | 'save', active: boolean): Promise<boolean> {
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<boolean> {
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
},
},
})
+17
View File
@@ -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',
+1
View File
@@ -20,6 +20,7 @@ export type PhoneAppId =
| 'sky-flappy'
| 'neon-drop'
| 'citymarkt'
| 'local-pages'
export type AppLaunchOrigin = {
borderRadius: number
+47
View File
@@ -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<PagesCategory, 'citymarkt'>
district: string
images: Array<{ id: string }>
title: string
}
export type PagesPage = {
hasMore: boolean
items: PagesPost[]
offset: number
}
+1
View File
@@ -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 },
+18
View File
@@ -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<Tab>('discover')
const screen = ref<Screen>('main')
const selectedListing = ref<MarketplaceListing | null>(null)
@@ -258,6 +261,14 @@ function setFeedback(key: string): void {
}, 2600)
}
async function shareToLocalPages(): Promise<void> {
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<void> {
await marketplace.load({
category: category.value,
@@ -695,6 +706,12 @@ onMounted(async () => {
<div class="citymarkt__seller"><span>{{ selectedListing.seller_name.charAt(0).toUpperCase() }}</span><div><strong>{{ selectedListing.seller_name }}</strong><small>{{ selectedListing.seller_active }} {{ phone.t('Apps.citymarkt.activeListings') }}</small></div></div>
<p v-if="selectedListing.phone_number" class="citymarkt__phone">{{ phone.t('Apps.citymarkt.phone') }}: {{ selectedListing.phone_number }}</p>
<template v-if="selectedListing.is_owner">
<button
v-if="selectedListing.status === 'active' || selectedListing.status === 'reserved'"
class="citymarkt__pages-share"
type="button"
@click="shareToLocalPages"
><Share2 :size="17" /><span><strong>{{ phone.t('Apps.localPages.cityMarktShare') }}</strong><small>{{ phone.t('Apps.localPages.cityMarktShareHint') }}</small></span></button>
<div class="citymarkt__owner-actions"><button v-if="selectedListing.status !== 'sold' && selectedListing.status !== 'removed'" @click="editListing">{{ phone.t('Apps.citymarkt.edit') }}</button><button v-if="selectedListing.status === 'reserved' || selectedListing.status === 'expired'" @click="setListingStatus('active')">{{ phone.t('Apps.citymarkt.makeActive') }}</button><button v-if="selectedListing.status === 'active' || selectedListing.status === 'reserved'" @click="setListingStatus('sold')">{{ phone.t('Apps.citymarkt.markSold') }}</button><button v-if="selectedListing.status !== 'sold' && selectedListing.status !== 'removed'" class="danger" @click="setListingStatus('removed')">{{ phone.t('Apps.citymarkt.remove') }}</button></div>
</template>
<template v-else-if="isAuthenticated">
@@ -884,4 +901,5 @@ onMounted(async () => {
:global(.citymarkt--light) .citymarkt__card-image--empty,:global(.citymarkt--light) .citymarkt__thumb--empty{background:linear-gradient(145deg,#ecece7,#dedfd8)!important}:global(.citymarkt--light) .citymarkt__photo-actions>button{border-color:#00000010}:global(.citymarkt--light) .citymarkt__selected-strip button{border-color:#00000018}
.citymarkt__sell>header strong{font-size:13px}.citymarkt__sell>header small{font-size:10px}.citymarkt__sell>header>button:last-child{font-size:11px}.citymarkt__sell-body h2{font-size:23px;line-height:1.15}.citymarkt__sell-body>p{font-size:11px;line-height:1.45}.citymarkt__sell-body label{font-size:10.5px}.citymarkt__sell-body input:not([type=checkbox]),.citymarkt__sell-body textarea{padding:11px 12px;font-size:12px}.citymarkt__sell-body input:not([type=checkbox]){min-height:41px}.citymarkt__sell-body textarea{line-height:1.4}.citymarkt__switch{font-size:10.5px!important}.citymarkt__previous{font-size:11px}.citymarkt__photo-actions strong{font-size:11px}.citymarkt__photo-actions small{font-size:8.5px;line-height:1.4}.citymarkt__selected-heading strong{font-size:12px}.citymarkt__selected-heading span{font-size:9px}.citymarkt__sell-body h3{font-size:18px}.citymarkt__sell-body>small{font-size:10px}.citymarkt__sell :deep(.citymarkt-select__trigger){height:41px;padding:0 12px;font-size:12px}.citymarkt__sell :deep(.citymarkt-select__menu button){min-height:35px;padding:8px;font-size:11px}.citymarkt__sell :deep(.citymarkt-gallery__empty strong){font-size:13px}.citymarkt__sell :deep(.citymarkt-gallery__empty small){font-size:9px;line-height:1.4}
.citymarkt__field-heading{display:flex;align-items:center;justify-content:space-between;gap:8px}.citymarkt__field-heading>small{color:#ff9c72;font-size:8.5px;font-weight:850;white-space:nowrap;transition:color .18s ease}.citymarkt__field-heading>small.valid{color:#62dc8e}
.citymarkt__pages-share{width:100%;margin:4px 0 8px;padding:10px 12px;border:1px solid #ffc92855;border-radius:12px;display:flex;align-items:center;gap:8px;text-align:left;background:#ffc92816;color:var(--yellow)!important}.citymarkt__pages-share span{flex:1}.citymarkt__pages-share strong,.citymarkt__pages-share small{display:block}.citymarkt__pages-share strong{font-size:10px}.citymarkt__pages-share small{color:var(--muted);font-size:8px}
</style>
File diff suppressed because one or more lines are too long
+86
View File
@@ -258,6 +258,38 @@ const marketplaceOffers = [
updated_at: '2026-08-06 11:56:00',
},
]
const pagesPosts = [
{
id: 'pages-1', account_id: 2, author_name: 'morgan', source_type: 'personal', citymarkt_listing_id: null,
title: 'Best sunset view above Vinewood', body: 'Take the small trail behind the observatory shortly before sunset. The view across Los Santos is incredible and there is enough space to park two cars.',
category: 'recommendation', district: 'vinewood', created_at: '2026-08-06 12:20:00', like_count: 18,
images: [{ media_id: 'sunset-drive', gradient: 'linear-gradient(145deg, #ff9a62, #5f2c82 58%, #141e30)', sort_order: 1 }],
},
{
id: 'pages-2', account_id: 1, author_name: 'demo', source_type: 'personal', citymarkt_listing_id: null,
title: 'Looking for people for a beach cruise', body: 'Meeting at Vespucci Pier tonight. Bring a clean car and a good playlist. Everyone is welcome.',
category: 'community', district: 'vespucci', created_at: '2026-08-06 11:10:00', like_count: 7,
images: [{ media_id: 'ocean-air', gradient: 'linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)', sort_order: 1 }],
},
{
id: 'pages-3', account_id: 3, author_name: 'jamie', source_type: 'personal', citymarkt_listing_id: null,
title: 'Mobile repair help around Sandy Shores', body: 'I can help with small repairs and jump starts around Sandy Shores this afternoon. Send me a message when you see me nearby.',
category: 'service', district: 'sandy_shores', created_at: '2026-08-05 19:40:00', like_count: 12, images: [],
},
]
const pagesReactions = [{ post_id: 'pages-1', account_id: 1, kind: 'like' }, { post_id: 'pages-3', account_id: 1, kind: 'save' }]
function pageView(post) {
const listing = marketplaceListings.find((item) => item.id === post.citymarkt_listing_id)
return {
...post,
citymarkt_price: listing?.price ?? null,
image: post.images[0]?.gradient ?? null,
is_liked: pagesReactions.some((item) => item.post_id === post.id && item.account_id === 1 && item.kind === 'like'),
is_owner: authenticated && post.account_id === 1,
is_saved: pagesReactions.some((item) => item.post_id === post.id && item.account_id === 1 && item.kind === 'save'),
}
}
function counts() {
return {
@@ -341,6 +373,60 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true })
return
}
if (endpoint === 'pages:list') {
const query = String(request.body.search ?? '').toLowerCase()
let items = pagesPosts
if (request.body.category && request.body.category !== 'all') items = items.filter((item) => item.category === request.body.category)
if (query) items = items.filter((item) => `${item.title} ${item.body}`.toLowerCase().includes(query))
if (request.body.saved) items = items.filter((post) => pagesReactions.some((reaction) => reaction.post_id === post.id && reaction.account_id === 1 && reaction.kind === 'save'))
response.json({ success: true, data: { hasMore: false, items: items.map(pageView), offset: 0 } })
return
}
if (endpoint === 'pages:get') {
const post = pagesPosts.find((item) => item.id === request.body.id)
response.json(post ? { success: true, data: pageView(post) } : { success: false, error: 'post_not_found' })
return
}
if (endpoint.startsWith('pages:') && !authenticated) {
response.json({ success: false, error: 'not_authenticated' })
return
}
if (endpoint === 'pages:list-own') {
response.json({ success: true, data: { hasMore: false, items: pagesPosts.filter((item) => item.account_id === 1).map(pageView), offset: 0 } })
return
}
if (endpoint === 'pages:create') {
const gradients = { 'sunset-drive': 'linear-gradient(145deg, #ff9a62, #5f2c82 58%, #141e30)', 'ocean-air': 'linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)', 'city-lights': 'linear-gradient(135deg, #fbc2eb, #a6c1ee 48%, #302b63)', 'desert-road': 'linear-gradient(150deg, #f6d365, #fda085 45%, #512b58)' }
const id = `pages-${Date.now()}`
const images = request.body.images.map((image, index) => ({ media_id: image.id, gradient: gradients[image.id] ?? 'linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)', sort_order: index + 1 }))
pagesPosts.unshift({ ...request.body, id, account_id: 1, author_name: 'demo', source_type: 'personal', citymarkt_listing_id: null, created_at: new Date().toISOString(), like_count: 0, images })
response.json({ success: true, data: { id } })
return
}
if (endpoint === 'pages:share-citymarkt') {
const listing = marketplaceListings.find((item) => item.id === request.body.listingId && item.seller_account_id === 1 && ['active', 'reserved'].includes(item.status))
if (!listing) { response.json({ success: false, error: 'citymarkt_not_found' }); return }
if (pagesPosts.some((item) => item.citymarkt_listing_id === listing.id)) { response.json({ success: false, error: 'citymarkt_already_shared' }); return }
if (pagesPosts.some((item) => item.account_id === 1 && item.source_type === 'citymarkt' && item.created_at.slice(0, 10) === '2026-08-06')) { response.json({ success: false, error: 'citymarkt_daily_limit' }); return }
const id = `pages-${Date.now()}`
pagesPosts.unshift({ id, account_id: 1, author_name: 'demo', source_type: 'citymarkt', citymarkt_listing_id: listing.id, title: listing.title, body: listing.description, category: 'citymarkt', district: listing.district, created_at: '2026-08-06 13:30:00', like_count: 0, images: listing.images })
response.json({ success: true, data: { id } })
return
}
if (endpoint === 'pages:react') {
const index = pagesReactions.findIndex((item) => item.post_id === request.body.id && item.account_id === 1 && item.kind === request.body.kind)
if (request.body.active && index < 0) pagesReactions.push({ post_id: request.body.id, account_id: 1, kind: request.body.kind })
if (!request.body.active && index >= 0) pagesReactions.splice(index, 1)
response.json({ success: true })
return
}
if (endpoint === 'pages:delete') {
const index = pagesPosts.findIndex((item) => item.id === request.body.id && item.account_id === 1)
if (index < 0) { response.json({ success: false, error: 'post_not_found' }); return }
pagesPosts.splice(index, 1)
response.json({ success: true })
return
}
if (endpoint === 'marketplace:list') {
const query = String(request.body.search ?? '').toLowerCase()
let items = marketplaceListings.filter((item) => ['active', 'reserved'].includes(item.status))
+11
View File
@@ -91,3 +91,14 @@ Config.Marketplace = {
"linear-gradient(135deg, #ffc75f, #f96d80 48%, #4b4453)",
},
}
Config.LocalPages = {
PageSize = 20,
MaxImages = 6,
TitleMinLength = 5,
TitleMaxLength = 80,
BodyMinLength = 10,
BodyMaxLength = 1500,
Categories = { "recommendation", "wanted", "service", "event", "place", "community" },
CityMarktSharesPerDay = 1,
}
+17
View File
@@ -263,6 +263,23 @@ Locales["en"] = {
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", currentLocation = "Current Location",
imageError = "The map image could not be loaded.", switchStyle = "Switch Map Type",
+1
View File
@@ -44,6 +44,7 @@ server_scripts {
'source/server/notes.lua',
'source/server/mail.lua',
'source/server/marketplace.lua',
'source/server/pages.lua',
}
files {
+7
View File
@@ -48,6 +48,13 @@ local server_callbacks = {
"marketplace:respond-offer",
"marketplace:report",
"marketplace:block",
"pages:list",
"pages:get",
"pages:list-own",
"pages:create",
"pages:share-citymarkt",
"pages:react",
"pages:delete",
"sim:insert",
"sim:eject",
"contacts:list",
+1 -1
View File
@@ -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-DnswpAt9.js"></script>
<script type="module" crossorigin src="./assets/sky-index-Bj9LYo1l.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-D9eWeoNZ.css">
</head>
<body>
+69
View File
@@ -502,6 +502,75 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_pages_posts",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "source_type", type = "ENUM('personal', 'citymarkt') NOT NULL DEFAULT 'personal'" },
{ name = "share_date", type = "DATE NULL" },
{ name = "citymarkt_listing_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "title", type = "VARCHAR(80) NOT NULL" },
{ name = "body", type = "TEXT NOT NULL" },
{ name = "category", type = "VARCHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "district", type = "VARCHAR(32) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_pages_citymarkt_listing", columns = "(`citymarkt_listing_id`)" },
{ name = "uniq_sky_phone_pages_daily_share", columns = "(`account_id`, `source_type`, `share_date`)" },
},
indexes = {
{ name = "idx_sky_phone_pages_feed", columns = "(`created_at`)" },
{ name = "idx_sky_phone_pages_category", columns = "(`category`, `created_at`)" },
{ name = "idx_sky_phone_pages_owner", columns = "(`account_id`, `created_at`)" },
{ name = "idx_sky_phone_pages_daily_share", columns = "(`account_id`, `source_type`, `created_at`)" },
},
foreignKeys = {
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
{ column = "citymarkt_listing_id", references = "`sky_phone_marketplace_listings` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_pages_images",
columns = {
{ 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 = "sort_order", type = "TINYINT UNSIGNED NOT NULL" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_pages_image_order", columns = "(`post_id`, `sort_order`)" },
},
foreignKeys = {
{ column = "post_id", references = "`sky_phone_pages_posts` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_pages_reactions",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "post_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "kind", type = "ENUM('like', 'save') NOT NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_pages_reaction", columns = "(`post_id`, `account_id`, `kind`)" },
},
foreignKeys = {
{ column = "post_id", references = "`sky_phone_pages_posts` (`id`) ON DELETE CASCADE" },
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
}
Bridge.Database.Migrate("sky_phone", schema)
+285
View File
@@ -0,0 +1,285 @@
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
return value:match("^%s*(.-)%s*$")
end
local function valid_text(value, minimum, maximum)
local length = type(value) == "string" and utf8.len(value) or nil
return length and length >= minimum and length <= maximum
end
local function new_id()
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
if not rows[1] or type(rows[1].id) ~= "string" then
error("[sky_phone] Database did not generate a Local Pages id.")
end
return rows[1].id
end
local function load_images(post_id)
return Bridge.Database.Query([[
SELECT `media_id`, `gradient`, `sort_order`
FROM `sky_phone_pages_images`
WHERE `post_id` = ?
ORDER BY `sort_order`
]], { post_id })
end
local function validate_images(source, imei, 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
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 }
end
return normalized
end
local function hydrate_posts(rows)
for _, post in ipairs(rows) do
post.images = load_images(post.id)
post.like_count = tonumber(post.like_count) or 0
post.is_liked = tonumber(post.is_liked) or 0
post.is_saved = tonumber(post.is_saved) or 0
post.is_owner = tonumber(post.is_owner) or 0
end
return rows
end
local function list_posts(account_id, where_clause, values, limit, offset)
local parameters = { account_id or 0, account_id or 0, account_id or 0 }
for _, value in ipairs(values) do parameters[#parameters + 1] = value end
parameters[#parameters + 1] = limit
parameters[#parameters + 1] = offset
return hydrate_posts(Bridge.Database.Query(([[
SELECT p.`id`, p.`title`, p.`body`, p.`category`, p.`district`, p.`source_type`,
p.`citymarkt_listing_id`, p.`created_at`,
SUBSTRING_INDEX(a.`email`, '@', 1) AS `author_name`,
(p.`account_id` = ?) AS `is_owner`,
EXISTS(SELECT 1 FROM `sky_phone_pages_reactions` r WHERE r.`post_id` = p.`id`
AND r.`account_id` = ? AND r.`kind` = 'like') AS `is_liked`,
EXISTS(SELECT 1 FROM `sky_phone_pages_reactions` r WHERE r.`post_id` = p.`id`
AND r.`account_id` = ? AND r.`kind` = 'save') AS `is_saved`,
(SELECT COUNT(*) FROM `sky_phone_pages_reactions` r
WHERE r.`post_id` = p.`id` AND r.`kind` = 'like') AS `like_count`,
(SELECT i.`gradient` FROM `sky_phone_pages_images` i
WHERE i.`post_id` = p.`id` ORDER BY i.`sort_order` LIMIT 1) AS `image`,
m.`price` AS `citymarkt_price`
FROM `sky_phone_pages_posts` p
JOIN `sky_phone_accounts` a ON a.`id` = p.`account_id`
LEFT JOIN `sky_phone_marketplace_listings` m ON m.`id` = p.`citymarkt_listing_id`
WHERE %s
ORDER BY p.`created_at` DESC
LIMIT ? OFFSET ?
]]):format(where_clause), parameters))
end
local function optional_account(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then return nil, error_response end
local rows = Bridge.Database.Query("SELECT `account_id` FROM `sky_phone_devices` WHERE `imei` = ? LIMIT 1", { session.imei })
return rows[1] and tonumber(rows[1].account_id) or nil, nil
end
Bridge.Callbacks.Register("sky_phone:pages:list", function(source, data)
if type(data) ~= "table" then return { success = false, error = "invalid_request" } end
local account_id, error_response = optional_account(source)
if error_response then return error_response end
local limit = Config.LocalPages.PageSize
local offset = math.max(0, math.floor(tonumber(data.offset) or 0))
local where = { "1 = 1" }
local values = {}
if data.category and data.category ~= "all" then
if data.category ~= "citymarkt" and not categories[data.category] then
return { success = false, error = "invalid_request" }
end
where[#where + 1] = "p.`category` = ?"
values[#values + 1] = data.category
end
local search = trim(data.search)
if search and search ~= "" then
if utf8.len(search) > 80 then return { success = false, error = "invalid_request" } end
where[#where + 1] = "(p.`title` LIKE ? OR p.`body` LIKE ?)"
values[#values + 1] = "%" .. search .. "%"
values[#values + 1] = "%" .. search .. "%"
end
if data.saved == true then
if not account_id then return { success = false, error = "not_authenticated" } end
where[#where + 1] = "EXISTS(SELECT 1 FROM `sky_phone_pages_reactions` sr WHERE sr.`post_id` = p.`id` AND sr.`account_id` = ? AND sr.`kind` = 'save')"
values[#values + 1] = account_id
end
local rows = list_posts(account_id, table.concat(where, " AND "), values, limit + 1, offset)
local has_more = #rows > limit
if has_more then rows[#rows] = nil end
return { success = true, data = { items = rows, offset = offset, hasMore = has_more } }
end)
Bridge.Callbacks.Register("sky_phone:pages:list-own", function(source)
local account, error_response = SkyPhone.RequireAccount(source)
if not account then return error_response end
local rows = list_posts(account.id, "p.`account_id` = ?", { account.id }, Config.LocalPages.PageSize, 0)
return { success = true, data = { items = rows, offset = 0, hasMore = false } }
end)
Bridge.Callbacks.Register("sky_phone:pages:get", function(source, data)
if type(data) ~= "table" or type(data.id) ~= "string" then
return { success = false, error = "post_not_found" }
end
local account_id, error_response = optional_account(source)
if error_response then return error_response end
local rows = list_posts(account_id, "p.`id` = ?", { data.id }, 1, 0)
return rows[1] and { success = true, data = rows[1] } or { success = false, error = "post_not_found" }
end)
Bridge.Callbacks.Register("sky_phone:pages:create", function(source, data)
local account, error_response = SkyPhone.RequireAccount(source)
if not account then return error_response end
if not SkyPhone.AllowOperation(source, "pages:create", 6, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" then return { success = false, error = "invalid_post" } end
local title = trim(data.title)
local body = trim(data.body)
local district = data.district == "" and nil or data.district
if not valid_text(title, Config.LocalPages.TitleMinLength, Config.LocalPages.TitleMaxLength)
or not valid_text(body, Config.LocalPages.BodyMinLength, Config.LocalPages.BodyMaxLength)
or not categories[data.category]
or (district and not districts[district])
then
return { success = false, error = "invalid_post" }
end
local images = validate_images(source, account.imei, data.images)
if not images then return { success = false, error = "invalid_images" } end
local id = new_id()
local statements = {{
query = [[INSERT INTO `sky_phone_pages_posts`
(`id`, `account_id`, `source_type`, `title`, `body`, `category`, `district`)
VALUES (?, ?, 'personal', ?, ?, ?, ?)]],
params = { id, account.id, title, body, data.category, district },
}}
for index, image in ipairs(images) do
statements[#statements + 1] = {
query = "INSERT INTO `sky_phone_pages_images` (`post_id`, `media_id`, `gradient`, `sort_order`) VALUES (?, ?, ?, ?)",
params = { id, image.id, image.gradient, index },
}
end
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
return { success = true, data = { id = id } }
end)
Bridge.Callbacks.Register("sky_phone:pages:share-citymarkt", function(source, data)
local account, error_response = SkyPhone.RequireAccount(source)
if not account then return error_response end
if not SkyPhone.AllowOperation(source, "pages:share-citymarkt", 3, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" or type(data.listingId) ~= "string" then
return { success = false, error = "citymarkt_not_found" }
end
local listings = Bridge.Database.Query([[
SELECT `id`, `title`, `description`, `district`
FROM `sky_phone_marketplace_listings`
WHERE `id` = ? AND `seller_account_id` = ? AND `status` IN ('active', 'reserved')
LIMIT 1
]], { data.listingId, account.id })
local listing = listings[1]
if not listing then return { success = false, error = "citymarkt_not_found" } end
local existing = Bridge.Database.Query("SELECT `id` FROM `sky_phone_pages_posts` WHERE `citymarkt_listing_id` = ? LIMIT 1", { listing.id })
if existing[1] then return { success = false, error = "citymarkt_already_shared" } end
local daily = Bridge.Database.Query([[
SELECT COUNT(*) AS `count` FROM `sky_phone_pages_posts`
WHERE `account_id` = ? AND `source_type` = 'citymarkt' AND `share_date` = CURRENT_DATE
]], { account.id })
if (tonumber(daily[1] and daily[1].count) or 0) >= Config.LocalPages.CityMarktSharesPerDay then
return { success = false, error = "citymarkt_daily_limit" }
end
local id = new_id()
local statements = {{
query = [[INSERT INTO `sky_phone_pages_posts`
(`id`, `account_id`, `source_type`, `share_date`, `citymarkt_listing_id`, `title`, `body`, `category`, `district`)
VALUES (?, ?, 'citymarkt', CURRENT_DATE, ?, ?, ?, 'citymarkt', ?)]],
params = { id, account.id, listing.id, listing.title, listing.description, listing.district },
}}
local images = Bridge.Database.Query([[
SELECT `media_id`, `gradient`, `sort_order` FROM `sky_phone_marketplace_images`
WHERE `listing_id` = ? ORDER BY `sort_order` LIMIT ?
]], { listing.id, Config.LocalPages.MaxImages })
for _, image in ipairs(images) do
statements[#statements + 1] = {
query = "INSERT INTO `sky_phone_pages_images` (`post_id`, `media_id`, `gradient`, `sort_order`) VALUES (?, ?, ?, ?)",
params = { id, image.media_id, image.gradient, image.sort_order },
}
end
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "citymarkt_daily_limit" }
end
return { success = true, data = { id = id } }
end)
Bridge.Callbacks.Register("sky_phone:pages:react", function(source, data)
local account, error_response = SkyPhone.RequireAccount(source)
if not account then return error_response end
if not SkyPhone.AllowOperation(source, "pages:react", 30, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" or type(data.id) ~= "string" or (data.kind ~= "like" and data.kind ~= "save") or type(data.active) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
local posts = Bridge.Database.Query("SELECT `id` FROM `sky_phone_pages_posts` WHERE `id` = ? LIMIT 1", { data.id })
if not posts[1] then return { success = false, error = "post_not_found" } end
if data.active then
Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_pages_reactions` (`post_id`, `account_id`, `kind`) VALUES (?, ?, ?)", { data.id, account.id, data.kind })
else
Bridge.Database.Query("DELETE FROM `sky_phone_pages_reactions` WHERE `post_id` = ? AND `account_id` = ? AND `kind` = ?", { data.id, account.id, data.kind })
end
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:pages:delete", function(source, data)
local account, error_response = SkyPhone.RequireAccount(source)
if not account then return error_response end
if type(data) ~= "table" or type(data.id) ~= "string" then
return { success = false, error = "post_not_found" }
end
local result = Bridge.Database.Query("DELETE FROM `sky_phone_pages_posts` WHERE `id` = ? AND `account_id` = ?", { data.id, account.id })
local affected = type(result) == "number" and result or type(result) == "table" and tonumber(result.affectedRows) or 0
return affected > 0 and { success = true } or { success = false, error = "post_not_found" }
end)
end)