feat: overhaul FlipTok and garage interfaces

This commit is contained in:
Leon.Schmidt
2026-08-16 21:51:04 +02:00
parent a97d09a6b2
commit 1bcf04d021
13 changed files with 994 additions and 408 deletions
-21
View File
@@ -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<boolean> {
const response = await nuiCall<FlipTokReport[]>('fliptok:admin-reports')
this.reports = response.success && response.data ? response.data : []
return response.success
},
async resolveReport(
id: string,
action: 'dismiss' | 'remove',
): Promise<boolean> {
const response = await nuiCall('fliptok:admin-resolve-report', {
action,
id,
})
if (response.success) await this.loadReports()
return response.success
},
},
})
+6 -14
View File
@@ -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[]
+1
View File
@@ -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
@@ -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('<ShieldCheck />')
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', () => {
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,14 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./GarageApp.vue', import.meta.url), 'utf8')
const garageServer = readFileSync(
new URL('../../../../sky_phone/source/server/garage.lua', import.meta.url),
'utf8',
)
const phoneConfig = readFileSync(
new URL('../../../../sky_phone/config/config.lua', import.meta.url),
'utf8',
)
describe('GarageApp Sky UI contract', () => {
it('uses first-party Sky UI without direct Konsta markup', () => {
@@ -26,7 +34,25 @@ describe('GarageApp Sky UI contract', () => {
it('raises only the Garage title block', () => {
expect(source).toContain('class="garage-navbar"')
expect(source).toMatch(
/\.garage-navbar :deep\(\.sky-navbar__title-container > div\)\s*\{[^}]*translateY\(-14px\)/s,
/\.garage-navbar :deep\(\.sky-navbar__title-container > div\)\s*\{[^}]*translateY\(-20px\)/s,
)
})
it('uses configurable vehicle imagery with an icon fallback', () => {
expect(source).toContain('vehicleImageUrl(vehicle)')
expect(source).toContain('@error="useVehicleIcon(vehicle)"')
expect(source).toContain('v-else :is="kindIcons[vehicle.kind]"')
expect(garageServer).toContain('imageUrl = vehicle_image_url(model)')
expect(phoneConfig).toContain(
'UrlTemplate = "https://cdn.sky-systems.net/vehicles/{model}.png"',
)
expect(phoneConfig).toContain('ModelNames = {}')
})
it('uses the draggable Sky sheet instead of a close button', () => {
expect(source).toContain('swipe-to-close')
expect(source).toContain('grabber-clickable')
expect(source).toContain('@swipeclose="selectedVehicle = null"')
expect(source).not.toContain('garage-detail__close')
})
})
+96 -45
View File
@@ -16,7 +16,6 @@ import {
ShieldAlert,
Warehouse,
Wrench,
X,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
@@ -36,7 +35,6 @@ import {
SkyDialogButton,
SkyEmptyState,
SkyGlass,
SkyLink,
SkyNavbar,
SkyScrollArea,
SkySearchbar,
@@ -57,6 +55,7 @@ const selectedVehicle = ref<GarageVehicle | null>(null)
const valetCandidate = ref<GarageVehicle | null>(null)
const toastOpened = ref(false)
const toastText = ref('')
const failedVehicleImages = ref<Record<string, true>>({})
const kindIcons: Record<GarageVehicleKind, typeof CarFront> = {
bike: Bike,
@@ -117,6 +116,18 @@ function modelName(vehicle: GarageVehicle): string {
return phone.t(`Apps.garage.kinds.${vehicle.kind}`)
}
function vehicleImageUrl(vehicle: GarageVehicle): string {
if (failedVehicleImages.value[vehicle.id]) return ''
return vehicle.imageUrl ?? ''
}
function useVehicleIcon(vehicle: GarageVehicle): void {
failedVehicleImages.value = {
...failedVehicleImages.value,
[vehicle.id]: true,
}
}
function conditionValue(vehicle: GarageVehicle): number | null {
const values = [vehicle.engine, vehicle.body].filter(
(value): value is number => value !== null,
@@ -344,7 +355,14 @@ onBeforeUnmount(() => {
@click="selectedVehicle = vehicle"
>
<span class="garage-vehicle__visual" :class="`is-${vehicle.kind}`">
<component :is="kindIcons[vehicle.kind]" :size="47" />
<img
v-if="vehicleImageUrl(vehicle)"
:src="vehicleImageUrl(vehicle)"
:alt="displayName(vehicle)"
loading="lazy"
@error="useVehicleIcon(vehicle)"
/>
<component v-else :is="kindIcons[vehicle.kind]" :size="47" />
<i :class="`is-${vehicle.status}`">
{{ phone.t(`Apps.garage.status.${vehicle.status}`) }}
</i>
@@ -394,24 +412,30 @@ onBeforeUnmount(() => {
<SkySheet
:opened="Boolean(selectedVehicle)"
:aria-label="selectedVehicle ? displayName(selectedVehicle) : undefined"
swipe-to-close
grabber-clickable
:grabber-label="phone.t('Common.close')"
@backdropclick="selectedVehicle = null"
@escape="selectedVehicle = null"
@grabberclick="selectedVehicle = null"
@swipeclose="selectedVehicle = null"
>
<section v-if="selectedVehicle" class="garage-detail">
<SkyLink
component="button"
icon-only
class="garage-detail__close"
:aria-label="phone.t('Common.close')"
@click="selectedVehicle = null"
>
<X :size="18" />
</SkyLink>
<span
class="garage-detail__visual"
:class="`is-${selectedVehicle.kind}`"
>
<component :is="kindIcons[selectedVehicle.kind]" :size="62" />
<img
v-if="vehicleImageUrl(selectedVehicle)"
:src="vehicleImageUrl(selectedVehicle)"
:alt="displayName(selectedVehicle)"
@error="useVehicleIcon(selectedVehicle)"
/>
<component
v-else
:is="kindIcons[selectedVehicle.kind]"
:size="62"
/>
</span>
<span
class="garage-detail__status"
@@ -423,7 +447,9 @@ onBeforeUnmount(() => {
<p>{{ modelName(selectedVehicle) }} · {{ selectedVehicle.plate }}</p>
<div class="garage-detail__location">
<MapPin :size="18" />
<i class="garage-detail__location-icon">
<MapPin :size="17" />
</i>
<span>
<small>{{ phone.t('Apps.garage.location') }}</small>
<strong>{{
@@ -564,7 +590,7 @@ onBeforeUnmount(() => {
--garage-separator: var(--sky-hairline);
}
.garage-navbar :deep(.sky-navbar__title-container > div) {
transform: translateY(-14px);
transform: translateY(-20px);
}
.garage-scroll {
padding-top: 0;
@@ -650,20 +676,31 @@ onBeforeUnmount(() => {
margin: 13px 0 10px;
}
.garage-filters {
height: 40px;
min-height: 40px;
margin-bottom: 13px;
padding: 0;
}
.garage-filters :deep(button) {
min-width: 0;
height: 40px;
min-height: 40px;
padding-right: 4px;
padding-left: 4px;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 11px;
font-weight: 600;
}
.garage-filters :deep(.sky-segmented__highlight) {
top: 0;
bottom: 0;
}
.garage-filters small {
min-width: 17px;
padding: 1px 4px;
border-radius: 9px;
border-radius: var(--sky-radius-pill);
background: rgb(36 120 255 / 13%);
font-size: 10px;
font-weight: 700;
@@ -715,26 +752,36 @@ onBeforeUnmount(() => {
background: #fff4dd;
color: #744914;
}
.garage-vehicle__visual > img,
.garage-detail__visual > img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.garage-vehicle__visual > img {
padding: 20px 7px 7px;
box-sizing: border-box;
}
.garage-vehicle__visual > i {
position: absolute;
top: 7px;
left: 50%;
display: inline-flex;
display: inline-grid;
width: max-content;
min-width: 48px;
max-width: 70px;
height: 14px;
padding: 0 6px;
align-items: center;
justify-content: center;
height: 16px;
padding: 1px 6px 0;
place-items: center;
box-sizing: border-box;
border-radius: 999px;
border-radius: var(--sky-radius-pill);
background: var(--sky-success);
color: #fff;
font-size: 7px;
font-style: normal;
font-weight: 720;
line-height: 1;
line-height: normal;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.02em;
@@ -782,7 +829,7 @@ onBeforeUnmount(() => {
flex: none;
padding: 2px 5px;
border: 1px solid var(--garage-separator);
border-radius: 5px;
border-radius: var(--sky-radius-control);
background: var(--garage-surface-muted);
color: var(--garage-text);
font-size: 8px;
@@ -826,25 +873,11 @@ onBeforeUnmount(() => {
}
.garage-detail {
position: relative;
padding: 12px 18px 28px;
padding: 6px 18px 28px;
background: var(--sky-surface);
color: var(--garage-text);
text-align: center;
}
.garage-detail__close {
position: absolute;
z-index: 2;
top: 10px;
right: 14px;
width: 30px;
height: 30px;
padding: 0;
display: grid;
place-items: center;
border-radius: var(--sky-radius-pill);
background: var(--sky-surface-muted);
color: var(--garage-secondary);
}
.garage-detail__visual {
width: 104px;
height: 82px;
@@ -855,6 +888,10 @@ onBeforeUnmount(() => {
background: var(--sky-app-accent-soft);
color: var(--garage-blue);
}
.garage-detail__visual > img {
padding: 7px;
box-sizing: border-box;
}
.garage-detail__visual.is-bike {
background: #f2eaff;
color: #57368e;
@@ -869,13 +906,17 @@ onBeforeUnmount(() => {
color: #744914;
}
.garage-detail__status {
display: inline-flex;
padding: 4px 9px;
border-radius: 999px;
min-height: 24px;
padding: 1px 10px 0;
display: inline-grid;
place-items: center;
box-sizing: border-box;
border-radius: var(--sky-radius-pill);
background: var(--sky-success-soft);
color: var(--sky-success);
font-size: 12px;
font-weight: 720;
line-height: normal;
text-transform: uppercase;
}
.garage-detail__status.is-out {
@@ -907,9 +948,19 @@ onBeforeUnmount(() => {
border-radius: var(--sky-radius-card);
border: 1px solid var(--sky-hairline);
background: var(--sky-app-accent-soft);
color: var(--garage-blue);
text-align: left;
}
.garage-detail__location-icon {
width: 31px;
height: 31px;
flex: none;
display: grid;
place-items: center;
border-radius: var(--sky-radius-control);
background: var(--garage-blue);
color: #fff;
font-style: normal;
}
.garage-detail__location span {
display: flex;
flex-direction: column;
@@ -952,7 +1003,7 @@ onBeforeUnmount(() => {
height: 4px;
display: block;
overflow: hidden;
border-radius: 3px;
border-radius: var(--sky-radius-pill);
background: rgb(118 118 128 / 16%);
}
.garage-metrics article > i b {
@@ -1021,7 +1072,7 @@ onBeforeUnmount(() => {
height: 3px;
margin-top: 7px;
overflow: hidden;
border-radius: 3px;
border-radius: var(--sky-radius-pill);
background: rgb(10 132 255 / 13%);
}
.garage-valet-live__track i {