From e81f0929913531aba57592b322b362ba24165c9c Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Fri, 14 Aug 2026 16:39:41 +0200 Subject: [PATCH] ENH - rebuild Picstagram with SKYUI Replace the legacy Konsta-based layout at its source with SKYUI components and responsive app-specific styling. Add media creation, profile and social improvements, comment reactions and replies, plus Activity and phone notification delivery with the required database migration. --- frontend/src/App.vue | 14 +- frontend/src/stores/phone.ts | 37 +- frontend/src/stores/picstagram.test.ts | 13 +- frontend/src/stores/picstagram.ts | 31 +- frontend/src/types/picstagram.ts | 7 + frontend/src/views/apps/PicstagramApp.vue | 4648 ++++++++++++--------- frontend/testserver/index.cjs | 75 +- sky_phone/config/locales/en.lua | 12 +- sky_phone/source/client/main.lua | 2 + sky_phone/source/server/db_migrate.lua | 24 +- sky_phone/source/server/picstagram.lua | 154 +- sky_phone/sql/install.sql | 13 +- 12 files changed, 2976 insertions(+), 2054 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 830a8ff..f9d4145 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -205,7 +205,15 @@ type PicstagramVerificationData = { type PicstagramNotificationData = { actor?: string device?: PhoneNotificationDevicePayload - kind?: 'like' | 'comment' | 'follow' | 'follow_request' | 'verified' + kind?: + | 'like' + | 'comment' + | 'comment_like' + | 'reply' + | 'follow' + | 'follow_request' + | 'request_accepted' + | 'verified' postId?: string text?: string title?: string @@ -1411,7 +1419,9 @@ onBeforeUnmount(() => { > { const response = await nuiCall<{ id: string }>( 'picstagram:publish-post', @@ -283,8 +285,9 @@ export const usePicstagramStore = defineStore('picstagram', { id: string, body: string, parentId?: string, + replyToId?: string, ): Promise { - return nuiCall('picstagram:comment', { body, id, parentId }) + return nuiCall('picstagram:comment', { body, id, parentId, replyToId }) }, async removeComment(id: string): Promise { const response = await nuiCall('picstagram:remove-comment', { id }) @@ -292,6 +295,29 @@ export const usePicstagramStore = defineStore('picstagram', { this.comments = this.comments.filter((comment) => comment.id !== id) return response.success }, + async reactComment(comment: PicstagramComment): Promise { + const active = !comment.is_liked + comment.is_liked = active + comment.like_count += active ? 1 : -1 + const response = await nuiCall('picstagram:comment-react', { + active, + id: comment.id, + }) + if (response.success) return + comment.is_liked = !active + comment.like_count += active ? -1 : 1 + }, + async loadConnections( + profileId: string, + mode: 'followers' | 'following', + ): Promise { + const response = await nuiCall( + 'picstagram:connections', + { mode, profileId }, + ) + this.connections = response.success && response.data ? response.data : [] + return response.success + }, async loadStories(): Promise { const response = await nuiCall('picstagram:stories') this.stories = response.success && response.data ? response.data : [] @@ -300,10 +326,11 @@ export const usePicstagramStore = defineStore('picstagram', { async publishStory( mediaId: number, body: string, + mediaType: 'photo' | 'video', ): Promise> { const response = await nuiCall<{ id: string }>( 'picstagram:publish-story', - { body, mediaId }, + { body, mediaId, mediaType }, ) if (response.success) await this.loadStories() return response diff --git a/frontend/src/types/picstagram.ts b/frontend/src/types/picstagram.ts index 07fc5ea..9aec5c6 100644 --- a/frontend/src/types/picstagram.ts +++ b/frontend/src/types/picstagram.ts @@ -20,6 +20,7 @@ export type PicstagramProfile = { export type PicstagramPostMedia = { id: number + media_type: 'photo' | 'video' position: number url: string } @@ -67,9 +68,12 @@ export type PicstagramComment = { display_name: string handle: string id: string + is_liked: boolean is_owner: boolean + like_count: number parent_id: string | null profile_id: string + reply_to_handle: string | null verified: boolean } @@ -82,6 +86,7 @@ export type PicstagramStory = { handle: string id: string is_owner: boolean + media_type: 'photo' | 'video' profile_id: string seen: boolean url: string @@ -104,6 +109,8 @@ export type PicstagramActivityKind = | 'request_accepted' | 'like' | 'comment' + | 'comment_like' + | 'reply' | 'verified' export type PicstagramActivity = { diff --git a/frontend/src/views/apps/PicstagramApp.vue b/frontend/src/views/apps/PicstagramApp.vue index 96070a0..bbfd31a 100644 --- a/frontend/src/views/apps/PicstagramApp.vue +++ b/frontend/src/views/apps/PicstagramApp.vue @@ -2,6 +2,7 @@ import { Bell, Bookmark, + Camera, Check, ChevronLeft, ChevronRight, @@ -9,73 +10,76 @@ import { Eye, Heart, Home, + ImagePlus, Images, LockKeyhole, MapPin, MessageCircle, MoreHorizontal, Plus, + Reply, Search, Send, Share2, ShieldAlert, Trash2, UserRound, + UsersRound, + Video, + X, } from 'lucide-vue-next' -import { - kActions, - kActionsButton, - kActionsGroup, - kBadge, - kBlock, - kButton, - kCard, - kChip, - kDialog, - kDialogButton, - kGlass, - kLink, - kList, - kListInput, - kListItem, - kMessagebar, - kNavbar, - kNavbarBackLink, - kPage, - kPreloader, - kSearchbar, - kSegmented, - kSegmentedButton, - kSheet, - kTabbar, - kTabbarLink, - kToast, - kToolbar, - kToolbarPane, - kToggle, -} from 'konsta/vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import picstagramIcon from '@/assets/img/app-icons/picstagram.webp' -import { useMessageMediaStore } from '@/stores/messageMedia' import { useEasyShareStore } from '@/stores/easyshare' +import { useMessageMediaStore } from '@/stores/messageMedia' import { usePhoneStore } from '@/stores/phone' import { usePicstagramStore } from '@/stores/picstagram' import type { PhoneMedia } from '@/types/media' import type { PicstagramActivity, + PicstagramComment, PicstagramPost, PicstagramProfile, PicstagramReportReason, PicstagramReportTarget, PicstagramStory, } from '@/types/picstagram' +import { + SkyActionButton, + SkyActionGroup, + SkyActionSheet, + SkyAppPage, + SkyButton, + SkyCard, + SkyChip, + SkyDialog, + SkyDialogButton, + SkyField, + SkyGlass, + SkyLink, + SkyList, + SkyListItem, + SkyMessagebar, + SkyNavbar, + SkyPillNavigation, + SkyScrollArea, + SkySearchbar, + SkySegmented, + SkySegmentedButton, + SkySheet, + SkySpinner, + SkyToast, + SkyToggle, +} from '@/ui' type Tab = 'home' | 'explore' | 'create' | 'activity' | 'profile' type AuthMode = 'login' | 'register' type ComposeKind = 'post' | 'story' +type ConnectionMode = 'followers' | 'following' type ProfileSection = 'photos' | 'saved' +type MediaSource = 'camera' | 'photos' const phone = usePhoneStore() const store = usePicstagramStore() @@ -100,10 +104,11 @@ const publishing = ref(false) const search = ref('') const profileSection = ref('photos') const selectedPost = ref(null) +const actionPost = ref(null) const selectedStory = ref(null) const commentsOpen = ref(false) const commentBody = ref('') -const replyTo = ref() +const replyingTo = ref(null) const actionsOpen = ref(false) const reportOpen = ref(false) const reportTarget = ref<{ @@ -122,6 +127,8 @@ const profileDraft = ref({ handle: '', private: false, }) +const connectionsOpen = ref(false) +const connectionsMode = ref('followers') const logoutDialogOpen = ref(false) const logoutSubmitting = ref(false) const deleteDialogOpen = ref(false) @@ -130,18 +137,43 @@ const storyViewersOpen = ref(false) const moderationOpen = ref(false) const feedback = ref('') const carouselIndexes = ref>({}) +const commentLikePulseId = ref(null) +const reactionPulse = ref<{ id: string; kind: 'like' | 'save' } | null>(null) let feedbackTimer: number | null = null let searchTimer: number | null = null +let commentLikePulseTimer: number | null = null +let reactionPulseTimer: number | null = null const currentProfile = computed(() => store.viewedProfile ?? store.profile) const unreadCount = computed( () => store.activities.filter((activity) => !activity.read_at).length, ) +const tabIndex = computed(() => + ['home', 'explore', 'create', 'activity', 'profile'].indexOf(tab.value), +) const profileGrid = computed(() => profileSection.value === 'saved' && currentProfile.value?.is_owner ? store.saved : store.profilePosts, ) +const commentThreads = computed(() => { + const roots = store.comments.filter((comment) => !comment.parent_id) + return roots.map((comment) => ({ + comment, + replies: store.comments + .filter((reply) => reply.parent_id === comment.id) + .map((reply) => { + const mention = reply.body.match(/^@([a-z0-9._-]+)\s+/i) + return { + ...reply, + display_body: mention + ? reply.body.slice(mention[0].length) + : reply.body, + display_reply_to_handle: mention?.[1] ?? reply.reply_to_handle, + } + }), + })) +}) const selectedStoryPosition = computed(() => selectedStory.value ? store.stories.findIndex((story) => story.id === selectedStory.value?.id) @@ -191,10 +223,6 @@ function t(key: string, replacements?: Record): string { return phone.t(`Apps.picstagram.${key}`, replacements) } -function inputValue(event: Event): string { - return (event.target as HTMLInputElement).value -} - function initials(value: string): string { return value.trim().slice(0, 2).toUpperCase() || 'PS' } @@ -260,9 +288,7 @@ async function showTab(next: Tab): Promise { tab.value = next selectedPost.value = null if (next === 'explore' && !store.explore.length) await store.loadExplore() - if (next === 'activity') { - await store.loadActivities() - } + if (next === 'activity') await store.loadActivities() if (next === 'profile' && store.profile) { await store.loadProfile({ profileId: store.profile.id }) store.viewedProfile = null @@ -276,11 +302,20 @@ async function openProfile(profile: PicstagramProfile | string): Promise { notify(errorMessage('profile_not_found')) return } + commentsOpen.value = false + connectionsOpen.value = false selectedPost.value = null tab.value = 'profile' profileSection.value = 'photos' } +async function openConnectionProfile( + profile: PicstagramProfile, +): Promise { + connectionsOpen.value = false + await openProfile(profile) +} + function goBackFromProfile(): void { store.viewedProfile = null tab.value = 'explore' @@ -297,15 +332,25 @@ function closePost(): void { async function openComments(post: PicstagramPost): Promise { selectedPost.value = post await store.loadComments(post.id) + replyingTo.value = null commentsOpen.value = true } +function startReply(comment: PicstagramComment): void { + replyingTo.value = comment + commentBody.value = '' +} + async function submitComment(): Promise { if (!selectedPost.value || !commentBody.value.trim()) return + const body = replyingTo.value + ? `@${replyingTo.value.handle} ${commentBody.value.trim()}` + : commentBody.value.trim() const response = await store.comment( selectedPost.value.id, - commentBody.value.trim(), - replyTo.value, + body, + replyingTo.value?.parent_id ?? replyingTo.value?.id, + replyingTo.value?.id, ) if (!response.success) { notify(errorMessage(response.error)) @@ -313,16 +358,29 @@ async function submitComment(): Promise { } selectedPost.value.comment_count += 1 commentBody.value = '' - replyTo.value = undefined + replyingTo.value = null await store.loadComments(selectedPost.value.id) } -function chooseComposeMedia(): void { +async function reactComment(comment: PicstagramComment): Promise { + if (commentLikePulseTimer !== null) window.clearTimeout(commentLikePulseTimer) + commentLikePulseId.value = comment.id + await store.reactComment(comment) + commentLikePulseTimer = window.setTimeout(() => { + commentLikePulseId.value = null + commentLikePulseTimer = null + }, 360) +} + +function openComposeMedia( + source: MediaSource, + mediaType: 'photo' | 'video', +): void { mediaPicker.begin( 'picstagram:compose', - 'photo', + mediaType, '/apps/picstagram?tab=create', - composeKind.value === 'post' ? 5 : 1, + composeKind.value === 'post' && mediaType === 'photo' ? 5 : 1, { caption: caption.value, commentsEnabled: commentsEnabled.value, @@ -332,12 +390,12 @@ function chooseComposeMedia(): void { }, ) void router.push({ - path: '/apps/photos', - query: { mediaAttachment: 'photo' }, + path: `/apps/${source}`, + query: { mediaAttachment: mediaType }, }) } -function chooseAvatar(): void { +function openAvatarMedia(source: MediaSource): void { mediaPicker.begin( 'picstagram:avatar', 'photo', @@ -346,7 +404,7 @@ function chooseAvatar(): void { { ...profileDraft.value }, ) void router.push({ - path: '/apps/photos', + path: `/apps/${source}`, query: { mediaAttachment: 'photo' }, }) } @@ -375,7 +433,8 @@ function clearAvatarSelection(): void { } async function publish(): Promise { - if (!selectedMedia.value.length || publishing.value) return + const media = selectedMedia.value[0] + if (!media || publishing.value) return publishing.value = true const response = composeKind.value === 'post' @@ -383,9 +442,10 @@ async function publish(): Promise { caption: caption.value, commentsEnabled: commentsEnabled.value, location: location.value, - mediaIds: selectedMedia.value.map((media) => media.id), + mediaIds: selectedMedia.value.map((entry) => entry.id), + mediaType: media.mediaType, }) - : await store.publishStory(selectedMedia.value[0].id, storyText.value) + : await store.publishStory(media.id, storyText.value, media.mediaType) publishing.value = false if (!response.success) { notify(errorMessage(response.error)) @@ -404,6 +464,16 @@ function updateCarousel(post: PicstagramPost, event: Event): void { ) } +function moveCarousel(post: PicstagramPost, direction: 1 | -1): void { + const current = carouselIndexes.value[post.id] ?? 0 + const next = Math.max(0, Math.min(post.media.length - 1, current + direction)) + const element = document.querySelector( + `[data-picstagram-carousel="${post.id}"]`, + ) + element?.scrollTo({ behavior: 'smooth', left: next * element.clientWidth }) + carouselIndexes.value[post.id] = next +} + async function react( post: PicstagramPost, kind: 'like' | 'save', @@ -411,14 +481,37 @@ async function react( if (!(await store.react(post, kind))) notify(errorMessage()) } +async function reactWithPulse( + post: PicstagramPost, + kind: 'like' | 'save', +): Promise { + if (reactionPulseTimer !== null) window.clearTimeout(reactionPulseTimer) + reactionPulse.value = { id: post.id, kind } + await react(post, kind) + reactionPulseTimer = window.setTimeout(() => { + reactionPulse.value = null + reactionPulseTimer = null + }, 440) +} + function likeFromMedia(post: PicstagramPost): void { - if (!post.is_liked) void react(post, 'like') + if (!post.is_liked) void reactWithPulse(post, 'like') } async function follow(profile: PicstagramProfile): Promise { if (!(await store.followProfile(profile))) notify(errorMessage()) } +async function openConnections(mode: ConnectionMode): Promise { + if (!currentProfile.value) return + connectionsMode.value = mode + if (!(await store.loadConnections(currentProfile.value.id, mode))) { + notify(errorMessage()) + return + } + connectionsOpen.value = true +} + function editProfile(): void { if (!store.profile) return profileDraft.value = { @@ -452,7 +545,12 @@ async function saveProfile(): Promise { } function showPostActions(post: PicstagramPost): void { - selectedPost.value = post + actionPost.value = post + actionsOpen.value = true +} + +function showProfileActions(): void { + actionPost.value = null actionsOpen.value = true } @@ -512,19 +610,14 @@ function reportCurrentProfile(): void { ) } -function editProfileFromActions(): void { - editProfile() - actionsOpen.value = false -} - -function openModerationFromActions(): void { - void openModeration() - actionsOpen.value = false +function reportComment(comment: PicstagramComment): void { + commentsOpen.value = false + startReport('comment', comment.id, `@${comment.handle}`) } function archiveSelectedPost(): void { - if (!selectedPost.value) return - void store.setPostStatus(selectedPost.value, 'archived') + if (!actionPost.value) return + void store.setPostStatus(actionPost.value, 'archived') actionsOpen.value = false } @@ -545,7 +638,7 @@ async function submitReport(): Promise { } async function confirmBlock(): Promise { - const profileId = selectedPost.value?.profile_id ?? currentProfile.value?.id + const profileId = actionPost.value?.profile_id ?? currentProfile.value?.id if (!profileId) return blockDialogOpen.value = false actionsOpen.value = false @@ -554,12 +647,13 @@ async function confirmBlock(): Promise { } async function deleteSelectedPost(): Promise { - if (!selectedPost.value) return - const post = selectedPost.value + if (!actionPost.value) return + const post = actionPost.value deleteDialogOpen.value = false actionsOpen.value = false if (await store.setPostStatus(post, 'removed')) { - selectedPost.value = null + if (selectedPost.value?.id === post.id) selectedPost.value = null + actionPost.value = null notify(t('deletePost')) } else notify(errorMessage()) } @@ -570,8 +664,7 @@ async function openStory(story: PicstagramStory): Promise { } async function openStoryGroup(stories: PicstagramStory[]): Promise { - const story = stories.find((item) => !item.seen) ?? stories[0] - await openStory(story) + await openStory(stories.find((item) => !item.seen) ?? stories[0]) } async function nextStory(direction: 1 | -1): Promise { @@ -606,6 +699,14 @@ async function openModeration(): Promise { return } moderationOpen.value = true + actionsOpen.value = false +} + +async function resolveReport( + id: string, + action: 'dismiss' | 'hide' | 'remove' | 'restore', +): Promise { + if (!(await store.resolveReport(id, action))) notify(errorMessage()) } function activityProfile(activity: PicstagramActivity): void { @@ -635,10 +736,7 @@ onMounted(async () => { await openProfile(easyShareId) } else if (easyShareId && route.query.easyShareKind === 'post') { const post = await store.loadPost(easyShareId) - if (post) { - tab.value = 'home' - selectedPost.value = post - } + if (post) selectedPost.value = post } const composeSelection = mediaPicker.consumeMany<{ caption?: string @@ -679,2183 +777,2721 @@ onMounted(async () => { onBeforeUnmount(() => { if (feedbackTimer !== null) window.clearTimeout(feedbackTimer) if (searchTimer !== null) window.clearTimeout(searchTimer) + if (commentLikePulseTimer !== null) window.clearTimeout(commentLikePulseTimer) + if (reactionPulseTimer !== null) window.clearTimeout(reactionPulseTimer) }) + + + + + + + + - + {{ t('home') }} + + - + {{ t('explore') }} + + - + {{ t('create') }} + + - - - {{ unreadCount }}{{ t('activity') }} + + - - + > + {{ t('profile') }} + + + - -
-
- - - {{ t('comments') }} - - {{ t('done') }} - - - -
-
-
- {{ t('noComments') }} -
- - - - - - -
- {{ t('reply') }} - - - -
-
- - - -
-
- - - {{ t('editProfile') }} - - {{ t('done') }} - - - -
- - - - {{ initials(profileDraft.displayName) }} - -
- {{ - t('chooseAvatar') - }} - {{ - t('removeAvatar') - }} -
- - - - - - - - - {{ - t('saveProfile') - }} -
-
- - - -
- -