mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
Merge branch 'dev' into weather-app
This commit is contained in:
@@ -1,16 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { kBadge } from 'konsta/vue'
|
||||
import { Minus } from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
|
||||
import { getPhoneAppLabel, isPhoneAppRemovable } from '@/config/apps'
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { useBillingStore } from '@/stores/billing'
|
||||
import { useCompaniesStore } from '@/stores/companies'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppDefinition } from '@/types/apps'
|
||||
import {
|
||||
reorderDirectionFromKeyboard,
|
||||
type ReorderDirection,
|
||||
} from '@/utils/keyboard'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -32,11 +37,13 @@ const emit = defineEmits<{
|
||||
dragstart: [event: PointerEvent]
|
||||
edit: []
|
||||
remove: []
|
||||
reorder: [direction: ReorderDirection]
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const mail = useMailStore()
|
||||
const billing = useBillingStore()
|
||||
const companies = useCompaniesStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
const darkchat = useDarkChatStore()
|
||||
const router = useRouter()
|
||||
@@ -54,15 +61,31 @@ const dragStyle = computed(() =>
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const iconStyle = computed(() =>
|
||||
props.app.kind === 'external' && props.app.iconBackground
|
||||
? { backgroundColor: props.app.iconBackground }
|
||||
: undefined,
|
||||
)
|
||||
const suppressClick = ref(false)
|
||||
let holdTimer: number | undefined
|
||||
let calendarTimer: number | undefined
|
||||
let pointerStart = { x: 0, y: 0 }
|
||||
let pointerTarget: HTMLElement | null = null
|
||||
let pointerId: number | null = null
|
||||
|
||||
watch(
|
||||
() => props.app.iconImage,
|
||||
() => {
|
||||
iconFailed.value = false
|
||||
},
|
||||
)
|
||||
|
||||
const unreadCount = computed(() => {
|
||||
if (props.app.id === 'mail') return mail.counts.unread
|
||||
if (props.app.id === 'citymarkt') return marketplace.counts.unread
|
||||
if (props.app.id === 'darkchat') return darkchat.unreadCount
|
||||
if (props.app.id === 'billing') return billing.overview?.unreadCount ?? 0
|
||||
if (props.app.id === 'companies') return companies.unreadCount
|
||||
return 0
|
||||
})
|
||||
const notificationBadgeColors = {
|
||||
@@ -119,6 +142,9 @@ function clearHold(): void {
|
||||
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
if (props.compact || event.button !== 0) return
|
||||
pointerTarget = event.currentTarget as HTMLElement
|
||||
pointerId = event.pointerId
|
||||
pointerTarget.setPointerCapture(pointerId)
|
||||
pointerStart = { x: event.clientX, y: event.clientY }
|
||||
clearHold()
|
||||
if (props.editMode) {
|
||||
@@ -157,35 +183,48 @@ function beginPointerDrag(event: PointerEvent): void {
|
||||
.closest<HTMLElement>('.springboard-page')
|
||||
?.getBoundingClientRect().width ?? 0
|
||||
isDragging.value = true
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', cancelPointerDrag)
|
||||
emit('dragstart', event)
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent): void {
|
||||
clearHold()
|
||||
if (!isDragging.value) return
|
||||
suppressClick.value = true
|
||||
emit('dragend', event)
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
removeDragListeners()
|
||||
if (isDragging.value) {
|
||||
suppressClick.value = true
|
||||
emit('dragend', event)
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
}
|
||||
releasePointerCapture()
|
||||
}
|
||||
|
||||
function cancelPointerDrag(): void {
|
||||
clearHold()
|
||||
if (!isDragging.value) return
|
||||
const wasDragging = isDragging.value
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
removeDragListeners()
|
||||
emit('dragcancel')
|
||||
releasePointerCapture()
|
||||
if (wasDragging) emit('dragcancel')
|
||||
}
|
||||
|
||||
function removeDragListeners(): void {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', cancelPointerDrag)
|
||||
function releasePointerCapture(): void {
|
||||
if (
|
||||
pointerTarget &&
|
||||
pointerId !== null &&
|
||||
pointerTarget.hasPointerCapture(pointerId)
|
||||
) {
|
||||
pointerTarget.releasePointerCapture(pointerId)
|
||||
}
|
||||
pointerTarget = null
|
||||
pointerId = null
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (!props.editMode) return
|
||||
const direction = reorderDirectionFromKeyboard(event)
|
||||
if (!direction) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('reorder', direction)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -198,7 +237,7 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
clearHold()
|
||||
if (calendarTimer !== undefined) window.clearInterval(calendarTimer)
|
||||
removeDragListeners()
|
||||
releasePointerCapture()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -216,12 +255,17 @@ onBeforeUnmount(() => {
|
||||
class="app-icon-button"
|
||||
:class="{ 'app-icon-button--compact': compact }"
|
||||
type="button"
|
||||
:aria-label="phone.t(app.labelKey)"
|
||||
:aria-label="getPhoneAppLabel(app, phone.t)"
|
||||
:aria-disabled="!app.route"
|
||||
:aria-keyshortcuts="
|
||||
editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
|
||||
"
|
||||
@click="launch"
|
||||
@contextmenu.prevent
|
||||
@keydown="onKeydown"
|
||||
@pointercancel="cancelPointerDrag"
|
||||
@pointerdown="onPointerDown"
|
||||
@lostpointercapture="cancelPointerDrag"
|
||||
@pointerleave="isDragging || clearHold()"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@@ -233,6 +277,7 @@ onBeforeUnmount(() => {
|
||||
app.iconClass,
|
||||
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
|
||||
]"
|
||||
:style="iconStyle"
|
||||
>
|
||||
<span v-if="app.id === 'calendar'" class="app-icon-calendar">
|
||||
<strong>{{ calendarWeekday }}</strong>
|
||||
@@ -262,14 +307,18 @@ onBeforeUnmount(() => {
|
||||
</k-badge>
|
||||
</span>
|
||||
<span v-if="showLabel" class="app-icon-label">{{
|
||||
phone.t(app.labelKey)
|
||||
getPhoneAppLabel(app, phone.t)
|
||||
}}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="editMode && !NON_REMOVABLE_PHONE_APP_IDS.has(app.id)"
|
||||
v-if="editMode && isPhoneAppRemovable(app)"
|
||||
class="app-icon-remove"
|
||||
type="button"
|
||||
:aria-label="phone.t('Home.removeApp', { app: phone.t(app.labelKey) })"
|
||||
:aria-label="
|
||||
phone.t('Home.removeApp', {
|
||||
app: getPhoneAppLabel(app, phone.t),
|
||||
})
|
||||
"
|
||||
@click.stop="emit('remove')"
|
||||
@pointerdown.stop
|
||||
>
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
<script setup lang="ts">
|
||||
import { kBlock, kButton, kPage, kPreloader } from 'konsta/vue'
|
||||
import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { getPhoneApp, isExternalPhoneApp } from '@/config/apps'
|
||||
import { useAppCatalogStore } from '@/stores/app-catalog'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type {
|
||||
CustomAppOpenRequest,
|
||||
ExternalPhoneAppDefinition,
|
||||
LaunchablePhoneAppId,
|
||||
SkyPhoneAppBridgeRequest,
|
||||
SkyPhoneAppBridgeResponse,
|
||||
SkyPhoneAppContextV1,
|
||||
} from '@/types/apps'
|
||||
import {
|
||||
createCustomAppBridgeRequestHandler,
|
||||
getCustomAppFrameBootstrapMessages,
|
||||
getSkyPhoneAppCapabilities,
|
||||
shouldReportCustomAppReady,
|
||||
} from '@/utils/customAppBridge'
|
||||
import {
|
||||
createCustomAppLifecycleReporter,
|
||||
customAppOrientationCoordinator,
|
||||
customAppLifecycleScheduler,
|
||||
getCustomAppSafeArea,
|
||||
} from '@/utils/customAppLifecycle'
|
||||
import {
|
||||
createLbPhoneFrameDocument,
|
||||
createLbPhoneHostSettings,
|
||||
getLbPhoneCallbackResource,
|
||||
usesLbPhoneHostRuntime,
|
||||
} from '@/utils/lbPhoneAppBridge'
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
const props = defineProps<{
|
||||
app: ExternalPhoneAppDefinition
|
||||
}>()
|
||||
|
||||
const PROTOCOL_VERSION = 1
|
||||
|
||||
const catalog = useAppCatalogStore()
|
||||
const notifications = useNotificationsStore()
|
||||
const phone = usePhoneStore()
|
||||
const router = useRouter()
|
||||
const frame = ref<HTMLIFrameElement | null>(null)
|
||||
const frameLoaded = ref(false)
|
||||
const frameUnavailable = ref(false)
|
||||
const skyBridgeReady = ref(false)
|
||||
const lbFrameDocument = ref<string | null>(null)
|
||||
let loadTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let frameDocumentController: AbortController | undefined
|
||||
const lifecycleAppId = props.app.id
|
||||
const initialOpenRequest: CustomAppOpenRequest | undefined =
|
||||
catalog.openRequests[lifecycleAppId]
|
||||
const orientation = customAppOrientationCoordinator.createSession((landscape) =>
|
||||
phone.setCameraLandscape(landscape),
|
||||
)
|
||||
|
||||
const lifecycle = createCustomAppLifecycleReporter({
|
||||
onFailure(event, error) {
|
||||
console.error(
|
||||
`[Custom apps] ${event} lifecycle failed for ${lifecycleAppId}: ${error}`,
|
||||
)
|
||||
},
|
||||
scheduler: customAppLifecycleScheduler,
|
||||
send: (event, data) =>
|
||||
nuiCall('custom-app:lifecycle', {
|
||||
appId: lifecycleAppId,
|
||||
...(data === undefined ? {} : { data }),
|
||||
event,
|
||||
}),
|
||||
})
|
||||
|
||||
const bridgeRequests = createCustomAppBridgeRequestHandler({
|
||||
createNotification(notification) {
|
||||
return notifications.show({
|
||||
...notification,
|
||||
appId: notification.appId as LaunchablePhoneAppId,
|
||||
})
|
||||
},
|
||||
getSourceApp: () => props.app,
|
||||
prepareExternalOpen: (appId, data) => catalog.requestOpen(appId, data),
|
||||
resolveTarget(appId) {
|
||||
const target = getPhoneApp(appId)
|
||||
return target
|
||||
? {
|
||||
external: isExternalPhoneApp(target),
|
||||
id: target.id,
|
||||
route: target.route,
|
||||
}
|
||||
: null
|
||||
},
|
||||
storageCall: (endpoint, payload) => nuiCall(endpoint, payload),
|
||||
})
|
||||
|
||||
const frameUrl = computed(() => {
|
||||
const url = new URL(props.app.ui)
|
||||
url.searchParams.set('skyPhoneAppId', props.app.id)
|
||||
return url.href
|
||||
})
|
||||
const frameOrigin = computed(() => new URL(frameUrl.value).origin)
|
||||
const lbHostRuntime = computed(() => usesLbPhoneHostRuntime(props.app))
|
||||
const frameMountable = computed(
|
||||
() => !lbHostRuntime.value || lbFrameDocument.value !== null,
|
||||
)
|
||||
const frameSource = computed(() =>
|
||||
lbHostRuntime.value ? undefined : frameUrl.value,
|
||||
)
|
||||
const postMessageOrigin = computed(() =>
|
||||
props.app.bundled || lbHostRuntime.value ? '*' : frameOrigin.value,
|
||||
)
|
||||
const sandbox = computed(() =>
|
||||
props.app.bundled || lbHostRuntime.value
|
||||
? 'allow-downloads allow-forms allow-modals allow-scripts'
|
||||
: 'allow-downloads allow-forms allow-modals allow-same-origin allow-scripts',
|
||||
)
|
||||
const frameReady = computed(
|
||||
() =>
|
||||
frameLoaded.value &&
|
||||
(props.app.bridgeMode === 'legacy' || skyBridgeReady.value),
|
||||
)
|
||||
const context = computed<SkyPhoneAppContextV1>(() => ({
|
||||
appId: props.app.id,
|
||||
capabilities: getSkyPhoneAppCapabilities(props.app.capabilities),
|
||||
colorScheme: phone.isDarkMode ? 'dark' : 'light',
|
||||
language: phone.lang,
|
||||
locale: {
|
||||
description: props.app.description,
|
||||
name: props.app.name,
|
||||
},
|
||||
phoneScale: phone.preferences.settings.phoneScale / 100,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
safeArea: getCustomAppSafeArea(props.app.orientation),
|
||||
}))
|
||||
const lbSettings = computed(() =>
|
||||
createLbPhoneHostSettings({
|
||||
deviceName: phone.device?.name ?? '',
|
||||
isDarkMode: phone.isDarkMode,
|
||||
language: phone.lang,
|
||||
preferences: phone.preferences,
|
||||
securityEnabled: phone.security.enabled,
|
||||
}),
|
||||
)
|
||||
|
||||
function postToFrame(payload: unknown): boolean {
|
||||
const target = frame.value?.contentWindow
|
||||
if (!target) return false
|
||||
|
||||
try {
|
||||
target.postMessage(cloneJsonData(payload), postMessageOrigin.value)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`[Custom apps] Could not message ${props.app.id}.`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function queueReadyLifecycle(): void {
|
||||
void lifecycle.report('open', initialOpenRequest?.data)
|
||||
void lifecycle.report('ready')
|
||||
}
|
||||
|
||||
function sendContext(): void {
|
||||
if (!skyBridgeReady.value) return
|
||||
postToFrame({
|
||||
appId: props.app.id,
|
||||
context: context.value,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
type: 'sky-phone-app:context',
|
||||
})
|
||||
}
|
||||
|
||||
function sendLbSettings(): void {
|
||||
if (!lbHostRuntime.value || !frameLoaded.value) return
|
||||
postToFrame({
|
||||
settings: lbSettings.value,
|
||||
type: 'sky-phone:lb-settings',
|
||||
})
|
||||
}
|
||||
|
||||
async function prepareLbFrameDocument(): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
frameDocumentController = controller
|
||||
try {
|
||||
const response = await fetch(frameUrl.value, {
|
||||
credentials: 'omit',
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`)
|
||||
}
|
||||
const html = await response.text()
|
||||
lbFrameDocument.value = createLbPhoneFrameDocument(html, {
|
||||
appName: props.app.id,
|
||||
resourceName: getLbPhoneCallbackResource(props.app),
|
||||
settings: lbSettings.value,
|
||||
ui: props.app.ui,
|
||||
})
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
if (loadTimeout !== undefined) clearTimeout(loadTimeout)
|
||||
loadTimeout = undefined
|
||||
frameUnavailable.value = true
|
||||
console.error(
|
||||
`[Custom apps] Could not prepare LB Phone frame ${props.app.id}.`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function flushOpenRequest(): void {
|
||||
const request = catalog.openRequests[props.app.id]
|
||||
if (!request || !frameLoaded.value) return
|
||||
if (props.app.bridgeMode === 'sky' && !skyBridgeReady.value) return
|
||||
|
||||
if (
|
||||
props.app.bridgeMode === 'legacy' ||
|
||||
postToFrame({
|
||||
appId: props.app.id,
|
||||
data: request.data,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
type: 'sky-phone-app:open',
|
||||
})
|
||||
) {
|
||||
catalog.consumeOpenRequest(props.app.id, request.sequence)
|
||||
}
|
||||
}
|
||||
|
||||
function flushHostMessages(): void {
|
||||
if (!frameLoaded.value) return
|
||||
if (props.app.bridgeMode === 'sky' && !skyBridgeReady.value) return
|
||||
|
||||
let lastDeliveredSequence = 0
|
||||
for (const message of catalog.hostMessages[props.app.id] ?? []) {
|
||||
const delivered = postToFrame(
|
||||
props.app.bridgeMode === 'sky'
|
||||
? {
|
||||
appId: props.app.id,
|
||||
payload: message.payload,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
type: 'sky-phone-app:message',
|
||||
}
|
||||
: message.payload,
|
||||
)
|
||||
if (!delivered) break
|
||||
lastDeliveredSequence = message.sequence
|
||||
}
|
||||
if (lastDeliveredSequence) {
|
||||
catalog.consumeHostMessages(props.app.id, lastDeliveredSequence)
|
||||
}
|
||||
}
|
||||
|
||||
function sendResponse(response: SkyPhoneAppBridgeResponse): void {
|
||||
postToFrame({
|
||||
appId: props.app.id,
|
||||
...response,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
type: 'sky-phone-app:response',
|
||||
})
|
||||
}
|
||||
|
||||
async function handleBridgeRequest(
|
||||
request: SkyPhoneAppBridgeRequest,
|
||||
): Promise<void> {
|
||||
const result = await bridgeRequests.handle(request)
|
||||
if (!result) return
|
||||
|
||||
sendResponse(result.response)
|
||||
if (result.effect?.type === 'close') {
|
||||
void router.push('/')
|
||||
} else if (result.effect?.type === 'open') {
|
||||
void router.push(result.effect.route)
|
||||
}
|
||||
}
|
||||
|
||||
function isTrustedFrameMessage(event: MessageEvent): boolean {
|
||||
if (event.source !== frame.value?.contentWindow) return false
|
||||
if (props.app.bundled || lbHostRuntime.value) {
|
||||
return event.origin === 'null' || event.origin === frameOrigin.value
|
||||
}
|
||||
return event.origin === frameOrigin.value
|
||||
}
|
||||
|
||||
function onFrameMessage(event: MessageEvent): void {
|
||||
if (!isTrustedFrameMessage(event)) return
|
||||
if (
|
||||
!event.data ||
|
||||
typeof event.data !== 'object' ||
|
||||
Array.isArray(event.data)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = event.data as Record<string, unknown>
|
||||
if (
|
||||
message.appId !== props.app.id ||
|
||||
message.protocolVersion !== PROTOCOL_VERSION
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'sky-phone-app:ready') {
|
||||
if (!skyBridgeReady.value) {
|
||||
if (loadTimeout !== undefined) clearTimeout(loadTimeout)
|
||||
loadTimeout = undefined
|
||||
skyBridgeReady.value = true
|
||||
frameUnavailable.value = false
|
||||
sendContext()
|
||||
flushOpenRequest()
|
||||
flushHostMessages()
|
||||
}
|
||||
if (shouldReportCustomAppReady(props.app.bridgeMode, 'bridge-ready')) {
|
||||
queueReadyLifecycle()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'sky-phone-app:request') {
|
||||
void handleBridgeRequest(message as unknown as SkyPhoneAppBridgeRequest)
|
||||
}
|
||||
}
|
||||
|
||||
function onFrameLoad(): void {
|
||||
if (props.app.bridgeMode === 'legacy') {
|
||||
if (loadTimeout !== undefined) clearTimeout(loadTimeout)
|
||||
loadTimeout = undefined
|
||||
}
|
||||
frameLoaded.value = true
|
||||
for (const message of getCustomAppFrameBootstrapMessages(
|
||||
props.app.compatibility,
|
||||
)) {
|
||||
postToFrame(message)
|
||||
}
|
||||
sendLbSettings()
|
||||
if (props.app.bridgeMode === 'legacy' || skyBridgeReady.value) {
|
||||
frameUnavailable.value = false
|
||||
}
|
||||
if (shouldReportCustomAppReady(props.app.bridgeMode, 'frame-load')) {
|
||||
queueReadyLifecycle()
|
||||
}
|
||||
flushOpenRequest()
|
||||
flushHostMessages()
|
||||
}
|
||||
|
||||
function closeApp(): void {
|
||||
void router.push('/')
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
window.addEventListener('message', onFrameMessage)
|
||||
orientation.apply(props.app.orientation)
|
||||
void lifecycle.report('open', initialOpenRequest?.data)
|
||||
if (props.app.bridgeMode === 'legacy' && initialOpenRequest) {
|
||||
catalog.consumeOpenRequest(props.app.id, initialOpenRequest.sequence)
|
||||
}
|
||||
loadTimeout = setTimeout(() => {
|
||||
loadTimeout = undefined
|
||||
frameUnavailable.value = true
|
||||
console.error(
|
||||
`[Custom apps] Frame readiness timed out for ${props.app.id}.`,
|
||||
)
|
||||
}, props.app.readyTimeoutMs)
|
||||
if (lbHostRuntime.value) void prepareLbFrameDocument()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (loadTimeout !== undefined) clearTimeout(loadTimeout)
|
||||
frameDocumentController?.abort()
|
||||
window.removeEventListener('message', onFrameMessage)
|
||||
orientation.release()
|
||||
void lifecycle.report('close')
|
||||
})
|
||||
|
||||
watch(context, sendContext, { deep: true })
|
||||
watch(lbSettings, sendLbSettings, { deep: true })
|
||||
watch(
|
||||
() => props.app.orientation,
|
||||
(nextOrientation) => orientation.apply(nextOrientation),
|
||||
)
|
||||
watch(() => catalog.hostMessages[props.app.id], flushHostMessages, {
|
||||
deep: true,
|
||||
})
|
||||
watch(() => catalog.openRequests[props.app.id], flushOpenRequest, {
|
||||
deep: true,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-page
|
||||
component="main"
|
||||
class="custom-app-page"
|
||||
:class="{
|
||||
'custom-app-page--landscape': app.orientation === 'landscape',
|
||||
}"
|
||||
>
|
||||
<iframe
|
||||
v-if="frameMountable"
|
||||
ref="frame"
|
||||
v-show="frameReady && !frameUnavailable"
|
||||
class="custom-app-frame"
|
||||
:class="{
|
||||
'custom-app-frame--fix-blur': app.compatibility.fixBlur === true,
|
||||
}"
|
||||
:sandbox="sandbox"
|
||||
:src="frameSource"
|
||||
:srcdoc="lbFrameDocument ?? undefined"
|
||||
:title="app.name"
|
||||
referrerpolicy="no-referrer"
|
||||
@load="onFrameLoad"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="!frameReady && !frameUnavailable"
|
||||
class="custom-app-state"
|
||||
role="status"
|
||||
>
|
||||
<k-preloader />
|
||||
<k-block>{{ phone.t('Apps.customApps.loading') }}</k-block>
|
||||
</div>
|
||||
|
||||
<div v-else-if="frameUnavailable" class="custom-app-state" role="alert">
|
||||
<k-block>
|
||||
<strong>{{ phone.t('Apps.customApps.unavailableTitle') }}</strong>
|
||||
<p>{{ phone.t('Apps.customApps.unavailableBody') }}</p>
|
||||
</k-block>
|
||||
<k-button rounded @click="closeApp">
|
||||
{{ phone.t('Apps.customApps.close') }}
|
||||
</k-button>
|
||||
</div>
|
||||
</k-page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-app-page {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: var(--phone-app-background, #000);
|
||||
}
|
||||
|
||||
.custom-app-page--landscape {
|
||||
top: 50%;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
left: 50%;
|
||||
width: 827px;
|
||||
height: 368px;
|
||||
width: 100cqh;
|
||||
height: 100cqw;
|
||||
transform: translate(-50%, -50%) rotate(90deg);
|
||||
}
|
||||
|
||||
.custom-app-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-app-frame--fix-blur {
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.custom-app-state {
|
||||
position: absolute;
|
||||
inset: 44px 20px 25px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.custom-app-state :deep(.k-block) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-app-state strong {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.custom-app-state p {
|
||||
margin: 0;
|
||||
color: rgb(142 142 147);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
</style>
|
||||
@@ -35,7 +35,10 @@ function closeFromOutside(event: PointerEvent): void {
|
||||
}
|
||||
|
||||
function closeFromEscape(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') opened.value = false
|
||||
if (event.key !== 'Escape' || !opened.value) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
opened.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Camera, Pause, Play } from 'lucide-vue-next'
|
||||
import { Camera, Pause, Play, TriangleAlert } from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { SmsMessageType } from '@/types/messages'
|
||||
|
||||
type MessageAttachment = {
|
||||
@@ -11,7 +12,9 @@ type MessageAttachment = {
|
||||
}
|
||||
|
||||
const props = defineProps<{ message: MessageAttachment }>()
|
||||
const phone = usePhoneStore()
|
||||
const playing = ref(false)
|
||||
const playbackFailed = ref(false)
|
||||
const video = ref<HTMLVideoElement>()
|
||||
|
||||
const imageStyles: Record<string, string> = {
|
||||
@@ -55,9 +58,18 @@ async function toggleVideo(): Promise<void> {
|
||||
playing.value = !playing.value
|
||||
return
|
||||
}
|
||||
if (video.value.paused) await video.value.play()
|
||||
else video.value.pause()
|
||||
playing.value = !video.value.paused
|
||||
if (!video.value.paused) {
|
||||
video.value.pause()
|
||||
return
|
||||
}
|
||||
playbackFailed.value = false
|
||||
try {
|
||||
await video.value.play()
|
||||
} catch (error) {
|
||||
playing.value = false
|
||||
playbackFailed.value = true
|
||||
console.error('[Messages] Could not play the attached video.', error)
|
||||
}
|
||||
}
|
||||
|
||||
function durationLabel(milliseconds: number | null): string {
|
||||
@@ -85,8 +97,13 @@ function durationLabel(milliseconds: number | null): string {
|
||||
v-else-if="message.message_type === 'video'"
|
||||
type="button"
|
||||
class="messages-attachment messages-attachment--video"
|
||||
:class="{ playing }"
|
||||
:class="{ playing, 'messages-attachment--failed': playbackFailed }"
|
||||
:style="{ background }"
|
||||
:aria-label="
|
||||
playbackFailed
|
||||
? phone.t('Apps.photos.errors.unsupported')
|
||||
: phone.t('Apps.photos.videoAlt')
|
||||
"
|
||||
@click="toggleVideo"
|
||||
>
|
||||
<video
|
||||
@@ -95,15 +112,25 @@ function durationLabel(milliseconds: number | null): string {
|
||||
:src="mediaUrl"
|
||||
playsinline
|
||||
preload="metadata"
|
||||
@play="playing = true"
|
||||
@pause="playing = false"
|
||||
@ended="playing = false"
|
||||
@error="playbackFailed = true"
|
||||
/>
|
||||
<span
|
||||
><Pause v-if="playing" :size="22" fill="currentColor" /><Play
|
||||
><TriangleAlert v-if="playbackFailed" :size="22" /><Pause
|
||||
v-else-if="playing"
|
||||
:size="22"
|
||||
fill="currentColor"
|
||||
/><Play
|
||||
v-else
|
||||
:size="22"
|
||||
fill="currentColor"
|
||||
/></span>
|
||||
<small>{{ durationLabel(message.media_duration_ms) }}</small>
|
||||
<small v-if="playbackFailed">{{
|
||||
phone.t('Apps.photos.errors.unsupported')
|
||||
}}</small>
|
||||
<small v-else>{{ durationLabel(message.media_duration_ms) }}</small>
|
||||
</button>
|
||||
<div v-else class="messages-attachment messages-attachment--gif">
|
||||
<img
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { kButton } from 'konsta/vue'
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
MessageCircle,
|
||||
UserRound,
|
||||
UserPlus,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { SmsSharedContact } from '@/types/messages'
|
||||
|
||||
const props = defineProps<{
|
||||
addLabel: string
|
||||
contact: SmsSharedContact
|
||||
messageLabel: string
|
||||
saved: boolean
|
||||
savedLabel: string
|
||||
}>()
|
||||
const emit = defineEmits<{ message: []; save: [] }>()
|
||||
const imageFailed = ref(false)
|
||||
watch(
|
||||
() => props.contact.avatar_url,
|
||||
() => {
|
||||
imageFailed.value = false
|
||||
},
|
||||
)
|
||||
const displayName = computed(
|
||||
() => props.contact.name.trim() || props.contact.phone_number,
|
||||
)
|
||||
const showNumber = computed(() => Boolean(props.contact.name.trim()))
|
||||
const initials = computed(() =>
|
||||
displayName.value
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join(''),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-contact-card">
|
||||
<div class="message-contact-card__identity">
|
||||
<span class="message-contact-card__avatar">
|
||||
<img
|
||||
v-if="contact.avatar_url && !imageFailed"
|
||||
:src="contact.avatar_url"
|
||||
alt=""
|
||||
@error="imageFailed = true"
|
||||
/>
|
||||
<b v-else-if="initials">{{ initials }}</b>
|
||||
<UserRound v-else :size="28" />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{{ displayName }}</strong>
|
||||
<small v-if="contact.organization">{{ contact.organization }}</small>
|
||||
<small v-if="showNumber">({{ contact.phone_number }})</small>
|
||||
</div>
|
||||
<ChevronRight :size="20" class="message-contact-card__chevron" />
|
||||
</div>
|
||||
<div class="message-contact-card__actions">
|
||||
<k-button rounded tonal @click.stop="emit('message')">
|
||||
<MessageCircle :size="17" />
|
||||
{{ messageLabel }}
|
||||
</k-button>
|
||||
<k-button
|
||||
rounded
|
||||
tonal
|
||||
:disabled="saved"
|
||||
@click.stop="emit('save')"
|
||||
>
|
||||
<Check v-if="saved" :size="17" />
|
||||
<UserPlus v-else :size="17" />
|
||||
{{ saved ? savedLabel : addLabel }}
|
||||
</k-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-contact-card {
|
||||
width: min(258px, 100%);
|
||||
width: min(258px, 72cqw);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 48%);
|
||||
border-radius: 21px;
|
||||
color: #151517;
|
||||
background: linear-gradient(155deg, rgb(255 255 255 / 96%), #edf5ff);
|
||||
box-shadow: 0 8px 24px rgb(22 63 112 / 14%);
|
||||
}
|
||||
|
||||
.message-contact-card__identity {
|
||||
display: grid;
|
||||
grid-template-columns: 58px minmax(0, 1fr) 20px;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
min-height: 82px;
|
||||
padding: 13px 12px;
|
||||
}
|
||||
|
||||
.message-contact-card__avatar {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
background: linear-gradient(145deg, #64d2ff, #0a84ff);
|
||||
box-shadow: 0 4px 14px rgb(10 132 255 / 24%);
|
||||
}
|
||||
|
||||
.message-contact-card__avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.message-contact-card__identity b {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.message-contact-card__identity div,
|
||||
.message-contact-card__identity strong,
|
||||
.message-contact-card__identity small {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-contact-card__identity strong,
|
||||
.message-contact-card__identity small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-contact-card__identity strong {
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.message-contact-card__identity small {
|
||||
margin-top: 3px;
|
||||
color: #6e6e73;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.message-contact-card__chevron {
|
||||
color: #8e8e93;
|
||||
}
|
||||
|
||||
.message-contact-card__actions {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid rgb(60 60 67 / 12%);
|
||||
background: rgb(255 255 255 / 58%);
|
||||
}
|
||||
|
||||
.message-contact-card__actions :deep(.button) {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
min-height: 36px;
|
||||
justify-content: flex-start;
|
||||
padding-inline: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:global(.phone-app.dark) .message-contact-card {
|
||||
color: #f7f7f7;
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
background: linear-gradient(155deg, #34363b, #242a33);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 24%);
|
||||
}
|
||||
|
||||
:global(.phone-app.dark) .message-contact-card__identity small {
|
||||
color: #aeaeb2;
|
||||
}
|
||||
|
||||
:global(.phone-app.dark) .message-contact-card__actions {
|
||||
border-top-color: rgb(255 255 255 / 10%);
|
||||
background: rgb(0 0 0 / 10%);
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
open: [notification: PhoneNotification]
|
||||
}>()
|
||||
const phone = usePhoneStore()
|
||||
const device = computed(() => props.notification.device!)
|
||||
@@ -69,6 +70,7 @@ const wrapperStyle = computed<CSSProperties>(() => ({ zoom: props.zoom }))
|
||||
<PhoneNotifications
|
||||
:notification="notification"
|
||||
@close="emit('close')"
|
||||
@open="emit('open', $event)"
|
||||
/>
|
||||
</k-app>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
|
||||
type PayphoneState =
|
||||
| 'idle'
|
||||
@@ -214,6 +215,7 @@ async function close(): Promise<void> {
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent): void {
|
||||
if (!isTrustedRootMessageSource(event.source, window)) return
|
||||
if (event.data?.type === 'payphone:open' && event.data.data) {
|
||||
const payload = event.data.data as PayphoneOpenPayload
|
||||
currency.value = payload.currency
|
||||
@@ -237,6 +239,7 @@ function onKeydown(event: KeyboardEvent): void {
|
||||
if (!visible.value) return
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
void close()
|
||||
return
|
||||
}
|
||||
@@ -254,7 +257,7 @@ function onKeydown(event: KeyboardEvent): void {
|
||||
onMounted(() => {
|
||||
prepareButtonSounds()
|
||||
window.addEventListener('message', onMessage)
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
window.addEventListener('keydown', onKeydown, true)
|
||||
ticker = window.setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 250)
|
||||
@@ -262,7 +265,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', onMessage)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
window.removeEventListener('keydown', onKeydown, true)
|
||||
if (ticker !== undefined) window.clearInterval(ticker)
|
||||
for (const sound of buttonSounds) {
|
||||
sound.pause()
|
||||
@@ -397,6 +400,7 @@ onBeforeUnmount(() => {
|
||||
rgb(0 0 0 / 84%) 72%
|
||||
);
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Pause,
|
||||
Plane,
|
||||
Play,
|
||||
RadioTower,
|
||||
Signal,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
@@ -30,6 +31,8 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { useMusicStore } from '@/stores/music'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import type { EasySharePayload } from '@/types/easyshare'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
const props = defineProps<{ opened: boolean }>()
|
||||
@@ -44,6 +47,7 @@ type ConnectivityPreference =
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const music = useMusicStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const router = useRouter()
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
const brightness = ref(phone.preferences.settings.screenBrightness)
|
||||
@@ -56,6 +60,7 @@ const volume = ref(
|
||||
)
|
||||
const flashlightActive = ref(false)
|
||||
const flashlightPending = ref(false)
|
||||
const easySharePending = ref(false)
|
||||
const previousAlertVolume = ref(volume.value || 75)
|
||||
|
||||
const inactiveGlassColors = {
|
||||
@@ -202,6 +207,23 @@ async function toggleFlashlight(): Promise<void> {
|
||||
flashlightPending.value = false
|
||||
}
|
||||
|
||||
async function shareOwnContact(): Promise<void> {
|
||||
if (easySharePending.value) return
|
||||
easySharePending.value = true
|
||||
const response = await nuiCall<EasySharePayload>('easyshare:own-contact')
|
||||
easySharePending.value = false
|
||||
if (!response.success || !response.data) {
|
||||
console.error(
|
||||
'[ControlCenter] Could not load own EasyShare contact.',
|
||||
response.error,
|
||||
)
|
||||
return
|
||||
}
|
||||
emit('close')
|
||||
easyShare.open(response.data)
|
||||
await easyShare.showNearby()
|
||||
}
|
||||
|
||||
function openApp(path: string): void {
|
||||
emit('close')
|
||||
void router.push(path)
|
||||
@@ -360,9 +382,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="control-center__middle-grid">
|
||||
<div
|
||||
class="control-center__round-control control-center__round-control--wide"
|
||||
>
|
||||
<div class="control-center__round-control">
|
||||
<k-glass
|
||||
component="button"
|
||||
type="button"
|
||||
@@ -385,6 +405,20 @@ onBeforeUnmount(() => {
|
||||
</k-glass>
|
||||
</div>
|
||||
|
||||
<div class="control-center__round-control">
|
||||
<k-glass
|
||||
component="button"
|
||||
type="button"
|
||||
class="control-center__round-button"
|
||||
:colors="inactiveGlassColors"
|
||||
:disabled="easySharePending || !phone.device?.sim"
|
||||
:aria-label="phone.t('ControlCenter.easyShareContact')"
|
||||
@click="shareOwnContact"
|
||||
>
|
||||
<RadioTower aria-hidden="true" />
|
||||
</k-glass>
|
||||
</div>
|
||||
|
||||
<k-glass
|
||||
class="control-center__slider control-center__slider--brightness"
|
||||
:colors="inactiveGlassColors"
|
||||
@@ -712,10 +746,6 @@ onBeforeUnmount(() => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.control-center__round-control--wide {
|
||||
grid-column: 1 / span 2;
|
||||
}
|
||||
|
||||
.control-center__round-control > span,
|
||||
.control-center__quick-action > span {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { kFab, kGlass } from 'konsta/vue'
|
||||
import { Camera, Flashlight, LockKeyhole, X } from 'lucide-vue-next'
|
||||
import { Camera, Flashlight, LockKeyhole, Trash2 } from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue'
|
||||
import { getPhoneApp } from '@/config/apps'
|
||||
import type { PhoneNotification } from '@/stores/notifications'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import {
|
||||
clampNotificationSwipeOffset,
|
||||
NOTIFICATION_SWIPE_ACTION_WIDTH,
|
||||
resolveNotificationSwipeAxis,
|
||||
shouldRevealNotificationAction,
|
||||
type NotificationSwipeAxis,
|
||||
} from '@/utils/notificationSwipe'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import type { PhonePreferencesV1 } from '@/utils/preferences'
|
||||
|
||||
@@ -19,6 +26,7 @@ const emit = defineEmits<{
|
||||
camera: []
|
||||
clearNotifications: []
|
||||
dismissNotification: [id: string]
|
||||
openNotification: [notification: PhoneNotification]
|
||||
unlock: []
|
||||
}>()
|
||||
|
||||
@@ -28,6 +36,9 @@ const now = ref(new Date())
|
||||
const dragOffset = ref(0)
|
||||
const dragging = ref(false)
|
||||
const flashlightActive = ref(false)
|
||||
const revealedNotificationId = ref<string | null>(null)
|
||||
const swipingNotificationId = ref<string | null>(null)
|
||||
const notificationSwipeOffset = ref(0)
|
||||
const shortcutColors = {
|
||||
bgIos: 'bg-ios-light-glass dark:bg-ios-dark-glass',
|
||||
activeBgIos: 'active:bg-white/90 dark:active:bg-white/20',
|
||||
@@ -36,6 +47,12 @@ const shortcutColors = {
|
||||
let pointerStart = 0
|
||||
let pointerStartedAt = 0
|
||||
let clockTicker: number | undefined
|
||||
let notificationPointerId: number | null = null
|
||||
let notificationPointerStartX = 0
|
||||
let notificationPointerStartY = 0
|
||||
let notificationPointerStartedAt = 0
|
||||
let notificationSwipeAxis: NotificationSwipeAxis | null = null
|
||||
let suppressNotificationClickUntil = 0
|
||||
|
||||
const date = computed(() =>
|
||||
new Intl.DateTimeFormat(phone.lang, {
|
||||
@@ -72,9 +89,7 @@ const flashlightShortcutColors = computed(() =>
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
if (props.preview) return
|
||||
if (
|
||||
(event.target as HTMLElement).closest(
|
||||
'button, .lock-screen__notifications',
|
||||
)
|
||||
(event.target as HTMLElement).closest('button, .lock-screen__notifications')
|
||||
)
|
||||
return
|
||||
pointerStart = event.clientY
|
||||
@@ -83,6 +98,131 @@ function onPointerDown(event: PointerEvent): void {
|
||||
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function openNotification(notification: PhoneNotification): void {
|
||||
if (performance.now() <= suppressNotificationClickUntil) return
|
||||
if (revealedNotificationId.value === notification.id) {
|
||||
closeNotificationAction()
|
||||
return
|
||||
}
|
||||
if (notification.route) emit('openNotification', notification)
|
||||
}
|
||||
|
||||
function notificationOffset(notificationId: string): number {
|
||||
if (swipingNotificationId.value === notificationId)
|
||||
return notificationSwipeOffset.value
|
||||
return revealedNotificationId.value === notificationId
|
||||
? -NOTIFICATION_SWIPE_ACTION_WIDTH
|
||||
: 0
|
||||
}
|
||||
|
||||
function notificationStyle(notificationId: string): Record<string, string> {
|
||||
return {
|
||||
'--notification-swipe': `${notificationOffset(notificationId)}px`,
|
||||
}
|
||||
}
|
||||
|
||||
function closeNotificationAction(): void {
|
||||
revealedNotificationId.value = null
|
||||
swipingNotificationId.value = null
|
||||
notificationSwipeOffset.value = 0
|
||||
}
|
||||
|
||||
function beginNotificationSwipe(
|
||||
notificationId: string,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
if (props.preview || event.button !== 0) return
|
||||
|
||||
if (
|
||||
revealedNotificationId.value &&
|
||||
revealedNotificationId.value !== notificationId
|
||||
) {
|
||||
revealedNotificationId.value = null
|
||||
}
|
||||
|
||||
notificationPointerId = event.pointerId
|
||||
notificationPointerStartX = event.clientX
|
||||
notificationPointerStartY = event.clientY
|
||||
notificationPointerStartedAt = performance.now()
|
||||
notificationSwipeAxis = null
|
||||
swipingNotificationId.value = notificationId
|
||||
notificationSwipeOffset.value =
|
||||
revealedNotificationId.value === notificationId
|
||||
? -NOTIFICATION_SWIPE_ACTION_WIDTH
|
||||
: 0
|
||||
}
|
||||
|
||||
function moveNotificationSwipe(
|
||||
notificationId: string,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
if (
|
||||
swipingNotificationId.value !== notificationId ||
|
||||
notificationPointerId !== event.pointerId
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - notificationPointerStartX
|
||||
const deltaY = event.clientY - notificationPointerStartY
|
||||
notificationSwipeAxis ??= resolveNotificationSwipeAxis(deltaX, deltaY)
|
||||
|
||||
if (notificationSwipeAxis === 'vertical') {
|
||||
swipingNotificationId.value = null
|
||||
notificationSwipeOffset.value = 0
|
||||
return
|
||||
}
|
||||
if (notificationSwipeAxis !== 'horizontal') return
|
||||
|
||||
const target = event.currentTarget as HTMLElement
|
||||
if (!target.hasPointerCapture(event.pointerId))
|
||||
target.setPointerCapture(event.pointerId)
|
||||
event.preventDefault()
|
||||
|
||||
const startingOffset =
|
||||
revealedNotificationId.value === notificationId
|
||||
? -NOTIFICATION_SWIPE_ACTION_WIDTH
|
||||
: 0
|
||||
notificationSwipeOffset.value = clampNotificationSwipeOffset(
|
||||
startingOffset + deltaX,
|
||||
)
|
||||
}
|
||||
|
||||
function finishNotificationSwipe(
|
||||
notificationId: string,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
if (
|
||||
swipingNotificationId.value !== notificationId ||
|
||||
notificationPointerId !== event.pointerId
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const elapsed = Math.max(1, performance.now() - notificationPointerStartedAt)
|
||||
const velocityX = (event.clientX - notificationPointerStartX) / elapsed
|
||||
const wasHorizontal = notificationSwipeAxis === 'horizontal'
|
||||
if (wasHorizontal) {
|
||||
suppressNotificationClickUntil = performance.now() + 120
|
||||
revealedNotificationId.value = shouldRevealNotificationAction(
|
||||
notificationSwipeOffset.value,
|
||||
velocityX,
|
||||
)
|
||||
? notificationId
|
||||
: null
|
||||
}
|
||||
|
||||
swipingNotificationId.value = null
|
||||
notificationSwipeOffset.value = 0
|
||||
notificationPointerId = null
|
||||
notificationSwipeAxis = null
|
||||
}
|
||||
|
||||
function dismissNotification(notificationId: string): void {
|
||||
closeNotificationAction()
|
||||
emit('dismissNotification', notificationId)
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (!dragging.value) return
|
||||
dragOffset.value = Math.min(0, event.clientY - pointerStart)
|
||||
@@ -190,35 +330,64 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
{{ phone.t('Notifications.clearAll') }}
|
||||
</button>
|
||||
<k-glass
|
||||
<div
|
||||
v-for="notification in props.notifications"
|
||||
:key="notification.id"
|
||||
class="lock-screen__notification"
|
||||
:highlight="false"
|
||||
class="lock-screen__notification-row"
|
||||
:class="{
|
||||
'is-revealed': revealedNotificationId === notification.id,
|
||||
'is-swiping': swipingNotificationId === notification.id,
|
||||
'is-action-visible': notificationOffset(notification.id) < -0.5,
|
||||
}"
|
||||
:style="notificationStyle(notification.id)"
|
||||
>
|
||||
<img
|
||||
v-if="getPhoneApp(notification.appId)?.iconImage"
|
||||
:src="getPhoneApp(notification.appId)?.iconImage"
|
||||
alt=""
|
||||
class="lock-screen__notification-icon"
|
||||
/>
|
||||
<div class="lock-screen__notification-copy">
|
||||
<div class="lock-screen__notification-heading">
|
||||
<strong>{{ notification.title }}</strong>
|
||||
<span>{{ phone.t('Notifications.now') }}</span>
|
||||
</div>
|
||||
<small v-if="notification.subtitle">{{ notification.subtitle }}</small>
|
||||
<p>{{ notification.text }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="lock-screen__notification-close"
|
||||
class="lock-screen__notification-clear"
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.close')"
|
||||
@click.stop="emit('dismissNotification', notification.id)"
|
||||
:tabindex="revealedNotificationId === notification.id ? 0 : -1"
|
||||
:aria-hidden="revealedNotificationId !== notification.id"
|
||||
:aria-label="phone.t('Notifications.clear')"
|
||||
@click.stop="dismissNotification(notification.id)"
|
||||
>
|
||||
<X :size="14" aria-hidden="true" />
|
||||
<Trash2 :size="18" :stroke-width="1.8" aria-hidden="true" />
|
||||
<span>{{ phone.t('Notifications.clear') }}</span>
|
||||
</button>
|
||||
</k-glass>
|
||||
<k-glass
|
||||
class="lock-screen__notification"
|
||||
:class="{ 'is-actionable': !!notification.route }"
|
||||
:highlight="false"
|
||||
:role="notification.route ? 'button' : undefined"
|
||||
:tabindex="notification.route ? 0 : undefined"
|
||||
:aria-label="
|
||||
notification.route ? phone.t('Notifications.open') : undefined
|
||||
"
|
||||
@click="openNotification(notification)"
|
||||
@keydown.enter.prevent="openNotification(notification)"
|
||||
@keydown.space.prevent="openNotification(notification)"
|
||||
@keydown.delete.prevent="dismissNotification(notification.id)"
|
||||
@pointerdown.stop="beginNotificationSwipe(notification.id, $event)"
|
||||
@pointermove.stop="moveNotificationSwipe(notification.id, $event)"
|
||||
@pointerup.stop="finishNotificationSwipe(notification.id, $event)"
|
||||
@pointercancel.stop="finishNotificationSwipe(notification.id, $event)"
|
||||
>
|
||||
<img
|
||||
v-if="getPhoneApp(notification.appId)?.iconImage"
|
||||
:src="getPhoneApp(notification.appId)?.iconImage"
|
||||
alt=""
|
||||
class="lock-screen__notification-icon"
|
||||
/>
|
||||
<div class="lock-screen__notification-copy">
|
||||
<div class="lock-screen__notification-heading">
|
||||
<strong>{{ notification.title }}</strong>
|
||||
<span>{{ phone.t('Notifications.now') }}</span>
|
||||
</div>
|
||||
<small v-if="notification.subtitle">{{
|
||||
notification.subtitle
|
||||
}}</small>
|
||||
<p>{{ notification.text }}</p>
|
||||
</div>
|
||||
</k-glass>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="!preview" class="lock-screen__footer">
|
||||
|
||||
@@ -4,13 +4,20 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import type { UploadReady } from '@/types/media'
|
||||
import { createGameView, type GameView } from '@/utils/gameView'
|
||||
import {
|
||||
bindMediaRecorderError,
|
||||
setBoundedMapEntry,
|
||||
stopMediaRecorder,
|
||||
} from '@/utils/mediaRecorder'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
|
||||
type RecordingChunk = { blob: Blob; durationMs: number }
|
||||
type PendingVideo = { blob: Blob; fileName: string }
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const pendingVideos = new Map<string, PendingVideo>()
|
||||
const maxPendingVideos = 3
|
||||
const captureFps = 30
|
||||
const maxCaptureEdge = 720
|
||||
const portraitAspect = 3 / 4
|
||||
@@ -22,12 +29,16 @@ let gameView: GameView | null = null
|
||||
let renderFrameId: number | undefined
|
||||
let lastRenderAt = 0
|
||||
let recorder: MediaRecorder | null = null
|
||||
let recordingStarting = false
|
||||
let stream: MediaStream | null = null
|
||||
let microphoneStream: MediaStream | null = null
|
||||
let chunks: RecordingChunk[] = []
|
||||
let lastChunkAt = 0
|
||||
let lastChunkTimecode: number | null = null
|
||||
let recordingStartedAt = 0
|
||||
let recordingGeneration = 0
|
||||
let flushTimer: number | undefined
|
||||
let removeRecorderErrorListener: (() => void) | null = null
|
||||
|
||||
function captureDimensions(): { height: number; width: number } {
|
||||
return landscape
|
||||
@@ -53,7 +64,11 @@ function ensureGameView(): GameView {
|
||||
if (gameView && !gameView.isLost()) return gameView
|
||||
gameView?.dispose()
|
||||
const dimensions = captureDimensions()
|
||||
gameView = createGameView(canvasRef.value)
|
||||
gameView = createGameView(canvasRef.value, {
|
||||
onContextRestored: () => {
|
||||
if (recorder?.state === 'recording') startRenderLoop()
|
||||
},
|
||||
})
|
||||
gameView.resize(
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
@@ -92,6 +107,7 @@ function resetRecording(): void {
|
||||
chunks = []
|
||||
lastChunkAt = 0
|
||||
lastChunkTimecode = null
|
||||
recordingStartedAt = 0
|
||||
}
|
||||
|
||||
function stopTracks(): void {
|
||||
@@ -102,8 +118,22 @@ function stopTracks(): void {
|
||||
}
|
||||
|
||||
function cleanupRecording(): void {
|
||||
if (recorder && recorder.state !== 'inactive') recorder.stop()
|
||||
recordingGeneration += 1
|
||||
recordingStarting = false
|
||||
const activeRecorder = recorder
|
||||
recorder = null
|
||||
removeRecorderErrorListener?.()
|
||||
removeRecorderErrorListener = null
|
||||
if (activeRecorder) {
|
||||
activeRecorder.ondataavailable = null
|
||||
if (activeRecorder.state !== 'inactive') {
|
||||
try {
|
||||
activeRecorder.stop()
|
||||
} catch (error) {
|
||||
console.error('[Camera] Could not stop the failed media recorder.', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
stopTracks()
|
||||
if (flushTimer !== undefined) window.clearInterval(flushTimer)
|
||||
flushTimer = undefined
|
||||
@@ -113,7 +143,7 @@ function cleanupRecording(): void {
|
||||
}
|
||||
|
||||
async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
if (recorder) return
|
||||
if (recorder || recordingStarting) return
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
window.postMessage(
|
||||
{
|
||||
@@ -128,7 +158,22 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
if (Number.isFinite(configuredBitrate) && configuredBitrate > 0) {
|
||||
bitrateBps = Math.round(configuredBitrate * 1000)
|
||||
}
|
||||
startRenderLoop()
|
||||
try {
|
||||
startRenderLoop()
|
||||
} catch (error) {
|
||||
console.error('[Camera] Could not start game-view recording.', error)
|
||||
cleanupRecording()
|
||||
window.postMessage(
|
||||
{
|
||||
data: { error: 'capture_failed', success: false },
|
||||
type: 'camera:recordError',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
return
|
||||
}
|
||||
recordingStarting = true
|
||||
const generation = ++recordingGeneration
|
||||
resetRecording()
|
||||
const videoStream = canvasRef.value?.captureStream(captureFps) ?? null
|
||||
if (!videoStream) {
|
||||
@@ -137,15 +182,23 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
}
|
||||
if (data.microphoneEnabled === true) {
|
||||
try {
|
||||
microphoneStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
autoGainControl: true,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
})
|
||||
const acquiredMicrophoneStream =
|
||||
await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
autoGainControl: true,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
})
|
||||
if (generation !== recordingGeneration) {
|
||||
acquiredMicrophoneStream.getTracks().forEach((track) => track.stop())
|
||||
videoStream.getTracks().forEach((track) => track.stop())
|
||||
return
|
||||
}
|
||||
microphoneStream = acquiredMicrophoneStream
|
||||
} catch {
|
||||
videoStream.getTracks().forEach((track) => track.stop())
|
||||
if (generation !== recordingGeneration) return
|
||||
cleanupRecording()
|
||||
window.postMessage(
|
||||
{
|
||||
@@ -161,16 +214,51 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
...videoStream.getVideoTracks(),
|
||||
...(microphoneStream?.getAudioTracks() ?? []),
|
||||
])
|
||||
if (generation !== recordingGeneration) {
|
||||
stopTracks()
|
||||
stopRenderLoop()
|
||||
return
|
||||
}
|
||||
const mimeType = [
|
||||
'video/webm;codecs=vp8,opus',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm',
|
||||
].find((type) => MediaRecorder.isTypeSupported(type))
|
||||
recorder = new MediaRecorder(stream, {
|
||||
...(mimeType ? { mimeType } : {}),
|
||||
audioBitsPerSecond: 128_000,
|
||||
videoBitsPerSecond: bitrateBps,
|
||||
})
|
||||
try {
|
||||
recorder = new MediaRecorder(stream, {
|
||||
...(mimeType ? { mimeType } : {}),
|
||||
audioBitsPerSecond: 128_000,
|
||||
videoBitsPerSecond: bitrateBps,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Camera] Could not create the media recorder.', error)
|
||||
cleanupRecording()
|
||||
window.postMessage(
|
||||
{
|
||||
data: { error: 'unsupported', success: false },
|
||||
type: 'camera:recordError',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
return
|
||||
}
|
||||
const activeRecorder = recorder
|
||||
removeRecorderErrorListener = bindMediaRecorderError(
|
||||
activeRecorder,
|
||||
() =>
|
||||
generation === recordingGeneration && recorder === activeRecorder,
|
||||
(event) => {
|
||||
console.error('[Camera] Media recorder failed while recording.', event)
|
||||
cleanupRecording()
|
||||
window.postMessage(
|
||||
{
|
||||
data: { error: 'capture_failed', success: false },
|
||||
type: 'camera:recordError',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
},
|
||||
)
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (!event.data.size) return
|
||||
const now = Date.now()
|
||||
@@ -185,7 +273,22 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
lastChunkAt = now
|
||||
chunks.push({ blob: event.data, durationMs })
|
||||
}
|
||||
recorder.start()
|
||||
try {
|
||||
recorder.start()
|
||||
} catch (error) {
|
||||
console.error('[Camera] Could not start the media recorder.', error)
|
||||
cleanupRecording()
|
||||
window.postMessage(
|
||||
{
|
||||
data: { error: 'capture_failed', success: false },
|
||||
type: 'camera:recordError',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
return
|
||||
}
|
||||
recordingStartedAt = Date.now()
|
||||
recordingStarting = false
|
||||
flushTimer = window.setInterval(() => {
|
||||
if (recorder?.state === 'recording') recorder.requestData()
|
||||
}, 1000)
|
||||
@@ -195,37 +298,78 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
||||
const correlationId = String(data.correlationId ?? '')
|
||||
if (!recorder || recorder.state === 'inactive' || !correlationId) return
|
||||
const generation = recordingGeneration
|
||||
const activeRecorder = recorder
|
||||
postRecordState(false, true)
|
||||
recorder.requestData()
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 120))
|
||||
recorder.stop()
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 120))
|
||||
if (flushTimer !== undefined) window.clearInterval(flushTimer)
|
||||
flushTimer = undefined
|
||||
stopTracks()
|
||||
recorder = null
|
||||
stopRenderLoop()
|
||||
const durationMs = chunks.reduce((sum, entry) => sum + entry.durationMs, 0)
|
||||
let blob = new Blob(
|
||||
chunks.map((entry) => entry.blob),
|
||||
{ type: 'video/webm' },
|
||||
)
|
||||
blob = await (
|
||||
fixWebmDuration as unknown as (
|
||||
source: Blob,
|
||||
duration: number,
|
||||
options: { logger: boolean },
|
||||
) => Promise<Blob>
|
||||
)(blob, durationMs, { logger: false })
|
||||
resetRecording()
|
||||
pendingVideos.set(correlationId, {
|
||||
blob,
|
||||
fileName: `camera-${correlationId}.webm`,
|
||||
})
|
||||
await nuiCall('media:requestUpload', {
|
||||
correlationId,
|
||||
mediaType: 'video',
|
||||
})
|
||||
const stopErrorListener = removeRecorderErrorListener
|
||||
stopErrorListener?.()
|
||||
if (removeRecorderErrorListener === stopErrorListener) {
|
||||
removeRecorderErrorListener = null
|
||||
}
|
||||
try {
|
||||
await stopMediaRecorder(activeRecorder)
|
||||
if (generation !== recordingGeneration) return
|
||||
const durationMs = Math.max(
|
||||
chunks.reduce((sum, entry) => sum + entry.durationMs, 0),
|
||||
recordingStartedAt ? Date.now() - recordingStartedAt : 0,
|
||||
)
|
||||
let blob = new Blob(
|
||||
chunks.map((entry) => entry.blob),
|
||||
{ type: 'video/webm' },
|
||||
)
|
||||
blob = await (
|
||||
fixWebmDuration as unknown as (
|
||||
source: Blob,
|
||||
duration: number,
|
||||
options: { logger: boolean },
|
||||
) => Promise<Blob>
|
||||
)(blob, durationMs, { logger: false })
|
||||
if (generation !== recordingGeneration) return
|
||||
setBoundedMapEntry(
|
||||
pendingVideos,
|
||||
correlationId,
|
||||
{ blob, fileName: `camera-${correlationId}.webm` },
|
||||
maxPendingVideos,
|
||||
)
|
||||
const response = await nuiCall('media:requestUpload', {
|
||||
correlationId,
|
||||
mediaType: 'video',
|
||||
})
|
||||
if (generation !== recordingGeneration) return
|
||||
if (!response.success) {
|
||||
pendingVideos.delete(correlationId)
|
||||
window.postMessage(
|
||||
{
|
||||
data: { correlationId, error: 'request_failed', success: false },
|
||||
type: 'media:uploadResult',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === recordingGeneration) {
|
||||
console.error('[Camera] Could not finalize the video recording.', error)
|
||||
pendingVideos.delete(correlationId)
|
||||
window.postMessage(
|
||||
{
|
||||
data: { correlationId, error: 'capture_failed', success: false },
|
||||
type: 'media:uploadResult',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (generation === recordingGeneration || recorder === activeRecorder) {
|
||||
if (recorder === activeRecorder) {
|
||||
recorder = null
|
||||
}
|
||||
stopTracks()
|
||||
stopRenderLoop()
|
||||
resetRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function renderFrames(view: GameView, count: number): Promise<void> {
|
||||
@@ -341,6 +485,7 @@ async function uploadReady(ready: UploadReady): Promise<void> {
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent): void {
|
||||
if (!isTrustedRootMessageSource(event.source, window)) return
|
||||
const message = event.data as {
|
||||
data?: Record<string, unknown>
|
||||
type?: string
|
||||
@@ -379,6 +524,9 @@ function onMessage(event: MessageEvent): void {
|
||||
}
|
||||
} else if (message.type === 'media:uploadReady') {
|
||||
void uploadReady(message.data as UploadReady)
|
||||
} else if (message.type === 'media:uploadResult') {
|
||||
const correlationId = String(message.data?.correlationId ?? '')
|
||||
if (correlationId) pendingVideos.delete(correlationId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
open: [notification: PhoneNotification]
|
||||
}>()
|
||||
const phone = usePhoneStore()
|
||||
const icon = computed(() =>
|
||||
@@ -18,6 +19,16 @@ const icon = computed(() =>
|
||||
? getPhoneApp(props.notification.appId)?.iconImage
|
||||
: undefined,
|
||||
)
|
||||
|
||||
function openNotification(event: MouseEvent): void {
|
||||
if (
|
||||
!props.notification?.route ||
|
||||
(event.target as HTMLElement).closest('button')
|
||||
) {
|
||||
return
|
||||
}
|
||||
emit('open', props.notification)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -29,7 +40,9 @@ const icon = computed(() =>
|
||||
:title-right-text="phone.t('Notifications.now')"
|
||||
button="close"
|
||||
class="phone-notification"
|
||||
:class="{ 'is-actionable': !!notification?.route }"
|
||||
@close="emit('close')"
|
||||
@click="openNotification"
|
||||
>
|
||||
<template v-if="icon" #icon>
|
||||
<img :src="icon" alt="" class="phone-notification__icon" />
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { PhoneCall } from 'lucide-vue-next'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
withDefaults(
|
||||
defineProps<{ controlCenterOpened?: boolean; lockable?: boolean }>(),
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
activeCallReturn?: boolean
|
||||
controlCenterOpened?: boolean
|
||||
lockable?: boolean
|
||||
}>(),
|
||||
{
|
||||
activeCallReturn: false,
|
||||
controlCenterOpened: false,
|
||||
lockable: false,
|
||||
},
|
||||
)
|
||||
const emit = defineEmits<{ controlCenter: []; lock: [] }>()
|
||||
const emit = defineEmits<{ activeCall: []; controlCenter: []; lock: [] }>()
|
||||
const time = ref('')
|
||||
let intervalId: number | undefined
|
||||
|
||||
@@ -23,6 +29,14 @@ function updateTime(): void {
|
||||
}).format(new Date())
|
||||
}
|
||||
|
||||
function handleTimeClick(): void {
|
||||
if (props.activeCallReturn) {
|
||||
emit('activeCall')
|
||||
return
|
||||
}
|
||||
emit('lock')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
intervalId = window.setInterval(updateTime, 30_000)
|
||||
@@ -38,10 +52,22 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
v-if="lockable"
|
||||
class="phone-status-bar__time phone-status-bar__time-button"
|
||||
:class="{
|
||||
'phone-status-bar__time-button--active-call': activeCallReturn,
|
||||
}"
|
||||
type="button"
|
||||
:aria-label="phone.t('LockScreen.label')"
|
||||
@click.stop="emit('lock')"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
activeCallReturn ? 'Apps.phone.returnToCall' : 'LockScreen.label',
|
||||
)
|
||||
"
|
||||
@click.stop="handleTimeClick"
|
||||
>
|
||||
<PhoneCall
|
||||
v-if="activeCallReturn"
|
||||
class="phone-status-bar__active-call-icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<time>{{ time }}</time>
|
||||
</button>
|
||||
<time v-else class="phone-status-bar__time">{{ time }}</time>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Headphones } from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import type { RadioHudConfig, RadioHudMember } from '@/types/radio'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
|
||||
type RadioHudEntry = RadioHudMember & {
|
||||
state: 'recent' | 'talking'
|
||||
@@ -122,6 +123,7 @@ function updateConfig(value: Partial<RadioHudConfig>): void {
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent<RadioHudMessage>): void {
|
||||
if (!isTrustedRootMessageSource(event.source, window)) return
|
||||
if (event.data?.type === 'radio:hud-config' && event.data.data) {
|
||||
updateConfig(event.data.data as Partial<RadioHudConfig>)
|
||||
} else if (event.data?.type === 'radio:hud-update' && event.data.data) {
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import { Image, MapPin, Music2, Play, UserRound } from 'lucide-vue-next'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { getPhoneApp, getPhoneAppLabel } from '@/config/apps'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { EasySharePayload } from '@/types/easyshare'
|
||||
import { openEasySharePayload } from '@/utils/easyshare'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
compact?: boolean
|
||||
payload: EasySharePayload
|
||||
variant?: 'darkchat' | 'flare' | 'messages'
|
||||
}>(),
|
||||
{ compact: false, variant: 'messages' },
|
||||
)
|
||||
const phone = usePhoneStore()
|
||||
const router = useRouter()
|
||||
const imageFailed = ref(false)
|
||||
const sourceApp = computed(() => getPhoneApp(props.payload.appId))
|
||||
const isVideo = computed(
|
||||
() =>
|
||||
props.payload.kind === 'video' ||
|
||||
(props.payload.appId === 'fliptok' && props.payload.kind === 'post'),
|
||||
)
|
||||
const fallbackIcon = computed(() => {
|
||||
if (props.payload.kind === 'location') return MapPin
|
||||
if (props.payload.kind === 'profile') return UserRound
|
||||
if (props.payload.kind === 'track' || props.payload.kind === 'playlist') {
|
||||
return Music2
|
||||
}
|
||||
if (props.payload.kind === 'video') return Play
|
||||
return Image
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.payload.imageUrl,
|
||||
() => {
|
||||
imageFailed.value = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="shared-content-card"
|
||||
:class="[
|
||||
`shared-content-card--${variant}`,
|
||||
{ 'shared-content-card--compact': compact },
|
||||
]"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openEasySharePayload(router, payload)"
|
||||
@keydown.enter.prevent="openEasySharePayload(router, payload)"
|
||||
@keydown.space.prevent="openEasySharePayload(router, payload)"
|
||||
>
|
||||
<div class="shared-content-card__source">
|
||||
<img v-if="sourceApp?.iconImage" :src="sourceApp.iconImage" alt="" />
|
||||
<span>
|
||||
<b>{{
|
||||
sourceApp ? getPhoneAppLabel(sourceApp, phone.t) : payload.appId
|
||||
}}</b>
|
||||
<small>{{ phone.t(`Apps.easyShare.kinds.${payload.kind}`) }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="shared-content-card__media">
|
||||
<video
|
||||
v-if="payload.imageUrl && !imageFailed && isVideo"
|
||||
:src="payload.imageUrl"
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
@error="imageFailed = true"
|
||||
/>
|
||||
<img
|
||||
v-else-if="payload.imageUrl && !imageFailed"
|
||||
:src="payload.imageUrl"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
@error="imageFailed = true"
|
||||
/>
|
||||
<span v-else><component :is="fallbackIcon" :size="30" /></span>
|
||||
</div>
|
||||
<div class="shared-content-card__copy">
|
||||
<small v-if="payload.subtitle">{{ payload.subtitle }}</small>
|
||||
<strong>{{ payload.title }}</strong>
|
||||
<p v-if="payload.copyText !== payload.title">{{ payload.copyText }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shared-content-card {
|
||||
width: min(262px, 100%);
|
||||
width: min(262px, 73cqw);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(60 60 67 / 13%);
|
||||
border-radius: 20px;
|
||||
color: #171719;
|
||||
background: rgb(255 255 255 / 96%);
|
||||
box-shadow: 0 8px 24px rgb(19 45 78 / 13%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shared-content-card:focus-visible {
|
||||
outline: 3px solid #0a84ff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.shared-content-card__source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 11px;
|
||||
}
|
||||
|
||||
.shared-content-card__source > img {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
border-radius: 7px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.shared-content-card__source b,
|
||||
.shared-content-card__source small {
|
||||
display: block;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.shared-content-card__source b {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.shared-content-card__source small {
|
||||
margin-top: 2px;
|
||||
color: #8e8e93;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.shared-content-card__media {
|
||||
height: 142px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(145deg, #dcecff, #b9d5ff);
|
||||
}
|
||||
|
||||
.shared-content-card__media > img,
|
||||
.shared-content-card__media > video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.shared-content-card__media > span {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #0a84ff;
|
||||
}
|
||||
|
||||
.shared-content-card__copy {
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.shared-content-card__copy small,
|
||||
.shared-content-card__copy strong,
|
||||
.shared-content-card__copy p {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.shared-content-card__copy small {
|
||||
margin-bottom: 3px;
|
||||
color: #0a84ff;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.shared-content-card__copy strong {
|
||||
display: -webkit-box;
|
||||
font-size: 14px;
|
||||
line-height: 1.25;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.shared-content-card__copy p {
|
||||
display: -webkit-box;
|
||||
margin-top: 5px;
|
||||
color: #636366;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.shared-content-card--compact {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
border-radius: 16px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.shared-content-card--compact .shared-content-card__source {
|
||||
grid-column: 2;
|
||||
padding: 7px 9px 0;
|
||||
}
|
||||
|
||||
.shared-content-card--compact .shared-content-card__media {
|
||||
grid-row: 1 / span 2;
|
||||
height: 78px;
|
||||
}
|
||||
|
||||
.shared-content-card--compact .shared-content-card__copy {
|
||||
grid-column: 2;
|
||||
padding: 5px 9px 8px;
|
||||
}
|
||||
|
||||
.shared-content-card--compact .shared-content-card__copy p,
|
||||
.shared-content-card--compact .shared-content-card__source small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shared-content-card--darkchat {
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
color: #f5f5f7;
|
||||
background: linear-gradient(155deg, #302c3d, #1b1922);
|
||||
box-shadow: 0 9px 25px rgb(0 0 0 / 28%);
|
||||
}
|
||||
|
||||
.shared-content-card--darkchat .shared-content-card__media {
|
||||
background: linear-gradient(145deg, #392f5c, #201c31);
|
||||
}
|
||||
|
||||
.shared-content-card--darkchat .shared-content-card__media > span,
|
||||
.shared-content-card--darkchat .shared-content-card__copy small {
|
||||
color: #bf9cff;
|
||||
}
|
||||
|
||||
.shared-content-card--darkchat .shared-content-card__copy p {
|
||||
color: #beb9c8;
|
||||
}
|
||||
|
||||
.shared-content-card--flare {
|
||||
border-color: rgb(255 255 255 / 58%);
|
||||
background: linear-gradient(155deg, #fff, #fff0f6);
|
||||
box-shadow: 0 8px 24px rgb(245 71 132 / 15%);
|
||||
}
|
||||
|
||||
.shared-content-card--flare .shared-content-card__media {
|
||||
background: linear-gradient(145deg, #ffd4e4, #ffc1d5);
|
||||
}
|
||||
|
||||
.shared-content-card--flare .shared-content-card__media > span,
|
||||
.shared-content-card--flare .shared-content-card__copy small {
|
||||
color: #ef3f7c;
|
||||
}
|
||||
|
||||
:global(.phone-app.dark) .shared-content-card--messages {
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
color: #f5f5f7;
|
||||
background: linear-gradient(155deg, #34363b, #242a33);
|
||||
}
|
||||
|
||||
:global(.phone-app.dark) .shared-content-card--messages .shared-content-card__copy p {
|
||||
color: #b8b8bd;
|
||||
}
|
||||
</style>
|
||||
@@ -42,7 +42,6 @@ async function insert(
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
void nuiCall('sim:picker-close')
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -31,6 +31,10 @@ import { useCallsStore } from '@/stores/calls'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { WidgetInstance } from '@/types/widgets'
|
||||
import {
|
||||
reorderDirectionFromKeyboard,
|
||||
type ReorderDirection,
|
||||
} from '@/utils/keyboard'
|
||||
import type { WeatherConditionId } from '@/types/weather'
|
||||
import { WIDGET_SPANS } from '@/utils/widgetLayout'
|
||||
|
||||
@@ -50,6 +54,7 @@ const emit = defineEmits<{
|
||||
dragstart: [event: PointerEvent]
|
||||
menu: []
|
||||
remove: []
|
||||
reorder: [direction: ReorderDirection]
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
@@ -75,6 +80,8 @@ let holdTimer: number | undefined
|
||||
let pointerStart = { x: 0, y: 0 }
|
||||
let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
let pointerTarget: HTMLElement | null = null
|
||||
let pointerId: number | null = null
|
||||
|
||||
const weatherIcons: Record<WeatherConditionId, Component> = {
|
||||
sunny: Sun,
|
||||
@@ -180,6 +187,9 @@ function onPointerDown(event: PointerEvent): void {
|
||||
) {
|
||||
return
|
||||
}
|
||||
pointerTarget = event.currentTarget as HTMLElement
|
||||
pointerId = event.pointerId
|
||||
pointerTarget.setPointerCapture(pointerId)
|
||||
pointerStart = { x: event.clientX, y: event.clientY }
|
||||
clearHold()
|
||||
if (props.editMode) {
|
||||
@@ -217,35 +227,48 @@ function beginDrag(event: PointerEvent): void {
|
||||
.closest<HTMLElement>('.springboard-page')
|
||||
?.getBoundingClientRect().width ?? 0
|
||||
isDragging.value = true
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', cancelDrag)
|
||||
emit('dragstart', event)
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent): void {
|
||||
clearHold()
|
||||
if (!isDragging.value) return
|
||||
suppressClick.value = true
|
||||
emit('dragend', event)
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
removeDragListeners()
|
||||
if (isDragging.value) {
|
||||
suppressClick.value = true
|
||||
emit('dragend', event)
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
}
|
||||
releasePointerCapture()
|
||||
}
|
||||
|
||||
function cancelDrag(): void {
|
||||
clearHold()
|
||||
if (!isDragging.value) return
|
||||
const wasDragging = isDragging.value
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
removeDragListeners()
|
||||
emit('dragcancel')
|
||||
releasePointerCapture()
|
||||
if (wasDragging) emit('dragcancel')
|
||||
}
|
||||
|
||||
function removeDragListeners(): void {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', cancelDrag)
|
||||
function releasePointerCapture(): void {
|
||||
if (
|
||||
pointerTarget &&
|
||||
pointerId !== null &&
|
||||
pointerTarget.hasPointerCapture(pointerId)
|
||||
) {
|
||||
pointerTarget.releasePointerCapture(pointerId)
|
||||
}
|
||||
pointerTarget = null
|
||||
pointerId = null
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (!props.editMode) return
|
||||
const direction = reorderDirectionFromKeyboard(event)
|
||||
if (!direction) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('reorder', direction)
|
||||
}
|
||||
|
||||
function openWidget(): void {
|
||||
@@ -283,7 +306,7 @@ async function messageContact(phoneNumber: string): Promise<void> {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearHold()
|
||||
removeDragListeners()
|
||||
releasePointerCapture()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -307,9 +330,14 @@ onBeforeUnmount(() => {
|
||||
:class="`home-widget--${instance.kind}`"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-keyshortcuts="
|
||||
editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
|
||||
"
|
||||
@click="openWidget"
|
||||
@contextmenu.prevent
|
||||
@keydown="onKeydown"
|
||||
@keydown.enter="openWidget"
|
||||
@lostpointercapture="cancelDrag"
|
||||
@pointercancel="cancelDrag"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointerleave="isDragging || clearHold()"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'vue'
|
||||
|
||||
import SpringboardWidget from '@/components/SpringboardWidget.vue'
|
||||
import { useWidgetsStore } from '@/stores/widgets'
|
||||
import type { ReorderDirection } from '@/utils/keyboard'
|
||||
import { WIDGET_HOME_ROWS, WIDGET_PAGE_ROWS } from '@/utils/widgetLayout'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -17,6 +18,7 @@ const emit = defineEmits<{
|
||||
dragstart: [id: string, event: PointerEvent]
|
||||
menu: [id: string]
|
||||
remove: [id: string]
|
||||
reorder: [id: string, direction: ReorderDirection]
|
||||
}>()
|
||||
const widgets = useWidgetsStore()
|
||||
const rows = computed(() =>
|
||||
@@ -51,6 +53,7 @@ const instances = computed(() =>
|
||||
@dragstart="emit('dragstart', instance.id, $event)"
|
||||
@menu="emit('menu', instance.id)"
|
||||
@remove="emit('remove', instance.id)"
|
||||
@reorder="emit('reorder', instance.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -71,12 +71,12 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-sheet
|
||||
:opened="opened"
|
||||
class="widget-config-sheet"
|
||||
:colors="sheetColors"
|
||||
@backdropclick="emit('close')"
|
||||
>
|
||||
<div class="widget-config-sheet">
|
||||
<k-sheet
|
||||
:opened="opened"
|
||||
:colors="sheetColors"
|
||||
@backdropclick="emit('close')"
|
||||
>
|
||||
<k-page
|
||||
v-if="instance"
|
||||
class="widget-config-page"
|
||||
@@ -185,11 +185,12 @@ watch(
|
||||
</section>
|
||||
</div>
|
||||
</k-page>
|
||||
</k-sheet>
|
||||
</k-sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.widget-config-sheet) {
|
||||
.widget-config-sheet :deep(.k-sheet) {
|
||||
z-index: 115;
|
||||
height: calc(100% - 22px);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -117,12 +117,12 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-sheet
|
||||
:opened="opened"
|
||||
class="widget-picker-sheet"
|
||||
:colors="sheetColors"
|
||||
@backdropclick="emit('close')"
|
||||
>
|
||||
<div class="widget-picker-sheet">
|
||||
<k-sheet
|
||||
:opened="opened"
|
||||
:colors="sheetColors"
|
||||
@backdropclick="emit('close')"
|
||||
>
|
||||
<k-page
|
||||
class="widget-picker-page"
|
||||
:class="{ 'widget-picker-page--dark': phone.isDarkMode }"
|
||||
@@ -206,11 +206,12 @@ watch(
|
||||
</p>
|
||||
</div>
|
||||
</k-page>
|
||||
</k-sheet>
|
||||
</k-sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.widget-picker-sheet) {
|
||||
.widget-picker-sheet :deep(.k-sheet) {
|
||||
z-index: 110;
|
||||
height: calc(100% - 22px);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { kDialog, kDialogButton } from 'konsta/vue'
|
||||
|
||||
import { useAppAuthStore, type AppAuthId } from '@/stores/app-auth'
|
||||
import { useCrewLinkStore } from '@/stores/crewlink'
|
||||
import { useFeatherStore } from '@/stores/feather'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { usePagesStore } from '@/stores/pages'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
const opened = defineModel<boolean>('opened', { default: false })
|
||||
const emit = defineEmits<{ loggedOut: [] }>()
|
||||
const props = defineProps<{ appId: AppAuthId; appName: string }>()
|
||||
|
||||
const appAuth = useAppAuthStore()
|
||||
const crewLink = useCrewLinkStore()
|
||||
const feather = useFeatherStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
const pages = usePagesStore()
|
||||
const phone = usePhoneStore()
|
||||
|
||||
function close(): void {
|
||||
opened.value = false
|
||||
}
|
||||
|
||||
function confirmLogout(): void {
|
||||
appAuth.signOut(props.appId)
|
||||
if (props.appId === 'citymarkt') marketplace.$reset()
|
||||
if (props.appId === 'local-pages') pages.$reset()
|
||||
if (props.appId === 'feather') feather.$reset()
|
||||
if (props.appId === 'crewlink') {
|
||||
crewLink.$reset()
|
||||
crewLink.error = 'not_authenticated'
|
||||
}
|
||||
opened.value = false
|
||||
emit('loggedOut')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-dialog :opened="opened" @backdropclick="close">
|
||||
<template #title>{{ phone.t('Common.signOutTitle', { app: appName }) }}</template>
|
||||
<p>{{ phone.t('Common.signOutBody', { app: appName }) }}</p>
|
||||
<template #buttons>
|
||||
<k-dialog-button @click="close">
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</k-dialog-button>
|
||||
<k-dialog-button
|
||||
strong
|
||||
class="account-logout-confirm"
|
||||
@click="confirmLogout"
|
||||
>
|
||||
{{ phone.t('Common.signOut') }}
|
||||
</k-dialog-button>
|
||||
</template>
|
||||
</k-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.account-logout-confirm {
|
||||
background: #e44760 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,414 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowRight,
|
||||
Camera,
|
||||
Images,
|
||||
LockKeyhole,
|
||||
Mail,
|
||||
UserRound,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
kButton,
|
||||
kGlass,
|
||||
kList,
|
||||
kListInput,
|
||||
kPreloader,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
} from 'konsta/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
avatarUrl: string | null
|
||||
body: string
|
||||
cameraLabel: string
|
||||
email: string
|
||||
emailLabel: string
|
||||
error: string
|
||||
eyebrow: string
|
||||
galleryLabel: string
|
||||
loginLabel: string
|
||||
maxUsernameLength?: number
|
||||
minUsernameLength?: number
|
||||
mode: 'login' | 'register'
|
||||
pending: boolean
|
||||
registerLabel: string
|
||||
title: string
|
||||
username: string
|
||||
usernameLabel: string
|
||||
usernamePlaceholder?: string
|
||||
}>(),
|
||||
{
|
||||
maxUsernameLength: 40,
|
||||
minUsernameLength: 2,
|
||||
usernamePlaceholder: '',
|
||||
},
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
camera: []
|
||||
gallery: []
|
||||
submit: []
|
||||
'update:mode': [value: 'login' | 'register']
|
||||
'update:username': [value: string]
|
||||
}>()
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
const length = props.username.trim().length
|
||||
return Boolean(
|
||||
props.email &&
|
||||
length >= props.minUsernameLength &&
|
||||
length <= props.maxUsernameLength,
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="app-profile-auth">
|
||||
<header class="app-profile-auth__hero">
|
||||
<span class="app-profile-auth__mark"><UserRound :size="23" /></span>
|
||||
<div>
|
||||
<small>{{ eyebrow }}</small>
|
||||
<h2>{{ title }}</h2>
|
||||
</div>
|
||||
<p>{{ body }}</p>
|
||||
</header>
|
||||
|
||||
<k-glass class="app-profile-auth__card">
|
||||
<k-segmented raised class="app-profile-auth__mode">
|
||||
<k-segmented-button
|
||||
class="app-profile-auth__mode-button app-profile-auth__mode-button--login"
|
||||
:class="{
|
||||
'app-profile-auth__mode-button--active': mode === 'login',
|
||||
}"
|
||||
:active="mode === 'login'"
|
||||
@click="emit('update:mode', 'login')"
|
||||
>
|
||||
{{ loginLabel }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
class="app-profile-auth__mode-button app-profile-auth__mode-button--register"
|
||||
:class="{
|
||||
'app-profile-auth__mode-button--active': mode === 'register',
|
||||
}"
|
||||
:active="mode === 'register'"
|
||||
@click="emit('update:mode', 'register')"
|
||||
>
|
||||
{{ registerLabel }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
|
||||
<div v-if="mode === 'register'" class="app-profile-auth__photo">
|
||||
<span class="app-profile-auth__avatar">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" alt="" />
|
||||
<UserRound v-else :size="28" />
|
||||
<i><Camera :size="11" /></i>
|
||||
</span>
|
||||
<div>
|
||||
<k-button rounded outline @click="emit('gallery')">
|
||||
<Images :size="15" />{{ galleryLabel }}
|
||||
</k-button>
|
||||
<k-button rounded outline @click="emit('camera')">
|
||||
<Camera :size="15" />{{ cameraLabel }}
|
||||
</k-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-profile-auth__identity">
|
||||
<span><Mail :size="17" /></span>
|
||||
<div>
|
||||
<small>{{ emailLabel }}</small>
|
||||
<strong>{{ email }}</strong>
|
||||
</div>
|
||||
<LockKeyhole :size="15" />
|
||||
</div>
|
||||
|
||||
<k-list inset strong class="app-profile-auth__fields">
|
||||
<k-list-input
|
||||
input-id="app-profile-auth-username"
|
||||
:label="usernameLabel"
|
||||
:value="username"
|
||||
:maxlength="maxUsernameLength"
|
||||
:placeholder="usernamePlaceholder"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
outline
|
||||
@input="
|
||||
emit('update:username', ($event.target as HTMLInputElement).value)
|
||||
"
|
||||
@keydown.enter="emit('submit')"
|
||||
/>
|
||||
</k-list>
|
||||
|
||||
<div v-if="error" class="app-profile-auth__error" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
<k-button
|
||||
large
|
||||
rounded
|
||||
class="app-profile-auth__submit"
|
||||
:disabled="!canSubmit || pending"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
<k-preloader v-if="pending" />
|
||||
<template v-else>
|
||||
<span>{{ mode === 'login' ? loginLabel : registerLabel }}</span>
|
||||
<ArrowRight :size="18" />
|
||||
</template>
|
||||
</k-button>
|
||||
</k-glass>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-profile-auth {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 4px 18px;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
.app-profile-auth__hero {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 46px minmax(0, 1fr);
|
||||
gap: 0 11px;
|
||||
margin: 0 12px 14px;
|
||||
text-align: left;
|
||||
}
|
||||
.app-profile-auth__mark {
|
||||
display: grid;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
grid-row: 1 / span 2;
|
||||
place-items: center;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--auth-accent, #ffd63e) 46%, transparent);
|
||||
border-radius: 15px;
|
||||
color: var(--auth-accent, var(--yellow, #ffd63e));
|
||||
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 14%, transparent);
|
||||
box-shadow: 0 10px 28px
|
||||
color-mix(in srgb, var(--auth-accent, #ffd63e) 16%, transparent);
|
||||
}
|
||||
.app-profile-auth__hero small {
|
||||
color: var(--auth-accent, var(--yellow, #ffd63e));
|
||||
font-size: 9px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.app-profile-auth__hero h2 {
|
||||
margin: 3px 0 0;
|
||||
color: inherit;
|
||||
font-size: 20px;
|
||||
line-height: 1.08;
|
||||
}
|
||||
.app-profile-auth__hero p {
|
||||
grid-column: 2;
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted, #9ba4aa);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.app-profile-auth__card {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 24px;
|
||||
background: color-mix(in srgb, var(--panel, #20262c) 90%, transparent);
|
||||
box-shadow: 0 22px 50px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(22px) saturate(1.15);
|
||||
}
|
||||
.app-profile-auth__card::before {
|
||||
position: absolute;
|
||||
top: -80px;
|
||||
right: -55px;
|
||||
width: 170px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 16%, transparent);
|
||||
filter: blur(38px);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
.app-profile-auth__mode {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0 0 12px;
|
||||
padding: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
.app-profile-auth__mode :deep(.app-profile-auth__mode-button) {
|
||||
border-radius: 3px;
|
||||
}
|
||||
.app-profile-auth__mode
|
||||
:deep(
|
||||
.app-profile-auth__mode-button--login.app-profile-auth__mode-button--active
|
||||
) {
|
||||
border-radius: 10px 3px 3px 10px;
|
||||
}
|
||||
.app-profile-auth__mode
|
||||
:deep(
|
||||
.app-profile-auth__mode-button--register.app-profile-auth__mode-button--active
|
||||
) {
|
||||
border-radius: 3px 10px 10px 3px;
|
||||
}
|
||||
.app-profile-auth__photo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 2px 11px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
text-align: left;
|
||||
}
|
||||
.app-profile-auth__avatar {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
border: 2px solid
|
||||
color-mix(in srgb, var(--auth-accent, #ffd63e) 58%, transparent);
|
||||
border-radius: 50%;
|
||||
color: var(--auth-accent, var(--yellow, #ffd63e));
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--auth-accent, #ffd63e) 10%,
|
||||
var(--panel, #20262c)
|
||||
);
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
.app-profile-auth__photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.app-profile-auth__avatar i {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -1px;
|
||||
display: grid;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
place-items: center;
|
||||
border: 2px solid var(--panel, #20262c);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: var(--auth-accent, #ffd63e);
|
||||
}
|
||||
.app-profile-auth__photo > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 6px;
|
||||
}
|
||||
.app-profile-auth__photo :deep(.k-button) {
|
||||
min-height: 32px;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: inherit;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
font-size: 11px;
|
||||
}
|
||||
.app-profile-auth__identity {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) 18px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 9px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-radius: 15px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
text-align: left;
|
||||
}
|
||||
.app-profile-auth__identity > span {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: var(--auth-accent, #ffd63e);
|
||||
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 13%, transparent);
|
||||
}
|
||||
.app-profile-auth__identity div {
|
||||
min-width: 0;
|
||||
}
|
||||
.app-profile-auth__identity small {
|
||||
display: block;
|
||||
margin-bottom: 1px;
|
||||
color: var(--muted, #9ba4aa);
|
||||
font-size: 9px;
|
||||
}
|
||||
.app-profile-auth__identity strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.app-profile-auth__identity > svg {
|
||||
color: var(--muted, #9ba4aa);
|
||||
}
|
||||
.app-profile-auth__fields {
|
||||
margin-top: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: 11px;
|
||||
margin-left: 0;
|
||||
color: inherit;
|
||||
background: rgba(255, 255, 255, 0.045) !important;
|
||||
text-align: left;
|
||||
}
|
||||
.app-profile-auth__fields :deep(.text-black) {
|
||||
color: inherit !important;
|
||||
}
|
||||
.app-profile-auth__fields :deep(.text-xs > div) {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--panel, #20262c) 94%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
.app-profile-auth__error {
|
||||
margin: -2px 1px 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255, 105, 97, 0.22);
|
||||
border-radius: 11px;
|
||||
color: #ff6961;
|
||||
background: rgba(255, 105, 97, 0.08);
|
||||
font-size: 11px;
|
||||
}
|
||||
.app-profile-auth__submit {
|
||||
--k-button-bg-color: var(--auth-accent, var(--yellow, #ffd63e));
|
||||
--k-button-text-color: #fff;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 17px;
|
||||
color: #fff !important;
|
||||
background: var(--auth-accent, var(--yellow, #ffd63e)) !important;
|
||||
box-shadow: 0 10px 26px
|
||||
color-mix(in srgb, var(--auth-accent, #ffd63e) 25%, transparent);
|
||||
font-weight: 750;
|
||||
}
|
||||
.app-profile-auth__submit:disabled {
|
||||
opacity: 0.46;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
kButton,
|
||||
kList,
|
||||
kListInput,
|
||||
kPreloader,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
} from 'konsta/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useAppAuthStore, type AppAuthId } from '@/stores/app-auth'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import {
|
||||
filterMailAddressInput,
|
||||
MAIL_ADDRESS_INPUT_MAX_LENGTH,
|
||||
normalizeMailAddress,
|
||||
} from '@/utils/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
appId: AppAuthId
|
||||
appName: string
|
||||
}>()
|
||||
const emit = defineEmits<{ signedIn: [] }>()
|
||||
|
||||
const account = useAccountStore()
|
||||
const appAuth = useAppAuthStore()
|
||||
const phone = usePhoneStore()
|
||||
const mode = ref<'login' | 'register'>('login')
|
||||
const email = ref(account.email)
|
||||
const password = ref('')
|
||||
const confirm = ref('')
|
||||
const pending = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
const normalized = normalizeMailAddress(email.value)
|
||||
const passwordValid = password.value.length >= 6 && password.value.length <= 64
|
||||
return Boolean(
|
||||
normalized &&
|
||||
passwordValid &&
|
||||
(mode.value === 'login' || (confirm.value && confirm.value === password.value)),
|
||||
)
|
||||
})
|
||||
|
||||
function setMode(next: 'login' | 'register'): void {
|
||||
mode.value = next
|
||||
confirm.value = ''
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function updateEmail(event: Event): void {
|
||||
email.value = filterMailAddressInput((event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function errorMessage(key?: string): string {
|
||||
const known = [
|
||||
'invalid_email',
|
||||
'invalid_password',
|
||||
'invalid_credentials',
|
||||
'email_taken',
|
||||
'rate_limited',
|
||||
]
|
||||
return phone.t(
|
||||
`Common.appAuth.errors.${key && known.includes(key) ? key : 'default'}`,
|
||||
)
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (!canSubmit.value || pending.value) return
|
||||
const normalized = normalizeMailAddress(email.value)
|
||||
if (!normalized) return
|
||||
|
||||
pending.value = true
|
||||
error.value = ''
|
||||
const response =
|
||||
mode.value === 'login'
|
||||
? await account.login(normalized, password.value)
|
||||
: await account.register(normalized, password.value)
|
||||
pending.value = false
|
||||
if (!response.success || !response.data) {
|
||||
error.value = errorMessage(response.error)
|
||||
return
|
||||
}
|
||||
|
||||
appAuth.signIn(props.appId, response.data.email)
|
||||
password.value = ''
|
||||
confirm.value = ''
|
||||
emit('signedIn')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="ifruit-app-auth">
|
||||
<small>{{ phone.t('Common.appAuth.eyebrow') }}</small>
|
||||
<h2>{{ phone.t('Common.appAuth.title', { app: appName }) }}</h2>
|
||||
<p>{{ phone.t('Common.appAuth.body', { app: appName }) }}</p>
|
||||
|
||||
<k-segmented raised class="ifruit-app-auth__mode">
|
||||
<k-segmented-button :active="mode === 'login'" @click="setMode('login')">
|
||||
{{ phone.t('Common.appAuth.login') }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button :active="mode === 'register'" @click="setMode('register')">
|
||||
{{ phone.t('Common.appAuth.register') }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
|
||||
<k-list inset strong class="ifruit-app-auth__fields">
|
||||
<k-list-input
|
||||
input-id="ifruit-app-auth-email"
|
||||
:label="phone.t('Common.appAuth.email')"
|
||||
:value="email"
|
||||
:maxlength="MAIL_ADDRESS_INPUT_MAX_LENGTH"
|
||||
autocomplete="username"
|
||||
inputmode="email"
|
||||
outline
|
||||
@input="updateEmail"
|
||||
/>
|
||||
<k-list-input
|
||||
input-id="ifruit-app-auth-password"
|
||||
:label="phone.t('Common.appAuth.password')"
|
||||
:value="password"
|
||||
type="password"
|
||||
maxlength="64"
|
||||
:autocomplete="mode === 'login' ? 'current-password' : 'new-password'"
|
||||
outline
|
||||
@input="password = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<k-list-input
|
||||
v-if="mode === 'register'"
|
||||
input-id="ifruit-app-auth-confirm"
|
||||
:label="phone.t('Common.appAuth.confirm')"
|
||||
:value="confirm"
|
||||
type="password"
|
||||
maxlength="64"
|
||||
autocomplete="new-password"
|
||||
outline
|
||||
@input="confirm = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</k-list>
|
||||
|
||||
<p v-if="error" class="ifruit-app-auth__error" role="alert">{{ error }}</p>
|
||||
<k-button large rounded :disabled="!canSubmit || pending" @click="submit">
|
||||
<k-preloader v-if="pending" />
|
||||
<template v-else>
|
||||
{{ phone.t(mode === 'login' ? 'Common.appAuth.loginAction' : 'Common.appAuth.registerAction') }}
|
||||
</template>
|
||||
</k-button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ifruit-app-auth {
|
||||
width: 100%;
|
||||
max-width: 310px;
|
||||
margin: auto;
|
||||
padding: 18px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.ifruit-app-auth > small {
|
||||
color: var(--k-color-primary);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ifruit-app-auth h2 {
|
||||
margin: 6px 0 4px;
|
||||
font-size: 21px;
|
||||
}
|
||||
.ifruit-app-auth > p {
|
||||
margin: 0 auto 14px;
|
||||
color: #8e8e93;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ifruit-app-auth__mode {
|
||||
margin: 0 8px 12px;
|
||||
}
|
||||
.ifruit-app-auth__fields {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.ifruit-app-auth .ifruit-app-auth__error {
|
||||
margin: -4px 12px 10px;
|
||||
color: #ff453a;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import AppProfileAuth from '@/components/account/AppProfileAuth.vue'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
defineProps<{
|
||||
avatarUrl: string | null
|
||||
email: string
|
||||
error: string
|
||||
mode: 'login' | 'register'
|
||||
pending: boolean
|
||||
username: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
camera: []
|
||||
gallery: []
|
||||
submit: []
|
||||
'update:mode': [value: 'login' | 'register']
|
||||
'update:username': [value: string]
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppProfileAuth
|
||||
:avatar-url="avatarUrl"
|
||||
:body="phone.t('Apps.citymarkt.authBody')"
|
||||
:camera-label="phone.t('Apps.citymarkt.takePhoto')"
|
||||
:email="email"
|
||||
:email-label="phone.t('Apps.citymarkt.profileEmail')"
|
||||
:error="error"
|
||||
:eyebrow="phone.t('Apps.citymarkt.authEyebrow')"
|
||||
:gallery-label="phone.t('Apps.citymarkt.chooseGallery')"
|
||||
:login-label="phone.t('Apps.citymarkt.login')"
|
||||
:mode="mode"
|
||||
:pending="pending"
|
||||
:register-label="phone.t('Apps.citymarkt.register')"
|
||||
:title="phone.t('Apps.citymarkt.authTitle')"
|
||||
:username="username"
|
||||
:username-label="phone.t('Apps.citymarkt.authUsername')"
|
||||
@camera="emit('camera')"
|
||||
@gallery="emit('gallery')"
|
||||
@submit="emit('submit')"
|
||||
@update:mode="emit('update:mode', $event)"
|
||||
@update:username="emit('update:username', $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -41,6 +41,9 @@ function select(value: string): void {
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
if (!isOpen.value) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
isOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -367,4 +367,15 @@ function relativeTime(timestamp: number): string {
|
||||
.feather-actions .is-bookmarked {
|
||||
color: #438cf5;
|
||||
}
|
||||
@supports not (color: color-mix(in srgb, white, black)) {
|
||||
.feather-post:active {
|
||||
background: rgb(127 127 127 / 4%);
|
||||
}
|
||||
.feather-follow {
|
||||
border-color: var(--feather-blue, #1d9bf0);
|
||||
}
|
||||
.feather-media {
|
||||
border-color: rgb(127 127 127 / 18%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user