From 1bcf04d021074cc79b59ff2ca39f75b2e142fc8b Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Sun, 16 Aug 2026 21:51:04 +0200 Subject: [PATCH] feat: overhaul FlipTok and garage interfaces --- frontend/src/stores/fliptok.ts | 21 - frontend/src/types/fliptok.ts | 20 +- frontend/src/types/garage.ts | 1 + .../views/apps/FlipTokApp.contract.test.ts | 25 +- frontend/src/views/apps/FlipTokApp.vue | 910 +++++++++++++----- .../src/views/apps/GarageApp.contract.test.ts | 28 +- frontend/src/views/apps/GarageApp.vue | 141 ++- sky_phone/config/config.lua | 10 +- sky_phone/config/locales/en.lua | 8 +- sky_phone/source/server/db_migrate.lua | 23 + sky_phone/source/server/fliptok.lua | 180 ++-- sky_phone/source/server/garage.lua | 25 + sky_phone/sql/install.sql | 10 + 13 files changed, 994 insertions(+), 408 deletions(-) diff --git a/frontend/src/stores/fliptok.ts b/frontend/src/stores/fliptok.ts index 0d24f12..0d2d7a0 100644 --- a/frontend/src/stores/fliptok.ts +++ b/frontend/src/stores/fliptok.ts @@ -7,7 +7,6 @@ import type { FlipTokPage, FlipTokProfile, FlipTokProfilePage, - FlipTokReport, FlipTokVideo, } from '@/types/fliptok' import { nuiCall, type NuiResponse } from '@/utils/nui' @@ -19,13 +18,11 @@ export const useFlipTokStore = defineStore('fliptok', { comments: [] as FlipTokComment[], connections: [] as FlipTokProfile[], feed: [] as FlipTokVideo[], - isAdmin: false, loading: false, musicTracks: [] as FlipTokMusicTrack[], mode: 'for-you' as 'for-you' | 'following', profile: null as FlipTokProfile | null, profileVideos: [] as FlipTokVideo[], - reports: [] as FlipTokReport[], searchResults: [] as FlipTokVideo[], viewedProfile: null as FlipTokProfile | null, }), @@ -65,7 +62,6 @@ export const useFlipTokStore = defineStore('fliptok', { const response = await nuiCall<{ authenticated: boolean feed?: FlipTokPage - isAdmin?: boolean musicTracks: FlipTokMusicTrack[] profile?: FlipTokProfile }>('fliptok:bootstrap') @@ -74,7 +70,6 @@ export const useFlipTokStore = defineStore('fliptok', { this.authenticated = response.data.authenticated === true this.profile = response.data.profile ?? null this.feed = response.data.feed?.items ?? [] - this.isAdmin = response.data.isAdmin === true this.musicTracks = response.data.musicTracks ?? [] return true }, @@ -247,21 +242,5 @@ export const useFlipTokStore = defineStore('fliptok', { this.activities = response.success && response.data ? response.data : [] if (response.success) await nuiCall('fliptok:mark-activities') }, - async loadReports(): Promise { - const response = await nuiCall('fliptok:admin-reports') - this.reports = response.success && response.data ? response.data : [] - return response.success - }, - async resolveReport( - id: string, - action: 'dismiss' | 'remove', - ): Promise { - const response = await nuiCall('fliptok:admin-resolve-report', { - action, - id, - }) - if (response.success) await this.loadReports() - return response.success - }, }, }) diff --git a/frontend/src/types/fliptok.ts b/frontend/src/types/fliptok.ts index e0a0791..744969e 100644 --- a/frontend/src/types/fliptok.ts +++ b/frontend/src/types/fliptok.ts @@ -29,6 +29,12 @@ export type FlipTokVideo = { is_saved: boolean like_count: number location: string + media?: Array<{ + id: number + mediaType: 'photo' | 'video' + url: string + }> + media_type?: 'photo' | 'video' cover_time_ms: number music_artist: string music_source: '' | 'audio' | 'youtube' @@ -54,20 +60,6 @@ export type FlipTokMusicTrack = { url: string } -export type FlipTokReport = { - caption: string - created_at: number - creator_display_name: string - creator_handle: string - details: string - id: string - reason: 'spam' | 'harassment' | 'dangerous' | 'illegal' | 'other' - reporter_display_name: string - reporter_handle: string - url: string - video_id: string -} - export type FlipTokProfilePage = { profile: FlipTokProfile videos: FlipTokVideo[] diff --git a/frontend/src/types/garage.ts b/frontend/src/types/garage.ts index ee67205..d3027ae 100644 --- a/frontend/src/types/garage.ts +++ b/frontend/src/types/garage.ts @@ -14,6 +14,7 @@ export type GarageVehicle = { engine: number | null fuel: number | null id: string + imageUrl?: string | null kind: GarageVehicleKind location: string model: number | string | null diff --git a/frontend/src/views/apps/FlipTokApp.contract.test.ts b/frontend/src/views/apps/FlipTokApp.contract.test.ts index 0fc116e..c9ecd0d 100644 --- a/frontend/src/views/apps/FlipTokApp.contract.test.ts +++ b/frontend/src/views/apps/FlipTokApp.contract.test.ts @@ -51,14 +51,17 @@ describe('FlipTokApp Sky UI contract', () => { expect(source).toContain('comment-like-pulse') expect(source).toContain('@keyframes comment-heart-pop') expect(source).toContain('class="comment-row comment-row--reply"') + expect(source).toContain('v-show="expandedCommentThreads.has(thread.comment.id)"') + expect(source).toContain("t('showReplies'") expect(source).toContain('form.comments-composer') }) - it('keeps moderation and profile actions compact and inside Sky UI', () => { - expect(source).toContain('class="moderation-button"') - expect(source).toContain('') - expect(source).toContain('.moderation-button__surface') - expect(source).toMatch(/\.profile-actions\s*\{[^}]*max-width:\s*286px/s) + it('sends reports to Discord and keeps moderation out of the phone UI', () => { + expect(source).not.toContain('moderationOpen') + expect(source).toContain('reportDiscordNote') + expect(serverSource).toContain('Config.FlipTok.ReportWebhookConvar') + expect(serverSource).toContain('PerformHttpRequest(webhook') + expect(serverSource).not.toContain('INSERT IGNORE INTO `sky_phone_fliptok_reports`') }) it('exposes follower lists and editable profile photos', () => { @@ -69,6 +72,18 @@ describe('FlipTokApp Sky UI contract', () => { expect(source).toContain('@click="removeProfilePhoto"') expect(source).toContain('profilePhotoSheetOpen') expect(source).toContain('avatarMediaId') + expect(source).toContain('class="overlay-screen connections-screen"') + expect(source).toContain('class="connections-tabs"') + }) + + it('supports camera videos, photo slides, and a safe composer return path', () => { + expect(source).toContain("chooseVideo('camera')") + expect(source).toContain("choosePhotoSlideshow('photos')") + expect(source).toContain("'/apps/fliptok?compose=1'") + expect(source).toContain("route.query.compose === '1'") + expect(source).toContain('mediaIds: selectedMediaItems.value.map') + expect(source).toContain('class="photo-slideshow"') + expect(migrationSource).toContain('sky_phone_fliptok_video_media') }) it('shows a transient follow confirmation', () => { diff --git a/frontend/src/views/apps/FlipTokApp.vue b/frontend/src/views/apps/FlipTokApp.vue index 50140f9..52dce55 100644 --- a/frontend/src/views/apps/FlipTokApp.vue +++ b/frontend/src/views/apps/FlipTokApp.vue @@ -10,16 +10,14 @@ import { Home, ImagePlus, Link2, - MapPin, MessageCircle, MoreHorizontal, Music2, + Pencil, Play, Plus, Reply, Search, - ShieldAlert, - ShieldCheck, Send, Share2, TriangleAlert, @@ -45,7 +43,6 @@ import { usePhoneStore } from '@/stores/phone' import type { FlipTokComment, FlipTokProfile, - FlipTokReport, FlipTokVideo, } from '@/types/fliptok' import type { PhoneMedia } from '@/types/media' @@ -57,6 +54,7 @@ import { SkyChip, SkyDialog, SkyDialogButton, + SkyDropdown, SkyField, SkyGlass, SkyLink, @@ -108,14 +106,15 @@ const logoutSubmitting = ref(false) const tab = ref('feed') const selectedVideo = ref(null) const selectedMedia = ref(null) +const selectedMediaItems = ref([]) const commentsOpen = ref(false) const actionsOpen = ref(false) -const visibilitySheetOpen = ref(false) +const visibilityMenuOpen = ref(false) +const visibilityMenuTarget = ref(null) const accountTypeSheetOpen = ref(false) const profilePhotoSheetOpen = ref(false) const composeOpen = ref(false) const profileEditOpen = ref(false) -const moderationOpen = ref(false) const musicSheetOpen = ref(false) const reportSheetOpen = ref(false) const connectionsOpen = ref(false) @@ -123,8 +122,8 @@ const connectionsMode = ref('followers') const search = ref('') const commentBody = ref('') const replyingTo = ref(null) +const expandedCommentThreads = ref(new Set()) const caption = ref('') -const location = ref('') const visibility = ref<'public' | 'followers' | 'private'>('public') const commentsEnabled = ref(true) const trimStartMs = ref(0) @@ -163,6 +162,7 @@ const profileDraft = ref({ handle: '', }) const selectedProfilePhoto = ref(null) +const feedCards = new Map() const videoElements = new Map() const musicElements = new Map() let flipTokYoutubePlayer: YouTubePlayer | null = null @@ -214,6 +214,19 @@ const canPublish = computed( caption.value.length <= 500 && !customMusicLoadFailed.value, ) +const selectedMediaType = computed(() => selectedMedia.value?.mediaType ?? null) +const visibilityMenuItems = computed(() => + visibilityOptions.map((option) => ({ + checked: visibility.value === option, + id: option, + label: + option === 'public' + ? t('public') + : option === 'followers' + ? t('followersOnly') + : t('private'), + })), +) const tabIndex = computed(() => ['feed', 'discover', 'create', 'activity', 'profile'].indexOf(tab.value), ) @@ -240,6 +253,12 @@ function compactCount(value: number): string { }).format(value) } +function videoMedia(video: FlipTokVideo) { + return video.media?.length + ? video.media + : [{ id: 0, mediaType: video.media_type ?? 'video', url: video.url }] +} + function parseYoutubeVideoId(value: string): string { const trimmed = value.trim() if (!trimmed || trimmed.length > 500) return '' @@ -398,6 +417,15 @@ function setVideoElement(id: string, element: unknown): void { setPlaybackFailed(id, false) } +function setFeedCard(id: string, element: unknown): void { + if (element instanceof HTMLElement) { + element.dataset.id = id + feedCards.set(id, element) + return + } + feedCards.delete(id) +} + function setMusicElement(id: string, element: unknown): void { if (element instanceof HTMLAudioElement) { musicElements.set(id, element) @@ -657,7 +685,9 @@ function observeVideos(): void { observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { - const video = entry.target as HTMLVideoElement + const card = entry.target as HTMLElement + const id = card.dataset.id ?? '' + const video = videoElements.get(id) if (entry.isIntersecting && entry.intersectionRatio > 0.72) { videoElements.forEach((item) => { if (item !== video) { @@ -668,7 +698,9 @@ function observeVideos(): void { pauseFlipTokYoutube(otherId) } }) - const id = video.dataset.id + musicElements.forEach((audio, audioId) => { + if (audioId !== id) audio.pause() + }) const item = id ? [ ...store.feed, @@ -676,11 +708,16 @@ function observeVideos(): void { ...store.profileVideos, ].find((candidate) => candidate.id === id) : undefined - if (item) void playFeedVideo(item, video) + if (item && video) void playFeedVideo(item, video) + else if (item?.music_source === 'youtube' && item.music_video_id) { + void playFlipTokYoutube(id, item.music_video_id, item.music_volume, 0) + } else if (item) { + const music = musicElements.get(id) + if (music) void music.play().catch(() => undefined) + } if (id) void nuiCall('fliptok:view', { id }) } else { - video.pause() - const id = video.dataset.id + video?.pause() if (id) { musicElements.get(id)?.pause() pauseFlipTokYoutube(id) @@ -690,7 +727,7 @@ function observeVideos(): void { }, { threshold: [0.72] }, ) - videoElements.forEach((video) => observer?.observe(video)) + feedCards.forEach((card) => observer?.observe(card)) } function togglePlayback(video: FlipTokVideo): void { @@ -854,10 +891,18 @@ async function changeMode(mode: 'for-you' | 'following'): Promise { async function openComments(video: FlipTokVideo): Promise { selectedVideo.value = video replyingTo.value = null + expandedCommentThreads.value = new Set() await store.loadComments(video.id) commentsOpen.value = true } +function toggleReplies(commentId: string): void { + const next = new Set(expandedCommentThreads.value) + if (next.has(commentId)) next.delete(commentId) + else next.add(commentId) + expandedCommentThreads.value = next +} + function startReply(comment: FlipTokComment): void { replyingTo.value = comment } @@ -881,11 +926,10 @@ async function submitComment(): Promise { await store.loadComments(selectedVideo.value.id) } -function chooseVideo(): void { - messageMedia.begin('fliptok:compose', 'video', '/apps/fliptok?compose=1', 1, { +function chooseMedia(source: 'camera' | 'photos', mediaType: 'photo' | 'video'): void { + messageMedia.begin('fliptok:compose', mediaType, '/apps/fliptok?compose=1', mediaType === 'photo' ? 10 : 1, { caption: caption.value, commentsEnabled: commentsEnabled.value, - location: location.value, visibility: visibility.value, trimStartMs: trimStartMs.value, trimEndMs: trimEndMs.value, @@ -897,13 +941,22 @@ function chooseVideo(): void { customMusicTitle: customMusicTitle.value, customMusicArtist: customMusicArtist.value, customMusicVideoId: customMusicVideoId.value, + selectedMediaItems: selectedMediaItems.value, }) void router.push({ - path: '/apps/photos', - query: { mediaAttachment: 'video' }, + path: `/apps/${source}`, + query: { mediaAttachment: mediaType }, }) } +function chooseVideo(source: 'camera' | 'photos' = 'photos'): void { + chooseMedia(source, 'video') +} + +function choosePhotoSlideshow(source: 'camera' | 'photos' = 'photos'): void { + chooseMedia(source, 'photo') +} + async function publish(draft = false): Promise { if (!selectedMedia.value || publishing.value) return publishing.value = true @@ -911,8 +964,9 @@ async function publish(draft = false): Promise { caption: caption.value, commentsEnabled: commentsEnabled.value, draft, - location: location.value, mediaId: selectedMedia.value.id, + mediaIds: selectedMediaItems.value.map((media) => media.id), + mediaType: selectedMediaType.value, trimStartMs: trimStartMs.value, trimEndMs: trimEndMs.value || null, coverTimeMs: coverTimeMs.value, @@ -936,8 +990,8 @@ async function publish(draft = false): Promise { function resetComposer(): void { selectedMedia.value = null + selectedMediaItems.value = [] caption.value = '' - location.value = '' visibility.value = 'public' commentsEnabled.value = true trimStartMs.value = 0 @@ -1030,7 +1084,19 @@ function formatDuration(value: number): string { function chooseVisibility(value: (typeof visibilityOptions)[number]): void { visibility.value = value - visibilitySheetOpen.value = false + visibilityMenuOpen.value = false +} + +function openVisibilityMenu(event: MouseEvent): void { + if (!(event.currentTarget instanceof HTMLElement)) return + visibilityMenuTarget.value = event.currentTarget + visibilityMenuOpen.value = true +} + +function selectVisibility(id: string): void { + if (visibilityOptions.includes(id as (typeof visibilityOptions)[number])) { + chooseVisibility(id as (typeof visibilityOptions)[number]) + } } function chooseAccountType(value: (typeof accountTypeOptions)[number]): void { @@ -1150,19 +1216,6 @@ async function blockCurrentProfile(): Promise { } } -async function openModeration(): Promise { - if (!(await store.loadReports())) return notify(t('errors.not_authorized')) - moderationOpen.value = true -} - -async function resolveReport( - report: FlipTokReport, - action: 'dismiss' | 'remove', -): Promise { - if (!(await store.resolveReport(report.id, action))) - notify(t('errors.default')) -} - function editProfile(): void { if (!store.profile) return profileDraft.value = { @@ -1296,7 +1349,6 @@ onMounted(async () => { const selection = messageMedia.consumeMany<{ caption?: string commentsEnabled?: boolean - location?: string visibility?: 'public' | 'followers' | 'private' trimStartMs?: number trimEndMs?: number @@ -1308,12 +1360,16 @@ onMounted(async () => { customMusicTitle?: string customMusicArtist?: string customMusicVideoId?: string + selectedMediaItems?: PhoneMedia[] }>('fliptok:compose') - if (selection?.media[0]) { - selectedMedia.value = selection.media[0] + if (selection) { + const restoredMedia = selection.media.length + ? selection.media + : (selection.context?.selectedMediaItems ?? []) + selectedMedia.value = restoredMedia[0] ?? null + selectedMediaItems.value = restoredMedia caption.value = selection.context?.caption ?? '' commentsEnabled.value = selection.context?.commentsEnabled ?? true - location.value = selection.context?.location ?? '' visibility.value = selection.context?.visibility ?? 'public' trimStartMs.value = selection.context?.trimStartMs ?? 0 trimEndMs.value = selection.context?.trimEndMs ?? 0 @@ -1331,6 +1387,7 @@ onMounted(async () => { composeOpen.value = true } await store.bootstrap() + if (route.query.compose === '1' && store.authenticated) composeOpen.value = true const easyShareId = String(route.query.easyShareId ?? '') if (easyShareId && route.query.easyShareKind === 'profile') { const profileId = Number(easyShareId) @@ -1355,6 +1412,7 @@ onBeforeUnmount(() => { if (reactionPulseTimer !== null) window.clearTimeout(reactionPulseTimer) followFeedbackTimers.forEach((timer) => window.clearTimeout(timer)) followFeedbackTimers.clear() + feedCards.clear() videoElements.forEach((video) => video.pause()) destroyFlipTokYoutube() }) @@ -1381,7 +1439,7 @@ onBeforeUnmount(() => { class="fliptok-auth__navbar" :title="t('name')" :scroll-el="null" - variant="large" + variant="medium" />
@@ -1504,8 +1562,22 @@ onBeforeUnmount(() => {
-
+
+
+ +