mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-02 11:18:57 +00:00
feat: overhaul FlipTok and garage interfaces
This commit is contained in:
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user