ENH - overhaul Local Pages profiles

This commit is contained in:
smx.pusha
2026-08-12 13:31:48 +02:00
parent 9710c1c220
commit f79aed3959
11 changed files with 1140 additions and 27 deletions
+66
View File
@@ -30,4 +30,70 @@ describe('Local Pages store', () => {
expect(pages.items[0]?.is_liked).toBe(true)
expect(pages.items[0]?.like_count).toBe(3)
})
it('removes an unsaved post from the saved profile list immediately', async () => {
mockNuiCall.mockResolvedValueOnce({ success: true })
const pages = usePagesStore()
pages.savedItems = [
{ id: 'saved-post', is_saved: true } as never,
{ id: 'other-post', is_saved: true } as never,
]
await pages.react('saved-post', 'save', false)
expect(pages.savedItems.map((item) => item.id)).toEqual(['other-post'])
})
it('adds a newly saved post to the profile list immediately', async () => {
mockNuiCall.mockResolvedValueOnce({ success: true })
const pages = usePagesStore()
pages.items = [{ id: 'feed-post', is_saved: false } as never]
await pages.react('feed-post', 'save', true)
expect(pages.savedItems.map((item) => item.id)).toEqual(['feed-post'])
expect(pages.savedItems[0]?.is_saved).toBe(true)
})
it('stores the profile returned by the server', async () => {
const profile = {
avatar_media_id: 42,
avatar_url: 'https://example.test/avatar.webp',
bio: 'Vinewood tips and city stories.',
email: 'demo@ifruit.com',
exists: true,
handle: 'demo',
post_count: 2,
}
mockNuiCall.mockResolvedValueOnce({ data: profile, success: true })
const response = await usePagesStore().saveProfile({
avatarMediaId: profile.avatar_media_id,
bio: profile.bio,
handle: profile.handle,
})
expect(response.success).toBe(true)
expect(usePagesStore().profile).toEqual(profile)
})
it('keeps an existing profile when an old browser mock returns no profile payload', async () => {
const pages = usePagesStore()
pages.profile = {
avatar_media_id: null,
avatar_url: null,
bio: '',
email: 'demo@ifruit.com',
exists: true,
handle: 'demo',
post_count: 2,
}
mockNuiCall
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({ data: { hasMore: false, items: [], offset: 0 }, success: true })
.mockResolvedValueOnce({ data: { hasMore: false, items: [], offset: 0 }, success: true })
expect(await pages.loadProfile()).toBe(true)
expect(pages.profile.handle).toBe('demo')
})
})
+41 -4
View File
@@ -1,6 +1,12 @@
import { defineStore } from 'pinia'
import type { PagesPage, PagesPost, PagesPostDraft } from '@/types/pages'
import type {
PagesPage,
PagesPost,
PagesPostDraft,
PagesProfile,
PagesProfileDraft,
} from '@/types/pages'
import { nuiCall, type NuiResponse } from '@/utils/nui'
export const usePagesStore = defineStore('pages', {
@@ -8,6 +14,7 @@ export const usePagesStore = defineStore('pages', {
items: [] as PagesPost[],
ownItems: [] as PagesPost[],
savedItems: [] as PagesPost[],
profile: null as PagesProfile | null,
isLoading: false,
}),
actions: {
@@ -19,13 +26,28 @@ export const usePagesStore = defineStore('pages', {
return response.success
},
async loadProfile(): Promise<boolean> {
const [own, saved] = await Promise.all([
const [profile, own, saved] = await Promise.all([
nuiCall<PagesProfile>('pages:profile'),
nuiCall<PagesPage>('pages:list-own'),
nuiCall<PagesPage>('pages:list', { saved: true }),
])
if (profile.success && profile.data) this.profile = profile.data
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
return profile.success && Boolean(profile.data ?? this.profile)
},
async saveProfile(draft: PagesProfileDraft): Promise<NuiResponse<PagesProfile>> {
const response = await nuiCall<PagesProfile>('pages:profile-save', draft)
if (response.success && response.data) {
this.profile = response.data
for (const item of [...this.items, ...this.ownItems, ...this.savedItems]) {
if (item.is_owner) {
item.author_avatar = response.data.avatar_url
item.author_name = response.data.handle
}
}
}
return response
},
get(id: string): Promise<NuiResponse<PagesPost>> {
return nuiCall<PagesPost>('pages:get', { id })
@@ -41,13 +63,28 @@ export const usePagesStore = defineStore('pages', {
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]) {
const knownItems = [...this.items, ...this.ownItems, ...this.savedItems]
for (const item of knownItems) {
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
}
if (kind === 'save' && !active) {
this.savedItems = this.savedItems.filter((item) => item.id !== id)
}
if (kind === 'save' && active && !this.savedItems.some((item) => item.id === id)) {
let savedItem = knownItems.find((item) => item.id === id)
if (!savedItem) {
const loaded = await this.get(id)
savedItem = loaded.success ? loaded.data : undefined
}
if (savedItem) {
savedItem.is_saved = true
this.savedItems.unshift(savedItem)
}
}
}
return response.success
},
+45
View File
@@ -2681,7 +2681,39 @@ const defaultLocales: LocaleTree = {
noPhoto: 'No photo attached',
signInTitle: 'Your Local Pages profile',
signInBody: 'Sign in to iFruit in Settings to publish and save posts.',
authEyebrow: 'Local Pages account',
authWelcome: 'Welcome to Local Pages',
authBody: 'Sign in or create an iFruit account to build your local profile.',
login: 'Sign in',
register: 'Register',
loginTitle: 'Continue with iFruit',
loginBody: 'Your posts, saved items and profile stay linked to your account.',
registerTitle: 'Create an iFruit account',
registerBody: 'Choose your new iFruit address. Your Local Pages profile comes next.',
authEmail: 'iFruit address',
authEmailPlaceholder: 'your.name',
authPassword: 'Password',
authPasswordPlaceholder: 'At least 6 characters',
authConfirmPassword: 'Confirm password',
authConfirmPlaceholder: 'Enter the password again',
showPassword: 'Show password',
hidePassword: 'Hide password',
localCreator: 'Local creator',
createProfile: 'Create your profile',
editProfile: 'Edit profile',
profileSetupBody: 'Your iFruit email is linked automatically.',
profileEmail: 'iFruit email',
profileHandle: 'Username',
profileHandlePlaceholder: 'your.name',
profileHandleHint: 'Use 324 lowercase letters, numbers, dots or underscores.',
profileBio: 'Bio',
profileBioPlaceholder: 'Tell the city a little about yourself...',
profilePhoto: 'Profile photo',
profilePhotoHint: 'Choose a photo from your Gallery or take a new one.',
removeProfilePhoto: 'Remove photo',
profileSave: 'Save profile',
profileCancel: 'Cancel',
profileSaved: 'Your profile was saved.',
myPosts: 'My posts',
saved: 'Saved',
save: 'Save',
@@ -2707,7 +2739,20 @@ const defaultLocales: LocaleTree = {
cityMarktShare: 'Share to Local Pages',
cityMarktShared: 'Shared to Local Pages.',
cityMarktShareHint: 'One CityMarkt share per day',
authErrors: {
invalid_email: 'Enter a valid 332 character iFruit address.',
invalid_password: 'Use a password between 6 and 64 characters.',
invalid_credentials: 'The iFruit address or password is incorrect.',
email_taken: 'This iFruit address is already registered.',
password_mismatch: 'The passwords do not match.',
rate_limited: 'Too many attempts. Try again shortly.',
default: 'The iFruit account request failed.',
},
errors: {
profile_required: 'Create your Local Pages profile first.',
invalid_profile: 'Check your username and bio.',
invalid_profile_image: 'Choose a valid photo from this phone.',
profile_handle_taken: 'This username is already taken.',
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.',
+17
View File
@@ -14,6 +14,7 @@ export type PagesImage = {
}
export type PagesPost = {
author_avatar: string | null
author_name: string
body: string
category: PagesCategory
@@ -40,6 +41,22 @@ export type PagesPostDraft = {
title: string
}
export type PagesProfile = {
avatar_media_id: number | null
avatar_url: string | null
bio: string
email: string
exists: boolean
handle: string
post_count: number
}
export type PagesProfileDraft = {
avatarMediaId: number
bio: string
handle: string
}
export type PagesPage = {
hasMore: boolean
items: PagesPost[]
+737 -19
View File
@@ -6,10 +6,15 @@ import {
ChevronLeft,
ChevronRight,
Compass,
Eye,
EyeOff,
Heart,
ImagePlus,
Images,
KeyRound,
Mail,
MapPin,
Pencil,
Plus,
Share2,
Store,
@@ -18,7 +23,6 @@ import {
X,
} from 'lucide-vue-next'
import {
kButton,
kGlass,
kIcon,
kNavbar,
@@ -38,7 +42,13 @@ import { useMessageMediaStore } from '@/stores/messageMedia'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePagesStore } from '@/stores/pages'
import { usePhoneStore } from '@/stores/phone'
import type { PagesCategory, PagesPost } from '@/types/pages'
import type { PagesCategory, PagesPost, PagesProfileDraft } from '@/types/pages'
import type { PhoneMedia } from '@/types/media'
import {
filterMailAddressInput,
MAIL_ADDRESS_INPUT_MAX_LENGTH,
normalizeMailAddress,
} from '@/utils/mail'
type SelectedPhoto = { background: string; id: string }
type ComposeDraft = {
@@ -49,6 +59,7 @@ type ComposeDraft = {
title: string
}
type MediaContext = { draft: ComposeDraft; photos: SelectedPhoto[] }
type ProfileMediaContext = { draft: PagesProfileDraft }
type Screen = 'main' | 'detail' | 'compose'
type Tab = 'feed' | 'create' | 'profile'
@@ -69,6 +80,16 @@ const search = ref('')
const category = ref<string>('all')
const feedback = ref('')
const reactionPending = ref(false)
const onboardingReady = ref(false)
const authMode = ref<'login' | 'register'>('login')
const authForm = ref({ confirm: '', email: '', password: '' })
const authPending = ref(false)
const authPasswordVisible = ref(false)
const authError = ref('')
const profileEditing = ref(false)
const profilePending = ref(false)
const profileDraft = ref<PagesProfileDraft>({ avatarMediaId: 0, bio: '', handle: '' })
const selectedProfilePhoto = ref<PhoneMedia | null>(null)
const pickedPhotos = ref<SelectedPhoto[]>([])
const draft = ref<ComposeDraft>({
body: '',
@@ -110,6 +131,22 @@ const canPublish = computed(() => {
const body = draft.value.body.trim().length
return title >= 5 && title <= 80 && body >= 10 && body <= 1500
})
const canSaveProfile = computed(() => {
const handle = profileDraft.value.handle.trim().toLowerCase()
return handle.length >= 3
&& handle.length <= 24
&& /^[a-z0-9][a-z0-9._]*[a-z0-9]$/.test(handle)
&& profileDraft.value.bio.trim().length <= 160
})
const profileAvatarUrl = computed(() => selectedProfilePhoto.value?.url
?? (profileDraft.value.avatarMediaId > 0 ? pages.profile?.avatar_url : null))
const authEmailValid = computed(() => normalizeMailAddress(authForm.value.email) !== null)
const authPasswordValid = computed(() => {
const length = authForm.value.password.length
return length >= 6 && length <= 64
})
const authConfirmValid = computed(() => authMode.value === 'login'
|| (authForm.value.confirm.length > 0 && authForm.value.confirm === authForm.value.password))
function label(group: string, value: string): string {
return phone.t(`Apps.localPages.${group}.${value}`)
@@ -147,12 +184,167 @@ async function selectTab(next: Tab): Promise<void> {
tab.value = 'profile'
return
}
if (!pages.profile) await pages.loadProfile()
if (!pages.profile?.exists) {
syncProfileDraft()
profileEditing.value = true
tab.value = 'profile'
screen.value = 'main'
return
}
screen.value = 'compose'
return
}
tab.value = next
screen.value = 'main'
if (next === 'profile' && isAuthenticated.value) await pages.loadProfile()
if (next === 'profile' && isAuthenticated.value) {
await pages.loadProfile()
ensureBrowserProfile()
syncProfileDraft()
profileEditing.value = !pages.profile?.exists
}
}
function updateAuthEmail(event: Event): void {
const input = event.target as HTMLInputElement
const filtered = filterMailAddressInput(input.value)
if (input.value !== filtered) input.value = filtered
authForm.value.email = filtered
}
function inputValue(event: Event): string {
return (event.target as HTMLInputElement).value
}
function switchAuthMode(mode: 'login' | 'register'): void {
authMode.value = mode
authForm.value.confirm = ''
authError.value = ''
}
function authErrorMessage(error?: string): string {
const known = ['invalid_email', 'invalid_password', 'invalid_credentials', 'email_taken', 'rate_limited']
return phone.t(`Apps.localPages.authErrors.${error && known.includes(error) ? error : 'default'}`)
}
async function submitAuth(): Promise<void> {
authError.value = ''
if (!authEmailValid.value) {
authError.value = authErrorMessage('invalid_email')
return
}
if (!authPasswordValid.value) {
authError.value = authErrorMessage('invalid_password')
return
}
if (!authConfirmValid.value) {
authError.value = phone.t('Apps.localPages.authErrors.password_mismatch')
return
}
const email = normalizeMailAddress(authForm.value.email)
if (!email) return
authPending.value = true
const response = authMode.value === 'login'
? await account.login(email, authForm.value.password)
: await account.register(email, authForm.value.password)
authPending.value = false
if (!response.success) {
authError.value = authErrorMessage(response.error)
return
}
authForm.value = { confirm: '', email: '', password: '' }
authPasswordVisible.value = false
await pages.loadProfile()
ensureBrowserProfile()
syncProfileDraft()
profileEditing.value = !pages.profile?.exists
tab.value = 'profile'
screen.value = 'main'
onboardingReady.value = true
}
function syncProfileDraft(): void {
profileDraft.value = {
avatarMediaId: pages.profile?.avatar_media_id ?? 0,
bio: pages.profile?.bio ?? '',
handle: pages.profile?.handle ?? account.email.split('@')[0].toLowerCase(),
}
selectedProfilePhoto.value = null
}
function ensureBrowserProfile(): void {
const onboardingScenario = new URLSearchParams(window.location.search).get('testScenario')
if (!import.meta.env.DEV || onboardingScenario === 'local-pages-onboarding' || pages.profile) return
const handle = account.email.split('@')[0].toLowerCase()
pages.profile = {
avatar_media_id: null,
avatar_url: null,
bio: '',
email: account.email,
exists: true,
handle,
post_count: pages.ownItems.length,
}
}
function editProfile(): void {
syncProfileDraft()
profileEditing.value = true
}
function cancelProfileEdit(): void {
if (!pages.profile?.exists) return
syncProfileDraft()
profileEditing.value = false
}
function openProfileMedia(app: 'camera' | 'photos'): void {
messageMedia.begin(
'local-pages:profile-avatar',
'photo',
'/apps/local-pages?profileEdit=1',
1,
{ draft: { ...profileDraft.value } } satisfies ProfileMediaContext,
)
void router.push({ path: `/apps/${app}`, query: { mediaAttachment: 'photo' } })
}
function removeProfilePhoto(): void {
selectedProfilePhoto.value = null
profileDraft.value.avatarMediaId = 0
}
async function saveProfile(): Promise<void> {
if (!canSaveProfile.value || profilePending.value) {
showFeedback('Apps.localPages.errors.invalid_profile')
return
}
profilePending.value = true
try {
const response = await pages.saveProfile({
avatarMediaId: selectedProfilePhoto.value?.id ?? profileDraft.value.avatarMediaId,
bio: profileDraft.value.bio.trim(),
handle: profileDraft.value.handle.trim().toLowerCase(),
})
if (!response.success) {
showFeedback(`Apps.localPages.errors.${response.error ?? 'default'}`)
return
}
const loaded = await pages.loadProfile()
ensureBrowserProfile()
if (!pages.profile?.exists || (!loaded && !import.meta.env.DEV)) {
showFeedback('Apps.localPages.errors.request_failed')
return
}
syncProfileDraft()
tab.value = 'profile'
screen.value = 'main'
profileEditing.value = false
await loadFeed()
showFeedback('Apps.localPages.profileSaved')
} finally {
profilePending.value = false
}
}
async function openPost(post: PagesPost): Promise<void> {
@@ -280,6 +472,7 @@ function sharePost(post: PagesPost): void {
onMounted(async () => {
const selection = messageMedia.consumeMany<MediaContext>('local-pages:compose')
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>('local-pages:profile-avatar')
if (selection) {
if (selection.context) {
draft.value = selection.context.draft
@@ -292,10 +485,35 @@ onMounted(async () => {
pickedPhotos.value.push({ background: `url(${JSON.stringify(media.url)})`, id })
}
}
if (route.query.compose === '1') screen.value = 'compose'
await loadFeed()
if (isAuthenticated.value) {
await pages.loadProfile()
ensureBrowserProfile()
if (!pages.profile?.exists) {
syncProfileDraft()
profileEditing.value = true
tab.value = 'profile'
screen.value = 'main'
} else {
await loadFeed()
}
if (profileSelection) {
if (profileSelection.context) profileDraft.value = profileSelection.context.draft
if (profileSelection.media[0]) {
selectedProfilePhoto.value = profileSelection.media[0]
profileDraft.value.avatarMediaId = profileSelection.media[0].id
}
profileEditing.value = true
tab.value = 'profile'
screen.value = 'main'
}
} else {
tab.value = 'profile'
screen.value = 'main'
}
onboardingReady.value = true
if (route.query.compose === '1' && pages.profile?.exists) screen.value = 'compose'
const easyShareId = String(route.query.easyShareId ?? '')
if (easyShareId && route.query.easyShareKind === 'post') {
if (pages.profile?.exists && easyShareId && route.query.easyShareKind === 'post') {
const response = await pages.get(easyShareId)
if (response.success && response.data) {
selected.value = response.data
@@ -314,13 +532,19 @@ onMounted(async () => {
:colors="{ bgIos: 'bg-transparent' }"
>
<template v-if="screen === 'main'">
<div v-if="!onboardingReady" class="pages__gate-loading">{{ phone.t('Common.loading') }}</div>
<k-navbar
v-if="onboardingReady && isAuthenticated && pages.profile?.exists"
class="pages-navbar"
:subtitle="phone.t(tab === 'feed' ? 'Apps.localPages.eyebrow' : 'Apps.localPages.name')"
:title="phone.t(tab === 'feed' ? 'Apps.localPages.name' : 'Apps.localPages.profile')"
/>
<section class="pages__content">
<section
v-if="onboardingReady"
class="pages__content"
:class="{ 'pages__content--gate': !isAuthenticated || !pages.profile?.exists }"
>
<template v-if="tab === 'feed'">
<k-glass class="pages-hero-glass">
<div class="pages__hero"><div><small>{{ phone.t('Apps.localPages.cityPulse') }}</small><strong>{{ phone.t('Apps.localPages.heroTitle') }}</strong><span>{{ phone.t('Apps.localPages.heroBody') }}</span></div><MapPin :size="40" /></div>
@@ -338,23 +562,117 @@ onMounted(async () => {
</template>
<template v-else>
<div v-if="!isAuthenticated" class="pages__empty"><UserRound :size="42" /><strong>{{ phone.t('Apps.localPages.signInTitle') }}</strong><span>{{ phone.t('Apps.localPages.signInBody') }}</span></div>
<section v-if="!isAuthenticated" class="pages__auth">
<header class="pages__auth-head">
<span><UserRound :size="23" /></span>
<div><small>{{ phone.t('Apps.localPages.authEyebrow') }}</small><strong>{{ phone.t('Apps.localPages.authWelcome') }}</strong><p>{{ phone.t('Apps.localPages.authBody') }}</p></div>
</header>
<div class="pages__auth-modes">
<button type="button" :class="{ active: authMode === 'login' }" @click="switchAuthMode('login')">{{ phone.t('Apps.localPages.login') }}</button>
<button type="button" :class="{ active: authMode === 'register' }" @click="switchAuthMode('register')">{{ phone.t('Apps.localPages.register') }}</button>
</div>
<form class="pages__auth-form" @submit.prevent="submitAuth">
<div class="pages__auth-copy"><strong>{{ phone.t(authMode === 'login' ? 'Apps.localPages.loginTitle' : 'Apps.localPages.registerTitle') }}</strong><p>{{ phone.t(authMode === 'login' ? 'Apps.localPages.loginBody' : 'Apps.localPages.registerBody') }}</p></div>
<label>
{{ phone.t('Apps.localPages.authEmail') }}
<k-glass class="pages__auth-field">
<Mail :size="16" />
<input :value="authForm.email" :maxlength="MAIL_ADDRESS_INPUT_MAX_LENGTH" autocomplete="username" autocapitalize="none" autocorrect="off" inputmode="email" spellcheck="false" :placeholder="phone.t('Apps.localPages.authEmailPlaceholder')" @input="updateAuthEmail" />
<span v-if="authForm.email && !authForm.email.includes('@')">@ifruit.com</span>
</k-glass>
</label>
<label>
{{ phone.t('Apps.localPages.authPassword') }}
<k-glass class="pages__auth-field">
<KeyRound :size="16" />
<input :value="authForm.password" :type="authPasswordVisible ? 'text' : 'password'" maxlength="64" :autocomplete="authMode === 'login' ? 'current-password' : 'new-password'" :placeholder="phone.t('Apps.localPages.authPasswordPlaceholder')" @input="authForm.password = inputValue($event)" />
<button type="button" :aria-label="phone.t(authPasswordVisible ? 'Apps.localPages.hidePassword' : 'Apps.localPages.showPassword')" @click="authPasswordVisible = !authPasswordVisible"><EyeOff v-if="authPasswordVisible" :size="16" /><Eye v-else :size="16" /></button>
</k-glass>
</label>
<label v-if="authMode === 'register'">
{{ phone.t('Apps.localPages.authConfirmPassword') }}
<k-glass class="pages__auth-field">
<KeyRound :size="16" />
<input :value="authForm.confirm" :type="authPasswordVisible ? 'text' : 'password'" maxlength="64" autocomplete="new-password" :placeholder="phone.t('Apps.localPages.authConfirmPlaceholder')" @input="authForm.confirm = inputValue($event)" />
</k-glass>
</label>
<p v-if="authError" class="pages__auth-error">{{ authError }}</p>
<k-glass class="pages__auth-submit"><button type="submit" :disabled="authPending">{{ phone.t(authMode === 'login' ? 'Apps.localPages.login' : 'Apps.localPages.register') }}</button></k-glass>
</form>
</section>
<template v-else>
<k-glass class="pages-profile-glass">
<div class="pages__profile"><span>{{ account.email.charAt(0).toUpperCase() }}</span><div><small>{{ phone.t('Apps.localPages.localCreator') }}</small><strong>@{{ account.email.split('@')[0] }}</strong><b>{{ pages.ownItems.length }} {{ phone.t('Apps.localPages.posts') }}</b></div></div>
</k-glass>
<k-glass class="pages-segmented-glass">
<div class="pages__segmented"><button :class="{ active: profileMode === 'own' }" @click="profileMode = 'own'">{{ phone.t('Apps.localPages.myPosts') }}</button><button :class="{ active: profileMode === 'saved' }" @click="profileMode = 'saved'">{{ phone.t('Apps.localPages.saved') }}</button></div>
</k-glass>
<section v-if="profileEditing || !pages.profile?.exists" class="pages__profile-editor">
<div class="pages__profile-editor-head">
<span><UserRound :size="21" /></span>
<div>
<strong>{{ phone.t(pages.profile?.exists ? 'Apps.localPages.editProfile' : 'Apps.localPages.createProfile') }}</strong>
<small>{{ phone.t('Apps.localPages.profileSetupBody') }}</small>
</div>
</div>
<div class="pages__profile-photo-editor">
<span class="pages__profile-photo-preview">
<img v-if="profileAvatarUrl" :src="profileAvatarUrl" :alt="phone.t('Apps.localPages.profilePhoto')" />
<UserRound v-else :size="30" />
</span>
<div>
<strong>{{ phone.t('Apps.localPages.profilePhoto') }}</strong>
<small>{{ phone.t('Apps.localPages.profilePhotoHint') }}</small>
<div class="pages__profile-photo-actions">
<k-glass><button type="button" @click="openProfileMedia('camera')"><Camera :size="15" />{{ phone.t('Apps.localPages.camera') }}</button></k-glass>
<k-glass><button type="button" @click="openProfileMedia('photos')"><Images :size="15" />{{ phone.t('Apps.localPages.gallery') }}</button></k-glass>
</div>
<button v-if="profileAvatarUrl" class="pages__profile-photo-remove" type="button" @click="removeProfilePhoto">{{ phone.t('Apps.localPages.removeProfilePhoto') }}</button>
</div>
</div>
<label>
{{ phone.t('Apps.localPages.profileEmail') }}
<k-glass class="pages__profile-field pages__profile-field--readonly">
<Mail :size="16" />
<input :value="pages.profile?.email || account.email" readonly />
</k-glass>
</label>
<label>
{{ phone.t('Apps.localPages.profileHandle') }}
<span>{{ profileDraft.handle.trim().length }}/24</span>
<k-glass class="pages__profile-field">
<b>@</b>
<input v-model="profileDraft.handle" maxlength="24" :placeholder="phone.t('Apps.localPages.profileHandlePlaceholder')" autocapitalize="none" />
</k-glass>
<small>{{ phone.t('Apps.localPages.profileHandleHint') }}</small>
</label>
<label>
{{ phone.t('Apps.localPages.profileBio') }}
<span>{{ profileDraft.bio.trim().length }}/160</span>
<k-glass class="pages__profile-field pages__profile-field--bio">
<textarea v-model="profileDraft.bio" maxlength="160" :placeholder="phone.t('Apps.localPages.profileBioPlaceholder')" />
</k-glass>
</label>
<div class="pages__profile-actions">
<k-glass v-if="pages.profile?.exists" class="pages__profile-action"><button type="button" @click="cancelProfileEdit">{{ phone.t('Apps.localPages.profileCancel') }}</button></k-glass>
<k-glass class="pages__profile-action pages__profile-action--save"><button type="button" :disabled="!canSaveProfile || profilePending" @click="saveProfile">{{ phone.t('Apps.localPages.profileSave') }}</button></k-glass>
</div>
</section>
<template v-else>
<k-glass class="pages-profile-glass">
<div class="pages__profile">
<span><img v-if="pages.profile.avatar_url" :src="pages.profile.avatar_url" alt="" /><template v-else>{{ pages.profile.handle.charAt(0).toUpperCase() }}</template></span>
<div><small>{{ phone.t('Apps.localPages.localCreator') }}</small><strong>@{{ pages.profile.handle }}</strong><b>{{ pages.profile.post_count }} {{ phone.t('Apps.localPages.posts') }}</b><p v-if="pages.profile.bio">{{ pages.profile.bio }}</p></div>
<button class="pages__profile-edit" type="button" :aria-label="phone.t('Apps.localPages.editProfile')" @click="editProfile"><Pencil :size="15" /></button>
</div>
</k-glass>
<k-glass class="pages-segmented-glass">
<div class="pages__segmented"><button :class="{ active: profileMode === 'own' }" @click="profileMode = 'own'">{{ phone.t('Apps.localPages.myPosts') }}</button><button :class="{ active: profileMode === 'saved' }" @click="profileMode = 'saved'">{{ phone.t('Apps.localPages.saved') }}</button></div>
</k-glass>
</template>
</template>
</template>
<div v-if="pages.isLoading" class="pages__empty">{{ phone.t('Common.loading') }}</div>
<div v-else-if="isAuthenticated || tab === 'feed'" class="pages__feed">
<div v-else-if="tab === 'feed' || (isAuthenticated && pages.profile?.exists && !profileEditing)" class="pages__feed">
<k-glass v-for="post in displayedPosts" :key="post.id" class="pages-post-glass">
<article class="pages__post">
<button class="pages__post-open" type="button" @click="openPost(post)">
<div class="pages__post-head"><span>{{ post.author_name.charAt(0).toUpperCase() }}</span><div><strong>@{{ post.author_name }}</strong><small><MapPin :size="10" /> {{ post.district ? phone.t(`Apps.citymarkt.districts.${post.district}`) : phone.t('Apps.localPages.allLosSantos') }} · {{ relativeDate(post.created_at) }}</small></div><i>{{ label('categories', post.category) }}</i></div>
<div class="pages__post-head"><span><img v-if="post.author_avatar" :src="post.author_avatar" alt="" /><template v-else>{{ post.author_name.charAt(0).toUpperCase() }}</template></span><div><strong>@{{ post.author_name }}</strong><small><MapPin :size="10" /> {{ post.district ? phone.t(`Apps.citymarkt.districts.${post.district}`) : phone.t('Apps.localPages.allLosSantos') }} · {{ relativeDate(post.created_at) }}</small></div><i>{{ label('categories', post.category) }}</i></div>
<div v-if="post.image" class="pages__cover" :style="{ background: post.image }"><b v-if="post.images.length > 1">1 / {{ post.images.length }}</b></div>
<h2>{{ post.title }}</h2><p>{{ post.body }}</p>
</button>
@@ -371,6 +689,7 @@ onMounted(async () => {
</section>
<k-tabbar
v-if="isAuthenticated && pages.profile?.exists"
component="nav"
icons
labels
@@ -410,12 +729,27 @@ onMounted(async () => {
</template>
<section v-else-if="screen === 'detail' && selected" class="pages__detail">
<header><k-button component="button" clear rounded @click="screen = 'main'"><ArrowLeft :size="20" /></k-button><strong>{{ phone.t('Apps.localPages.post') }}</strong><k-button v-if="selected.is_owner" component="button" clear rounded class="danger" @click="removePost"><Trash2 :size="18" /></k-button><k-button v-else component="button" clear rounded @click="react('save')"><Bookmark :size="18" :fill="selected.is_saved ? 'currentColor' : 'none'" /></k-button></header>
<header>
<k-glass component="button" type="button" class="pages__detail-control" @click="screen = 'main'">
<ArrowLeft :size="20" />
</k-glass>
<strong>{{ phone.t('Apps.localPages.post') }}</strong>
<k-glass v-if="selected.is_owner" component="button" type="button" class="pages__detail-control danger" @click="removePost">
<Trash2 :size="18" />
</k-glass>
<k-glass v-else component="button" type="button" class="pages__detail-control" @click="react('save')">
<Bookmark :size="18" :fill="selected.is_saved ? 'currentColor' : 'none'" />
</k-glass>
</header>
<div class="pages__detail-scroll">
<div v-if="selected.images.length" class="pages__gallery" :style="{ background: selected.images[galleryIndex]?.gradient }"><button v-if="selected.images.length > 1" @click="moveGallery(-1)"><ChevronLeft /></button><button v-if="selected.images.length > 1" @click="moveGallery(1)"><ChevronRight /></button><span>{{ galleryIndex + 1 }} / {{ selected.images.length }}</span></div>
<article><div class="pages__author"><span>{{ selected.author_name.charAt(0).toUpperCase() }}</span><div><strong>@{{ selected.author_name }}</strong><small>{{ relativeDate(selected.created_at) }}</small></div><i>{{ label('categories', selected.category) }}</i></div><h1>{{ selected.title }}</h1><p>{{ selected.body }}</p><div class="pages__location"><MapPin :size="17" /><div><small>{{ phone.t('Apps.localPages.location') }}</small><strong>{{ selected.district ? phone.t(`Apps.citymarkt.districts.${selected.district}`) : phone.t('Apps.localPages.allLosSantos') }}</strong></div></div><button v-if="selected.source_type === 'citymarkt'" class="pages__market-link" @click="openCityMarktListing"><Store :size="18" /><span><small>{{ phone.t('Apps.localPages.sharedFrom') }}</small><strong>{{ phone.t('Apps.localPages.openCityMarkt') }}</strong></span><b v-if="selected.citymarkt_price">${{ Number(selected.citymarkt_price).toLocaleString() }}</b></button></article>
<article><div class="pages__author"><span><img v-if="selected.author_avatar" :src="selected.author_avatar" alt="" /><template v-else>{{ selected.author_name.charAt(0).toUpperCase() }}</template></span><div><strong>@{{ selected.author_name }}</strong><small>{{ relativeDate(selected.created_at) }}</small></div><i>{{ label('categories', selected.category) }}</i></div><h1>{{ selected.title }}</h1><p>{{ selected.body }}</p><div class="pages__location"><MapPin :size="17" /><div><small>{{ phone.t('Apps.localPages.location') }}</small><strong>{{ selected.district ? phone.t(`Apps.citymarkt.districts.${selected.district}`) : phone.t('Apps.localPages.allLosSantos') }}</strong></div></div><button v-if="selected.source_type === 'citymarkt'" class="pages__market-link" @click="openCityMarktListing"><Store :size="18" /><span><small>{{ phone.t('Apps.localPages.sharedFrom') }}</small><strong>{{ phone.t('Apps.localPages.openCityMarkt') }}</strong></span><b v-if="selected.citymarkt_price">${{ Number(selected.citymarkt_price).toLocaleString() }}</b></button></article>
</div>
<div class="pages__detail-actions"><button :class="{ active: selected.is_liked }" @click="react('like')"><Heart :size="19" :fill="selected.is_liked ? 'currentColor' : 'none'" />{{ selected.like_count }} {{ phone.t('Apps.localPages.likes') }}</button><button @click="sharePost(selected)"><Share2 :size="19" />{{ phone.t('Apps.easyShare.share') }}</button><button @click="react('save')"><Bookmark :size="19" :fill="selected.is_saved ? 'currentColor' : 'none'" />{{ phone.t('Apps.localPages.save') }}</button></div>
<k-glass class="pages__detail-actions">
<button type="button" :class="{ active: selected.is_liked }" @click="react('like')"><Heart :size="19" :fill="selected.is_liked ? 'currentColor' : 'none'" />{{ selected.like_count }} {{ phone.t('Apps.localPages.likes') }}</button>
<button type="button" @click="sharePost(selected)"><Share2 :size="19" />{{ phone.t('Apps.easyShare.share') }}</button>
<button type="button" @click="react('save')"><Bookmark :size="19" :fill="selected.is_saved ? 'currentColor' : 'none'" />{{ phone.t('Apps.localPages.save') }}</button>
</k-glass>
</section>
<section v-else class="pages__compose">
@@ -547,6 +881,18 @@ onMounted(async () => {
height: auto;
padding: 108px 13px 112px;
}
.pages__content--gate {
padding: 68px 18px 34px;
}
.pages__gate-loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
font-size: 12px;
font-weight: 750;
}
.pages-hero-glass,
.pages-profile-glass,
.pages-segmented-glass,
@@ -739,4 +1085,376 @@ onMounted(async () => {
color: #17191a;
box-shadow: 0 4px 12px #00000030;
}
.pages__detail > header .pages__detail-control,
.pages__detail-actions {
overflow: hidden;
border-radius: 10px;
background: var(--color-ios-dark-glass);
box-shadow: var(--shadow-ios-dark-glass);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.pages--light .pages__detail-control,
.pages--light .pages__detail-actions {
background: var(--color-ios-light-glass);
box-shadow: var(--shadow-ios-light-glass);
}
.pages__auth {
padding: 2px 1px 18px;
}
.pages__auth-head {
padding: 5px 2px 15px;
display: flex;
align-items: flex-start;
gap: 11px;
}
.pages__auth-head > span {
width: 44px;
height: 44px;
flex: none;
border-radius: 12px;
display: grid;
place-items: center;
background: #ffd63e1b;
color: var(--yellow);
}
.pages__auth-head div {
min-width: 0;
}
.pages__auth-head small,
.pages__auth-head strong {
display: block;
}
.pages__auth-head small {
color: var(--yellow);
font-size: 9px;
font-weight: 900;
letter-spacing: .06em;
text-transform: uppercase;
}
.pages__auth-head strong {
margin-top: 2px;
font-size: 18px;
}
.pages__auth-head p,
.pages__auth-copy p {
margin: 3px 0 0;
color: var(--muted);
font-size: 11px;
line-height: 1.35;
}
.pages__auth-modes {
margin-bottom: 14px;
padding: 4px;
border-radius: 11px;
display: flex;
background: var(--panel);
}
.pages__auth-modes button {
min-height: 34px;
flex: 1;
border-radius: 8px;
background: transparent;
font-size: 12px;
font-weight: 750;
}
.pages__auth-modes button.active {
background: var(--yellow);
color: #17191a;
}
.pages__auth-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.pages__auth-copy strong {
font-size: 15px;
}
.pages__auth-form label {
color: var(--muted);
font-size: 12px;
font-weight: 750;
}
.pages__auth-field {
min-height: 44px;
margin-top: 6px;
padding: 0 12px;
border-radius: 11px;
display: flex;
align-items: center;
gap: 7px;
color: var(--yellow);
}
.pages__auth-field input {
min-width: 0;
flex: 1;
padding: 11px 0;
border: 0;
outline: 0;
background: transparent;
color: #f7f7f2;
font-size: 13px;
}
.pages--light .pages__auth-field input {
color: #171b1e;
}
.pages__auth-field > span {
color: var(--muted);
font-size: 11px;
font-weight: 600;
}
.pages__auth-field button {
width: 30px;
height: 30px;
margin-right: -7px;
display: grid;
place-items: center;
background: transparent;
color: var(--muted);
}
.pages__auth-error {
margin: -2px 2px 0;
color: #ff6961;
font-size: 11px;
line-height: 1.35;
}
.pages__auth-submit {
border-radius: 11px;
overflow: hidden;
color: var(--yellow);
}
.pages__auth-submit button {
width: 100%;
min-height: 44px;
background: transparent;
font-size: 13px;
font-weight: 850;
}
.pages__auth-submit button:disabled {
opacity: .45;
}
.pages__profile > div {
min-width: 0;
flex: 1;
}
.pages__profile p {
margin: 7px 0 0;
color: var(--muted);
font-size: 11px;
line-height: 1.35;
}
.pages__profile-edit {
width: 34px;
height: 34px;
flex: none;
border-radius: 10px;
display: grid;
place-items: center;
background: #ffffff0d;
color: var(--yellow);
}
.pages--light .pages__profile-edit {
background: #0000000a;
}
.pages__profile-editor {
padding: 2px 1px 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.pages__profile-editor-head {
padding: 5px 2px 2px;
display: flex;
align-items: center;
gap: 10px;
}
.pages__profile-editor-head > span {
width: 42px;
height: 42px;
flex: none;
border-radius: 12px;
display: grid;
place-items: center;
background: #ffd63e1b;
color: var(--yellow);
}
.pages__profile-editor-head strong,
.pages__profile-editor-head small {
display: block;
}
.pages__profile-editor-head strong {
font-size: 17px;
}
.pages__profile-editor-head small {
margin-top: 2px;
color: var(--muted);
font-size: 11px;
line-height: 1.3;
}
.pages__profile-photo-editor {
padding: 12px;
border: 1px solid #ffffff14;
border-radius: 12px;
display: flex;
align-items: center;
gap: 12px;
background: #ffffff08;
}
.pages--light .pages__profile-photo-editor {
border-color: #00000012;
background: #00000005;
}
.pages__profile-photo-preview {
width: 70px;
height: 70px;
flex: none;
overflow: hidden;
border-radius: 50%;
display: grid;
place-items: center;
background: #ffd63e1b;
color: var(--yellow);
}
.pages__profile-photo-preview img,
.pages__profile > span img,
.pages__post-head > span img,
.pages__author > span img {
width: 100%;
height: 100%;
object-fit: cover;
}
.pages__profile-photo-editor > div {
min-width: 0;
flex: 1;
}
.pages__profile-photo-editor strong,
.pages__profile-photo-editor small {
display: block;
}
.pages__profile-photo-editor strong {
font-size: 13px;
}
.pages__profile-photo-editor small {
margin-top: 2px;
color: var(--muted);
font-size: 10px;
line-height: 1.3;
}
.pages__profile-photo-actions {
margin-top: 8px;
display: flex;
gap: 6px;
}
.pages__profile-photo-actions > * {
min-width: 0;
flex: 1;
border-radius: 9px;
overflow: hidden;
}
.pages__profile-photo-actions button {
width: 100%;
min-height: 34px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
background: transparent;
color: var(--yellow);
font-size: 10px;
font-weight: 800;
}
.pages__profile-photo-remove {
margin-top: 7px;
padding: 0;
background: transparent;
color: #ff6961;
font-size: 10px;
font-weight: 700;
}
.pages__profile-editor label {
display: block;
color: var(--muted);
font-size: 12px;
font-weight: 750;
}
.pages__profile-editor label > span {
float: right;
font-size: 10px;
font-weight: 600;
}
.pages__profile-editor label > small {
display: block;
margin: 5px 2px 0;
font-size: 10px;
font-weight: 500;
line-height: 1.3;
}
.pages__profile-field {
min-height: 44px;
margin-top: 6px;
padding: 0 12px;
border-radius: 11px;
display: flex;
align-items: center;
gap: 7px;
color: var(--yellow);
}
.pages__profile-field input,
.pages__profile-field textarea {
min-width: 0;
width: 100%;
margin: 0;
padding: 11px 0;
border: 0;
outline: 0;
background: transparent;
color: inherit;
font-size: 13px;
}
.pages__profile-field input,
.pages__profile-field textarea {
color: #f7f7f2;
}
.pages--light .pages__profile-field input,
.pages--light .pages__profile-field textarea {
color: #171b1e;
}
.pages__profile-field--readonly {
color: var(--muted);
}
.pages__profile-field--readonly input {
color: var(--muted);
}
.pages__profile-field--bio {
align-items: flex-start;
}
.pages__profile-field textarea {
min-height: 88px;
resize: none;
line-height: 1.4;
}
.pages__profile-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.pages__profile-action {
min-width: 96px;
border-radius: 11px;
overflow: hidden;
}
.pages__profile-action button {
width: 100%;
min-height: 42px;
padding: 0 14px;
background: transparent;
font-size: 12px;
font-weight: 800;
}
.pages__profile-action--save {
color: var(--yellow);
}
.pages__profile-action button:disabled {
opacity: .4;
}
</style>
+60 -2
View File
@@ -2458,6 +2458,15 @@ const pagesReactions = [
{ post_id: 'pages-1', account_id: 1, kind: 'like' },
{ post_id: 'pages-3', account_id: 1, kind: 'save' },
]
let pagesProfile = {
avatar_media_id: null,
avatar_url: null,
bio: 'Vinewood tips and city stories.',
email: 'demo@ifruit.com',
exists: true,
handle: 'demo',
}
let pagesOnboardingCompleted = false
function pageView(post) {
const listing = marketplaceListings.find(
@@ -2465,6 +2474,7 @@ function pageView(post) {
)
return {
...post,
author_avatar: post.account_id === 1 ? pagesProfile.avatar_url : null,
citymarkt_price: listing?.price ?? null,
image: post.images[0]?.gradient ?? null,
is_liked: pagesReactions.some(
@@ -7500,6 +7510,54 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: false, error: 'not_authenticated' })
return
}
if (endpoint === 'pages:profile') {
const email = linkedAccount?.email ?? pagesProfile.email
const onboarding =
testScenario === 'local-pages-onboarding' && !pagesOnboardingCompleted
response.json({
success: true,
data: {
avatar_media_id: onboarding ? null : pagesProfile.avatar_media_id,
avatar_url: onboarding ? null : pagesProfile.avatar_url,
bio: onboarding ? '' : pagesProfile.bio,
email,
exists: onboarding ? false : pagesProfile.exists,
handle: onboarding ? email.split('@')[0] : pagesProfile.handle,
post_count: pagesPosts.filter((item) => item.account_id === 1).length,
},
})
return
}
if (endpoint === 'pages:profile-save') {
pagesOnboardingCompleted = true
const avatarMediaId = Number(request.body.avatarMediaId) || 0
const avatarMedia = avatarMediaId > 0
? mockMedia.find((item) => item.id === avatarMediaId && item.mediaType === 'photo')
: null
if (avatarMediaId > 0 && !avatarMedia) {
response.json({ success: false, error: 'invalid_profile_image' })
return
}
pagesProfile = {
avatar_media_id: avatarMedia?.id ?? null,
avatar_url: avatarMedia?.url ?? null,
bio: String(request.body.bio ?? '').trim(),
email: linkedAccount?.email ?? pagesProfile.email,
exists: true,
handle: String(request.body.handle ?? '').trim().toLowerCase(),
}
pagesPosts.forEach((post) => {
if (post.account_id === 1) post.author_name = pagesProfile.handle
})
response.json({
success: true,
data: {
...pagesProfile,
post_count: pagesPosts.filter((item) => item.account_id === 1).length,
},
})
return
}
if (endpoint === 'pages:list-own') {
response.json({
success: true,
@@ -7530,7 +7588,7 @@ app.post('/api/:endpoint', (request, response) => {
...request.body,
id,
account_id: 1,
author_name: 'demo',
author_name: pagesProfile.handle,
source_type: 'personal',
citymarkt_listing_id: null,
created_at: new Date().toISOString(),
@@ -7570,7 +7628,7 @@ app.post('/api/:endpoint', (request, response) => {
pagesPosts.unshift({
id,
account_id: 1,
author_name: 'demo',
author_name: pagesProfile.handle,
source_type: 'citymarkt',
citymarkt_listing_id: listing.id,
title: listing.title,
+3
View File
@@ -360,6 +360,9 @@ Config.Marketplace = {
Config.LocalPages = {
PageSize = 20,
MaxImages = 6,
ProfileHandleMinLength = 3,
ProfileHandleMaxLength = 24,
ProfileBioMaxLength = 160,
TitleMinLength = 5,
TitleMaxLength = 80,
BodyMinLength = 10,
+13 -1
View File
@@ -1245,13 +1245,25 @@ Locales["en"] = {
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.",
authEyebrow = "Local Pages account", authWelcome = "Welcome to Local Pages",
authBody = "Sign in or create an iFruit account to build your local profile.", login = "Sign in", register = "Register",
loginTitle = "Continue with iFruit", loginBody = "Your posts, saved items and profile stay linked to your account.",
registerTitle = "Create an iFruit account", registerBody = "Choose your new iFruit address. Your Local Pages profile comes next.",
authEmail = "iFruit address", authEmailPlaceholder = "your.name", authPassword = "Password",
authPasswordPlaceholder = "At least 6 characters", authConfirmPassword = "Confirm password",
authConfirmPlaceholder = "Enter the password again", showPassword = "Show password", hidePassword = "Hide password",
localCreator = "Local creator", myPosts = "My posts", saved = "Saved", save = "Save", likes = "likes",
createProfile = "Create your profile", editProfile = "Edit profile", profileSetupBody = "Your iFruit email is linked automatically.",
profileEmail = "iFruit email", profileHandle = "Username", profileHandlePlaceholder = "your.name",
profileHandleHint = "Use 3-24 lowercase letters, numbers, dots or underscores.", profileBio = "Bio",
profileBioPlaceholder = "Tell the city a little about yourself...", profilePhoto = "Profile photo", profilePhotoHint = "Choose a photo from your Gallery or take a new one.", removeProfilePhoto = "Remove photo", profileSave = "Save profile", profileCancel = "Cancel", profileSaved = "Your profile was saved.",
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." },
authErrors = { invalid_email = "Enter a valid 3-32 character iFruit address.", invalid_password = "Use a password between 6 and 64 characters.", invalid_credentials = "The iFruit address or password is incorrect.", email_taken = "This iFruit address is already registered.", password_mismatch = "The passwords do not match.", rate_limited = "Too many attempts. Try again shortly.", default = "The iFruit account request failed." },
errors = { profile_required = "Create your Local Pages profile first.", invalid_profile = "Check your username and bio.", invalid_profile_image = "Choose a valid photo from this phone.", profile_handle_taken = "This username is already taken.", 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",
+2
View File
@@ -59,6 +59,8 @@ local server_callbacks = {
"marketplace:block",
"pages:list",
"pages:get",
"pages:profile",
"pages:profile-save",
"pages:list-own",
"pages:create",
"pages:share-citymarkt",
+31
View File
@@ -775,6 +775,37 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_pages_profiles",
columns = {
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
{
name = "handle",
type = "VARCHAR(24) NOT NULL",
characterSet = "ascii",
collation = "ascii_general_ci",
},
{ name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
{ 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 = "account_id",
uniqueKeys = {
{ name = "uniq_sky_phone_pages_profile_handle", columns = "(`handle`)" },
},
indexes = {
{ name = "idx_sky_phone_pages_profile_avatar", columns = "(`avatar_media_id`)" },
},
foreignKeys = {
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
{ column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_pages_posts",
columns = {
+125 -1
View File
@@ -14,6 +14,27 @@ local function valid_text(value, minimum, maximum)
return length and length >= minimum and length <= maximum
end
local function normalize_handle(value)
local handle = trim(value)
if not handle then return nil end
handle = handle:lower():gsub("^@", "")
local length = utf8.len(handle)
if not length
or length < Config.LocalPages.ProfileHandleMinLength
or length > Config.LocalPages.ProfileHandleMaxLength
or not handle:match("^[a-z0-9][a-z0-9._]*[a-z0-9]$")
then
return nil
end
return handle
end
local function default_handle(account)
local email_name = type(account.email) == "string" and account.email:match("^([^@]+)") or ""
local candidate = email_name:lower():gsub("[^a-z0-9._]", "_"):sub(1, Config.LocalPages.ProfileHandleMaxLength)
return normalize_handle(candidate) or ("local%s"):format(account.id)
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
@@ -83,7 +104,8 @@ local function list_posts(account_id, where_clause, values, limit, 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`, UNIX_TIMESTAMP(p.`created_at`) AS `created_at_unix`,
SUBSTRING_INDEX(a.`email`, '@', 1) AS `author_name`,
COALESCE(profile.`handle`, SUBSTRING_INDEX(a.`email`, '@', 1)) AS `author_name`,
avatar.`url` AS `author_avatar`,
(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`,
@@ -96,6 +118,8 @@ local function list_posts(account_id, where_clause, values, limit, offset)
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_pages_profiles` profile ON profile.`account_id` = p.`account_id`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id`
LEFT JOIN `sky_phone_marketplace_listings` m ON m.`id` = p.`citymarkt_listing_id`
WHERE %s
ORDER BY p.`created_at` DESC
@@ -110,6 +134,102 @@ local function optional_account(source)
return rows[1] and tonumber(rows[1].account_id) or nil, nil
end
local function profile_dto(account)
local rows = Bridge.Database.Query([[
SELECT profile.`handle`, profile.`bio`, profile.`avatar_media_id`, avatar.`url` AS `avatar_url`,
(SELECT COUNT(*) FROM `sky_phone_pages_posts` post
WHERE post.`account_id` = ?) AS `post_count`
FROM `sky_phone_accounts` account
LEFT JOIN `sky_phone_pages_profiles` profile ON profile.`account_id` = account.`id`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id`
WHERE account.`id` = ?
LIMIT 1
]], { account.id, account.id })
local row = rows[1]
if not row then
error(("[sky_phone] Could not load Local Pages profile account %s."):format(account.id))
end
return {
avatar_media_id = tonumber(row.avatar_media_id),
avatar_url = row.avatar_url,
bio = row.bio or "",
email = account.email,
exists = row.handle ~= nil,
handle = row.handle or default_handle(account),
post_count = tonumber(row.post_count) or 0,
}
end
local function require_profile(account_id)
local rows = Bridge.Database.Query([[
SELECT `account_id`, `handle`, `bio`
FROM `sky_phone_pages_profiles`
WHERE `account_id` = ?
LIMIT 1
]], { account_id })
if not rows[1] then
return nil, { success = false, error = "profile_required" }
end
return rows[1], nil
end
Bridge.Callbacks.Register("sky_phone:pages:profile", function(source)
local account, error_response = SkyPhone.RequireAccount(source)
if not account then return error_response end
return { success = true, data = profile_dto(account) }
end)
Bridge.Callbacks.Register("sky_phone:pages:profile-save", function(source, data)
local account, error_response = SkyPhone.RequireAccount(source)
if not account then return error_response end
if not SkyPhone.AllowOperation(source, "pages:profile-save", 12, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" or type(data.bio) ~= "string" then
return { success = false, error = "invalid_profile" }
end
local handle = normalize_handle(data.handle)
local bio = trim(data.bio)
local avatar_media_id = tonumber(data.avatarMediaId)
if not handle or not valid_text(bio, 0, Config.LocalPages.ProfileBioMaxLength)
or not avatar_media_id or avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id)
then
return { success = false, error = "invalid_profile" }
end
if avatar_media_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(avatar_media_id), "photo") then
Bridge.Debug("warn", "[sky_phone] Rejected unowned Local Pages profile image from source %s.", tostring(source))
return { success = false, error = "invalid_profile_image" }
end
local duplicate = Bridge.Database.Query([[
SELECT `account_id`
FROM `sky_phone_pages_profiles`
WHERE `handle` = ? AND `account_id` <> ?
LIMIT 1
]], { handle, account.id })
if duplicate[1] then
return { success = false, error = "profile_handle_taken" }
end
local existing = Bridge.Database.Query([[
SELECT `account_id`
FROM `sky_phone_pages_profiles`
WHERE `account_id` = ?
LIMIT 1
]], { account.id })
if existing[1] then
Bridge.Database.Query([[
UPDATE `sky_phone_pages_profiles`
SET `handle` = ?, `bio` = ?, `avatar_media_id` = NULLIF(?, 0)
WHERE `account_id` = ?
]], { handle, bio, avatar_media_id, account.id })
else
Bridge.Database.Query([[
INSERT INTO `sky_phone_pages_profiles` (`account_id`, `handle`, `bio`, `avatar_media_id`)
VALUES (?, ?, ?, NULLIF(?, 0))
]], { account.id, handle, bio, avatar_media_id })
end
return { success = true, data = profile_dto(account) }
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)
@@ -163,6 +283,8 @@ 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
local _, profile_error = require_profile(account.id)
if profile_error then return profile_error end
if not SkyPhone.AllowOperation(source, "pages:create", 6, 60) then
return { success = false, error = "rate_limited" }
end
@@ -201,6 +323,8 @@ 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
local _, profile_error = require_profile(account.id)
if profile_error then return profile_error end
if not SkyPhone.AllowOperation(source, "pages:share-citymarkt", 3, 60) then
return { success = false, error = "rate_limited" }
end