From f79aed395929db89188ac84de9e8954027110849 Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:31:48 +0200 Subject: [PATCH] ENH - overhaul Local Pages profiles --- frontend/src/stores/pages.test.ts | 66 ++ frontend/src/stores/pages.ts | 45 +- frontend/src/stores/phone.ts | 45 ++ frontend/src/types/pages.ts | 17 + frontend/src/views/apps/LocalPagesApp.vue | 756 +++++++++++++++++++++- frontend/testserver/index.cjs | 62 +- sky_phone/config/config.lua | 3 + sky_phone/config/locales/en.lua | 14 +- sky_phone/source/client/main.lua | 2 + sky_phone/source/server/db_migrate.lua | 31 + sky_phone/source/server/pages.lua | 126 +++- 11 files changed, 1140 insertions(+), 27 deletions(-) diff --git a/frontend/src/stores/pages.test.ts b/frontend/src/stores/pages.test.ts index e2d027c..322f444 100644 --- a/frontend/src/stores/pages.test.ts +++ b/frontend/src/stores/pages.test.ts @@ -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') + }) }) diff --git a/frontend/src/stores/pages.ts b/frontend/src/stores/pages.ts index a5e492d..1d2df3e 100644 --- a/frontend/src/stores/pages.ts +++ b/frontend/src/stores/pages.ts @@ -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 { - const [own, saved] = await Promise.all([ + const [profile, own, saved] = await Promise.all([ + nuiCall('pages:profile'), nuiCall('pages:list-own'), nuiCall('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> { + const response = await nuiCall('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> { return nuiCall('pages:get', { id }) @@ -41,13 +63,28 @@ export const usePagesStore = defineStore('pages', { async react(id: string, kind: 'like' | 'save', active: boolean): Promise { const response = await nuiCall('pages:react', { active, id, kind }) if (response.success) { - for (const item of [...this.items, ...this.ownItems, ...this.savedItems]) { + 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 }, diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index f7adedd..61412d4 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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 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.', 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 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.', diff --git a/frontend/src/types/pages.ts b/frontend/src/types/pages.ts index 4eca591..02cc24e 100644 --- a/frontend/src/types/pages.ts +++ b/frontend/src/types/pages.ts @@ -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[] diff --git a/frontend/src/views/apps/LocalPagesApp.vue b/frontend/src/views/apps/LocalPagesApp.vue index 6ce5132..56d2a19 100644 --- a/frontend/src/views/apps/LocalPagesApp.vue +++ b/frontend/src/views/apps/LocalPagesApp.vue @@ -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('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({ avatarMediaId: 0, bio: '', handle: '' }) +const selectedProfilePhoto = ref(null) const pickedPhotos = ref([]) const draft = ref({ 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 { 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 { + 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 { + 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 { @@ -280,6 +472,7 @@ function sharePost(post: PagesPost): void { onMounted(async () => { const selection = messageMedia.consumeMany('local-pages:compose') + const profileSelection = messageMedia.consumeMany('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' }" >