mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
102af69e48
Complete the FlipTok UI, store, mock API, and server audit. Add reliable photo-slide publishing, comments, privacy checks, action error handling, deletion, localization, and profile interaction states. Replace the photo carousel scroll path with a GPU-accelerated drag and swipe track that supports rapid consecutive gestures, and cover the corrected behavior with focused contract, store, and mock smoke tests.
6057 lines
152 KiB
Vue
6057 lines
152 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
Bell,
|
|
Bookmark,
|
|
Camera,
|
|
Check,
|
|
ChevronDown,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Compass,
|
|
Heart,
|
|
Home,
|
|
ImagePlus,
|
|
Link2,
|
|
MessageCircle,
|
|
MoreHorizontal,
|
|
Music2,
|
|
Pencil,
|
|
Play,
|
|
Plus,
|
|
Reply,
|
|
Search,
|
|
Send,
|
|
Share2,
|
|
TriangleAlert,
|
|
Trash2,
|
|
UserRound,
|
|
UsersRound,
|
|
Video,
|
|
X,
|
|
} from 'lucide-vue-next'
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
import flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
|
|
import { useFlipTokStore } from '@/stores/fliptok'
|
|
import { useEasyShareStore } from '@/stores/easyshare'
|
|
import { useMessageMediaStore } from '@/stores/messageMedia'
|
|
import {
|
|
loadYouTubeApi,
|
|
type YouTubeApi,
|
|
type YouTubePlayer,
|
|
} from '@/stores/music'
|
|
import { usePhoneStore } from '@/stores/phone'
|
|
import type {
|
|
FlipTokComment,
|
|
FlipTokProfile,
|
|
FlipTokVideo,
|
|
} from '@/types/fliptok'
|
|
import type { PhoneMedia } from '@/types/media'
|
|
import {
|
|
SkyAppPage,
|
|
SkyBlock,
|
|
SkyBlockTitle,
|
|
SkyButton,
|
|
SkyChip,
|
|
SkyDialog,
|
|
SkyDialogButton,
|
|
SkyDropdown,
|
|
SkyField,
|
|
SkyGlass,
|
|
SkyLink,
|
|
SkyList,
|
|
SkyListButton,
|
|
SkyListItem,
|
|
SkyMessagebar,
|
|
SkyNavbar,
|
|
SkyPillNavigation,
|
|
SkyRange,
|
|
SkyScrollArea,
|
|
SkySearchbar,
|
|
SkySegmented,
|
|
SkySegmentedButton,
|
|
SkySheet,
|
|
SkySpinner,
|
|
SkyToast,
|
|
SkyToggle,
|
|
} from '@/ui'
|
|
import { nuiCall } from '@/utils/nui'
|
|
|
|
type Tab = 'feed' | 'discover' | 'create' | 'activity' | 'profile'
|
|
type AuthMode = 'login' | 'register'
|
|
type ConnectionMode = 'followers' | 'following'
|
|
type ProfileMediaContext = {
|
|
draft: {
|
|
accountType: string
|
|
avatarMediaId: number
|
|
bio: string
|
|
displayName: string
|
|
handle: string
|
|
}
|
|
selectedPhoto: PhoneMedia | null
|
|
}
|
|
|
|
const phone = usePhoneStore()
|
|
const store = useFlipTokStore()
|
|
const messageMedia = useMessageMediaStore()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const authMode = ref<AuthMode>('login')
|
|
const authHandle = ref('')
|
|
const authDisplayName = ref('')
|
|
const authPassword = ref('')
|
|
const authConfirmPassword = ref('')
|
|
const authSubmitting = ref(false)
|
|
const logoutDialogOpen = ref(false)
|
|
const logoutSubmitting = ref(false)
|
|
const deleteDialogOpen = ref(false)
|
|
const deleteSubmitting = ref(false)
|
|
const tab = ref<Tab>('feed')
|
|
const selectedVideo = ref<FlipTokVideo | null>(null)
|
|
const selectedMedia = ref<PhoneMedia | null>(null)
|
|
const selectedMediaItems = ref<PhoneMedia[]>([])
|
|
const commentsOpen = ref(false)
|
|
const actionsOpen = ref(false)
|
|
const visibilityMenuOpen = ref(false)
|
|
const visibilityMenuTarget = ref<HTMLElement | null>(null)
|
|
const accountTypeSheetOpen = ref(false)
|
|
const profilePhotoSheetOpen = ref(false)
|
|
const composeOpen = ref(false)
|
|
const profileEditOpen = ref(false)
|
|
const musicSheetOpen = ref(false)
|
|
const reportSheetOpen = ref(false)
|
|
const connectionsOpen = ref(false)
|
|
const connectionsMode = ref<ConnectionMode>('followers')
|
|
const search = ref('')
|
|
const commentBody = ref('')
|
|
const replyingTo = ref<FlipTokComment | null>(null)
|
|
const expandedCommentThreads = ref(new Set<string>())
|
|
const caption = ref('')
|
|
const visibility = ref<'public' | 'followers' | 'private'>('public')
|
|
const commentsEnabled = ref(true)
|
|
const trimStartMs = ref(0)
|
|
const trimEndMs = ref(0)
|
|
const coverTimeMs = ref(0)
|
|
const originalVolume = ref(100)
|
|
const musicVolume = ref(35)
|
|
const musicTrack = ref('')
|
|
const customMusicUrl = ref('')
|
|
const customMusicDraftUrl = ref('')
|
|
const customMusicTitle = ref('')
|
|
const customMusicArtist = ref('')
|
|
const customMusicVideoId = ref('')
|
|
const customMusicResolving = ref(false)
|
|
const customMusicLoadFailed = ref(false)
|
|
const videoDurationMs = ref(0)
|
|
const previewVideo = ref<HTMLVideoElement | null>(null)
|
|
const composerMusic = ref<HTMLAudioElement | null>(null)
|
|
const reportReason = ref<
|
|
'spam' | 'harassment' | 'dangerous' | 'illegal' | 'other'
|
|
>('spam')
|
|
const reportDetails = ref('')
|
|
const reportSubmitting = ref(false)
|
|
const publishing = ref(false)
|
|
const profileSaving = ref(false)
|
|
const profileFollowPending = ref(false)
|
|
const feedback = ref('')
|
|
const likedPulseId = ref<string | null>(null)
|
|
const commentLikePulseId = ref<string | null>(null)
|
|
const reactionPulse = ref<{ id: string; kind: 'like' | 'save' } | null>(null)
|
|
const playbackFailedIds = ref(new Set<string>())
|
|
const followFeedbackIds = ref(new Set<string>())
|
|
const followPendingIds = ref(new Set<string>())
|
|
const feedBeforePreview = ref<FlipTokVideo[] | null>(null)
|
|
const photoSlideIndexes = ref<Record<string, number>>({})
|
|
const composerPhotoIndex = ref(0)
|
|
const profileDraft = ref({
|
|
accountType: 'person',
|
|
avatarMediaId: 0,
|
|
bio: '',
|
|
displayName: '',
|
|
handle: '',
|
|
})
|
|
const selectedProfilePhoto = ref<PhoneMedia | null>(null)
|
|
const feedCards = new Map<string, HTMLElement>()
|
|
const videoElements = new Map<string, HTMLVideoElement>()
|
|
const musicElements = new Map<string, HTMLAudioElement>()
|
|
const photoSlideElements = new Map<string, HTMLElement>()
|
|
const photoSlideAnimations = new Map<HTMLElement, Animation>()
|
|
let photoSlideDrag: {
|
|
currentX: number
|
|
element: HTMLElement
|
|
frame: number | null
|
|
lastMoveAt: number
|
|
lastX: number
|
|
pointerId: number
|
|
startIndex: number
|
|
startTranslateX: number
|
|
startX: number
|
|
track: HTMLElement
|
|
velocity: number
|
|
} | null = null
|
|
let flipTokYoutubePlayer: YouTubePlayer | null = null
|
|
let flipTokYoutubeApi: YouTubeApi | null = null
|
|
let flipTokYoutubeOwner = ''
|
|
let flipTokYoutubeVideoId = ''
|
|
let observer: IntersectionObserver | null = null
|
|
let videoClickTimer: number | null = null
|
|
let likePulseTimer: number | null = null
|
|
let commentLikePulseTimer: number | null = null
|
|
let reactionPulseTimer: number | null = null
|
|
let feedbackTimer: number | null = null
|
|
let searchTimer: number | null = null
|
|
const followFeedbackTimers = new Map<string, number>()
|
|
const visibilityOptions = ['public', 'followers', 'private'] as const
|
|
const accountTypeOptions = [
|
|
'person',
|
|
'business',
|
|
'organization',
|
|
'media',
|
|
'event',
|
|
] as const
|
|
const reportReasonOptions = [
|
|
'spam',
|
|
'harassment',
|
|
'dangerous',
|
|
'illegal',
|
|
'other',
|
|
] as const
|
|
const discoveryTags = [
|
|
{ labelKey: 'trendLosSantos', value: '#LosSantos' },
|
|
{ labelKey: 'trendRoleplay', value: '#Roleplay' },
|
|
{ labelKey: 'trendTrending', value: '#Trending' },
|
|
] as const
|
|
|
|
const currentProfile = computed(() => store.viewedProfile ?? store.profile)
|
|
const selectedMusic = computed(() =>
|
|
store.musicTracks.find((track) => track.id === musicTrack.value),
|
|
)
|
|
const composerMusicUrl = computed(
|
|
() =>
|
|
selectedMusic.value?.url ??
|
|
(customMusicVideoId.value ? '' : customMusicUrl.value.trim()),
|
|
)
|
|
const hasMusic = computed(() =>
|
|
Boolean(selectedMusic.value || customMusicUrl.value.trim()),
|
|
)
|
|
const customMusicDraftError = computed(() => {
|
|
const value = customMusicDraftUrl.value.trim()
|
|
return value && !validCustomMusicUrl(value) ? t('invalidCustomSoundLink') : ''
|
|
})
|
|
const canPublish = computed(
|
|
() =>
|
|
Boolean(selectedMedia.value) &&
|
|
caption.value.length <= 500 &&
|
|
!customMusicLoadFailed.value,
|
|
)
|
|
const selectedMediaType = computed(() => selectedMedia.value?.mediaType ?? null)
|
|
const selectedComposerPhoto = computed(
|
|
() =>
|
|
selectedMediaItems.value[composerPhotoIndex.value] ?? selectedMedia.value,
|
|
)
|
|
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),
|
|
)
|
|
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),
|
|
}))
|
|
})
|
|
|
|
function t(key: string, values?: Record<string, string>): string {
|
|
return phone.t(`Apps.fliptok.${key}`, values)
|
|
}
|
|
|
|
function initials(name: string): string {
|
|
return name.trim().slice(0, 2).toUpperCase() || 'FT'
|
|
}
|
|
|
|
function compactCount(value: number): string {
|
|
return new Intl.NumberFormat(phone.lang, {
|
|
maximumFractionDigits: 1,
|
|
notation: 'compact',
|
|
}).format(value)
|
|
}
|
|
|
|
function videoMedia(video: FlipTokVideo) {
|
|
return video.media?.length
|
|
? video.media
|
|
: [{ id: 0, mediaType: video.media_type ?? 'video', url: video.url }]
|
|
}
|
|
|
|
function photoSlideIndex(videoId: string): number {
|
|
return photoSlideIndexes.value[videoId] ?? 0
|
|
}
|
|
|
|
function setPhotoSlideElement(videoId: string, element: unknown): void {
|
|
if (element instanceof HTMLElement) {
|
|
photoSlideElements.set(videoId, element)
|
|
const track = element.firstElementChild
|
|
if (track instanceof HTMLElement) {
|
|
track.style.transform = `translate3d(-${photoSlideIndex(videoId) * 100}%, 0, 0)`
|
|
}
|
|
return
|
|
}
|
|
const previous = photoSlideElements.get(videoId)
|
|
if (previous) cancelPhotoSlideAnimation(previous)
|
|
photoSlideElements.delete(videoId)
|
|
}
|
|
|
|
function updatePhotoSlideIndex(videoId: string, index: number): void {
|
|
const normalizedIndex = Math.max(0, index)
|
|
if (photoSlideIndexes.value[videoId] === normalizedIndex) return
|
|
photoSlideIndexes.value = {
|
|
...photoSlideIndexes.value,
|
|
[videoId]: normalizedIndex,
|
|
}
|
|
}
|
|
|
|
function photoSlideTranslateX(track: HTMLElement): number {
|
|
const transform = window.getComputedStyle(track).transform
|
|
if (!transform || transform === 'none') return 0
|
|
try {
|
|
return new DOMMatrixReadOnly(transform).m41
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
function beginPhotoSlideDrag(videoId: string, event: PointerEvent): void {
|
|
if (event.pointerType === 'mouse' && event.button !== 0) return
|
|
const element = photoSlideElements.get(videoId)
|
|
const track = element?.firstElementChild
|
|
if (!element || !(track instanceof HTMLElement)) return
|
|
const currentTransform = window.getComputedStyle(track).transform
|
|
cancelPhotoSlideAnimation(element)
|
|
track.style.transform =
|
|
currentTransform && currentTransform !== 'none'
|
|
? currentTransform
|
|
: `translate3d(-${photoSlideIndex(videoId) * 100}%, 0, 0)`
|
|
const startTranslateX = photoSlideTranslateX(track)
|
|
const now = performance.now()
|
|
photoSlideDrag = {
|
|
currentX: event.clientX,
|
|
element,
|
|
frame: null,
|
|
lastMoveAt: now,
|
|
lastX: event.clientX,
|
|
pointerId: event.pointerId,
|
|
startIndex: photoSlideIndex(videoId),
|
|
startTranslateX,
|
|
startX: event.clientX,
|
|
track,
|
|
velocity: 0,
|
|
}
|
|
element.classList.add('photo-slideshow--dragging')
|
|
element.setPointerCapture(event.pointerId)
|
|
}
|
|
|
|
function renderPhotoSlideDrag(
|
|
drag: NonNullable<typeof photoSlideDrag>,
|
|
): void {
|
|
drag.frame = null
|
|
if (photoSlideDrag !== drag) return
|
|
const translateX = drag.startTranslateX + (drag.currentX - drag.startX)
|
|
drag.track.style.transform = `translate3d(${translateX}px, 0, 0)`
|
|
}
|
|
|
|
function updatePhotoSlideDrag(event: PointerEvent): void {
|
|
if (!photoSlideDrag || photoSlideDrag.pointerId !== event.pointerId) return
|
|
event.preventDefault()
|
|
const samples = event.getCoalescedEvents?.() ?? [event]
|
|
const sample = samples.at(-1) ?? event
|
|
const now = performance.now()
|
|
const elapsed = Math.max(1, now - photoSlideDrag.lastMoveAt)
|
|
const instantVelocity = (photoSlideDrag.lastX - sample.clientX) / elapsed
|
|
photoSlideDrag.velocity =
|
|
photoSlideDrag.velocity * 0.68 + instantVelocity * 0.32
|
|
photoSlideDrag.currentX = sample.clientX
|
|
photoSlideDrag.lastX = sample.clientX
|
|
photoSlideDrag.lastMoveAt = now
|
|
if (photoSlideDrag.frame === null) {
|
|
const drag = photoSlideDrag
|
|
drag.frame = window.requestAnimationFrame(() => renderPhotoSlideDrag(drag))
|
|
}
|
|
}
|
|
|
|
function endPhotoSlideDrag(video: FlipTokVideo, event: PointerEvent): void {
|
|
if (!photoSlideDrag || photoSlideDrag.pointerId !== event.pointerId) return
|
|
const drag = photoSlideDrag
|
|
const { element, pointerId } = drag
|
|
drag.currentX = event.clientX
|
|
if (drag.frame !== null) window.cancelAnimationFrame(drag.frame)
|
|
renderPhotoSlideDrag(drag)
|
|
const lastIndex = videoMedia(video).length - 1
|
|
const distance = drag.startX - drag.currentX
|
|
const shouldAdvance =
|
|
Math.abs(distance) > element.clientWidth * 0.12 ||
|
|
Math.abs(drag.velocity) > 0.35
|
|
const direction = Math.sign(distance || drag.velocity)
|
|
const nextIndex = Math.max(
|
|
0,
|
|
Math.min(
|
|
lastIndex,
|
|
shouldAdvance ? drag.startIndex + direction : drag.startIndex,
|
|
),
|
|
)
|
|
if (element.hasPointerCapture(pointerId)) element.releasePointerCapture(pointerId)
|
|
element.classList.remove('photo-slideshow--dragging')
|
|
photoSlideDrag = null
|
|
settlePhotoSlide(video.id, element, nextIndex)
|
|
}
|
|
|
|
function cancelPhotoSlideAnimation(element: HTMLElement): void {
|
|
const animation = photoSlideAnimations.get(element)
|
|
animation?.cancel()
|
|
photoSlideAnimations.delete(element)
|
|
element.classList.remove('photo-slideshow--settling')
|
|
}
|
|
|
|
function settlePhotoSlide(
|
|
videoId: string,
|
|
element: HTMLElement,
|
|
index: number,
|
|
): void {
|
|
cancelPhotoSlideAnimation(element)
|
|
const track = element.firstElementChild
|
|
if (!(track instanceof HTMLElement)) return
|
|
const startTransform = window.getComputedStyle(track).transform
|
|
const targetTransform = `translate3d(-${index * 100}%, 0, 0)`
|
|
const animationTarget = `translate3d(${-index * element.clientWidth}px, 0, 0)`
|
|
updatePhotoSlideIndex(videoId, index)
|
|
element.classList.add('photo-slideshow--settling')
|
|
const animation = track.animate(
|
|
[
|
|
{ transform: startTransform === 'none' ? track.style.transform : startTransform },
|
|
{ transform: animationTarget },
|
|
],
|
|
{
|
|
duration: 420,
|
|
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
|
fill: 'forwards',
|
|
},
|
|
)
|
|
photoSlideAnimations.set(element, animation)
|
|
animation.onfinish = () => {
|
|
track.style.transform = targetTransform
|
|
animation.cancel()
|
|
photoSlideAnimations.delete(element)
|
|
element.classList.remove('photo-slideshow--settling')
|
|
}
|
|
}
|
|
|
|
function moveComposerPhoto(direction: -1 | 1): void {
|
|
composerPhotoIndex.value = Math.max(
|
|
0,
|
|
Math.min(
|
|
selectedMediaItems.value.length - 1,
|
|
composerPhotoIndex.value + direction,
|
|
),
|
|
)
|
|
}
|
|
|
|
function parseYoutubeVideoId(value: string): string {
|
|
const trimmed = value.trim()
|
|
if (!trimmed || trimmed.length > 500) return ''
|
|
try {
|
|
const url = new URL(trimmed)
|
|
if (url.protocol !== 'https:' || url.username || url.password || url.port) {
|
|
return ''
|
|
}
|
|
const host = url.hostname.toLowerCase()
|
|
let videoId = ''
|
|
if (host === 'youtu.be' || host === 'www.youtu.be') {
|
|
videoId = url.pathname.split('/')[1] ?? ''
|
|
} else if (
|
|
[
|
|
'youtube.com',
|
|
'www.youtube.com',
|
|
'm.youtube.com',
|
|
'music.youtube.com',
|
|
'youtube-nocookie.com',
|
|
'www.youtube-nocookie.com',
|
|
].includes(host)
|
|
) {
|
|
videoId =
|
|
url.searchParams.get('v') ??
|
|
url.pathname.match(/^\/(?:shorts|embed|live)\/([a-z0-9_-]+)/i)?.[1] ??
|
|
''
|
|
}
|
|
return /^[a-z0-9_-]{11}$/i.test(videoId) ? videoId : ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function validCustomMusicUrl(value: string): boolean {
|
|
const audioExtensions = new Set([
|
|
'aac',
|
|
'm4a',
|
|
'mp3',
|
|
'oga',
|
|
'ogg',
|
|
'opus',
|
|
'wav',
|
|
'webm',
|
|
])
|
|
const trimmed = value.trim()
|
|
if (parseYoutubeVideoId(trimmed)) return true
|
|
if (
|
|
!trimmed ||
|
|
trimmed.length > 2048 ||
|
|
/[\s\u0000-\u001f\u007f]/.test(trimmed)
|
|
)
|
|
return false
|
|
|
|
try {
|
|
const url = new URL(trimmed)
|
|
const host = url.hostname.toLowerCase()
|
|
const extension = url.pathname.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase()
|
|
const labels = host.split('.')
|
|
return (
|
|
url.protocol === 'https:' &&
|
|
!url.username &&
|
|
!url.password &&
|
|
!url.port &&
|
|
host.includes('.') &&
|
|
host !== 'localhost' &&
|
|
!host.endsWith('.localhost') &&
|
|
!host.endsWith('.local') &&
|
|
!host.endsWith('.internal') &&
|
|
!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) &&
|
|
/^[a-z0-9.-]+$/.test(host) &&
|
|
labels.every(
|
|
(label) =>
|
|
label.length > 0 &&
|
|
label.length <= 63 &&
|
|
!label.startsWith('-') &&
|
|
!label.endsWith('-'),
|
|
) &&
|
|
Boolean(extension && audioExtensions.has(extension))
|
|
)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function formatTimestamp(value: number): string {
|
|
if (!Number.isFinite(value) || value <= 0) return ''
|
|
const timestamp = new Date(value)
|
|
const now = new Date()
|
|
const sameDay =
|
|
timestamp.getFullYear() === now.getFullYear() &&
|
|
timestamp.getMonth() === now.getMonth() &&
|
|
timestamp.getDate() === now.getDate()
|
|
return new Intl.DateTimeFormat(phone.lang, {
|
|
...(sameDay ? {} : { day: '2-digit', month: 'short' }),
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}).format(timestamp)
|
|
}
|
|
|
|
function notify(message: string): void {
|
|
if (feedbackTimer !== null) window.clearTimeout(feedbackTimer)
|
|
feedback.value = message
|
|
feedbackTimer = window.setTimeout(() => {
|
|
feedback.value = ''
|
|
feedbackTimer = null
|
|
}, 2400)
|
|
}
|
|
|
|
function inputValue(event: Event): string {
|
|
return (event.target as HTMLInputElement).value
|
|
}
|
|
|
|
async function submitAuth(): Promise<void> {
|
|
if (
|
|
authMode.value === 'register' &&
|
|
authPassword.value !== authConfirmPassword.value
|
|
) {
|
|
notify(t('passwordsMismatch'))
|
|
return
|
|
}
|
|
authSubmitting.value = true
|
|
const response =
|
|
authMode.value === 'login'
|
|
? await store.login(authHandle.value, authPassword.value)
|
|
: await store.register(
|
|
authDisplayName.value,
|
|
authHandle.value,
|
|
authPassword.value,
|
|
)
|
|
authSubmitting.value = false
|
|
if (!response.success) notify(t(`errors.${response.error ?? 'default'}`))
|
|
}
|
|
|
|
async function confirmLogout(): Promise<void> {
|
|
logoutSubmitting.value = true
|
|
const response = await store.logout()
|
|
logoutSubmitting.value = false
|
|
if (!response.success) {
|
|
notify(t(`errors.${response.error ?? 'default'}`))
|
|
return
|
|
}
|
|
logoutDialogOpen.value = false
|
|
profileEditOpen.value = false
|
|
tab.value = 'feed'
|
|
authHandle.value = ''
|
|
authPassword.value = ''
|
|
authConfirmPassword.value = ''
|
|
}
|
|
|
|
function setVideoElement(id: string, element: unknown): void {
|
|
if (element instanceof HTMLVideoElement) {
|
|
videoElements.set(id, element)
|
|
return
|
|
}
|
|
videoElements.delete(id)
|
|
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)
|
|
return
|
|
}
|
|
musicElements.delete(id)
|
|
}
|
|
|
|
function setPlaybackFailed(id: string, failed: boolean): void {
|
|
const next = new Set(playbackFailedIds.value)
|
|
if (failed) next.add(id)
|
|
else next.delete(id)
|
|
playbackFailedIds.value = next
|
|
}
|
|
|
|
async function playFeedVideo(
|
|
video: FlipTokVideo,
|
|
element: HTMLVideoElement,
|
|
showNotice = false,
|
|
): Promise<boolean> {
|
|
const id = video.id
|
|
setPlaybackFailed(id, false)
|
|
try {
|
|
await element.play()
|
|
} catch (error) {
|
|
setPlaybackFailed(id, true)
|
|
console.error(`[FlipTok] Could not play video ${id}.`, error)
|
|
if (showNotice) notify(t('errors.video_not_found'))
|
|
return false
|
|
}
|
|
|
|
if (video.music_source === 'youtube' && video.music_video_id) {
|
|
await playFlipTokYoutube(
|
|
id,
|
|
video.music_video_id,
|
|
video.music_volume,
|
|
element.currentTime - video.trim_start_ms / 1000,
|
|
)
|
|
} else {
|
|
pauseFlipTokYoutube()
|
|
const music = musicElements.get(id)
|
|
if (!music) return true
|
|
try {
|
|
await music.play()
|
|
} catch (error) {
|
|
console.error(`[FlipTok] Could not play music for video ${id}.`, error)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
function handleFeedVideoError(id: string, event: Event): void {
|
|
setPlaybackFailed(id, true)
|
|
const code = (event.currentTarget as HTMLVideoElement).error?.code
|
|
console.error(
|
|
`[FlipTok] Video ${id} could not be decoded or loaded${code ? ` (media error ${code})` : ''}.`,
|
|
)
|
|
}
|
|
|
|
function retryPlayback(video: FlipTokVideo): void {
|
|
const element = videoElements.get(video.id)
|
|
if (element) void playFeedVideo(video, element, true)
|
|
}
|
|
|
|
function clearCustomMusicDetails(): void {
|
|
customMusicTitle.value = ''
|
|
customMusicArtist.value = ''
|
|
customMusicVideoId.value = ''
|
|
}
|
|
|
|
function chooseMusicTrack(trackId: string): void {
|
|
musicTrack.value = trackId
|
|
customMusicUrl.value = ''
|
|
customMusicDraftUrl.value = ''
|
|
clearCustomMusicDetails()
|
|
customMusicLoadFailed.value = false
|
|
musicSheetOpen.value = false
|
|
}
|
|
|
|
function openMusicSheet(): void {
|
|
customMusicDraftUrl.value = customMusicUrl.value
|
|
musicSheetOpen.value = true
|
|
}
|
|
|
|
async function chooseCustomMusic(): Promise<void> {
|
|
const value = customMusicDraftUrl.value.trim()
|
|
if (!validCustomMusicUrl(value)) return
|
|
const youtubeVideoId = parseYoutubeVideoId(value)
|
|
if (youtubeVideoId) {
|
|
customMusicResolving.value = true
|
|
const response = await nuiCall<{
|
|
artist: string
|
|
title: string
|
|
url: string
|
|
videoId: string
|
|
}>('fliptok:music-metadata', { url: value })
|
|
customMusicResolving.value = false
|
|
if (!response.success || !response.data) {
|
|
notify(t(`errors.${response.error ?? 'invalid_music_url'}`))
|
|
return
|
|
}
|
|
customMusicUrl.value = response.data.url
|
|
customMusicTitle.value = response.data.title
|
|
customMusicArtist.value = response.data.artist
|
|
customMusicVideoId.value = response.data.videoId
|
|
} else {
|
|
customMusicUrl.value = value
|
|
clearCustomMusicDetails()
|
|
}
|
|
musicTrack.value = ''
|
|
customMusicLoadFailed.value = false
|
|
musicSheetOpen.value = false
|
|
}
|
|
|
|
function markComposerMusicReady(): void {
|
|
customMusicLoadFailed.value = false
|
|
}
|
|
|
|
function markComposerMusicFailed(): void {
|
|
if (customMusicUrl.value) customMusicLoadFailed.value = true
|
|
}
|
|
|
|
function destroyFlipTokYoutube(): void {
|
|
try {
|
|
flipTokYoutubePlayer?.destroy()
|
|
} catch (error) {
|
|
console.error('[FlipTok] YouTube player cleanup failed.', error)
|
|
}
|
|
flipTokYoutubePlayer = null
|
|
flipTokYoutubeApi = null
|
|
flipTokYoutubeOwner = ''
|
|
flipTokYoutubeVideoId = ''
|
|
document.getElementById('sky-phone-fliptok-youtube-player')?.remove()
|
|
}
|
|
|
|
function pauseFlipTokYoutube(owner?: string): void {
|
|
if (owner && flipTokYoutubeOwner !== owner) return
|
|
flipTokYoutubePlayer?.pauseVideo()
|
|
}
|
|
|
|
function seekFlipTokYoutube(owner: string, seconds: number): void {
|
|
if (flipTokYoutubeOwner !== owner) return
|
|
flipTokYoutubePlayer?.seekTo(Math.max(0, seconds), true)
|
|
}
|
|
|
|
async function playFlipTokYoutube(
|
|
owner: string,
|
|
videoId: string,
|
|
volume: number,
|
|
seconds = 0,
|
|
): Promise<void> {
|
|
flipTokYoutubeOwner = owner
|
|
try {
|
|
const api = await loadYouTubeApi()
|
|
flipTokYoutubeApi = api
|
|
if (flipTokYoutubePlayer) {
|
|
if (flipTokYoutubeVideoId !== videoId) {
|
|
flipTokYoutubeVideoId = videoId
|
|
flipTokYoutubePlayer.loadVideoById(videoId)
|
|
}
|
|
flipTokYoutubePlayer.setVolume(volume)
|
|
flipTokYoutubePlayer.seekTo(Math.max(0, seconds), true)
|
|
flipTokYoutubePlayer.playVideo()
|
|
if (owner === 'composer') customMusicLoadFailed.value = false
|
|
return
|
|
}
|
|
|
|
const host = document.createElement('div')
|
|
host.id = 'sky-phone-fliptok-youtube-player'
|
|
host.style.position = 'fixed'
|
|
host.style.left = '-10000px'
|
|
host.style.top = '0'
|
|
host.style.width = '200px'
|
|
host.style.height = '200px'
|
|
host.style.pointerEvents = 'none'
|
|
document.body.append(host)
|
|
flipTokYoutubeVideoId = videoId
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
flipTokYoutubePlayer = new api.Player(host, {
|
|
height: '200',
|
|
width: '200',
|
|
videoId,
|
|
playerVars: {
|
|
autoplay: 1,
|
|
controls: 0,
|
|
disablekb: 1,
|
|
fs: 0,
|
|
origin: window.location.origin,
|
|
playsinline: 1,
|
|
rel: 0,
|
|
},
|
|
events: {
|
|
onError: () => {
|
|
if (flipTokYoutubeOwner === 'composer')
|
|
customMusicLoadFailed.value = true
|
|
destroyFlipTokYoutube()
|
|
reject(new Error('YouTube rejected the selected sound.'))
|
|
},
|
|
onReady: (event) => {
|
|
flipTokYoutubePlayer = event.target
|
|
event.target.setVolume(volume)
|
|
event.target.seekTo(Math.max(0, seconds), true)
|
|
event.target.playVideo()
|
|
if (owner === 'composer') customMusicLoadFailed.value = false
|
|
resolve()
|
|
},
|
|
onStateChange: (event) => {
|
|
if (
|
|
event.data === flipTokYoutubeApi?.PlayerState.ENDED &&
|
|
flipTokYoutubePlayer
|
|
) {
|
|
flipTokYoutubePlayer.seekTo(0, true)
|
|
flipTokYoutubePlayer.playVideo()
|
|
}
|
|
},
|
|
},
|
|
})
|
|
})
|
|
} catch (error) {
|
|
if (owner === 'composer') customMusicLoadFailed.value = true
|
|
console.error('[FlipTok] YouTube sound playback failed.', error)
|
|
}
|
|
}
|
|
|
|
function textParts(
|
|
value: string,
|
|
): Array<{ kind: 'text' | 'hashtag' | 'mention'; value: string }> {
|
|
return value
|
|
.split(/([#@][A-Za-z0-9._]+)/g)
|
|
.filter(Boolean)
|
|
.map((part) => ({
|
|
kind: part.startsWith('#')
|
|
? 'hashtag'
|
|
: part.startsWith('@')
|
|
? 'mention'
|
|
: 'text',
|
|
value: part,
|
|
}))
|
|
}
|
|
|
|
async function openTextLink(part: {
|
|
kind: string
|
|
value: string
|
|
}): Promise<void> {
|
|
if (part.kind === 'hashtag') {
|
|
search.value = part.value
|
|
tab.value = 'discover'
|
|
await runSearch()
|
|
} else if (part.kind === 'mention') {
|
|
await openProfile(undefined, part.value.slice(1))
|
|
}
|
|
}
|
|
|
|
function observeVideos(): void {
|
|
observer?.disconnect()
|
|
observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
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) {
|
|
item.pause()
|
|
const otherId = item.dataset.id ?? ''
|
|
const otherAudio = musicElements.get(otherId)
|
|
otherAudio?.pause()
|
|
pauseFlipTokYoutube(otherId)
|
|
}
|
|
})
|
|
musicElements.forEach((audio, audioId) => {
|
|
if (audioId !== id) audio.pause()
|
|
})
|
|
const item = id
|
|
? [
|
|
...store.feed,
|
|
...store.searchResults,
|
|
...store.profileVideos,
|
|
].find((candidate) => candidate.id === id)
|
|
: undefined
|
|
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()
|
|
if (id) {
|
|
musicElements.get(id)?.pause()
|
|
pauseFlipTokYoutube(id)
|
|
}
|
|
}
|
|
})
|
|
},
|
|
{ threshold: [0.72] },
|
|
)
|
|
feedCards.forEach((card) => observer?.observe(card))
|
|
}
|
|
|
|
function togglePlayback(video: FlipTokVideo): void {
|
|
const element = videoElements.get(video.id)
|
|
if (!element) return
|
|
const music = musicElements.get(video.id)
|
|
if (element.paused) {
|
|
void playFeedVideo(video, element, true)
|
|
} else {
|
|
element.pause()
|
|
music?.pause()
|
|
pauseFlipTokYoutube(video.id)
|
|
}
|
|
}
|
|
|
|
function prepareFeedVideo(
|
|
video: FlipTokVideo,
|
|
element: HTMLVideoElement,
|
|
): void {
|
|
setPlaybackFailed(video.id, false)
|
|
element.volume = (Number(video.original_volume) || 0) / 100
|
|
const start = Math.min(
|
|
(Number(video.trim_start_ms) || 0) / 1000,
|
|
element.duration || 0,
|
|
)
|
|
if (element.currentTime < start) element.currentTime = start
|
|
}
|
|
|
|
function enforceVideoTrim(
|
|
video: FlipTokVideo,
|
|
element: HTMLVideoElement,
|
|
): void {
|
|
const end = (video.trim_end_ms ?? Math.round(element.duration * 1000)) / 1000
|
|
if (
|
|
element.currentTime < video.trim_start_ms / 1000 ||
|
|
element.currentTime >= end
|
|
) {
|
|
element.currentTime = video.trim_start_ms / 1000
|
|
const music = musicElements.get(video.id)
|
|
if (music) music.currentTime = 0
|
|
seekFlipTokYoutube(video.id, 0)
|
|
} else {
|
|
const music = musicElements.get(video.id)
|
|
if (music?.duration) {
|
|
const expected =
|
|
(element.currentTime - video.trim_start_ms / 1000) % music.duration
|
|
if (Math.abs(music.currentTime - expected) > 0.65)
|
|
music.currentTime = expected
|
|
}
|
|
if (
|
|
flipTokYoutubeOwner === video.id &&
|
|
flipTokYoutubePlayer &&
|
|
Math.abs(
|
|
flipTokYoutubePlayer.getCurrentTime() -
|
|
(element.currentTime - video.trim_start_ms / 1000),
|
|
) > 0.65
|
|
) {
|
|
seekFlipTokYoutube(
|
|
video.id,
|
|
element.currentTime - video.trim_start_ms / 1000,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
function prepareMusic(video: FlipTokVideo, element: HTMLAudioElement): void {
|
|
element.volume = video.music_volume / 100
|
|
}
|
|
|
|
function handleVideoClick(video: FlipTokVideo): void {
|
|
if (videoClickTimer !== null) window.clearTimeout(videoClickTimer)
|
|
videoClickTimer = window.setTimeout(() => {
|
|
videoClickTimer = null
|
|
togglePlayback(video)
|
|
}, 230)
|
|
}
|
|
|
|
async function handleVideoDoubleClick(video: FlipTokVideo): Promise<void> {
|
|
if (videoClickTimer !== null) {
|
|
window.clearTimeout(videoClickTimer)
|
|
videoClickTimer = null
|
|
}
|
|
if (!video.is_liked) await store.react(video, 'like')
|
|
likedPulseId.value = video.id
|
|
if (likePulseTimer !== null) window.clearTimeout(likePulseTimer)
|
|
likePulseTimer = window.setTimeout(() => {
|
|
likedPulseId.value = null
|
|
likePulseTimer = null
|
|
}, 650)
|
|
}
|
|
|
|
async function reactWithPulse(
|
|
video: FlipTokVideo,
|
|
kind: 'like' | 'save',
|
|
): Promise<void> {
|
|
reactionPulse.value = null
|
|
await nextTick()
|
|
reactionPulse.value = { id: video.id, kind }
|
|
if (reactionPulseTimer !== null) window.clearTimeout(reactionPulseTimer)
|
|
if (feedbackTimer !== null) window.clearTimeout(feedbackTimer)
|
|
reactionPulseTimer = window.setTimeout(() => {
|
|
reactionPulse.value = null
|
|
reactionPulseTimer = null
|
|
}, 480)
|
|
await store.react(video, kind)
|
|
}
|
|
|
|
async function reactCommentWithPulse(comment: FlipTokComment): Promise<void> {
|
|
const shouldPulse = !comment.is_liked
|
|
const request = store.reactComment(comment)
|
|
|
|
if (shouldPulse) {
|
|
commentLikePulseId.value = null
|
|
await nextTick()
|
|
commentLikePulseId.value = comment.id
|
|
if (commentLikePulseTimer !== null)
|
|
window.clearTimeout(commentLikePulseTimer)
|
|
commentLikePulseTimer = window.setTimeout(() => {
|
|
commentLikePulseId.value = null
|
|
commentLikePulseTimer = null
|
|
}, 420)
|
|
}
|
|
|
|
await request
|
|
}
|
|
|
|
async function followFromFeed(video: FlipTokVideo): Promise<void> {
|
|
if (video.is_following || followPendingIds.value.has(video.id)) return
|
|
|
|
const pending = new Set(followPendingIds.value)
|
|
pending.add(video.id)
|
|
followPendingIds.value = pending
|
|
const followed = await store.follow(video)
|
|
const settled = new Set(followPendingIds.value)
|
|
settled.delete(video.id)
|
|
followPendingIds.value = settled
|
|
if (!followed) {
|
|
notify(t('errors.default'))
|
|
return
|
|
}
|
|
if (!video.is_following) return
|
|
|
|
const visible = new Set(followFeedbackIds.value)
|
|
visible.add(video.id)
|
|
followFeedbackIds.value = visible
|
|
const previousTimer = followFeedbackTimers.get(video.id)
|
|
if (previousTimer !== undefined) window.clearTimeout(previousTimer)
|
|
followFeedbackTimers.set(
|
|
video.id,
|
|
window.setTimeout(() => {
|
|
const next = new Set(followFeedbackIds.value)
|
|
next.delete(video.id)
|
|
followFeedbackIds.value = next
|
|
followFeedbackTimers.delete(video.id)
|
|
}, 950),
|
|
)
|
|
}
|
|
|
|
async function changeMode(mode: 'for-you' | 'following'): Promise<void> {
|
|
if (!(await store.loadFeed(mode))) {
|
|
notify(t('errors.default'))
|
|
return
|
|
}
|
|
feedBeforePreview.value = null
|
|
await nextTick()
|
|
observeVideos()
|
|
}
|
|
|
|
async function openComments(video: FlipTokVideo): Promise<void> {
|
|
selectedVideo.value = video
|
|
replyingTo.value = null
|
|
expandedCommentThreads.value = new Set()
|
|
if (!(await store.loadComments(video.id))) {
|
|
notify(t('errors.video_not_found'))
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
function cancelReply(): void {
|
|
replyingTo.value = null
|
|
}
|
|
|
|
async function submitComment(): Promise<void> {
|
|
if (!selectedVideo.value || !commentBody.value.trim()) return
|
|
const response = await store.comment(
|
|
selectedVideo.value.id,
|
|
commentBody.value.trim(),
|
|
replyingTo.value?.id,
|
|
)
|
|
if (!response.success)
|
|
return notify(t(`errors.${response.error ?? 'default'}`))
|
|
commentBody.value = ''
|
|
replyingTo.value = null
|
|
selectedVideo.value.comment_count += 1
|
|
await store.loadComments(selectedVideo.value.id)
|
|
}
|
|
|
|
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,
|
|
visibility: visibility.value,
|
|
trimStartMs: trimStartMs.value,
|
|
trimEndMs: trimEndMs.value,
|
|
coverTimeMs: coverTimeMs.value,
|
|
originalVolume: originalVolume.value,
|
|
musicVolume: musicVolume.value,
|
|
musicTrack: musicTrack.value,
|
|
customMusicUrl: customMusicUrl.value,
|
|
customMusicTitle: customMusicTitle.value,
|
|
customMusicArtist: customMusicArtist.value,
|
|
customMusicVideoId: customMusicVideoId.value,
|
|
selectedMediaItems: selectedMediaItems.value,
|
|
},
|
|
)
|
|
void router.push({
|
|
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<void> {
|
|
if (!selectedMedia.value || publishing.value) return
|
|
const mediaIds = (
|
|
selectedMediaItems.value.length
|
|
? selectedMediaItems.value
|
|
: [selectedMedia.value]
|
|
).map((media) => media.id)
|
|
publishing.value = true
|
|
const response = await nuiCall('fliptok:publish', {
|
|
caption: caption.value,
|
|
commentsEnabled: commentsEnabled.value,
|
|
draft,
|
|
mediaId: selectedMedia.value.id,
|
|
mediaIds,
|
|
mediaType: selectedMediaType.value,
|
|
trimStartMs: trimStartMs.value,
|
|
trimEndMs: trimEndMs.value || null,
|
|
coverTimeMs: coverTimeMs.value,
|
|
originalVolume: originalVolume.value,
|
|
musicVolume: hasMusic.value ? musicVolume.value : 0,
|
|
musicTrack: musicTrack.value,
|
|
customMusicUrl: musicTrack.value ? '' : customMusicUrl.value,
|
|
visibility: visibility.value,
|
|
})
|
|
publishing.value = false
|
|
if (!response.success)
|
|
return notify(t(`errors.${response.error ?? 'default'}`))
|
|
resetComposer()
|
|
composeOpen.value = false
|
|
tab.value = 'feed'
|
|
feedBeforePreview.value = null
|
|
await store.loadFeed('for-you')
|
|
await nextTick()
|
|
observeVideos()
|
|
notify(t(draft ? 'draftSaved' : 'published'))
|
|
}
|
|
|
|
function resetComposer(): void {
|
|
selectedMedia.value = null
|
|
selectedMediaItems.value = []
|
|
composerPhotoIndex.value = 0
|
|
caption.value = ''
|
|
visibility.value = 'public'
|
|
commentsEnabled.value = true
|
|
trimStartMs.value = 0
|
|
trimEndMs.value = 0
|
|
coverTimeMs.value = 0
|
|
originalVolume.value = 100
|
|
musicVolume.value = 35
|
|
musicTrack.value = ''
|
|
customMusicUrl.value = ''
|
|
customMusicDraftUrl.value = ''
|
|
clearCustomMusicDetails()
|
|
customMusicLoadFailed.value = false
|
|
pauseFlipTokYoutube('composer')
|
|
videoDurationMs.value = 0
|
|
}
|
|
|
|
function rangeNumber(value: unknown): number {
|
|
if (typeof value === 'number') return value
|
|
if (value instanceof Event)
|
|
return Number((value.target as HTMLInputElement).value)
|
|
return Number(value) || 0
|
|
}
|
|
|
|
function loadComposerVideo(event: Event): void {
|
|
const element = event.target as HTMLVideoElement
|
|
videoDurationMs.value = Math.max(1000, Math.floor(element.duration * 1000))
|
|
if (!trimEndMs.value || trimEndMs.value > videoDurationMs.value)
|
|
trimEndMs.value = videoDurationMs.value
|
|
coverTimeMs.value = Math.min(
|
|
Math.max(coverTimeMs.value, trimStartMs.value),
|
|
trimEndMs.value,
|
|
)
|
|
element.currentTime = coverTimeMs.value / 1000
|
|
element.volume = originalVolume.value / 100
|
|
}
|
|
|
|
function handleComposerPlayback(playing: boolean): void {
|
|
if (customMusicVideoId.value) {
|
|
if (playing) {
|
|
void playFlipTokYoutube(
|
|
'composer',
|
|
customMusicVideoId.value,
|
|
musicVolume.value,
|
|
((previewVideo.value?.currentTime ?? 0) * 1000 - trimStartMs.value) /
|
|
1000,
|
|
)
|
|
} else {
|
|
pauseFlipTokYoutube('composer')
|
|
}
|
|
return
|
|
}
|
|
pauseFlipTokYoutube('composer')
|
|
if (!composerMusic.value) return
|
|
if (playing) void composerMusic.value.play().catch(() => undefined)
|
|
else composerMusic.value.pause()
|
|
}
|
|
|
|
function enforceComposerTrim(event: Event): void {
|
|
const element = event.target as HTMLVideoElement
|
|
if (trimEndMs.value && element.currentTime * 1000 >= trimEndMs.value) {
|
|
element.currentTime = trimStartMs.value / 1000
|
|
if (composerMusic.value) composerMusic.value.currentTime = 0
|
|
seekFlipTokYoutube('composer', 0)
|
|
}
|
|
}
|
|
|
|
function updateTrimStart(value: unknown): void {
|
|
trimStartMs.value = Math.min(
|
|
rangeNumber(value),
|
|
Math.max(0, trimEndMs.value - 500),
|
|
)
|
|
coverTimeMs.value = Math.max(coverTimeMs.value, trimStartMs.value)
|
|
}
|
|
|
|
function updateTrimEnd(value: unknown): void {
|
|
trimEndMs.value = Math.max(rangeNumber(value), trimStartMs.value + 500)
|
|
coverTimeMs.value = Math.min(coverTimeMs.value, trimEndMs.value)
|
|
}
|
|
|
|
function updateCover(value: unknown): void {
|
|
coverTimeMs.value = rangeNumber(value)
|
|
if (previewVideo.value)
|
|
previewVideo.value.currentTime = coverTimeMs.value / 1000
|
|
}
|
|
|
|
function formatDuration(value: number): string {
|
|
const seconds = Math.max(0, Math.round(value / 1000))
|
|
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
|
}
|
|
|
|
function chooseVisibility(value: (typeof visibilityOptions)[number]): void {
|
|
visibility.value = value
|
|
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 {
|
|
profileDraft.value.accountType = value
|
|
accountTypeSheetOpen.value = false
|
|
}
|
|
|
|
async function runSearch(): Promise<void> {
|
|
await store.discover(search.value)
|
|
}
|
|
|
|
function openDiscoveredVideo(video: FlipTokVideo): void {
|
|
if (!feedBeforePreview.value) feedBeforePreview.value = [...store.feed]
|
|
store.feed = [video]
|
|
tab.value = 'feed'
|
|
}
|
|
|
|
async function openFeedTab(): Promise<void> {
|
|
if (feedBeforePreview.value) {
|
|
store.feed = feedBeforePreview.value
|
|
feedBeforePreview.value = null
|
|
}
|
|
tab.value = 'feed'
|
|
await nextTick()
|
|
observeVideos()
|
|
}
|
|
|
|
async function openProfile(profileId?: number, handle?: string): Promise<void> {
|
|
const loaded = await store.loadProfile(handle ? { handle } : { profileId })
|
|
if (loaded) {
|
|
commentsOpen.value = false
|
|
actionsOpen.value = false
|
|
tab.value = 'profile'
|
|
return
|
|
}
|
|
notify(t('errors.profile_not_found'))
|
|
}
|
|
|
|
async function openOwnProfile(): Promise<void> {
|
|
if (store.profile) {
|
|
if (!(await store.loadProfile({ profileId: store.profile.id }))) {
|
|
notify(t('errors.profile_not_found'))
|
|
return
|
|
}
|
|
store.viewedProfile = null
|
|
} else store.showOwnProfile()
|
|
tab.value = 'profile'
|
|
}
|
|
|
|
async function openConnections(mode: ConnectionMode): Promise<void> {
|
|
const profile = currentProfile.value
|
|
if (!profile) return
|
|
connectionsMode.value = mode
|
|
if (!(await store.loadConnections(profile.id, mode))) {
|
|
notify(t('errors.default'))
|
|
return
|
|
}
|
|
connectionsOpen.value = true
|
|
}
|
|
|
|
async function openConnectionProfile(profile: FlipTokProfile): Promise<void> {
|
|
connectionsOpen.value = false
|
|
await openProfile(profile.id)
|
|
}
|
|
|
|
async function followConnection(profile: FlipTokProfile): Promise<void> {
|
|
if (profile.is_owner || profile.is_following) return
|
|
if (!(await store.followProfile(profile))) notify(t('errors.default'))
|
|
}
|
|
|
|
function openActions(video: FlipTokVideo): void {
|
|
selectedVideo.value = video
|
|
actionsOpen.value = true
|
|
}
|
|
|
|
async function shareVideo(video: FlipTokVideo): Promise<void> {
|
|
const response = await nuiCall('fliptok:share', { id: video.id })
|
|
if (!response.success) {
|
|
notify(t(`errors.${response.error ?? 'default'}`))
|
|
return
|
|
}
|
|
video.share_count += 1
|
|
useEasyShareStore().open({
|
|
appId: 'fliptok',
|
|
copyText: `@${video.handle}: ${video.caption}`,
|
|
id: video.id,
|
|
imageUrl: video.url,
|
|
kind: 'post',
|
|
link: `skyphone://fliptok/video/${video.id}`,
|
|
subtitle: `@${video.handle}`,
|
|
title: video.caption || video.display_name,
|
|
})
|
|
}
|
|
|
|
function shareCurrentProfile(): void {
|
|
const profile = currentProfile.value
|
|
if (!profile) return
|
|
useEasyShareStore().open({
|
|
appId: 'fliptok',
|
|
copyText: `@${profile.handle}`,
|
|
id: profile.id,
|
|
kind: 'profile',
|
|
link: `skyphone://fliptok/profile/${profile.id}`,
|
|
subtitle: `@${profile.handle}`,
|
|
title: profile.display_name,
|
|
})
|
|
}
|
|
|
|
async function reportVideo(): Promise<void> {
|
|
if (!selectedVideo.value || reportSubmitting.value) return
|
|
reportSubmitting.value = true
|
|
const response = await nuiCall('fliptok:report', {
|
|
details: reportDetails.value.trim(),
|
|
id: selectedVideo.value.id,
|
|
reason: reportReason.value,
|
|
})
|
|
reportSubmitting.value = false
|
|
if (!response.success)
|
|
return notify(t(`errors.${response.error ?? 'default'}`))
|
|
actionsOpen.value = false
|
|
reportSheetOpen.value = false
|
|
reportDetails.value = ''
|
|
notify(t('reported'))
|
|
}
|
|
|
|
function openReport(): void {
|
|
actionsOpen.value = false
|
|
reportSheetOpen.value = true
|
|
}
|
|
|
|
async function blockCreator(): Promise<void> {
|
|
if (!selectedVideo.value) return
|
|
const blocked = await store.blockProfile(selectedVideo.value.profile_id)
|
|
if (!blocked) return notify(t('errors.default'))
|
|
actionsOpen.value = false
|
|
notify(t('blocked'))
|
|
}
|
|
|
|
async function blockCurrentProfile(): Promise<void> {
|
|
if (!currentProfile.value || currentProfile.value.is_owner) return
|
|
if (await store.blockProfile(currentProfile.value.id)) {
|
|
await openOwnProfile()
|
|
notify(t('blocked'))
|
|
}
|
|
}
|
|
|
|
function editProfile(): void {
|
|
if (!store.profile) return
|
|
profileDraft.value = {
|
|
accountType: store.profile.account_type,
|
|
avatarMediaId: store.profile.avatar_media_id ?? 0,
|
|
bio: store.profile.bio,
|
|
displayName: store.profile.display_name,
|
|
handle: store.profile.handle,
|
|
}
|
|
selectedProfilePhoto.value = null
|
|
profileEditOpen.value = true
|
|
}
|
|
|
|
function openProfileMedia(app: 'camera' | 'photos'): void {
|
|
profilePhotoSheetOpen.value = false
|
|
messageMedia.begin(
|
|
'fliptok:profile-avatar',
|
|
'photo',
|
|
'/apps/fliptok?profileEdit=1',
|
|
1,
|
|
{
|
|
draft: { ...profileDraft.value },
|
|
selectedPhoto: selectedProfilePhoto.value,
|
|
} satisfies ProfileMediaContext,
|
|
)
|
|
void router.push({
|
|
path: `/apps/${app}`,
|
|
query: { mediaAttachment: 'photo' },
|
|
})
|
|
}
|
|
|
|
function removeProfilePhoto(): void {
|
|
selectedProfilePhoto.value = null
|
|
profileDraft.value.avatarMediaId = 0
|
|
profilePhotoSheetOpen.value = false
|
|
}
|
|
|
|
async function saveProfile(): Promise<void> {
|
|
if (profileSaving.value) return
|
|
profileSaving.value = true
|
|
const response = await nuiCall<FlipTokProfile>('fliptok:update-profile', {
|
|
...profileDraft.value,
|
|
avatarMediaId:
|
|
selectedProfilePhoto.value?.id ?? profileDraft.value.avatarMediaId,
|
|
})
|
|
profileSaving.value = false
|
|
if (!response.success || !response.data)
|
|
return notify(t(`errors.${response.error ?? 'default'}`))
|
|
store.profile = response.data
|
|
store.viewedProfile = null
|
|
selectedProfilePhoto.value = null
|
|
profileEditOpen.value = false
|
|
}
|
|
|
|
async function followCurrentProfile(): Promise<void> {
|
|
const profile = currentProfile.value
|
|
if (!profile || profile.is_owner || profileFollowPending.value) return
|
|
profileFollowPending.value = true
|
|
const success = await store.followProfile(profile)
|
|
profileFollowPending.value = false
|
|
if (!success) notify(t('errors.default'))
|
|
}
|
|
|
|
function requestDeleteVideo(): void {
|
|
if (!selectedVideo.value?.is_owner) return
|
|
actionsOpen.value = false
|
|
deleteDialogOpen.value = true
|
|
}
|
|
|
|
async function confirmDeleteVideo(): Promise<void> {
|
|
if (!selectedVideo.value || deleteSubmitting.value) return
|
|
deleteSubmitting.value = true
|
|
const deleted = await store.deleteVideo(selectedVideo.value.id)
|
|
deleteSubmitting.value = false
|
|
if (!deleted) {
|
|
notify(t('errors.video_not_found'))
|
|
return
|
|
}
|
|
deleteDialogOpen.value = false
|
|
selectedVideo.value = null
|
|
notify(t('videoDeleted'))
|
|
}
|
|
|
|
watch(tab, async (value) => {
|
|
videoElements.forEach((video) => video.pause())
|
|
musicElements.forEach((audio) => audio.pause())
|
|
pauseFlipTokYoutube()
|
|
if (value === 'activity' && !(await store.loadActivities()))
|
|
notify(t('errors.default'))
|
|
if (value === 'discover' && store.searchResults.length === 0)
|
|
await runSearch()
|
|
if (value === 'feed') {
|
|
await nextTick()
|
|
observeVideos()
|
|
}
|
|
})
|
|
|
|
watch(commentsOpen, (opened) => {
|
|
if (opened) return
|
|
expandedCommentThreads.value = new Set()
|
|
replyingTo.value = null
|
|
})
|
|
|
|
watch(
|
|
() => selectedMediaItems.value.length,
|
|
(length) => {
|
|
composerPhotoIndex.value = Math.max(
|
|
0,
|
|
Math.min(composerPhotoIndex.value, length - 1),
|
|
)
|
|
},
|
|
)
|
|
|
|
watch(
|
|
() => store.authenticated,
|
|
async (authenticated) => {
|
|
if (!authenticated) return
|
|
await nextTick()
|
|
observeVideos()
|
|
},
|
|
)
|
|
|
|
watch(search, () => {
|
|
if (searchTimer !== null) window.clearTimeout(searchTimer)
|
|
searchTimer = window.setTimeout(() => {
|
|
searchTimer = null
|
|
void runSearch()
|
|
}, 250)
|
|
})
|
|
|
|
watch(originalVolume, (value) => {
|
|
if (previewVideo.value) previewVideo.value.volume = value / 100
|
|
})
|
|
|
|
watch(musicVolume, (value) => {
|
|
if (composerMusic.value) composerMusic.value.volume = value / 100
|
|
if (flipTokYoutubeOwner === 'composer') flipTokYoutubePlayer?.setVolume(value)
|
|
})
|
|
|
|
watch(
|
|
[composerMusicUrl, customMusicVideoId],
|
|
async ([value, youtubeVideoId]) => {
|
|
customMusicLoadFailed.value = false
|
|
await nextTick()
|
|
if (youtubeVideoId) {
|
|
if (previewVideo.value && !previewVideo.value.paused)
|
|
void playFlipTokYoutube(
|
|
'composer',
|
|
youtubeVideoId,
|
|
musicVolume.value,
|
|
(previewVideo.value.currentTime * 1000 - trimStartMs.value) / 1000,
|
|
)
|
|
return
|
|
}
|
|
pauseFlipTokYoutube('composer')
|
|
if (!value || !composerMusic.value) return
|
|
composerMusic.value.volume = musicVolume.value / 100
|
|
composerMusic.value.currentTime = 0
|
|
if (previewVideo.value && !previewVideo.value.paused)
|
|
void composerMusic.value.play().catch(() => undefined)
|
|
},
|
|
)
|
|
|
|
onMounted(async () => {
|
|
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>(
|
|
'fliptok:profile-avatar',
|
|
)
|
|
if (profileSelection?.context) {
|
|
profileDraft.value = profileSelection.context.draft
|
|
selectedProfilePhoto.value = profileSelection.context.selectedPhoto
|
|
}
|
|
if (profileSelection?.media[0]) {
|
|
selectedProfilePhoto.value = profileSelection.media[0]
|
|
profileDraft.value.avatarMediaId = profileSelection.media[0].id
|
|
}
|
|
if (profileSelection) {
|
|
tab.value = 'profile'
|
|
profileEditOpen.value = true
|
|
}
|
|
const selection = messageMedia.consumeMany<{
|
|
caption?: string
|
|
commentsEnabled?: boolean
|
|
visibility?: 'public' | 'followers' | 'private'
|
|
trimStartMs?: number
|
|
trimEndMs?: number
|
|
coverTimeMs?: number
|
|
originalVolume?: number
|
|
musicVolume?: number
|
|
musicTrack?: string
|
|
customMusicUrl?: string
|
|
customMusicTitle?: string
|
|
customMusicArtist?: string
|
|
customMusicVideoId?: string
|
|
selectedMediaItems?: PhoneMedia[]
|
|
}>('fliptok:compose')
|
|
if (selection) {
|
|
const restoredMedia = selection.media.length
|
|
? selection.media
|
|
: (selection.context?.selectedMediaItems ?? [])
|
|
selectedMedia.value = restoredMedia[0] ?? null
|
|
selectedMediaItems.value = restoredMedia
|
|
composerPhotoIndex.value = 0
|
|
caption.value = selection.context?.caption ?? ''
|
|
commentsEnabled.value = selection.context?.commentsEnabled ?? true
|
|
visibility.value = selection.context?.visibility ?? 'public'
|
|
trimStartMs.value = selection.context?.trimStartMs ?? 0
|
|
trimEndMs.value = selection.context?.trimEndMs ?? 0
|
|
coverTimeMs.value = selection.context?.coverTimeMs ?? 0
|
|
originalVolume.value = selection.context?.originalVolume ?? 100
|
|
musicVolume.value = selection.context?.musicVolume ?? 35
|
|
musicTrack.value = selection.context?.musicTrack ?? ''
|
|
customMusicUrl.value = selection.context?.customMusicUrl ?? ''
|
|
customMusicTitle.value = selection.context?.customMusicTitle ?? ''
|
|
customMusicArtist.value = selection.context?.customMusicArtist ?? ''
|
|
customMusicVideoId.value =
|
|
selection.context?.customMusicVideoId ??
|
|
parseYoutubeVideoId(customMusicUrl.value)
|
|
customMusicLoadFailed.value = false
|
|
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)
|
|
if (Number.isInteger(profileId) && profileId > 0)
|
|
await openProfile(profileId)
|
|
} else if (easyShareId && route.query.easyShareKind === 'post') {
|
|
const video = await store.loadVideo(easyShareId)
|
|
if (video) {
|
|
store.feed = [video]
|
|
tab.value = 'feed'
|
|
}
|
|
}
|
|
await nextTick()
|
|
observeVideos()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
observer?.disconnect()
|
|
if (videoClickTimer !== null) window.clearTimeout(videoClickTimer)
|
|
if (likePulseTimer !== null) window.clearTimeout(likePulseTimer)
|
|
if (commentLikePulseTimer !== null) window.clearTimeout(commentLikePulseTimer)
|
|
if (reactionPulseTimer !== null) window.clearTimeout(reactionPulseTimer)
|
|
if (feedbackTimer !== null) window.clearTimeout(feedbackTimer)
|
|
if (searchTimer !== null) window.clearTimeout(searchTimer)
|
|
followFeedbackTimers.forEach((timer) => window.clearTimeout(timer))
|
|
followFeedbackTimers.clear()
|
|
if (photoSlideDrag) {
|
|
if (photoSlideDrag.frame !== null) {
|
|
window.cancelAnimationFrame(photoSlideDrag.frame)
|
|
}
|
|
if (photoSlideDrag.element.hasPointerCapture(photoSlideDrag.pointerId)) {
|
|
photoSlideDrag.element.releasePointerCapture(photoSlideDrag.pointerId)
|
|
}
|
|
photoSlideDrag.element.classList.remove('photo-slideshow--dragging')
|
|
photoSlideDrag = null
|
|
}
|
|
Array.from(photoSlideAnimations.keys()).forEach(cancelPhotoSlideAnimation)
|
|
feedCards.clear()
|
|
photoSlideElements.clear()
|
|
videoElements.forEach((video) => video.pause())
|
|
destroyFlipTokYoutube()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<SkyAppPage
|
|
class="fliptok-page"
|
|
:dark="phone.isDarkMode"
|
|
:label="t('name')"
|
|
accent="#ff2d55"
|
|
accent-soft="rgba(255, 45, 85, 0.18)"
|
|
>
|
|
<div v-if="store.loading && !store.feed.length" class="state">
|
|
<SkySpinner :label="t('loading')" /><span>{{ t('loading') }}</span>
|
|
</div>
|
|
|
|
<section
|
|
v-else-if="!store.authenticated"
|
|
class="fliptok-auth"
|
|
:class="{ 'fliptok-auth--dark': phone.isDarkMode }"
|
|
>
|
|
<SkyNavbar
|
|
class="fliptok-auth__navbar"
|
|
:title="t('name')"
|
|
:scroll-el="null"
|
|
variant="medium"
|
|
/>
|
|
<div class="fliptok-auth__body">
|
|
<SkyGlass class="fliptok-auth__glass">
|
|
<header class="fliptok-auth__hero">
|
|
<img :src="flipTokIcon" alt="" />
|
|
<div>
|
|
<h1>{{ t('authTitle') }}</h1>
|
|
<p>
|
|
{{ t(authMode === 'login' ? 'loginBody' : 'registerBody') }}
|
|
</p>
|
|
</div>
|
|
</header>
|
|
|
|
<SkySegmented
|
|
class="fliptok-auth__segment"
|
|
:active-index="authMode === 'login' ? 0 : 1"
|
|
:item-count="2"
|
|
raised
|
|
strong
|
|
rounded
|
|
>
|
|
<SkySegmentedButton
|
|
:active="authMode === 'login'"
|
|
@click="authMode = 'login'"
|
|
>
|
|
{{ t('login') }}
|
|
</SkySegmentedButton>
|
|
<SkySegmentedButton
|
|
:active="authMode === 'register'"
|
|
@click="authMode = 'register'"
|
|
>
|
|
{{ t('register') }}
|
|
</SkySegmentedButton>
|
|
</SkySegmented>
|
|
|
|
<form class="fliptok-auth__form" @submit.prevent="submitAuth">
|
|
<SkyBlockTitle>{{ t('accountDetails') }}</SkyBlockTitle>
|
|
<SkyList inset strong>
|
|
<SkyField
|
|
v-if="authMode === 'register'"
|
|
id="fliptok-display-name"
|
|
:label="t('displayName')"
|
|
:placeholder="t('displayNamePlaceholder')"
|
|
:value="authDisplayName"
|
|
maxlength="40"
|
|
clear-button
|
|
@input="authDisplayName = inputValue($event)"
|
|
/>
|
|
<SkyField
|
|
id="fliptok-handle"
|
|
:label="t('username')"
|
|
:placeholder="t('usernamePlaceholder')"
|
|
:value="authHandle"
|
|
maxlength="24"
|
|
autocapitalize="none"
|
|
autocomplete="username"
|
|
clear-button
|
|
@input="authHandle = inputValue($event)"
|
|
/>
|
|
<SkyField
|
|
id="fliptok-password"
|
|
type="password"
|
|
:label="t('password')"
|
|
:placeholder="t('passwordPlaceholder')"
|
|
:value="authPassword"
|
|
:autocomplete="
|
|
authMode === 'login' ? 'current-password' : 'new-password'
|
|
"
|
|
maxlength="72"
|
|
@input="authPassword = inputValue($event)"
|
|
/>
|
|
<SkyField
|
|
v-if="authMode === 'register'"
|
|
id="fliptok-confirm-password"
|
|
type="password"
|
|
:label="t('confirmPassword')"
|
|
:placeholder="t('confirmPasswordPlaceholder')"
|
|
:value="authConfirmPassword"
|
|
autocomplete="new-password"
|
|
maxlength="72"
|
|
@input="authConfirmPassword = inputValue($event)"
|
|
/>
|
|
</SkyList>
|
|
<SkyButton large rounded type="submit" :disabled="authSubmitting">
|
|
<SkySpinner v-if="authSubmitting" :label="t('loading')" />
|
|
<template v-else>{{
|
|
t(authMode === 'login' ? 'login' : 'createAccount')
|
|
}}</template>
|
|
</SkyButton>
|
|
<SkyBlock v-if="authMode === 'register'" class="fliptok-auth__hint">
|
|
{{ t('registrationHint') }}
|
|
</SkyBlock>
|
|
</form>
|
|
</SkyGlass>
|
|
</div>
|
|
</section>
|
|
|
|
<template v-else-if="tab === 'feed'">
|
|
<header class="feed-header">
|
|
<button
|
|
:class="{ active: store.mode === 'following' }"
|
|
@click="changeMode('following')"
|
|
>
|
|
{{ t('following') }}
|
|
</button>
|
|
<button
|
|
:class="{ active: store.mode === 'for-you' }"
|
|
@click="changeMode('for-you')"
|
|
>
|
|
{{ t('forYou') }}
|
|
</button>
|
|
</header>
|
|
<SkyLink
|
|
component="button"
|
|
icon-only
|
|
class="feed-search"
|
|
:aria-label="t('discover')"
|
|
@click="tab = 'discover'"
|
|
>
|
|
<Search />
|
|
</SkyLink>
|
|
<main class="video-feed">
|
|
<article
|
|
v-for="video in store.feed"
|
|
:key="video.id"
|
|
:ref="(el) => setFeedCard(video.id, el)"
|
|
class="video-card"
|
|
:class="{ 'video-card--photo': video.media_type === 'photo' }"
|
|
>
|
|
<div
|
|
v-if="video.media_type === 'photo'"
|
|
:ref="(el) => setPhotoSlideElement(video.id, el)"
|
|
class="photo-slideshow"
|
|
@pointerdown="beginPhotoSlideDrag(video.id, $event)"
|
|
@pointermove="updatePhotoSlideDrag"
|
|
@pointerup="endPhotoSlideDrag(video, $event)"
|
|
@pointercancel="endPhotoSlideDrag(video, $event)"
|
|
>
|
|
<div class="photo-slideshow__track">
|
|
<img
|
|
v-for="media in videoMedia(video)"
|
|
:key="media.id || media.url"
|
|
:src="media.url"
|
|
alt=""
|
|
/>
|
|
</div>
|
|
</div>
|
|
<span
|
|
v-if="video.media_type === 'photo' && videoMedia(video).length > 1"
|
|
class="photo-slideshow__count"
|
|
>
|
|
{{ photoSlideIndex(video.id) + 1 }} /
|
|
{{ videoMedia(video).length }}
|
|
</span>
|
|
<video
|
|
v-else
|
|
:ref="(el) => setVideoElement(video.id, el)"
|
|
:data-id="video.id"
|
|
:src="video.url"
|
|
loop
|
|
playsinline
|
|
preload="metadata"
|
|
@loadedmetadata="
|
|
prepareFeedVideo(video, $event.target as HTMLVideoElement)
|
|
"
|
|
@timeupdate="
|
|
enforceVideoTrim(video, $event.target as HTMLVideoElement)
|
|
"
|
|
@error="handleFeedVideoError(video.id, $event)"
|
|
/>
|
|
<audio
|
|
v-if="video.music_url && video.music_source !== 'youtube'"
|
|
:ref="(el) => setMusicElement(video.id, el)"
|
|
:src="video.music_url"
|
|
loop
|
|
preload="metadata"
|
|
@loadedmetadata="
|
|
prepareMusic(video, $event.target as HTMLAudioElement)
|
|
"
|
|
/>
|
|
<div
|
|
class="video-shade"
|
|
:class="{
|
|
'video-shade--passive': video.media_type === 'photo',
|
|
}"
|
|
@click="video.media_type !== 'photo' && handleVideoClick(video)"
|
|
@dblclick.prevent="
|
|
video.media_type !== 'photo' && handleVideoDoubleClick(video)
|
|
"
|
|
/>
|
|
<button
|
|
v-if="playbackFailedIds.has(video.id)"
|
|
type="button"
|
|
class="video-playback-fallback"
|
|
:aria-label="`${t('errors.video_not_found')} ${phone.t('Common.start')}`"
|
|
@click.stop="retryPlayback(video)"
|
|
>
|
|
<TriangleAlert />
|
|
<strong>{{ t('errors.video_not_found') }}</strong>
|
|
<span>{{ phone.t('Common.start') }}</span>
|
|
</button>
|
|
<Transition name="double-like">
|
|
<Heart
|
|
v-if="likedPulseId === video.id"
|
|
class="double-like-heart"
|
|
fill="currentColor"
|
|
/>
|
|
</Transition>
|
|
<section class="video-copy">
|
|
<div class="creator-line">
|
|
<button
|
|
class="creator-link"
|
|
@click="openProfile(video.profile_id)"
|
|
>
|
|
<strong>@{{ video.handle }}</strong></button
|
|
><Check
|
|
v-if="video.verified"
|
|
class="verified"
|
|
:aria-label="t('verified')"
|
|
/>
|
|
</div>
|
|
<p>
|
|
<template
|
|
v-for="(part, index) in textParts(video.caption)"
|
|
:key="`${video.id}-${index}`"
|
|
>
|
|
<button
|
|
v-if="part.kind !== 'text'"
|
|
class="caption-link"
|
|
@click="openTextLink(part)"
|
|
>
|
|
{{ part.value }}
|
|
</button>
|
|
<template v-else>{{ part.value }}</template>
|
|
</template>
|
|
</p>
|
|
<div class="sound">
|
|
<Music2 />{{
|
|
video.music_title ||
|
|
(video.music_url && !video.music_track
|
|
? t('customSound')
|
|
: video.display_name)
|
|
}}
|
|
·
|
|
{{
|
|
video.music_artist ||
|
|
(video.music_url && !video.music_track
|
|
? video.display_name
|
|
: t('originalSound'))
|
|
}}
|
|
</div>
|
|
</section>
|
|
<aside class="video-actions">
|
|
<div class="video-profile-action">
|
|
<button
|
|
class="avatar"
|
|
:aria-label="video.display_name"
|
|
@click="openProfile(video.profile_id)"
|
|
>
|
|
<img v-if="video.avatar_url" :src="video.avatar_url" alt="" />
|
|
<template v-else>{{ initials(video.display_name) }}</template>
|
|
</button>
|
|
<button
|
|
v-if="
|
|
!video.is_owner &&
|
|
(!video.is_following || followFeedbackIds.has(video.id))
|
|
"
|
|
class="follow-dot"
|
|
:class="{
|
|
'follow-dot--confirmed': video.is_following,
|
|
'follow-dot--pending': followPendingIds.has(video.id),
|
|
}"
|
|
:disabled="followPendingIds.has(video.id)"
|
|
@click="followFromFeed(video)"
|
|
>
|
|
<Check v-if="video.is_following" /><Plus v-else />
|
|
</button>
|
|
</div>
|
|
<button
|
|
:class="{
|
|
liked: video.is_liked,
|
|
'reaction-pop':
|
|
reactionPulse?.id === video.id &&
|
|
reactionPulse.kind === 'like',
|
|
'reaction-pop--like':
|
|
reactionPulse?.id === video.id &&
|
|
reactionPulse.kind === 'like',
|
|
}"
|
|
@click="reactWithPulse(video, 'like')"
|
|
>
|
|
<Heart fill="currentColor" /><span>{{
|
|
compactCount(video.like_count)
|
|
}}</span>
|
|
</button>
|
|
<button @click="openComments(video)">
|
|
<MessageCircle fill="currentColor" /><span>{{
|
|
compactCount(video.comment_count)
|
|
}}</span>
|
|
</button>
|
|
<button
|
|
:class="{
|
|
saved: video.is_saved,
|
|
'reaction-pop':
|
|
reactionPulse?.id === video.id &&
|
|
reactionPulse.kind === 'save',
|
|
'reaction-pop--save':
|
|
reactionPulse?.id === video.id &&
|
|
reactionPulse.kind === 'save',
|
|
}"
|
|
@click="reactWithPulse(video, 'save')"
|
|
>
|
|
<Bookmark fill="currentColor" /><span>{{ t('save') }}</span>
|
|
</button>
|
|
<button @click="shareVideo(video)">
|
|
<Share2 /><span>{{ compactCount(video.share_count) }}</span>
|
|
</button>
|
|
<button :aria-label="t('more')" @click="openActions(video)">
|
|
<MoreHorizontal />
|
|
</button>
|
|
</aside>
|
|
</article>
|
|
<div v-if="!store.feed.length" class="empty-feed">
|
|
<Video /><strong>{{ t('emptyFeed') }}</strong
|
|
><span>{{ t('emptyFeedBody') }}</span>
|
|
</div>
|
|
</main>
|
|
</template>
|
|
|
|
<template v-else-if="tab === 'discover'">
|
|
<SkyNavbar
|
|
class="fliptok-navbar fliptok-discover-navbar"
|
|
:title="t('discover')"
|
|
variant="medium"
|
|
>
|
|
<template #subnavbar>
|
|
<SkySearchbar
|
|
v-model="search"
|
|
class="discover-search"
|
|
:label="t('searchPlaceholder')"
|
|
:clear-label="t('clearSearch')"
|
|
:placeholder="t('searchPlaceholder')"
|
|
/>
|
|
</template>
|
|
</SkyNavbar>
|
|
<SkyScrollArea padded with-tabbar class="light-screen discover-screen">
|
|
<div class="trend-pills">
|
|
<SkyChip
|
|
v-for="trend in discoveryTags"
|
|
:key="trend.value"
|
|
component="button"
|
|
type="button"
|
|
@click="search = trend.value"
|
|
>
|
|
{{ t(trend.labelKey) }}
|
|
</SkyChip>
|
|
</div>
|
|
<div class="video-grid">
|
|
<button
|
|
v-for="video in store.searchResults"
|
|
:key="video.id"
|
|
@click="openDiscoveredVideo(video)"
|
|
>
|
|
<img v-if="video.media_type === 'photo'" :src="video.url" alt="" />
|
|
<video
|
|
v-else
|
|
:src="video.url"
|
|
preload="metadata"
|
|
muted
|
|
@loadedmetadata="
|
|
($event.target as HTMLVideoElement).currentTime =
|
|
video.cover_time_ms / 1000
|
|
"
|
|
/><span><Play />{{ compactCount(video.view_count) }}</span>
|
|
</button>
|
|
</div>
|
|
</SkyScrollArea>
|
|
</template>
|
|
|
|
<template v-else-if="tab === 'activity'">
|
|
<SkyNavbar
|
|
class="fliptok-navbar"
|
|
:title="t('activity')"
|
|
variant="large"
|
|
/>
|
|
<SkyScrollArea padded with-tabbar class="light-screen activity-list">
|
|
<article
|
|
v-for="activity in store.activities"
|
|
:key="activity.id"
|
|
@click="openProfile(activity.profile_id)"
|
|
>
|
|
<div class="activity-avatar">
|
|
<img v-if="activity.avatar_url" :src="activity.avatar_url" alt="" />
|
|
<template v-else>{{ initials(activity.display_name) }}</template>
|
|
</div>
|
|
<p>
|
|
<strong
|
|
>{{ activity.display_name }}
|
|
<Check v-if="activity.verified" class="verified" /></strong
|
|
><span>{{ t(`activityKinds.${activity.kind}`) }}</span>
|
|
</p>
|
|
</article>
|
|
<div v-if="!store.activities.length" class="light-empty">
|
|
<Bell /><strong>{{ t('noActivity') }}</strong>
|
|
</div>
|
|
</SkyScrollArea>
|
|
</template>
|
|
|
|
<template v-else-if="tab === 'profile'">
|
|
<SkyNavbar
|
|
class="fliptok-navbar fliptok-profile-navbar"
|
|
title=""
|
|
:show-back="Boolean(store.viewedProfile)"
|
|
:back-label="phone.t('Common.back')"
|
|
back-appearance="surface"
|
|
variant="medium"
|
|
@back="openOwnProfile"
|
|
>
|
|
<template v-if="currentProfile" #right>
|
|
<div class="profile-navbar-actions">
|
|
<SkyLink
|
|
v-if="currentProfile.is_owner"
|
|
component="button"
|
|
icon-only
|
|
:aria-label="t('editProfile')"
|
|
@click="editProfile"
|
|
>
|
|
<Pencil />
|
|
</SkyLink>
|
|
<SkyLink
|
|
component="button"
|
|
icon-only
|
|
:aria-label="phone.t('Apps.easyShare.shareProfile')"
|
|
@click="shareCurrentProfile"
|
|
>
|
|
<Share2 />
|
|
</SkyLink>
|
|
</div>
|
|
</template>
|
|
</SkyNavbar>
|
|
<SkyScrollArea
|
|
v-if="currentProfile"
|
|
padded
|
|
with-tabbar
|
|
class="light-screen profile-screen"
|
|
>
|
|
<div class="profile-avatar">
|
|
<img
|
|
v-if="currentProfile.avatar_url"
|
|
:src="currentProfile.avatar_url"
|
|
alt=""
|
|
/>
|
|
<template v-else>{{
|
|
initials(currentProfile.display_name)
|
|
}}</template>
|
|
</div>
|
|
<h1>
|
|
{{ currentProfile.display_name }}
|
|
<Check v-if="currentProfile.verified" class="verified" />
|
|
</h1>
|
|
<p class="handle">@{{ currentProfile.handle }}</p>
|
|
<div class="profile-stats">
|
|
<button type="button" @click="openConnections('following')">
|
|
<strong>{{ currentProfile.following }}</strong
|
|
><span>{{ t('following') }}</span>
|
|
</button>
|
|
<button type="button" @click="openConnections('followers')">
|
|
<strong>{{ currentProfile.followers }}</strong
|
|
><span>{{ t('followers') }}</span>
|
|
</button>
|
|
<div>
|
|
<strong>{{ currentProfile.video_count }}</strong
|
|
><span>{{ t('videos') }}</span>
|
|
</div>
|
|
</div>
|
|
<p class="bio">{{ currentProfile.bio || t('emptyBio') }}</p>
|
|
<div v-if="!currentProfile.is_owner" class="profile-actions">
|
|
<template>
|
|
<SkyButton
|
|
rounded
|
|
:disabled="profileFollowPending"
|
|
@click="followCurrentProfile"
|
|
>
|
|
{{ currentProfile.is_following ? t('unfollow') : t('follow') }}
|
|
</SkyButton>
|
|
<SkyButton
|
|
rounded
|
|
tonal
|
|
class="danger-button"
|
|
@click="blockCurrentProfile"
|
|
>{{ t('block') }}</SkyButton
|
|
>
|
|
</template>
|
|
</div>
|
|
<div class="profile-video-grid">
|
|
<button
|
|
v-for="video in store.profileVideos"
|
|
:key="video.id"
|
|
@click="openDiscoveredVideo(video)"
|
|
>
|
|
<img v-if="video.media_type === 'photo'" :src="video.url" alt="" />
|
|
<video
|
|
v-else
|
|
:src="video.url"
|
|
muted
|
|
playsinline
|
|
preload="metadata"
|
|
@loadedmetadata="
|
|
($event.target as HTMLVideoElement).currentTime =
|
|
video.cover_time_ms / 1000
|
|
"
|
|
/>
|
|
<span><Play />{{ compactCount(video.view_count) }}</span>
|
|
</button>
|
|
</div>
|
|
</SkyScrollArea>
|
|
</template>
|
|
|
|
<SkyPillNavigation
|
|
v-if="
|
|
store.authenticated &&
|
|
!composeOpen &&
|
|
!profileEditOpen &&
|
|
!connectionsOpen
|
|
"
|
|
class="main-tabs"
|
|
:label="t('navigation')"
|
|
layout="full"
|
|
>
|
|
<SkySegmented
|
|
class="main-tabs__segments"
|
|
:active-index="tabIndex"
|
|
:item-count="5"
|
|
:aria-label="t('navigation')"
|
|
navigation
|
|
>
|
|
<SkySegmentedButton
|
|
:active="tab === 'feed'"
|
|
class="main-tab"
|
|
@click="openFeedTab"
|
|
>
|
|
<Home /><small>{{ t('home') }}</small>
|
|
</SkySegmentedButton>
|
|
<SkySegmentedButton
|
|
:active="tab === 'discover'"
|
|
class="main-tab"
|
|
@click="tab = 'discover'"
|
|
>
|
|
<Compass /><small>{{ t('discover') }}</small>
|
|
</SkySegmentedButton>
|
|
<SkySegmentedButton
|
|
class="main-tab main-tab--create"
|
|
@click="composeOpen = true"
|
|
>
|
|
<span class="create-icon" aria-hidden="true"><Plus /></span
|
|
><small>{{ t('create') }}</small>
|
|
</SkySegmentedButton>
|
|
<SkySegmentedButton
|
|
:active="tab === 'activity'"
|
|
class="main-tab"
|
|
@click="tab = 'activity'"
|
|
>
|
|
<Bell /><small>{{ t('activity') }}</small>
|
|
</SkySegmentedButton>
|
|
<SkySegmentedButton
|
|
:active="tab === 'profile'"
|
|
class="main-tab"
|
|
@click="openOwnProfile"
|
|
>
|
|
<UserRound /><small>{{ t('profile') }}</small>
|
|
</SkySegmentedButton>
|
|
</SkySegmented>
|
|
</SkyPillNavigation>
|
|
|
|
<section v-if="composeOpen" class="overlay-screen compose-screen">
|
|
<SkyNavbar
|
|
:title="t('newVideo')"
|
|
:show-back="true"
|
|
:back-label="t('cancel')"
|
|
back-appearance="surface"
|
|
@back="composeOpen = false"
|
|
/>
|
|
<SkyScrollArea padded class="compose-body">
|
|
<header class="compose-intro">
|
|
<span><Video /></span>
|
|
<div>
|
|
<strong>{{ t('createTitle') }}</strong>
|
|
<p>{{ t('createBody') }}</p>
|
|
</div>
|
|
</header>
|
|
<SkyGlass v-if="!selectedMedia" class="media-picker">
|
|
<header>
|
|
<ImagePlus /><strong>{{ t('chooseMedia') }}</strong>
|
|
<span>{{ t('chooseMediaHint') }}</span>
|
|
</header>
|
|
<div class="media-source-grid">
|
|
<button type="button" @click="chooseVideo('camera')">
|
|
<Camera /><strong>{{ t('recordVideo') }}</strong>
|
|
</button>
|
|
<button type="button" @click="chooseVideo('photos')">
|
|
<Video /><strong>{{ t('chooseVideo') }}</strong>
|
|
</button>
|
|
<button type="button" @click="choosePhotoSlideshow('photos')">
|
|
<ImagePlus /><strong>{{ t('photoSlideshow') }}</strong>
|
|
</button>
|
|
</div>
|
|
</SkyGlass>
|
|
<SkyGlass v-else class="media-preview">
|
|
<video
|
|
v-if="selectedMediaType === 'video'"
|
|
ref="previewVideo"
|
|
:src="selectedMedia.url"
|
|
controls
|
|
playsinline
|
|
@loadedmetadata="loadComposerVideo"
|
|
@play="handleComposerPlayback(true)"
|
|
@pause="handleComposerPlayback(false)"
|
|
@timeupdate="enforceComposerTrim"
|
|
/>
|
|
<div v-else class="compose-photo-preview">
|
|
<img
|
|
v-if="selectedComposerPhoto"
|
|
:key="selectedComposerPhoto.id"
|
|
:src="selectedComposerPhoto.url"
|
|
alt=""
|
|
/>
|
|
<template v-if="selectedMediaItems.length > 1">
|
|
<button
|
|
type="button"
|
|
class="compose-photo-preview__arrow compose-photo-preview__arrow--previous"
|
|
:disabled="composerPhotoIndex === 0"
|
|
:aria-label="t('previousPhoto')"
|
|
@click="moveComposerPhoto(-1)"
|
|
>
|
|
<ChevronLeft />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="compose-photo-preview__arrow compose-photo-preview__arrow--next"
|
|
:disabled="composerPhotoIndex === selectedMediaItems.length - 1"
|
|
:aria-label="t('nextPhoto')"
|
|
@click="moveComposerPhoto(1)"
|
|
>
|
|
<ChevronRight />
|
|
</button>
|
|
<span class="compose-photo-preview__count">
|
|
{{ composerPhotoIndex + 1 }} / {{ selectedMediaItems.length }}
|
|
</span>
|
|
<div class="compose-photo-preview__dots" aria-hidden="true">
|
|
<span
|
|
v-for="media in selectedMediaItems"
|
|
:key="media.id"
|
|
:class="{
|
|
active:
|
|
selectedMediaItems[composerPhotoIndex]?.id === media.id,
|
|
}"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="media-preview__actions">
|
|
<SkyButton
|
|
small
|
|
rounded
|
|
tonal
|
|
@click="
|
|
selectedMediaType === 'photo'
|
|
? choosePhotoSlideshow('photos')
|
|
: chooseVideo('photos')
|
|
"
|
|
>
|
|
{{ t('changeMedia') }}
|
|
</SkyButton>
|
|
<SkyButton
|
|
small
|
|
rounded
|
|
tonal
|
|
@click="
|
|
selectedMediaType === 'photo'
|
|
? choosePhotoSlideshow('camera')
|
|
: chooseVideo('camera')
|
|
"
|
|
>
|
|
<Camera />{{ t('camera') }}
|
|
</SkyButton>
|
|
</div>
|
|
<audio
|
|
v-if="composerMusicUrl"
|
|
ref="composerMusic"
|
|
:src="composerMusicUrl"
|
|
loop
|
|
preload="metadata"
|
|
@loadedmetadata="markComposerMusicReady"
|
|
@error="markComposerMusicFailed"
|
|
/>
|
|
</SkyGlass>
|
|
<SkyGlass
|
|
v-if="selectedMediaType === 'video' && videoDurationMs"
|
|
class="editor-card"
|
|
>
|
|
<h3>{{ t('trimAndCover') }}</h3>
|
|
<label>
|
|
<span
|
|
>{{ t('trimStart') }}
|
|
<strong>{{ formatDuration(trimStartMs) }}</strong></span
|
|
>
|
|
<SkyRange
|
|
:value="trimStartMs"
|
|
:min="0"
|
|
:max="Math.max(0, trimEndMs - 500)"
|
|
:step="100"
|
|
@input="updateTrimStart"
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span
|
|
>{{ t('trimEnd') }}
|
|
<strong>{{ formatDuration(trimEndMs) }}</strong></span
|
|
>
|
|
<SkyRange
|
|
:value="trimEndMs"
|
|
:min="Math.min(videoDurationMs, trimStartMs + 500)"
|
|
:max="videoDurationMs"
|
|
:step="100"
|
|
@input="updateTrimEnd"
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span
|
|
>{{ t('coverFrame') }}
|
|
<strong>{{ formatDuration(coverTimeMs) }}</strong></span
|
|
>
|
|
<SkyRange
|
|
:value="coverTimeMs"
|
|
:min="trimStartMs"
|
|
:max="trimEndMs"
|
|
:step="100"
|
|
@input="updateCover"
|
|
/>
|
|
</label>
|
|
</SkyGlass>
|
|
<SkyGlass v-if="selectedMedia" class="editor-card">
|
|
<button class="sound-picker" type="button" @click="openMusicSheet">
|
|
<span><Music2 />{{ t('sounds') }}</span>
|
|
<strong>{{
|
|
selectedMusic
|
|
? `${selectedMusic.title} · ${selectedMusic.artist}`
|
|
: customMusicTitle
|
|
? `${customMusicTitle} · ${customMusicArtist}`
|
|
: customMusicUrl
|
|
? t('customSound')
|
|
: t('originalOnly')
|
|
}}</strong>
|
|
<ChevronDown />
|
|
</button>
|
|
<label>
|
|
<span
|
|
>{{ t('originalVolume') }}
|
|
<strong>{{ originalVolume }}%</strong></span
|
|
>
|
|
<SkyRange
|
|
:value="originalVolume"
|
|
:min="0"
|
|
:max="100"
|
|
:step="1"
|
|
@input="originalVolume = rangeNumber($event)"
|
|
/>
|
|
</label>
|
|
<label :class="{ disabled: !hasMusic }">
|
|
<span
|
|
>{{ t('musicVolume') }}
|
|
<strong>{{ hasMusic ? `${musicVolume}%` : '—' }}</strong></span
|
|
>
|
|
<SkyRange
|
|
:value="musicVolume"
|
|
:min="0"
|
|
:max="100"
|
|
:step="1"
|
|
:disabled="!hasMusic"
|
|
@input="musicVolume = rangeNumber($event)"
|
|
/>
|
|
</label>
|
|
<p v-if="customMusicLoadFailed" class="custom-sound-error">
|
|
{{ t('customSoundLoadFailed') }}
|
|
</p>
|
|
</SkyGlass>
|
|
<SkyList inset strong class="compose-form-list">
|
|
<SkyField
|
|
type="textarea"
|
|
:label="t('caption')"
|
|
:placeholder="t('captionPlaceholder')"
|
|
:value="caption"
|
|
maxlength="500"
|
|
@input="caption = inputValue($event)"
|
|
/>
|
|
<SkyListItem
|
|
link
|
|
link-component="button"
|
|
content-class="w-full"
|
|
:title="t('whoCanWatch')"
|
|
:after="
|
|
visibility === 'public'
|
|
? t('public')
|
|
: visibility === 'followers'
|
|
? t('followersOnly')
|
|
: t('private')
|
|
"
|
|
@click="openVisibilityMenu"
|
|
/>
|
|
<SkyListItem :title="t('allowComments')">
|
|
<template #after>
|
|
<SkyToggle
|
|
v-model="commentsEnabled"
|
|
:aria-label="t('allowComments')"
|
|
/>
|
|
</template>
|
|
</SkyListItem>
|
|
</SkyList>
|
|
<div class="compose-actions">
|
|
<SkyButton
|
|
tonal
|
|
rounded
|
|
:disabled="!canPublish"
|
|
@click="publish(true)"
|
|
>{{ t('saveDraft') }}</SkyButton
|
|
><SkyButton
|
|
rounded
|
|
:disabled="!canPublish || publishing"
|
|
@click="publish(false)"
|
|
>{{ publishing ? t('publishing') : t('post') }}</SkyButton
|
|
>
|
|
</div>
|
|
</SkyScrollArea>
|
|
</section>
|
|
|
|
<section v-if="profileEditOpen" class="overlay-screen profile-edit">
|
|
<SkyNavbar
|
|
:title="t('editProfile')"
|
|
:show-back="true"
|
|
:back-label="t('backToProfile')"
|
|
back-appearance="surface"
|
|
@back="profileEditOpen = false"
|
|
>
|
|
<template #right>
|
|
<SkyButton
|
|
small
|
|
rounded
|
|
class="done-button"
|
|
:disabled="profileSaving"
|
|
@click="saveProfile"
|
|
>
|
|
{{ profileSaving ? t('savingProfile') : t('done') }}
|
|
</SkyButton>
|
|
</template>
|
|
</SkyNavbar>
|
|
<SkyScrollArea padded class="profile-edit__body">
|
|
<section class="profile-photo-editor">
|
|
<button
|
|
class="profile-photo-trigger"
|
|
type="button"
|
|
:aria-label="t('changePhoto')"
|
|
@click="profilePhotoSheetOpen = true"
|
|
>
|
|
<span class="profile-avatar profile-avatar--editor">
|
|
<img
|
|
v-if="selectedProfilePhoto?.url || store.profile?.avatar_url"
|
|
:src="
|
|
selectedProfilePhoto?.url ?? store.profile?.avatar_url ?? ''
|
|
"
|
|
alt=""
|
|
/>
|
|
<template v-else>{{
|
|
initials(profileDraft.displayName)
|
|
}}</template>
|
|
</span>
|
|
<span class="profile-photo-trigger__badge" aria-hidden="true">
|
|
<Camera />
|
|
</span>
|
|
</button>
|
|
<SkyLink
|
|
class="profile-photo-change"
|
|
@click="profilePhotoSheetOpen = true"
|
|
>
|
|
{{ t('changePhoto') }}
|
|
</SkyLink>
|
|
</section>
|
|
<SkyBlockTitle>{{ t('profileDetails') }}</SkyBlockTitle>
|
|
<SkyList strong class="profile-form-list">
|
|
<SkyField
|
|
id="fliptok-profile-display-name"
|
|
:label="t('displayName')"
|
|
:value="profileDraft.displayName"
|
|
maxlength="40"
|
|
clear-button
|
|
@input="profileDraft.displayName = inputValue($event)"
|
|
/>
|
|
<SkyField
|
|
id="fliptok-profile-handle"
|
|
:label="t('username')"
|
|
:value="profileDraft.handle"
|
|
maxlength="24"
|
|
autocapitalize="none"
|
|
clear-button
|
|
@input="profileDraft.handle = inputValue($event)"
|
|
/>
|
|
<SkyField
|
|
id="fliptok-profile-bio"
|
|
type="textarea"
|
|
:label="t('bio')"
|
|
:value="profileDraft.bio"
|
|
maxlength="160"
|
|
:spellcheck="false"
|
|
@input="profileDraft.bio = inputValue($event)"
|
|
/>
|
|
<SkyListItem
|
|
link
|
|
link-component="button"
|
|
:title="t('accountType')"
|
|
:after="t(`accountTypes.${profileDraft.accountType}`)"
|
|
@click="accountTypeSheetOpen = true"
|
|
/>
|
|
</SkyList>
|
|
|
|
<SkyBlockTitle>{{ t('account') }}</SkyBlockTitle>
|
|
<SkyList inset strong class="profile-account-list">
|
|
<SkyListButton
|
|
class="logout-button"
|
|
variant="danger"
|
|
@click="logoutDialogOpen = true"
|
|
>
|
|
{{ t('logout') }}
|
|
</SkyListButton>
|
|
</SkyList>
|
|
</SkyScrollArea>
|
|
</section>
|
|
|
|
<div v-if="commentsOpen" class="fliptok-sheet">
|
|
<SkySheet
|
|
:opened="commentsOpen"
|
|
:aria-label="t('comments')"
|
|
grabber-clickable
|
|
swipe-to-close
|
|
@backdropclick="commentsOpen = false"
|
|
@escape="commentsOpen = false"
|
|
@grabberclick="commentsOpen = false"
|
|
@swipeclose="commentsOpen = false"
|
|
>
|
|
<div class="comments-sheet">
|
|
<header>
|
|
<strong>
|
|
{{ t('comments') }} ·
|
|
{{ compactCount(selectedVideo?.comment_count ?? 0) }}
|
|
</strong>
|
|
</header>
|
|
<div class="comments-list">
|
|
<div
|
|
v-for="thread in commentThreads"
|
|
:key="thread.comment.id"
|
|
class="comment-thread"
|
|
>
|
|
<article class="comment-row">
|
|
<button
|
|
class="comment-avatar"
|
|
type="button"
|
|
@click="openProfile(thread.comment.profile_id)"
|
|
>
|
|
<img
|
|
v-if="thread.comment.avatar_url"
|
|
:src="thread.comment.avatar_url"
|
|
alt=""
|
|
/>
|
|
<template v-else>{{
|
|
initials(thread.comment.display_name)
|
|
}}</template>
|
|
</button>
|
|
<div class="comment-content">
|
|
<header>
|
|
<button
|
|
type="button"
|
|
@click="openProfile(thread.comment.profile_id)"
|
|
>
|
|
{{ thread.comment.display_name }}
|
|
<Check v-if="thread.comment.verified" class="verified" />
|
|
</button>
|
|
<time
|
|
:datetime="
|
|
new Date(thread.comment.created_at).toISOString()
|
|
"
|
|
>
|
|
{{ formatTimestamp(thread.comment.created_at) }}
|
|
</time>
|
|
</header>
|
|
<p>
|
|
<template
|
|
v-for="(part, index) in textParts(thread.comment.body)"
|
|
:key="`${thread.comment.id}-${index}`"
|
|
>
|
|
<button
|
|
v-if="part.kind !== 'text'"
|
|
class="caption-link"
|
|
type="button"
|
|
@click="openTextLink(part)"
|
|
>
|
|
{{ part.value }}
|
|
</button>
|
|
<template v-else>{{ part.value }}</template>
|
|
</template>
|
|
</p>
|
|
<footer>
|
|
<button type="button" @click="startReply(thread.comment)">
|
|
<Reply />{{ t('reply') }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
:class="{
|
|
liked: thread.comment.is_liked,
|
|
'comment-like-pulse':
|
|
commentLikePulseId === thread.comment.id,
|
|
}"
|
|
@click="reactCommentWithPulse(thread.comment)"
|
|
>
|
|
<Heart
|
|
:fill="
|
|
thread.comment.is_liked ? 'currentColor' : 'none'
|
|
"
|
|
/>
|
|
{{ compactCount(thread.comment.like_count) }}
|
|
</button>
|
|
</footer>
|
|
</div>
|
|
</article>
|
|
<button
|
|
v-if="thread.replies.length"
|
|
type="button"
|
|
class="replies-toggle"
|
|
@click="toggleReplies(thread.comment.id)"
|
|
>
|
|
<span></span>
|
|
{{
|
|
expandedCommentThreads.has(thread.comment.id)
|
|
? t('hideReplies', {
|
|
count: String(thread.replies.length),
|
|
})
|
|
: t('showReplies', {
|
|
count: String(thread.replies.length),
|
|
})
|
|
}}
|
|
</button>
|
|
<article
|
|
v-for="reply in thread.replies"
|
|
v-show="expandedCommentThreads.has(thread.comment.id)"
|
|
:key="reply.id"
|
|
class="comment-row comment-row--reply"
|
|
>
|
|
<button
|
|
class="comment-avatar"
|
|
type="button"
|
|
@click="openProfile(reply.profile_id)"
|
|
>
|
|
<img v-if="reply.avatar_url" :src="reply.avatar_url" alt="" />
|
|
<template v-else>{{ initials(reply.display_name) }}</template>
|
|
</button>
|
|
<div class="comment-content">
|
|
<header>
|
|
<button
|
|
type="button"
|
|
@click="openProfile(reply.profile_id)"
|
|
>
|
|
{{ reply.display_name }}
|
|
<Check v-if="reply.verified" class="verified" />
|
|
</button>
|
|
<time :datetime="new Date(reply.created_at).toISOString()">
|
|
{{ formatTimestamp(reply.created_at) }}
|
|
</time>
|
|
</header>
|
|
<p>
|
|
<span v-if="reply.reply_to_handle" class="reply-handle"
|
|
>@{{ reply.reply_to_handle }}</span
|
|
>
|
|
{{ reply.body }}
|
|
</p>
|
|
<footer>
|
|
<button type="button" @click="startReply(thread.comment)">
|
|
<Reply />{{ t('reply') }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
:class="{
|
|
liked: reply.is_liked,
|
|
'comment-like-pulse': commentLikePulseId === reply.id,
|
|
}"
|
|
@click="reactCommentWithPulse(reply)"
|
|
>
|
|
<Heart
|
|
:fill="reply.is_liked ? 'currentColor' : 'none'"
|
|
/>{{ compactCount(reply.like_count) }}
|
|
</button>
|
|
</footer>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
<div v-if="!store.comments.length" class="light-empty">
|
|
{{ t('noComments') }}
|
|
</div>
|
|
</div>
|
|
<form
|
|
v-if="selectedVideo?.comments_enabled"
|
|
class="comments-composer"
|
|
@submit.prevent="submitComment"
|
|
>
|
|
<div v-if="replyingTo" class="replying-to">
|
|
<span>{{
|
|
t('replyingTo', { handle: `@${replyingTo.handle}` })
|
|
}}</span>
|
|
<button
|
|
type="button"
|
|
:aria-label="t('cancelReply')"
|
|
@click="cancelReply"
|
|
>
|
|
<X />
|
|
</button>
|
|
</div>
|
|
<SkyMessagebar
|
|
v-model="commentBody"
|
|
class="comments-messagebar"
|
|
embedded
|
|
:outline="false"
|
|
:aria-label="t('addComment')"
|
|
:placeholder="
|
|
replyingTo ? t('replyPlaceholder') : t('addComment')
|
|
"
|
|
>
|
|
<template #right>
|
|
<SkyLink
|
|
component="button"
|
|
icon-only
|
|
type="submit"
|
|
:aria-label="phone.t('Common.send')"
|
|
>
|
|
<Send />
|
|
</SkyLink>
|
|
</template>
|
|
</SkyMessagebar>
|
|
</form>
|
|
<p v-else class="comments-disabled">
|
|
{{ t('errors.comments_disabled') }}
|
|
</p>
|
|
</div>
|
|
</SkySheet>
|
|
</div>
|
|
|
|
<div v-if="actionsOpen" class="fliptok-sheet">
|
|
<SkySheet
|
|
:opened="actionsOpen"
|
|
grabber-clickable
|
|
swipe-to-close
|
|
@backdropclick="actionsOpen = false"
|
|
@grabberclick="actionsOpen = false"
|
|
@swipeclose="actionsOpen = false"
|
|
>
|
|
<div class="action-sheet">
|
|
<SkyList inset strong
|
|
><SkyListItem
|
|
v-if="selectedVideo?.is_owner"
|
|
link
|
|
link-component="button"
|
|
content-class="w-full"
|
|
class="danger"
|
|
:title="t('removeVideo')"
|
|
@click="requestDeleteVideo" /><SkyListItem
|
|
v-if="!selectedVideo?.is_owner"
|
|
link
|
|
link-component="button"
|
|
content-class="w-full"
|
|
:title="t('report')"
|
|
@click="openReport" /><SkyListItem
|
|
v-if="!selectedVideo?.is_owner"
|
|
link
|
|
link-component="button"
|
|
content-class="w-full"
|
|
class="danger"
|
|
:title="t('block')"
|
|
@click="blockCreator" /></SkyList
|
|
><SkyButton large rounded tonal @click="actionsOpen = false">{{
|
|
t('cancel')
|
|
}}</SkyButton>
|
|
</div>
|
|
</SkySheet>
|
|
</div>
|
|
<div v-if="reportSheetOpen" class="fliptok-sheet">
|
|
<SkySheet
|
|
:opened="reportSheetOpen"
|
|
grabber-clickable
|
|
swipe-to-close
|
|
@backdropclick="reportSheetOpen = false"
|
|
@grabberclick="reportSheetOpen = false"
|
|
@swipeclose="reportSheetOpen = false"
|
|
>
|
|
<div class="selection-sheet report-sheet">
|
|
<h3>{{ t('report') }}</h3>
|
|
<p class="report-sheet__note">{{ t('reportDiscordNote') }}</p>
|
|
<SkyList inset strong>
|
|
<SkyListItem
|
|
v-for="reason in reportReasonOptions"
|
|
:key="reason"
|
|
link
|
|
link-component="button"
|
|
content-class="w-full"
|
|
:chevron="false"
|
|
:title="t(`reportReasons.${reason}`)"
|
|
@click="reportReason = reason"
|
|
>
|
|
<template #after
|
|
><Check v-if="reportReason === reason" class="selection-check"
|
|
/></template>
|
|
</SkyListItem>
|
|
</SkyList>
|
|
<SkyList inset strong>
|
|
<SkyField
|
|
type="textarea"
|
|
:label="t('reportDetails')"
|
|
:placeholder="t('reportDetails')"
|
|
:value="reportDetails"
|
|
maxlength="500"
|
|
@input="reportDetails = inputValue($event)"
|
|
/>
|
|
</SkyList>
|
|
<div class="report-sheet__actions">
|
|
<SkyButton
|
|
rounded
|
|
:disabled="reportSubmitting"
|
|
@click="reportVideo"
|
|
>{{ t('submitReport') }}</SkyButton
|
|
>
|
|
<SkyButton
|
|
rounded
|
|
tonal
|
|
:disabled="reportSubmitting"
|
|
@click="reportSheetOpen = false"
|
|
>{{ t('cancel') }}</SkyButton
|
|
>
|
|
</div>
|
|
</div>
|
|
</SkySheet>
|
|
</div>
|
|
<div v-if="musicSheetOpen" class="fliptok-sheet">
|
|
<SkySheet
|
|
:opened="musicSheetOpen"
|
|
grabber-clickable
|
|
swipe-to-close
|
|
@backdropclick="musicSheetOpen = false"
|
|
@grabberclick="musicSheetOpen = false"
|
|
@swipeclose="musicSheetOpen = false"
|
|
>
|
|
<div class="selection-sheet music-selection-sheet">
|
|
<h3>{{ t('chooseSound') }}</h3>
|
|
<SkyList inset strong>
|
|
<SkyListItem
|
|
link
|
|
link-component="button"
|
|
content-class="w-full"
|
|
:chevron="false"
|
|
:title="t('originalOnly')"
|
|
@click="chooseMusicTrack('')"
|
|
>
|
|
<template #after
|
|
><Check
|
|
v-if="!musicTrack && !customMusicUrl"
|
|
class="selection-check"
|
|
/></template>
|
|
</SkyListItem>
|
|
<SkyListItem
|
|
v-for="track in store.musicTracks"
|
|
:key="track.id"
|
|
link
|
|
link-component="button"
|
|
content-class="w-full"
|
|
:chevron="false"
|
|
:title="track.title"
|
|
:after="track.artist"
|
|
@click="chooseMusicTrack(track.id)"
|
|
>
|
|
<template #after
|
|
><Check
|
|
v-if="musicTrack === track.id"
|
|
class="selection-check"
|
|
/><span v-else>{{ track.artist }}</span></template
|
|
>
|
|
</SkyListItem>
|
|
</SkyList>
|
|
<p v-if="!store.musicTracks.length" class="sheet-note">
|
|
{{ t('noMusic') }}
|
|
</p>
|
|
<section class="custom-sound-editor">
|
|
<header>
|
|
<span><Link2 /></span>
|
|
<div>
|
|
<strong>{{ t('customSound') }}</strong>
|
|
<p>{{ t('customSoundHint') }}</p>
|
|
</div>
|
|
<Check
|
|
v-if="customMusicUrl && !musicTrack"
|
|
class="selection-check"
|
|
/>
|
|
</header>
|
|
<SkyList inset strong>
|
|
<SkyField
|
|
id="fliptok-custom-sound-url"
|
|
type="url"
|
|
input-mode="url"
|
|
:label="t('customSoundLink')"
|
|
:placeholder="t('customSoundPlaceholder')"
|
|
:value="customMusicDraftUrl"
|
|
maxlength="2048"
|
|
:help="t('customSoundFormats')"
|
|
:error="customMusicDraftError"
|
|
clear-button
|
|
:clear-label="phone.t('Common.clear')"
|
|
@input="customMusicDraftUrl = inputValue($event)"
|
|
/>
|
|
</SkyList>
|
|
<audio
|
|
v-if="
|
|
validCustomMusicUrl(customMusicDraftUrl) &&
|
|
!parseYoutubeVideoId(customMusicDraftUrl)
|
|
"
|
|
:src="customMusicDraftUrl.trim()"
|
|
controls
|
|
preload="metadata"
|
|
/>
|
|
<p
|
|
v-if="customMusicTitle && customMusicDraftUrl === customMusicUrl"
|
|
class="custom-sound-metadata"
|
|
>
|
|
<strong>{{ customMusicTitle }}</strong>
|
|
<span>{{ customMusicArtist }}</span>
|
|
</p>
|
|
<SkyButton
|
|
rounded
|
|
:disabled="
|
|
customMusicResolving ||
|
|
!validCustomMusicUrl(customMusicDraftUrl)
|
|
"
|
|
@click="chooseCustomMusic"
|
|
>
|
|
<SkySpinner
|
|
v-if="customMusicResolving"
|
|
:label="t('loadingSound')"
|
|
/>
|
|
<template v-else>{{ t('useCustomSound') }}</template>
|
|
</SkyButton>
|
|
</section>
|
|
<SkyButton large rounded tonal @click="musicSheetOpen = false">{{
|
|
t('cancel')
|
|
}}</SkyButton>
|
|
</div>
|
|
</SkySheet>
|
|
</div>
|
|
<SkyDropdown
|
|
:items="visibilityMenuItems"
|
|
:label="t('whoCanWatch')"
|
|
:opened="visibilityMenuOpen"
|
|
placement="auto"
|
|
:target="visibilityMenuTarget"
|
|
@backdropclick="visibilityMenuOpen = false"
|
|
@escape="visibilityMenuOpen = false"
|
|
@positionerror="visibilityMenuOpen = false"
|
|
@select="selectVisibility"
|
|
/>
|
|
<div v-if="profilePhotoSheetOpen" class="fliptok-sheet">
|
|
<SkySheet
|
|
:opened="profilePhotoSheetOpen"
|
|
:aria-label="t('profilePhoto')"
|
|
grabber-clickable
|
|
swipe-to-close
|
|
@backdropclick="profilePhotoSheetOpen = false"
|
|
@escape="profilePhotoSheetOpen = false"
|
|
@grabberclick="profilePhotoSheetOpen = false"
|
|
@swipeclose="profilePhotoSheetOpen = false"
|
|
>
|
|
<div class="selection-sheet profile-photo-sheet">
|
|
<h3>{{ t('profilePhoto') }}</h3>
|
|
<SkyList inset strong>
|
|
<SkyListItem
|
|
link
|
|
link-component="button"
|
|
content-class="w-full"
|
|
:title="t('chooseFromGallery')"
|
|
@click="openProfileMedia('photos')"
|
|
>
|
|
<template #media><ImagePlus /></template>
|
|
</SkyListItem>
|
|
<SkyListItem
|
|
link
|
|
link-component="button"
|
|
content-class="w-full"
|
|
:title="t('takePhoto')"
|
|
@click="openProfileMedia('camera')"
|
|
>
|
|
<template #media><Camera /></template>
|
|
</SkyListItem>
|
|
<SkyListItem
|
|
v-if="selectedProfilePhoto || profileDraft.avatarMediaId"
|
|
link
|
|
link-component="button"
|
|
content-class="w-full"
|
|
:chevron="false"
|
|
class="danger"
|
|
:title="t('removePhoto')"
|
|
@click="removeProfilePhoto"
|
|
>
|
|
<template #media><Trash2 /></template>
|
|
</SkyListItem>
|
|
</SkyList>
|
|
<SkyButton large rounded tonal @click="profilePhotoSheetOpen = false">
|
|
{{ t('cancel') }}
|
|
</SkyButton>
|
|
</div>
|
|
</SkySheet>
|
|
</div>
|
|
<div v-if="accountTypeSheetOpen" class="fliptok-sheet">
|
|
<SkySheet
|
|
:opened="accountTypeSheetOpen"
|
|
grabber-clickable
|
|
swipe-to-close
|
|
@backdropclick="accountTypeSheetOpen = false"
|
|
@grabberclick="accountTypeSheetOpen = false"
|
|
@swipeclose="accountTypeSheetOpen = false"
|
|
>
|
|
<div class="selection-sheet">
|
|
<h3>{{ t('accountType') }}</h3>
|
|
<SkyList inset strong
|
|
><SkyListItem
|
|
v-for="option in accountTypeOptions"
|
|
:key="option"
|
|
link
|
|
link-component="button"
|
|
content-class="w-full"
|
|
:chevron="false"
|
|
:title="t(`accountTypes.${option}`)"
|
|
@click="chooseAccountType(option)"
|
|
><template #after
|
|
><Check
|
|
v-if="profileDraft.accountType === option"
|
|
class="selection-check" /></template></SkyListItem></SkyList
|
|
><SkyButton
|
|
large
|
|
rounded
|
|
tonal
|
|
@click="accountTypeSheetOpen = false"
|
|
>{{ t('cancel') }}</SkyButton
|
|
>
|
|
</div></SkySheet
|
|
>
|
|
</div>
|
|
<section v-if="connectionsOpen" class="overlay-screen connections-screen">
|
|
<SkyNavbar
|
|
:title="t(connectionsMode)"
|
|
:show-back="true"
|
|
:back-label="phone.t('Common.back')"
|
|
back-appearance="surface"
|
|
@back="connectionsOpen = false"
|
|
/>
|
|
<SkyScrollArea padded class="connections-body">
|
|
<SkySegmented
|
|
class="connections-tabs"
|
|
:active-index="connectionsMode === 'followers' ? 0 : 1"
|
|
:item-count="2"
|
|
raised
|
|
strong
|
|
rounded
|
|
>
|
|
<SkySegmentedButton
|
|
:active="connectionsMode === 'followers'"
|
|
@click="openConnections('followers')"
|
|
>{{ t('followers') }}</SkySegmentedButton
|
|
>
|
|
<SkySegmentedButton
|
|
:active="connectionsMode === 'following'"
|
|
@click="openConnections('following')"
|
|
>{{ t('following') }}</SkySegmentedButton
|
|
>
|
|
</SkySegmented>
|
|
<SkyList
|
|
v-if="store.connections.length"
|
|
inset
|
|
strong
|
|
class="connections-list"
|
|
>
|
|
<SkyListItem
|
|
v-for="profile in store.connections"
|
|
:key="profile.id"
|
|
:title="profile.display_name"
|
|
:text="`@${profile.handle}`"
|
|
>
|
|
<template #media>
|
|
<button
|
|
type="button"
|
|
class="connection-profile"
|
|
:aria-label="profile.display_name"
|
|
@click="openConnectionProfile(profile)"
|
|
>
|
|
<span class="connection-avatar">
|
|
<img
|
|
v-if="profile.avatar_url"
|
|
:src="profile.avatar_url"
|
|
alt=""
|
|
/>
|
|
<template v-else>{{
|
|
initials(profile.display_name)
|
|
}}</template>
|
|
</span>
|
|
</button>
|
|
</template>
|
|
<template #after>
|
|
<SkyButton
|
|
v-if="!profile.is_owner"
|
|
small
|
|
rounded
|
|
:tonal="profile.is_following"
|
|
:disabled="profile.is_following"
|
|
@click="followConnection(profile)"
|
|
>{{
|
|
profile.is_following ? t('unfollow') : t('follow')
|
|
}}</SkyButton
|
|
>
|
|
</template>
|
|
</SkyListItem>
|
|
</SkyList>
|
|
<div v-else class="light-empty connections-empty">
|
|
<UsersRound /><strong>{{ t('noConnections') }}</strong>
|
|
</div>
|
|
</SkyScrollArea>
|
|
</section>
|
|
<SkyDialog
|
|
:opened="logoutDialogOpen"
|
|
@backdropclick="!logoutSubmitting && (logoutDialogOpen = false)"
|
|
>
|
|
<template #title>{{ t('signOutTitle') }}</template>
|
|
<p>{{ t('signOutBody') }}</p>
|
|
<template #buttons>
|
|
<SkyDialogButton
|
|
:disabled="logoutSubmitting"
|
|
@click="logoutDialogOpen = false"
|
|
>
|
|
{{ t('cancel') }}
|
|
</SkyDialogButton>
|
|
<SkyDialogButton
|
|
strong
|
|
class="logout-dialog-button"
|
|
:disabled="logoutSubmitting"
|
|
@click="confirmLogout"
|
|
>
|
|
{{ logoutSubmitting ? t('signingOut') : t('logout') }}
|
|
</SkyDialogButton>
|
|
</template>
|
|
</SkyDialog>
|
|
<SkyDialog
|
|
:opened="deleteDialogOpen"
|
|
@backdropclick="!deleteSubmitting && (deleteDialogOpen = false)"
|
|
>
|
|
<template #title>{{ t('deleteVideoTitle') }}</template>
|
|
<p>{{ t('deleteVideoBody') }}</p>
|
|
<template #buttons>
|
|
<SkyDialogButton
|
|
:disabled="deleteSubmitting"
|
|
@click="deleteDialogOpen = false"
|
|
>
|
|
{{ t('cancel') }}
|
|
</SkyDialogButton>
|
|
<SkyDialogButton
|
|
strong
|
|
class="logout-dialog-button"
|
|
:disabled="deleteSubmitting"
|
|
@click="confirmDeleteVideo"
|
|
>
|
|
{{ deleteSubmitting ? t('deletingVideo') : t('removeVideo') }}
|
|
</SkyDialogButton>
|
|
</template>
|
|
</SkyDialog>
|
|
<SkyToast
|
|
:opened="Boolean(feedback)"
|
|
position="center"
|
|
@click="feedback = ''"
|
|
>
|
|
{{ feedback }}
|
|
</SkyToast>
|
|
</SkyAppPage>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.fliptok-page {
|
|
height: 100%;
|
|
overflow: hidden;
|
|
background: #000;
|
|
color: #fff;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
|
}
|
|
.state,
|
|
.light-empty,
|
|
.empty-feed {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
height: 100%;
|
|
text-align: center;
|
|
}
|
|
.feed-header {
|
|
position: absolute;
|
|
z-index: 20;
|
|
top: 15px;
|
|
left: 50%;
|
|
display: flex;
|
|
gap: 18px;
|
|
transform: translateX(-50%);
|
|
}
|
|
.feed-header button {
|
|
border: 0;
|
|
background: none;
|
|
color: #aaa;
|
|
font-size: 13px;
|
|
font-weight: 650;
|
|
padding: 6px 0;
|
|
}
|
|
.feed-header button.active {
|
|
color: #fff;
|
|
border-bottom: 2px solid #fff;
|
|
}
|
|
.feed-search {
|
|
position: absolute;
|
|
z-index: 20;
|
|
top: 49px;
|
|
right: 14px;
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 50%;
|
|
background: rgba(20, 20, 22, 0.56);
|
|
color: #fff !important;
|
|
backdrop-filter: blur(12px);
|
|
}
|
|
.feed-search svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
.video-feed {
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
scroll-snap-type: y mandatory;
|
|
scrollbar-width: none;
|
|
}
|
|
.video-card {
|
|
position: relative;
|
|
height: 100%;
|
|
scroll-snap-align: start;
|
|
background: #111;
|
|
}
|
|
.video-card video {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
.video-shade {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: linear-gradient(
|
|
180deg,
|
|
rgba(0, 0, 0, 0.18),
|
|
transparent 36%,
|
|
rgba(0, 0, 0, 0.74)
|
|
);
|
|
}
|
|
.video-playback-fallback {
|
|
position: absolute;
|
|
z-index: 10;
|
|
top: 50%;
|
|
left: 50%;
|
|
width: min(230px, 72%);
|
|
display: grid;
|
|
justify-items: center;
|
|
gap: 7px;
|
|
border: 1px solid rgb(255 255 255 / 24%);
|
|
border-radius: 18px;
|
|
padding: 16px;
|
|
background: rgb(18 18 20 / 88%);
|
|
color: #fff;
|
|
text-align: center;
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
.video-playback-fallback svg {
|
|
width: 28px;
|
|
height: 28px;
|
|
color: #ff9f0a;
|
|
}
|
|
.video-playback-fallback strong {
|
|
font-size: 12px;
|
|
}
|
|
.video-playback-fallback span {
|
|
color: #64a8ff;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
}
|
|
.video-copy {
|
|
position: absolute;
|
|
left: 13px;
|
|
right: 65px;
|
|
bottom: 76px;
|
|
text-shadow: 0 1px 3px #000;
|
|
}
|
|
.video-copy p {
|
|
margin: 2px 0;
|
|
font-size: 12px;
|
|
line-height: 1.35;
|
|
}
|
|
.creator-line {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.verified {
|
|
display: inline-block;
|
|
width: 14px;
|
|
height: 14px;
|
|
stroke-width: 3;
|
|
color: #0a84ff;
|
|
fill: #0a84ff;
|
|
stroke: #fff;
|
|
}
|
|
.location,
|
|
.sound {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
font-size: 10px;
|
|
margin-top: 1px;
|
|
}
|
|
.location svg,
|
|
.sound svg {
|
|
width: 12px;
|
|
}
|
|
.video-actions {
|
|
position: absolute;
|
|
right: 8px;
|
|
bottom: 70px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.video-actions button {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
border: 0;
|
|
background: none;
|
|
color: #fff;
|
|
font-size: 9px;
|
|
text-shadow: 0 1px 3px #000;
|
|
}
|
|
.video-actions svg {
|
|
width: 25px;
|
|
height: 25px;
|
|
filter: drop-shadow(0 1px 2px #000);
|
|
}
|
|
.video-actions .avatar {
|
|
width: 38px;
|
|
height: 38px;
|
|
border-radius: 50%;
|
|
justify-content: center;
|
|
background: #fff;
|
|
color: #111;
|
|
font-weight: 800;
|
|
text-shadow: none;
|
|
}
|
|
.follow-dot {
|
|
width: 18px;
|
|
height: 18px !important;
|
|
border-radius: 50% !important;
|
|
background: #ff2d55 !important;
|
|
margin-top: -19px;
|
|
}
|
|
.follow-dot svg {
|
|
width: 12px !important;
|
|
}
|
|
.follow-dot--pending {
|
|
opacity: 0.65;
|
|
}
|
|
.follow-dot--confirmed {
|
|
animation: follow-confirmed-fade 950ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
|
}
|
|
@keyframes follow-confirmed-fade {
|
|
0%,
|
|
48% {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
transform: scale(0.72);
|
|
}
|
|
}
|
|
.liked svg {
|
|
color: #ff2d55 !important;
|
|
}
|
|
.saved svg {
|
|
color: #ffd60a !important;
|
|
}
|
|
.video-actions button span {
|
|
color: #fff;
|
|
}
|
|
.main-tabs {
|
|
z-index: 30 !important;
|
|
background: rgba(8, 8, 8, 0.94) !important;
|
|
color: #fff !important;
|
|
}
|
|
.main-tabs svg {
|
|
width: 20px;
|
|
}
|
|
.create-icon {
|
|
position: relative;
|
|
display: block;
|
|
width: 32px;
|
|
height: 24px;
|
|
color: #fff;
|
|
}
|
|
.create-icon::before,
|
|
.create-icon::after {
|
|
position: absolute;
|
|
content: '';
|
|
border: 1px solid rgba(255, 255, 255, 0.28);
|
|
}
|
|
.create-icon::before {
|
|
top: 4px;
|
|
left: 2px;
|
|
width: 23px;
|
|
height: 17px;
|
|
border-radius: 6px;
|
|
background: #44417f;
|
|
transform: rotate(-8deg);
|
|
}
|
|
.create-icon::after {
|
|
top: 2px;
|
|
left: 7px;
|
|
width: 24px;
|
|
height: 19px;
|
|
border-radius: 7px;
|
|
background: #625fca;
|
|
}
|
|
.create-icon svg {
|
|
position: absolute;
|
|
z-index: 1;
|
|
top: 7px;
|
|
left: 15px;
|
|
width: 9px !important;
|
|
height: 9px !important;
|
|
fill: currentColor;
|
|
stroke-width: 2.4;
|
|
}
|
|
.light-screen,
|
|
.overlay-screen {
|
|
position: absolute;
|
|
inset: 0 0 50px;
|
|
background: #f2f2f7;
|
|
color: #111;
|
|
overflow-y: auto;
|
|
padding-top: 48px;
|
|
}
|
|
.discover-screen {
|
|
padding: 56px 10px 60px;
|
|
}
|
|
.trend-pills {
|
|
display: flex;
|
|
gap: 6px;
|
|
overflow: auto;
|
|
padding: 8px 2px;
|
|
}
|
|
.trend-pills button {
|
|
border: 0;
|
|
border-radius: 18px;
|
|
background: #fff;
|
|
padding: 7px 10px;
|
|
font-size: 10px;
|
|
white-space: nowrap;
|
|
}
|
|
.video-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 2px;
|
|
}
|
|
.video-grid button {
|
|
position: relative;
|
|
aspect-ratio: 3/4;
|
|
border: 0;
|
|
padding: 0;
|
|
background: #ddd;
|
|
overflow: hidden;
|
|
}
|
|
.video-grid video {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
.video-grid span {
|
|
position: absolute;
|
|
left: 4px;
|
|
bottom: 4px;
|
|
display: flex;
|
|
color: #fff;
|
|
font-size: 9px;
|
|
}
|
|
.video-grid svg {
|
|
width: 10px;
|
|
}
|
|
.activity-list {
|
|
padding: 54px 12px;
|
|
}
|
|
.activity-list article,
|
|
.comments-list article {
|
|
display: flex;
|
|
gap: 10px;
|
|
padding: 10px 2px;
|
|
border-bottom: 1px solid #ddd;
|
|
}
|
|
.activity-avatar,
|
|
.comment-avatar {
|
|
display: grid;
|
|
place-items: center;
|
|
flex: 0 0 34px;
|
|
height: 34px;
|
|
border-radius: 50%;
|
|
background: #111;
|
|
color: #fff;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
}
|
|
.activity-list p,
|
|
.comments-list p {
|
|
display: flex;
|
|
flex-direction: column;
|
|
margin: 0;
|
|
font-size: 11px;
|
|
}
|
|
.activity-list strong,
|
|
.comments-list strong {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
}
|
|
.activity-list p span {
|
|
color: #666;
|
|
margin-top: 2px;
|
|
}
|
|
.profile-screen {
|
|
padding: 72px 20px;
|
|
text-align: center;
|
|
}
|
|
.profile-avatar {
|
|
display: grid;
|
|
place-items: center;
|
|
width: 76px;
|
|
height: 76px;
|
|
margin: auto;
|
|
border-radius: 50%;
|
|
background: #111;
|
|
color: #fff;
|
|
font-weight: 800;
|
|
font-size: 22px;
|
|
}
|
|
.profile-screen h1 {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
gap: 4px;
|
|
margin: 10px 0 2px;
|
|
font-size: 18px;
|
|
}
|
|
.handle {
|
|
color: #777;
|
|
font-size: 11px;
|
|
}
|
|
.profile-stats {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 28px;
|
|
margin: 18px 0;
|
|
}
|
|
.profile-stats div {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.profile-stats span {
|
|
font-size: 10px;
|
|
color: #777;
|
|
}
|
|
.bio {
|
|
font-size: 11px;
|
|
min-height: 20px;
|
|
}
|
|
.overlay-screen {
|
|
z-index: 40;
|
|
inset: 0;
|
|
}
|
|
.compose-body {
|
|
padding: 16px;
|
|
}
|
|
.media-picker {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 100%;
|
|
height: 190px;
|
|
border: 1px dashed #aaa;
|
|
border-radius: 18px;
|
|
background: #fff;
|
|
color: #111;
|
|
}
|
|
.media-picker svg {
|
|
width: 38px;
|
|
height: 38px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.media-picker span {
|
|
font-size: 10px;
|
|
color: #777;
|
|
}
|
|
.media-preview {
|
|
position: relative;
|
|
height: 220px;
|
|
border-radius: 18px;
|
|
overflow: hidden;
|
|
background: #000;
|
|
}
|
|
.media-preview video {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
.media-preview button {
|
|
position: absolute;
|
|
right: 8px;
|
|
bottom: 8px;
|
|
border: 0;
|
|
border-radius: 15px;
|
|
padding: 6px 10px;
|
|
}
|
|
.compose-body textarea {
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
min-height: 82px;
|
|
margin-top: 10px;
|
|
border: 0;
|
|
border-radius: 14px;
|
|
background: #fff;
|
|
padding: 12px;
|
|
font: inherit;
|
|
}
|
|
.field,
|
|
.toggle-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
border-radius: 14px;
|
|
background: #fff;
|
|
padding: 11px;
|
|
font-size: 11px;
|
|
}
|
|
.field svg {
|
|
width: 16px;
|
|
}
|
|
.field input,
|
|
.field select,
|
|
.toggle-row select {
|
|
flex: 1;
|
|
border: 0;
|
|
outline: 0;
|
|
background: transparent;
|
|
}
|
|
.field span {
|
|
flex: 1;
|
|
}
|
|
.compose-actions {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 8px;
|
|
margin-top: 15px;
|
|
}
|
|
.nav-button,
|
|
.done-button {
|
|
border: 0;
|
|
background: none;
|
|
color: #0a84ff;
|
|
}
|
|
.nav-button svg {
|
|
width: 19px;
|
|
height: 19px;
|
|
}
|
|
.profile-edit {
|
|
padding-top: 54px;
|
|
}
|
|
.sheet-handle {
|
|
width: 34px;
|
|
height: 4px;
|
|
border-radius: 2px;
|
|
background: #aaa;
|
|
margin: 7px auto;
|
|
}
|
|
.comments-sheet {
|
|
height: min(62vh, 430px);
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0 12px 12px;
|
|
color: #111;
|
|
}
|
|
.comments-sheet > header {
|
|
display: flex;
|
|
justify-content: center;
|
|
position: relative;
|
|
padding: 7px;
|
|
}
|
|
.comments-sheet > header button {
|
|
position: absolute;
|
|
right: 0;
|
|
border: 0;
|
|
background: #ddd;
|
|
border-radius: 50%;
|
|
width: 25px;
|
|
height: 25px;
|
|
}
|
|
.comments-sheet > header svg {
|
|
width: 14px;
|
|
}
|
|
.comments-list {
|
|
flex: 1;
|
|
overflow: auto;
|
|
}
|
|
.action-sheet {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 8px 12px 18px;
|
|
}
|
|
.action-sheet button {
|
|
border: 0;
|
|
border-bottom: 1px solid #ddd;
|
|
background: #fff;
|
|
padding: 13px;
|
|
color: #0a84ff;
|
|
}
|
|
.action-sheet .danger {
|
|
color: #ff3b30;
|
|
}
|
|
.empty-feed {
|
|
padding: 30px;
|
|
}
|
|
.empty-feed svg {
|
|
width: 42px;
|
|
}
|
|
.empty-feed span {
|
|
font-size: 11px;
|
|
color: #aaa;
|
|
}
|
|
.fliptok-page {
|
|
position: relative;
|
|
}
|
|
.feed-header {
|
|
top: 34px;
|
|
}
|
|
.main-tabs {
|
|
position: absolute !important;
|
|
right: 0;
|
|
bottom: 0;
|
|
left: 0;
|
|
height: 52px;
|
|
}
|
|
.main-tabs :deep(.gap-4) {
|
|
gap: 0 !important;
|
|
width: 100% !important;
|
|
}
|
|
.main-tabs :deep(a) {
|
|
flex: 1 1 0 !important;
|
|
width: auto !important;
|
|
min-width: 0 !important;
|
|
}
|
|
.verified {
|
|
box-sizing: border-box;
|
|
padding: 2px;
|
|
border-radius: 50%;
|
|
background: #0a84ff;
|
|
color: #fff;
|
|
fill: none;
|
|
stroke: #fff;
|
|
}
|
|
.feed-header {
|
|
top: 48px;
|
|
}
|
|
.video-copy {
|
|
bottom: 72px;
|
|
}
|
|
.video-actions {
|
|
bottom: 70px;
|
|
}
|
|
.main-tabs {
|
|
bottom: 0;
|
|
height: 68px;
|
|
padding: 0 8px 22px !important;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
background: rgba(0, 0, 0, 0.92) !important;
|
|
backdrop-filter: blur(20px);
|
|
}
|
|
.main-tabs :deep(.gap-4) {
|
|
height: 46px !important;
|
|
}
|
|
.main-tabs :deep(a) {
|
|
overflow: hidden;
|
|
font-size: 9px !important;
|
|
}
|
|
.main-tabs :deep(svg) {
|
|
width: 19px;
|
|
height: 19px;
|
|
}
|
|
.light-screen,
|
|
.overlay-screen {
|
|
background: #000;
|
|
color: #f5f5f7;
|
|
}
|
|
.discover-screen {
|
|
padding-top: 66px;
|
|
}
|
|
.discover-screen :deep(input) {
|
|
color: #f5f5f7 !important;
|
|
background: #1c1c1e !important;
|
|
}
|
|
.discover-screen :deep(input::placeholder) {
|
|
color: #8e8e93 !important;
|
|
}
|
|
.trend-pills button {
|
|
border: 1px solid #2c2c2e;
|
|
background: #1c1c1e;
|
|
color: #f5f5f7;
|
|
}
|
|
.video-grid button {
|
|
background: #1c1c1e;
|
|
}
|
|
.activity-list {
|
|
padding-top: 66px;
|
|
}
|
|
.activity-list article,
|
|
.comments-list article {
|
|
border-color: #2c2c2e;
|
|
}
|
|
.activity-list p span {
|
|
color: #a1a1a6;
|
|
}
|
|
.light-empty {
|
|
color: #8e8e93;
|
|
}
|
|
.profile-screen {
|
|
padding-top: 84px;
|
|
}
|
|
.profile-screen .handle,
|
|
.profile-stats span {
|
|
color: #a1a1a6;
|
|
}
|
|
.profile-avatar,
|
|
.activity-avatar,
|
|
.comment-avatar {
|
|
background: #2c2c2e;
|
|
color: #fff;
|
|
}
|
|
.profile-screen :deep(button) {
|
|
border: 1px solid #3a3a3c;
|
|
background: #1c1c1e;
|
|
color: #f5f5f7;
|
|
}
|
|
.compose-screen,
|
|
.profile-edit {
|
|
padding-top: 54px;
|
|
}
|
|
.compose-body {
|
|
padding: 18px 14px 36px;
|
|
}
|
|
.media-picker {
|
|
border-color: #48484a;
|
|
background: #1c1c1e;
|
|
color: #f5f5f7;
|
|
}
|
|
.media-picker span {
|
|
color: #a1a1a6;
|
|
}
|
|
.media-preview {
|
|
border: 1px solid #3a3a3c;
|
|
}
|
|
.media-preview button {
|
|
color: #fff;
|
|
background: rgba(28, 28, 30, 0.9);
|
|
}
|
|
.compose-body textarea {
|
|
border: 1px solid #3a3a3c;
|
|
background: #1c1c1e;
|
|
color: #f5f5f7;
|
|
outline: none;
|
|
}
|
|
.compose-body textarea::placeholder,
|
|
.field input::placeholder {
|
|
color: #8e8e93;
|
|
}
|
|
.compose-body textarea:focus,
|
|
.field:focus-within {
|
|
border-color: #0a84ff;
|
|
}
|
|
.field,
|
|
.toggle-row {
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
min-height: 50px;
|
|
border: 1px solid transparent;
|
|
background: #1c1c1e;
|
|
color: #f5f5f7;
|
|
}
|
|
.field input {
|
|
color: #f5f5f7;
|
|
}
|
|
.visibility-row {
|
|
justify-content: flex-start;
|
|
text-align: left;
|
|
}
|
|
.visibility-row span {
|
|
flex: 1;
|
|
}
|
|
.visibility-row strong {
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
color: #a1a1a6;
|
|
}
|
|
.visibility-row svg {
|
|
width: 15px;
|
|
color: #8e8e93;
|
|
}
|
|
.toggle-row {
|
|
justify-content: space-between;
|
|
}
|
|
.toggle-row :deep(input) {
|
|
cursor: pointer;
|
|
}
|
|
.compose-actions :deep(button) {
|
|
min-height: 42px;
|
|
}
|
|
.compose-actions :deep(button:disabled) {
|
|
color: #8e8e93 !important;
|
|
background: #1c1c1e !important;
|
|
opacity: 0.62;
|
|
}
|
|
.moderation-button {
|
|
width: 44px;
|
|
height: 44px;
|
|
padding: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
color: var(--sky-app-accent);
|
|
}
|
|
|
|
.moderation-button__surface {
|
|
width: 34px;
|
|
height: 34px;
|
|
display: grid;
|
|
place-items: center;
|
|
border-radius: 50%;
|
|
background: var(--sky-surface-variant);
|
|
box-shadow: inset 0 0 0 1px var(--sky-hairline);
|
|
}
|
|
|
|
.moderation-button__surface svg {
|
|
width: 17px;
|
|
height: 17px;
|
|
}
|
|
.done-button {
|
|
font-weight: 650;
|
|
}
|
|
.fliptok-sheet :deep(.sky-sheet__panel) {
|
|
color: #f5f5f7;
|
|
}
|
|
.sheet-handle {
|
|
background: rgba(255, 255, 255, 0.28);
|
|
}
|
|
.comments-sheet {
|
|
box-sizing: border-box;
|
|
height: min(66vh, 460px);
|
|
color: #f5f5f7;
|
|
}
|
|
.comments-sheet > header button {
|
|
display: grid;
|
|
place-items: center;
|
|
color: #f5f5f7;
|
|
background: #2c2c2e;
|
|
}
|
|
.comments-list p span {
|
|
margin-top: 3px;
|
|
color: #d1d1d6;
|
|
}
|
|
.action-sheet,
|
|
.selection-sheet {
|
|
box-sizing: border-box;
|
|
padding: 0 12px 28px;
|
|
color: #f5f5f7;
|
|
background: #151517;
|
|
}
|
|
.action-sheet :deep(ul),
|
|
.selection-sheet :deep(ul) {
|
|
background: #1c1c1e;
|
|
}
|
|
.action-sheet :deep([class*='title']),
|
|
.selection-sheet :deep([class*='title']) {
|
|
color: #f5f5f7;
|
|
}
|
|
.action-sheet .danger :deep([class*='title']) {
|
|
color: #ff453a;
|
|
}
|
|
.action-sheet :deep(.sky-button),
|
|
.selection-sheet :deep(.sky-button) {
|
|
margin-top: 10px;
|
|
color: #0a84ff;
|
|
background: #1c1c1e;
|
|
}
|
|
.selection-sheet h3 {
|
|
margin: 4px 0 12px;
|
|
text-align: center;
|
|
font-size: 16px;
|
|
}
|
|
.selection-check {
|
|
width: 18px;
|
|
color: #0a84ff;
|
|
}
|
|
.fliptok-navbar {
|
|
transform: translateY(24px);
|
|
}
|
|
.media-picker strong {
|
|
font-size: 14px;
|
|
}
|
|
.media-picker span {
|
|
font-size: 11px;
|
|
}
|
|
.compose-body textarea {
|
|
font-size: 13px;
|
|
}
|
|
.field,
|
|
.toggle-row {
|
|
font-size: 12px;
|
|
}
|
|
.discover-screen {
|
|
padding-top: 100px;
|
|
}
|
|
.activity-list {
|
|
padding-top: 100px;
|
|
}
|
|
.profile-screen {
|
|
padding-top: 110px;
|
|
}
|
|
.video-shade {
|
|
cursor: pointer;
|
|
touch-action: manipulation;
|
|
}
|
|
.double-like-heart {
|
|
position: absolute;
|
|
z-index: 8;
|
|
top: 50%;
|
|
left: 50%;
|
|
width: 76px;
|
|
height: 76px;
|
|
color: #fff;
|
|
pointer-events: none;
|
|
filter: drop-shadow(0 3px 12px rgba(0, 0, 0, 0.45));
|
|
transform: translate(-50%, -50%) rotate(-9deg);
|
|
}
|
|
.double-like-enter-active,
|
|
.double-like-leave-active {
|
|
transition:
|
|
opacity 0.2s ease,
|
|
transform 0.28s cubic-bezier(0.2, 0.85, 0.35, 1.25);
|
|
}
|
|
.double-like-enter-from,
|
|
.double-like-leave-to {
|
|
opacity: 0;
|
|
transform: translate(-50%, -50%) scale(0.35) rotate(-18deg);
|
|
}
|
|
.video-actions button {
|
|
position: relative;
|
|
}
|
|
.reaction-pop--like svg {
|
|
color: #ff2d55 !important;
|
|
}
|
|
.reaction-pop--save svg {
|
|
color: #ffd60a !important;
|
|
}
|
|
.reaction-pop svg {
|
|
animation: reaction-button-pop 0.46s cubic-bezier(0.2, 0.9, 0.25, 1.3);
|
|
}
|
|
@keyframes reaction-button-pop {
|
|
0% {
|
|
filter: drop-shadow(0 0 0 currentColor);
|
|
transform: scale(1);
|
|
}
|
|
38% {
|
|
filter: drop-shadow(0 0 5px currentColor);
|
|
transform: scale(1.48) rotate(-9deg);
|
|
}
|
|
68% {
|
|
filter: drop-shadow(0 0 2px currentColor);
|
|
transform: scale(0.9) rotate(3deg);
|
|
}
|
|
100% {
|
|
filter: drop-shadow(0 0 0 currentColor);
|
|
transform: scale(1);
|
|
}
|
|
}
|
|
|
|
.fliptok-auth {
|
|
--fliptok-auth-bg: #e9ebf1;
|
|
--fliptok-auth-text: #111;
|
|
--fliptok-auth-secondary: #6e6e73;
|
|
position: absolute;
|
|
inset: 0;
|
|
box-sizing: border-box;
|
|
background: var(--fliptok-auth-bg);
|
|
color: var(--fliptok-auth-text);
|
|
overflow-y: auto;
|
|
}
|
|
.fliptok-auth--dark {
|
|
--fliptok-auth-bg: #08080a;
|
|
--fliptok-auth-text: #f5f5f7;
|
|
--fliptok-auth-secondary: #98989d;
|
|
}
|
|
.fliptok-auth::before {
|
|
position: absolute;
|
|
top: 88px;
|
|
left: 50%;
|
|
width: 240px;
|
|
height: 240px;
|
|
border-radius: 50%;
|
|
background: rgba(86, 72, 205, 0.2);
|
|
content: '';
|
|
filter: blur(58px);
|
|
pointer-events: none;
|
|
transform: translateX(-50%);
|
|
}
|
|
.fliptok-auth__navbar {
|
|
position: sticky !important;
|
|
z-index: 2;
|
|
top: 0;
|
|
}
|
|
.fliptok-auth__body {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: flex;
|
|
min-height: calc(100% - 58px);
|
|
align-items: center;
|
|
box-sizing: border-box;
|
|
padding: 18px 14px 34px;
|
|
}
|
|
.fliptok-auth__glass {
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
|
border-radius: 26px !important;
|
|
padding: 18px 0 16px;
|
|
overflow: hidden;
|
|
}
|
|
.fliptok-auth:not(.fliptok-auth--dark) .fliptok-auth__glass {
|
|
border-color: rgba(255, 255, 255, 0.7);
|
|
}
|
|
.fliptok-auth__hero {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 13px;
|
|
padding: 2px 20px 18px;
|
|
}
|
|
.fliptok-auth__hero img {
|
|
width: 58px;
|
|
height: 58px;
|
|
flex: 0 0 58px;
|
|
border-radius: 15px;
|
|
box-shadow: 0 8px 24px rgba(45, 36, 135, 0.24);
|
|
}
|
|
.fliptok-auth__hero h1 {
|
|
margin: 0;
|
|
font-size: 20px;
|
|
letter-spacing: -0.35px;
|
|
}
|
|
.fliptok-auth__hero p {
|
|
margin: 4px 0 0;
|
|
color: var(--fliptok-auth-secondary);
|
|
font-size: 11px;
|
|
line-height: 1.3;
|
|
}
|
|
.fliptok-auth__segment {
|
|
box-sizing: border-box;
|
|
width: calc(100% - 32px);
|
|
margin: 0 16px 10px;
|
|
overflow: hidden;
|
|
}
|
|
.fliptok-auth__form :deep(.sky-block-title) {
|
|
margin-top: 12px;
|
|
margin-bottom: 5px;
|
|
}
|
|
.fliptok-auth__form :deep(.sky-list) {
|
|
margin-top: 0;
|
|
margin-bottom: 12px;
|
|
}
|
|
.fliptok-auth__form :deep(input),
|
|
.fliptok-auth__form :deep(textarea) {
|
|
color: var(--fliptok-auth-text) !important;
|
|
}
|
|
.fliptok-auth__form > .sky-button {
|
|
box-sizing: border-box;
|
|
width: calc(100% - 32px);
|
|
margin: 0 16px;
|
|
}
|
|
.fliptok-auth__hint {
|
|
margin: 9px 24px 0 !important;
|
|
padding: 0 !important;
|
|
color: var(--fliptok-auth-secondary);
|
|
font-size: 10px;
|
|
line-height: 1.35;
|
|
text-align: center;
|
|
}
|
|
.logout-button {
|
|
color: #ff3b30 !important;
|
|
}
|
|
.logout-dialog-button {
|
|
color: #fff !important;
|
|
background: #ff3b30 !important;
|
|
}
|
|
.logout-dialog-button:active {
|
|
background: #d70015 !important;
|
|
}
|
|
.profile-form-list :deep(textarea) {
|
|
min-height: 70px;
|
|
}
|
|
.creator-link,
|
|
.caption-link {
|
|
display: inline;
|
|
border: 0;
|
|
padding: 0;
|
|
background: none;
|
|
color: inherit;
|
|
font: inherit;
|
|
}
|
|
.caption-link {
|
|
color: #8fc5ff;
|
|
font-weight: 650;
|
|
}
|
|
.profile-actions {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
}
|
|
.danger-button {
|
|
color: #ff453a !important;
|
|
}
|
|
.profile-video-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 2px;
|
|
margin: 22px -20px 0;
|
|
}
|
|
.profile-video-grid button {
|
|
position: relative;
|
|
aspect-ratio: 3 / 4;
|
|
overflow: hidden;
|
|
border: 0;
|
|
padding: 0;
|
|
background: #1c1c1e;
|
|
}
|
|
.profile-video-grid video {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
.profile-video-grid span {
|
|
position: absolute;
|
|
bottom: 5px;
|
|
left: 5px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
color: #fff;
|
|
font-size: 9px;
|
|
}
|
|
.profile-video-grid svg {
|
|
width: 10px;
|
|
}
|
|
.editor-card {
|
|
margin-top: 10px;
|
|
border: 1px solid #3a3a3c;
|
|
border-radius: 15px;
|
|
background: #1c1c1e;
|
|
padding: 12px;
|
|
}
|
|
.editor-card h3 {
|
|
margin: 0 0 9px;
|
|
font-size: 13px;
|
|
}
|
|
.editor-card label {
|
|
display: block;
|
|
padding: 6px 0;
|
|
}
|
|
.editor-card label > span {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
color: #d1d1d6;
|
|
font-size: 10px;
|
|
}
|
|
.editor-card label.disabled {
|
|
opacity: 0.45;
|
|
}
|
|
.sound-picker {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
width: 100%;
|
|
border: 0;
|
|
border-bottom: 1px solid #3a3a3c;
|
|
padding: 0 0 10px;
|
|
background: none;
|
|
color: #fff;
|
|
text-align: left;
|
|
}
|
|
.sound-picker span {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
grid-column: 1;
|
|
font-size: 10px;
|
|
color: #a1a1a6;
|
|
}
|
|
.sound-picker strong {
|
|
grid-column: 1;
|
|
margin-top: 3px;
|
|
font-size: 12px;
|
|
}
|
|
.sound-picker svg {
|
|
width: 14px;
|
|
}
|
|
.sound-picker > svg {
|
|
grid-column: 2;
|
|
grid-row: 1 / 3;
|
|
align-self: center;
|
|
}
|
|
.report-sheet textarea {
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
min-height: 74px;
|
|
margin: 0 0 10px;
|
|
border: 1px solid #3a3a3c;
|
|
border-radius: 12px;
|
|
background: #1c1c1e;
|
|
color: #fff;
|
|
padding: 10px;
|
|
}
|
|
.report-sheet {
|
|
padding-bottom: calc(var(--sky-safe-area-bottom) + var(--sky-space-3));
|
|
}
|
|
.report-sheet__actions {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: var(--sky-space-2);
|
|
margin-top: var(--sky-space-3);
|
|
}
|
|
.report-sheet__actions :deep(.sky-button) {
|
|
width: 100%;
|
|
min-width: 0;
|
|
min-height: 44px;
|
|
margin: 0;
|
|
padding-inline: 10px;
|
|
font-size: 13px;
|
|
}
|
|
.report-sheet__actions :deep(.sky-button:first-child) {
|
|
color: #fff;
|
|
background: var(--sky-app-accent);
|
|
}
|
|
.sheet-note {
|
|
color: #a1a1a6;
|
|
padding: 0 12px;
|
|
font-size: 11px;
|
|
}
|
|
.moderation-screen {
|
|
z-index: 45;
|
|
padding-top: 78px;
|
|
}
|
|
.moderation-body {
|
|
padding: 12px;
|
|
}
|
|
.moderation-body h2 {
|
|
margin: 8px 2px 12px;
|
|
font-size: 20px;
|
|
}
|
|
.report-card {
|
|
display: grid;
|
|
grid-template-columns: 86px 1fr;
|
|
gap: 10px;
|
|
margin-bottom: 10px;
|
|
border-radius: 15px;
|
|
background: #1c1c1e;
|
|
padding: 9px;
|
|
}
|
|
.report-card video {
|
|
width: 86px;
|
|
height: 116px;
|
|
border-radius: 10px;
|
|
object-fit: cover;
|
|
background: #000;
|
|
}
|
|
.report-card small,
|
|
.report-card p {
|
|
display: block;
|
|
margin: 4px 0;
|
|
color: #a1a1a6;
|
|
font-size: 10px;
|
|
}
|
|
.report-actions {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
margin-top: 7px;
|
|
}
|
|
.report-actions :deep(button) {
|
|
width: 100%;
|
|
min-height: 28px;
|
|
font-size: 10px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* FlipTok layout follows SkyUI's page, navbar, scroll and pill contracts. */
|
|
.fliptok-page {
|
|
background: var(--sky-bg);
|
|
color: var(--sky-text);
|
|
font-family: var(--sky-font-family);
|
|
}
|
|
|
|
.fliptok-auth,
|
|
.fliptok-auth--dark {
|
|
--fliptok-auth-bg: var(--sky-bg);
|
|
--fliptok-auth-text: var(--sky-text);
|
|
--fliptok-auth-secondary: var(--sky-muted);
|
|
}
|
|
|
|
.fliptok-auth__glass,
|
|
.media-picker,
|
|
.media-preview,
|
|
.editor-card {
|
|
border-color: var(--sky-hairline);
|
|
background: var(--sky-surface);
|
|
color: var(--sky-text);
|
|
box-shadow: var(--sky-shadow-glass);
|
|
}
|
|
|
|
.fliptok-navbar {
|
|
transform: none;
|
|
}
|
|
|
|
.light-screen {
|
|
position: relative;
|
|
inset: auto;
|
|
min-height: 0;
|
|
padding-top: var(--sky-page-space);
|
|
flex: 1 1 auto;
|
|
background: var(--sky-bg);
|
|
color: var(--sky-text);
|
|
}
|
|
|
|
.overlay-screen {
|
|
position: absolute;
|
|
z-index: 40;
|
|
inset: 0;
|
|
min-height: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
background: var(--sky-bg);
|
|
color: var(--sky-text);
|
|
}
|
|
|
|
.discover-screen,
|
|
.activity-list,
|
|
.profile-screen {
|
|
padding-top: var(--sky-space-2);
|
|
}
|
|
|
|
.discover-search {
|
|
width: 100%;
|
|
min-width: 0;
|
|
}
|
|
|
|
.trend-pills {
|
|
margin: 0 calc(0px - var(--sky-space-1)) var(--sky-space-2);
|
|
padding: var(--sky-space-1);
|
|
}
|
|
|
|
.trend-pills button {
|
|
border: 0;
|
|
background: var(--sky-surface);
|
|
color: var(--sky-text);
|
|
}
|
|
|
|
.activity-list article {
|
|
min-height: var(--sky-touch-target);
|
|
align-items: center;
|
|
border-color: var(--sky-hairline);
|
|
}
|
|
|
|
.activity-list p span,
|
|
.profile-screen .handle,
|
|
.profile-stats span,
|
|
.media-picker span,
|
|
.compose-intro p,
|
|
.connection-profile small,
|
|
.comment-content time {
|
|
color: var(--sky-muted);
|
|
}
|
|
|
|
.avatar,
|
|
.profile-avatar,
|
|
.activity-avatar,
|
|
.comment-avatar,
|
|
.connection-avatar {
|
|
overflow: hidden;
|
|
}
|
|
|
|
.avatar img,
|
|
.profile-avatar img,
|
|
.activity-avatar img,
|
|
.comment-avatar img,
|
|
.connection-avatar img {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.profile-screen {
|
|
padding-right: var(--sky-page-gutter);
|
|
padding-left: var(--sky-page-gutter);
|
|
}
|
|
|
|
.profile-avatar {
|
|
background: var(--sky-surface-variant);
|
|
color: var(--sky-text);
|
|
}
|
|
|
|
.profile-stats {
|
|
gap: var(--sky-space-2);
|
|
}
|
|
|
|
.profile-stats button,
|
|
.profile-stats div {
|
|
min-width: 78px;
|
|
min-height: 58px;
|
|
padding: var(--sky-space-2);
|
|
display: flex;
|
|
align-items: center;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
gap: 2px;
|
|
border: 0;
|
|
border-radius: var(--sky-radius-control);
|
|
background: var(--sky-surface);
|
|
color: var(--sky-text);
|
|
font: inherit;
|
|
}
|
|
|
|
.profile-stats button:active {
|
|
background: var(--sky-pressed);
|
|
}
|
|
|
|
.profile-actions {
|
|
max-width: 286px;
|
|
margin: 0 auto;
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 6px;
|
|
}
|
|
|
|
.profile-actions :deep(.sky-button) {
|
|
width: 100%;
|
|
min-height: 44px;
|
|
min-width: 0;
|
|
padding-inline: 12px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.profile-actions :deep(.sky-button svg) {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
|
|
.profile-actions :deep(.sky-button:last-child:nth-child(odd)) {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.main-tabs {
|
|
position: absolute !important;
|
|
right: calc(var(--sky-safe-area-right) + var(--sky-page-gutter));
|
|
bottom: calc(var(--sky-safe-area-bottom) + 10px);
|
|
left: calc(var(--sky-safe-area-left) + var(--sky-page-gutter));
|
|
height: auto;
|
|
min-height: 56px;
|
|
padding: 0 !important;
|
|
border: 0;
|
|
background: transparent !important;
|
|
color: var(--sky-text) !important;
|
|
backdrop-filter: none;
|
|
}
|
|
|
|
.main-tabs__segments {
|
|
width: 100%;
|
|
}
|
|
|
|
.main-tab {
|
|
min-width: 0;
|
|
padding-inline: var(--sky-space-1) !important;
|
|
}
|
|
|
|
.main-tab svg {
|
|
width: 19px;
|
|
height: 19px;
|
|
}
|
|
|
|
.main-tab small {
|
|
max-width: 100%;
|
|
overflow: hidden;
|
|
font-size: 9px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.video-copy {
|
|
right: 58px;
|
|
bottom: calc(var(--sky-safe-area-bottom) + 78px);
|
|
}
|
|
|
|
.video-actions {
|
|
right: 10px;
|
|
bottom: calc(var(--sky-safe-area-bottom) + 78px);
|
|
gap: 4px;
|
|
}
|
|
|
|
.video-actions button {
|
|
min-height: 31px;
|
|
font-size: 8px;
|
|
}
|
|
|
|
.video-actions svg {
|
|
width: 22px;
|
|
height: 22px;
|
|
}
|
|
|
|
.video-actions .avatar {
|
|
width: 34px;
|
|
height: 34px;
|
|
}
|
|
|
|
.follow-dot {
|
|
width: 16px;
|
|
height: 16px !important;
|
|
margin-top: -17px;
|
|
}
|
|
|
|
.follow-dot svg {
|
|
width: 10px !important;
|
|
height: 10px !important;
|
|
}
|
|
|
|
.create-icon::before {
|
|
border-color: var(--sky-hairline);
|
|
background: var(--sky-app-accent-soft);
|
|
}
|
|
|
|
.create-icon::after {
|
|
border-color: var(--sky-app-accent-tint);
|
|
background: var(--sky-app-accent);
|
|
}
|
|
|
|
.compose-screen,
|
|
.profile-edit,
|
|
.moderation-screen {
|
|
padding-top: 0;
|
|
}
|
|
|
|
.compose-body,
|
|
.profile-edit__body {
|
|
min-height: 0;
|
|
flex: 1 1 auto;
|
|
}
|
|
|
|
.profile-edit__body {
|
|
padding-right: var(--sky-page-gutter);
|
|
padding-bottom: calc(var(--sky-safe-area-bottom) + var(--sky-space-6));
|
|
padding-left: var(--sky-page-gutter);
|
|
}
|
|
|
|
.compose-intro {
|
|
margin-bottom: var(--sky-space-3);
|
|
padding: var(--sky-space-3);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sky-space-3);
|
|
border-radius: var(--sky-radius-card);
|
|
background: var(--sky-app-accent-soft);
|
|
}
|
|
|
|
.compose-intro > span {
|
|
width: var(--sky-touch-target);
|
|
height: var(--sky-touch-target);
|
|
display: grid;
|
|
flex: 0 0 var(--sky-touch-target);
|
|
place-items: center;
|
|
border-radius: 50%;
|
|
background: var(--sky-app-accent);
|
|
color: #fff;
|
|
}
|
|
|
|
.compose-intro svg {
|
|
width: 21px;
|
|
height: 21px;
|
|
}
|
|
|
|
.compose-intro strong {
|
|
display: block;
|
|
font-size: 15px;
|
|
}
|
|
|
|
.compose-intro p {
|
|
margin: 3px 0 0;
|
|
font-size: 11px;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
.media-picker {
|
|
min-height: 188px;
|
|
height: auto;
|
|
border: 1px dashed var(--sky-hairline);
|
|
}
|
|
|
|
.media-picker:active {
|
|
background: var(--sky-pressed);
|
|
}
|
|
|
|
.editor-card {
|
|
border-color: var(--sky-hairline);
|
|
background: var(--sky-surface);
|
|
}
|
|
|
|
.editor-card label > span,
|
|
.sound-picker span {
|
|
color: var(--sky-muted);
|
|
}
|
|
|
|
.sound-picker {
|
|
border-color: var(--sky-hairline);
|
|
color: var(--sky-text);
|
|
}
|
|
|
|
.custom-sound-error {
|
|
margin: var(--sky-space-2) 0 0;
|
|
color: var(--sky-danger);
|
|
font-size: 11px;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
.music-selection-sheet {
|
|
max-height: min(76vh, 620px);
|
|
padding-bottom: calc(var(--sky-safe-area-bottom) + var(--sky-space-3));
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.custom-sound-editor {
|
|
margin-top: var(--sky-space-3);
|
|
padding: var(--sky-space-3);
|
|
border: 1px solid var(--sky-hairline);
|
|
border-radius: var(--sky-radius-card);
|
|
background: var(--sky-surface-variant);
|
|
}
|
|
|
|
.custom-sound-editor > header {
|
|
display: grid;
|
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
align-items: center;
|
|
gap: var(--sky-space-2);
|
|
}
|
|
|
|
.custom-sound-editor > header > span {
|
|
width: 34px;
|
|
height: 34px;
|
|
display: grid;
|
|
place-items: center;
|
|
border-radius: 50%;
|
|
background: var(--sky-app-accent-soft);
|
|
color: var(--sky-app-accent);
|
|
}
|
|
|
|
.custom-sound-editor > header svg {
|
|
width: 17px;
|
|
height: 17px;
|
|
}
|
|
|
|
.custom-sound-editor > header strong {
|
|
display: block;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.custom-sound-editor > header p {
|
|
margin: 2px 0 0;
|
|
color: var(--sky-muted);
|
|
font-size: 10px;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
.custom-sound-editor :deep(.sky-list) {
|
|
margin: var(--sky-space-3) 0 0;
|
|
}
|
|
|
|
.custom-sound-editor audio {
|
|
width: 100%;
|
|
height: 34px;
|
|
margin-top: var(--sky-space-2);
|
|
}
|
|
|
|
.custom-sound-metadata {
|
|
margin: var(--sky-space-2) 0 0;
|
|
padding: var(--sky-space-2) var(--sky-space-3);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
border-radius: var(--sky-radius-control);
|
|
background: var(--sky-app-accent-soft);
|
|
}
|
|
|
|
.custom-sound-metadata strong {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.custom-sound-metadata span {
|
|
color: var(--sky-muted);
|
|
font-size: 10px;
|
|
}
|
|
|
|
.custom-sound-editor :deep(.sky-button) {
|
|
width: 100%;
|
|
}
|
|
|
|
.compose-form-list {
|
|
margin-right: 0;
|
|
margin-left: 0;
|
|
}
|
|
|
|
.compose-actions {
|
|
margin-bottom: calc(var(--sky-safe-area-bottom) + var(--sky-space-2));
|
|
}
|
|
|
|
.profile-photo-editor {
|
|
margin: var(--sky-space-3) 0 var(--sky-space-4);
|
|
padding: var(--sky-space-5) var(--sky-space-3);
|
|
display: grid;
|
|
justify-items: center;
|
|
gap: var(--sky-space-2);
|
|
overflow: hidden;
|
|
border: 1px solid var(--sky-hairline);
|
|
border-radius: var(--sky-radius-card);
|
|
background:
|
|
radial-gradient(
|
|
circle at 50% 20%,
|
|
var(--sky-app-accent-soft),
|
|
transparent 62%
|
|
),
|
|
var(--sky-surface);
|
|
box-shadow: var(--sky-shadow-thumb);
|
|
}
|
|
|
|
.profile-photo-trigger {
|
|
position: relative;
|
|
width: 90px;
|
|
height: 90px;
|
|
padding: 0;
|
|
border: 0;
|
|
border-radius: 50%;
|
|
background: transparent;
|
|
color: var(--sky-text);
|
|
box-shadow:
|
|
0 0 0 4px var(--sky-surface),
|
|
0 0 0 6px var(--sky-app-accent-tint),
|
|
var(--sky-shadow-thumb);
|
|
}
|
|
|
|
.profile-avatar--editor {
|
|
width: 90px;
|
|
height: 90px;
|
|
margin: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
font-size: 22px;
|
|
}
|
|
|
|
.profile-photo-trigger__badge {
|
|
position: absolute;
|
|
right: -1px;
|
|
bottom: 2px;
|
|
width: 28px;
|
|
height: 28px;
|
|
display: grid;
|
|
place-items: center;
|
|
border: 3px solid var(--sky-bg);
|
|
border-radius: 50%;
|
|
background: var(--sky-app-accent);
|
|
color: #fff;
|
|
}
|
|
|
|
.profile-photo-trigger__badge svg {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
|
|
.profile-photo-change {
|
|
min-height: 30px;
|
|
font-size: 12px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.profile-form-list {
|
|
margin-right: 0;
|
|
margin-left: 0;
|
|
}
|
|
|
|
.profile-form-list,
|
|
.profile-account-list {
|
|
overflow: hidden;
|
|
border: 1px solid var(--sky-hairline);
|
|
border-radius: var(--sky-radius-card);
|
|
background: var(--sky-surface);
|
|
box-shadow: var(--sky-shadow-thumb);
|
|
}
|
|
|
|
.profile-account-list {
|
|
margin-right: 0;
|
|
margin-bottom: var(--sky-space-4);
|
|
margin-left: 0;
|
|
}
|
|
|
|
.profile-edit__body :deep(.sky-block-title) {
|
|
margin-right: var(--sky-space-1);
|
|
margin-left: var(--sky-space-1);
|
|
}
|
|
|
|
.done-button {
|
|
min-width: 58px;
|
|
color: #fff;
|
|
background: var(--sky-app-accent);
|
|
box-shadow: 0 6px 16px var(--sky-app-accent-soft);
|
|
}
|
|
|
|
.profile-photo-sheet :deep(.sky-list-item__media svg) {
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
|
|
.profile-photo-sheet :deep(.danger .sky-list-item__row) {
|
|
color: var(--sky-danger);
|
|
}
|
|
|
|
.fliptok-sheet :deep(.sky-sheet__panel),
|
|
.fliptok-connections-sheet :deep(.sky-sheet__panel) {
|
|
overflow: hidden;
|
|
background: var(--sky-surface);
|
|
color: var(--sky-text);
|
|
}
|
|
|
|
.comments-sheet {
|
|
height: min(68vh, 500px);
|
|
padding: 0 var(--sky-space-3)
|
|
calc(var(--sky-safe-area-bottom) + var(--sky-space-2));
|
|
color: var(--sky-text);
|
|
}
|
|
|
|
.comments-sheet > header {
|
|
min-height: var(--sky-touch-target);
|
|
padding: 0;
|
|
align-items: center;
|
|
}
|
|
|
|
.comments-sheet > header button,
|
|
.connections-sheet > header > button {
|
|
width: 32px;
|
|
height: 32px;
|
|
min-width: 32px;
|
|
min-height: 32px;
|
|
display: grid;
|
|
place-items: center;
|
|
border: 0;
|
|
border-radius: 50%;
|
|
background: var(--sky-surface-variant);
|
|
color: var(--sky-text);
|
|
}
|
|
|
|
.comments-list {
|
|
padding: 0 var(--sky-space-1);
|
|
}
|
|
|
|
.comment-thread {
|
|
padding: var(--sky-space-2) 0;
|
|
border-bottom: 1px solid var(--sky-hairline);
|
|
}
|
|
|
|
.comments-list .comment-row {
|
|
padding: var(--sky-space-2) 0;
|
|
align-items: flex-start;
|
|
border: 0;
|
|
}
|
|
|
|
.comments-list .comment-row--reply {
|
|
margin-left: 38px;
|
|
}
|
|
|
|
.comment-row--reply .comment-avatar {
|
|
flex-basis: 28px;
|
|
width: 28px;
|
|
height: 28px;
|
|
}
|
|
|
|
.comment-content {
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.comment-content header {
|
|
min-height: 18px;
|
|
padding: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--sky-space-2);
|
|
}
|
|
|
|
.comment-content header button {
|
|
position: static;
|
|
width: auto;
|
|
height: auto;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
border: 0;
|
|
border-radius: 0;
|
|
background: transparent;
|
|
color: var(--sky-text);
|
|
font: inherit;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.comment-content time {
|
|
flex: none;
|
|
font-size: 9px;
|
|
}
|
|
|
|
.comments-list .comment-content p {
|
|
margin: 3px 0 5px;
|
|
display: block;
|
|
color: var(--sky-text);
|
|
font-size: 11px;
|
|
line-height: 1.35;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.comment-content footer {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sky-space-4);
|
|
}
|
|
|
|
.comment-content footer button {
|
|
min-height: 24px;
|
|
padding: 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--sky-space-1);
|
|
border: 0;
|
|
background: transparent;
|
|
color: var(--sky-muted);
|
|
font: inherit;
|
|
font-size: 10px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.comment-content footer svg {
|
|
width: 13px;
|
|
height: 13px;
|
|
}
|
|
|
|
.comment-content footer .liked {
|
|
color: var(--sky-app-accent);
|
|
}
|
|
|
|
.comment-like-pulse svg {
|
|
transform-origin: center;
|
|
animation: comment-heart-pop 420ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
}
|
|
|
|
@keyframes comment-heart-pop {
|
|
0% {
|
|
transform: scale(1);
|
|
}
|
|
38% {
|
|
transform: scale(1.32) rotate(-7deg);
|
|
filter: drop-shadow(0 0 3px var(--sky-app-accent));
|
|
}
|
|
72% {
|
|
transform: scale(0.94) rotate(3deg);
|
|
}
|
|
100% {
|
|
transform: scale(1);
|
|
}
|
|
}
|
|
|
|
.reply-handle {
|
|
margin-right: 3px;
|
|
color: var(--sky-app-accent);
|
|
font-weight: 650;
|
|
}
|
|
|
|
.comments-sheet form.comments-composer {
|
|
display: block;
|
|
padding: var(--sky-space-1) 0 0;
|
|
border-top: 1px solid var(--sky-hairline);
|
|
}
|
|
|
|
.comments-messagebar {
|
|
width: 100%;
|
|
padding: var(--sky-space-1) 0 var(--sky-space-2);
|
|
background: transparent;
|
|
}
|
|
|
|
.comments-disabled {
|
|
margin: 0;
|
|
padding: var(--sky-space-3) var(--sky-space-2)
|
|
calc(var(--sky-safe-area-bottom) + var(--sky-space-3));
|
|
border-top: 1px solid var(--sky-hairline);
|
|
color: var(--sky-muted);
|
|
font-size: 10px;
|
|
text-align: center;
|
|
}
|
|
|
|
.replying-to {
|
|
min-height: 20px;
|
|
padding: 0 var(--sky-space-2) 2px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--sky-space-2);
|
|
color: var(--sky-muted);
|
|
font-size: 9px;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.replying-to span {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.comments-sheet form .replying-to button {
|
|
width: 20px;
|
|
height: 20px;
|
|
min-width: 20px;
|
|
min-height: 20px;
|
|
display: grid;
|
|
place-items: center;
|
|
border: 0;
|
|
border-radius: 50%;
|
|
background: var(--sky-pressed);
|
|
color: var(--sky-text);
|
|
}
|
|
|
|
.comments-sheet form .replying-to svg {
|
|
width: 11px;
|
|
height: 11px;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.follow-dot--confirmed,
|
|
.comment-like-pulse svg {
|
|
animation-duration: 1ms;
|
|
}
|
|
}
|
|
|
|
/* FlipTok's media viewport is bound to the phone app, never the browser. */
|
|
.fliptok-page {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.video-feed {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
.video-card {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 100%;
|
|
max-height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.photo-slideshow,
|
|
.compose-photo-preview {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.photo-slideshow {
|
|
overflow: hidden;
|
|
}
|
|
|
|
.photo-slideshow__track,
|
|
.compose-photo-preview {
|
|
display: flex;
|
|
}
|
|
|
|
.photo-slideshow__track {
|
|
width: 100%;
|
|
height: 100%;
|
|
backface-visibility: hidden;
|
|
transform: translate3d(0, 0, 0);
|
|
will-change: transform;
|
|
}
|
|
|
|
.compose-photo-preview {
|
|
overflow-x: auto;
|
|
scroll-snap-type: x mandatory;
|
|
scrollbar-width: none;
|
|
}
|
|
|
|
.compose-photo-preview::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.photo-slideshow__track img,
|
|
.compose-photo-preview img {
|
|
width: 100%;
|
|
height: 100%;
|
|
flex: 0 0 100%;
|
|
object-fit: cover;
|
|
scroll-snap-align: center;
|
|
}
|
|
|
|
.photo-slideshow__track img {
|
|
backface-visibility: hidden;
|
|
pointer-events: none;
|
|
transform: translateZ(0);
|
|
-webkit-user-drag: none;
|
|
}
|
|
|
|
.photo-slideshow__count {
|
|
position: absolute;
|
|
top: calc(var(--sky-safe-area-top) + 58px);
|
|
right: 12px;
|
|
padding: 4px 7px;
|
|
border-radius: 999px;
|
|
background: rgb(0 0 0 / 55%);
|
|
color: #fff;
|
|
font-size: 9px;
|
|
}
|
|
|
|
.video-profile-action {
|
|
position: relative;
|
|
width: 40px;
|
|
min-height: 44px;
|
|
display: grid;
|
|
justify-items: center;
|
|
}
|
|
|
|
.video-actions .follow-dot {
|
|
position: absolute;
|
|
z-index: 2;
|
|
bottom: 0;
|
|
left: 50%;
|
|
margin: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
.video-actions .follow-dot--confirmed {
|
|
animation-name: follow-confirmed-fade-centered;
|
|
}
|
|
|
|
@keyframes follow-confirmed-fade-centered {
|
|
0%,
|
|
48% {
|
|
opacity: 1;
|
|
transform: translateX(-50%) scale(1);
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
transform: translateX(-50%) scale(0.72);
|
|
}
|
|
}
|
|
|
|
.video-actions button {
|
|
width: 44px;
|
|
min-width: 44px;
|
|
min-height: 44px;
|
|
gap: 2px;
|
|
}
|
|
|
|
.video-actions button > span {
|
|
min-width: 26px;
|
|
text-align: center;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.discover-screen {
|
|
padding-top: 0;
|
|
}
|
|
|
|
.video-grid span,
|
|
.profile-video-grid span {
|
|
display: inline-flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 3px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.video-grid img,
|
|
.profile-video-grid img {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.fliptok-auth__body {
|
|
min-height: calc(100% - 48px);
|
|
justify-content: center;
|
|
padding-top: 10px;
|
|
padding-bottom: calc(var(--sky-safe-area-bottom) + 18px);
|
|
}
|
|
|
|
.fliptok-auth__glass {
|
|
max-width: 350px;
|
|
margin: auto;
|
|
}
|
|
|
|
.profile-navbar-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
}
|
|
|
|
.profile-navbar-actions svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
|
|
.profile-stats button,
|
|
.profile-stats div,
|
|
.profile-screen :deep(.profile-stats button) {
|
|
min-width: 70px;
|
|
min-height: 48px;
|
|
padding: 4px;
|
|
border: 0;
|
|
border-radius: 0;
|
|
background: transparent;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.profile-stats button:active {
|
|
background: var(--sky-pressed);
|
|
}
|
|
|
|
.comment-content header button {
|
|
max-width: 150px;
|
|
overflow: hidden;
|
|
font-size: 10px;
|
|
line-height: 1.15;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.replies-toggle {
|
|
min-height: 28px;
|
|
margin: 0 0 2px 42px;
|
|
padding: 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
border: 0;
|
|
background: transparent;
|
|
color: var(--sky-muted);
|
|
font-size: 10px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.replies-toggle span {
|
|
width: 24px;
|
|
height: 1px;
|
|
background: var(--sky-hairline);
|
|
}
|
|
|
|
.report-sheet {
|
|
box-sizing: border-box;
|
|
min-height: min(72vh, 560px);
|
|
max-height: min(82vh, 650px);
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.report-sheet__note {
|
|
margin: -4px 4px 10px;
|
|
color: var(--sky-muted);
|
|
font-size: 10px;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
.media-picker {
|
|
padding: var(--sky-space-4);
|
|
align-items: stretch;
|
|
justify-content: flex-start;
|
|
gap: var(--sky-space-3);
|
|
}
|
|
|
|
.media-picker > header {
|
|
display: grid;
|
|
justify-items: center;
|
|
gap: 3px;
|
|
text-align: center;
|
|
}
|
|
|
|
.media-picker > header svg {
|
|
width: 28px;
|
|
height: 28px;
|
|
color: var(--sky-app-accent);
|
|
}
|
|
|
|
.media-picker > header strong,
|
|
.media-picker > header span {
|
|
max-width: 100%;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.media-picker > header strong {
|
|
font-size: 13px;
|
|
line-height: 1.25;
|
|
}
|
|
|
|
.media-picker > header span {
|
|
color: var(--sky-muted);
|
|
font-size: 10px;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
.media-source-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 6px;
|
|
}
|
|
|
|
.media-source-grid button {
|
|
min-width: 0;
|
|
min-height: 78px;
|
|
padding: 8px 4px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
border: 1px solid var(--sky-hairline);
|
|
border-radius: var(--sky-radius-control);
|
|
background: var(--sky-surface-variant);
|
|
color: var(--sky-text);
|
|
font: inherit;
|
|
font-size: 9px;
|
|
line-height: 1.2;
|
|
text-align: center;
|
|
}
|
|
|
|
.media-source-grid button strong {
|
|
width: 100%;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.media-source-grid svg {
|
|
width: 20px;
|
|
height: 20px;
|
|
color: var(--sky-app-accent);
|
|
}
|
|
|
|
.media-preview__actions {
|
|
display: flex;
|
|
gap: 6px;
|
|
}
|
|
|
|
.media-preview__actions :deep(.sky-button) {
|
|
flex: 1 1 0;
|
|
}
|
|
|
|
.compose-photo-preview {
|
|
height: 300px;
|
|
border-radius: var(--sky-radius-control);
|
|
}
|
|
|
|
.connections-screen {
|
|
z-index: 46;
|
|
}
|
|
|
|
.connections-body {
|
|
min-height: 0;
|
|
flex: 1 1 auto;
|
|
}
|
|
|
|
.connections-tabs {
|
|
width: 100%;
|
|
margin-bottom: 11px;
|
|
}
|
|
|
|
.connections-list {
|
|
margin: 0;
|
|
overflow: hidden;
|
|
border: 1px solid var(--sky-hairline);
|
|
border-radius: var(--sky-radius-card);
|
|
}
|
|
|
|
.connections-list :deep(.sky-list-item__content) {
|
|
min-height: 62px;
|
|
}
|
|
|
|
.connections-list :deep(.sky-list-item__title) {
|
|
max-width: 112px;
|
|
overflow: hidden;
|
|
font-size: 12px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.connections-list :deep(.sky-list-item__text) {
|
|
color: var(--sky-muted);
|
|
font-size: 9px;
|
|
}
|
|
|
|
.connections-empty {
|
|
min-height: 180px;
|
|
}
|
|
|
|
.connections-sheet {
|
|
box-sizing: border-box;
|
|
min-height: 300px;
|
|
max-height: min(70vh, 520px);
|
|
padding: 0 var(--sky-space-3)
|
|
calc(var(--sky-safe-area-bottom) + var(--sky-space-3));
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.connections-sheet > header {
|
|
min-height: var(--sky-touch-target);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.connections-sheet > header > div {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sky-space-2);
|
|
}
|
|
|
|
.connections-sheet > header svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
|
|
.connections-list {
|
|
min-height: 0;
|
|
flex: 1 1 auto;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.connection-row {
|
|
min-height: 60px;
|
|
padding: var(--sky-space-2) 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sky-space-2);
|
|
border-top: 1px solid var(--sky-hairline);
|
|
}
|
|
|
|
.connection-profile {
|
|
min-width: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
flex: 1;
|
|
gap: var(--sky-space-3);
|
|
border: 0;
|
|
background: transparent;
|
|
color: var(--sky-text);
|
|
text-align: left;
|
|
}
|
|
|
|
.connection-avatar {
|
|
width: var(--sky-touch-target);
|
|
height: var(--sky-touch-target);
|
|
display: grid;
|
|
flex: 0 0 var(--sky-touch-target);
|
|
place-items: center;
|
|
border-radius: 50%;
|
|
background: var(--sky-surface-variant);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.connection-profile > span:last-child {
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.connection-profile strong {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
}
|
|
|
|
.moderation-body {
|
|
min-height: 0;
|
|
flex: 1 1 auto;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.creator-line .verified,
|
|
.comment-content .verified,
|
|
.profile-screen .verified {
|
|
flex: 0 0 auto;
|
|
align-self: center;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.connections-list .connection-profile {
|
|
width: var(--sky-touch-target);
|
|
min-width: var(--sky-touch-target);
|
|
flex: 0 0 var(--sky-touch-target);
|
|
}
|
|
|
|
/* Feed interaction and photo posts stay inside the phone viewport. */
|
|
.video-feed {
|
|
overscroll-behavior-y: contain;
|
|
contain: layout paint;
|
|
}
|
|
|
|
.video-card {
|
|
block-size: 100%;
|
|
inline-size: 100%;
|
|
}
|
|
|
|
.video-shade--passive {
|
|
pointer-events: none;
|
|
}
|
|
|
|
.photo-slideshow {
|
|
position: absolute;
|
|
inset: 0;
|
|
overscroll-behavior-inline: contain;
|
|
cursor: grab;
|
|
touch-action: pan-y;
|
|
user-select: none;
|
|
contain: layout paint;
|
|
}
|
|
|
|
.compose-photo-preview__arrow {
|
|
width: 44px;
|
|
height: 44px;
|
|
min-width: 44px;
|
|
min-height: 44px;
|
|
padding: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
border: 1px solid rgb(255 255 255 / 22%);
|
|
border-radius: 50%;
|
|
background: rgb(14 14 16 / 58%);
|
|
color: #fff;
|
|
box-shadow: 0 4px 14px rgb(0 0 0 / 24%);
|
|
backdrop-filter: blur(10px);
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.photo-slideshow--dragging,
|
|
.photo-slideshow--settling {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.compose-photo-preview__arrow svg {
|
|
width: 17px;
|
|
height: 17px;
|
|
}
|
|
|
|
.compose-photo-preview__arrow:disabled {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.photo-slideshow__count {
|
|
top: calc(var(--sky-safe-area-top) + 58px);
|
|
right: 12px;
|
|
z-index: 1;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* The avatar follow control must not inherit the generic action height. */
|
|
.video-actions .follow-dot {
|
|
width: 20px !important;
|
|
height: 20px !important;
|
|
min-width: 20px !important;
|
|
min-height: 20px !important;
|
|
padding: 0 !important;
|
|
display: grid;
|
|
place-items: center;
|
|
border: 2px solid #111;
|
|
border-radius: 50% !important;
|
|
background: var(--sky-app-accent) !important;
|
|
}
|
|
|
|
.video-actions .follow-dot svg {
|
|
width: 11px !important;
|
|
height: 11px !important;
|
|
margin: 0;
|
|
}
|
|
|
|
/* No unused action row is reserved above Discover. */
|
|
.fliptok-discover-navbar.sky-navbar--no-navigation {
|
|
padding-top: calc(var(--sky-navbar-safe-area-top) + var(--sky-space-2));
|
|
}
|
|
|
|
.compose-photo-preview {
|
|
position: relative;
|
|
display: block;
|
|
overflow: hidden;
|
|
background: #111;
|
|
}
|
|
|
|
.compose-photo-preview img {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.compose-photo-preview__arrow {
|
|
position: absolute;
|
|
z-index: 2;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
|
|
.compose-photo-preview__arrow--previous {
|
|
left: 10px;
|
|
}
|
|
|
|
.compose-photo-preview__arrow--next {
|
|
right: 10px;
|
|
}
|
|
|
|
.compose-photo-preview__count {
|
|
position: absolute;
|
|
z-index: 2;
|
|
top: 10px;
|
|
right: 10px;
|
|
padding: 4px 8px;
|
|
border-radius: var(--sky-radius-pill);
|
|
background: rgb(14 14 16 / 58%);
|
|
color: #fff;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.compose-photo-preview__dots {
|
|
position: absolute;
|
|
z-index: 2;
|
|
right: 42px;
|
|
bottom: 10px;
|
|
left: 42px;
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.compose-photo-preview__dots span {
|
|
width: 5px;
|
|
height: 5px;
|
|
border-radius: 50%;
|
|
background: rgb(255 255 255 / 48%);
|
|
box-shadow: 0 1px 3px rgb(0 0 0 / 36%);
|
|
}
|
|
|
|
.compose-photo-preview__dots span.active {
|
|
width: 13px;
|
|
border-radius: var(--sky-radius-pill);
|
|
background: #fff;
|
|
}
|
|
|
|
/* Login and register center in the remaining phone area and still scroll. */
|
|
.fliptok-auth {
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.fliptok-auth__navbar {
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.fliptok-auth__body {
|
|
min-height: 0;
|
|
padding-top: var(--sky-space-3);
|
|
flex: 1 1 auto;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.fliptok-auth__glass {
|
|
margin-block: auto;
|
|
}
|
|
|
|
/* Profile actions are separate icon surfaces instead of one shared pill. */
|
|
.fliptok-profile-navbar :deep(.sky-navbar__right) {
|
|
overflow: visible;
|
|
background: transparent;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.profile-navbar-actions {
|
|
gap: 6px;
|
|
}
|
|
|
|
.profile-navbar-actions :deep(.sky-link) {
|
|
width: 38px;
|
|
height: 38px;
|
|
min-width: 38px;
|
|
min-height: 38px;
|
|
border: 1px solid var(--sky-hairline);
|
|
border-radius: var(--sky-radius-control);
|
|
background: var(--sky-surface);
|
|
color: var(--sky-text);
|
|
box-shadow: var(--sky-shadow-thumb);
|
|
}
|
|
|
|
.connections-list :deep(.sky-list-item__media) {
|
|
width: var(--sky-touch-target);
|
|
min-width: var(--sky-touch-target);
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
|
|
.connections-list .connection-profile {
|
|
height: var(--sky-touch-target);
|
|
padding: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
|
|
.connections-list .connection-avatar {
|
|
line-height: 1;
|
|
text-align: center;
|
|
}
|
|
|
|
.connections-list :deep(.sky-button:disabled) {
|
|
opacity: 0.72;
|
|
}
|
|
</style>
|