mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:01:31 +00:00
ADD - build Local Pages city feed
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
@@ -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')),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -20,6 +20,7 @@ export type PhoneAppId =
|
||||
| 'sky-flappy'
|
||||
| 'neon-drop'
|
||||
| 'citymarkt'
|
||||
| 'local-pages'
|
||||
|
||||
export type AppLaunchOrigin = {
|
||||
borderRadius: number
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -44,6 +44,7 @@ server_scripts {
|
||||
'source/server/notes.lua',
|
||||
'source/server/mail.lua',
|
||||
'source/server/marketplace.lua',
|
||||
'source/server/pages.lua',
|
||||
}
|
||||
|
||||
files {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user