mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
352 lines
9.6 KiB
Vue
352 lines
9.6 KiB
Vue
<script setup lang="ts">
|
|
import { kBadge } from 'konsta/vue'
|
|
import { Minus } from 'lucide-vue-next'
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
|
|
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'
|
|
import {
|
|
springboardPageDragCompensation,
|
|
springboardSwipeIntent,
|
|
} from '@/utils/springboardDrag'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
app: PhoneAppDefinition
|
|
compact?: boolean
|
|
editMode?: boolean
|
|
externalDragVisual?: boolean
|
|
showLabel?: boolean
|
|
}>(),
|
|
{
|
|
compact: false,
|
|
editMode: false,
|
|
externalDragVisual: false,
|
|
showLabel: true,
|
|
},
|
|
)
|
|
const emit = defineEmits<{
|
|
dragcancel: []
|
|
dragend: [event: PointerEvent]
|
|
dragmove: [event: PointerEvent]
|
|
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()
|
|
const iconFailed = ref(false)
|
|
const isDragging = ref(false)
|
|
const calendarToday = ref(new Date())
|
|
const dragOffset = ref({ x: 0, y: 0 })
|
|
let dragStartPage = 0
|
|
let dragPageWidth = 0
|
|
const dragStyle = computed(() =>
|
|
isDragging.value && !props.externalDragVisual
|
|
? {
|
|
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
|
}
|
|
: undefined,
|
|
)
|
|
const dragPointerStyle = computed(() =>
|
|
isDragging.value && !props.externalDragVisual
|
|
? {
|
|
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
|
}
|
|
: 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 = {
|
|
bg: 'bg-[#ff3b30]',
|
|
text: 'text-white',
|
|
}
|
|
const calendarWeekday = computed(() =>
|
|
new Intl.DateTimeFormat(phone.lang, { weekday: 'short' })
|
|
.format(calendarToday.value)
|
|
.replace(/\.$/, ''),
|
|
)
|
|
const calendarDay = computed(() => calendarToday.value.getDate())
|
|
|
|
function launch(event: MouseEvent): void {
|
|
if (props.editMode || suppressClick.value) {
|
|
suppressClick.value = false
|
|
return
|
|
}
|
|
if (!props.app.route) return
|
|
|
|
const button = event.currentTarget as HTMLElement
|
|
const screen = button.closest('.phone-screen')
|
|
const icon = button.querySelector<HTMLElement>('.app-icon')
|
|
if (screen && icon) {
|
|
const origin = icon.getBoundingClientRect()
|
|
const target = screen.getBoundingClientRect()
|
|
const scaleX = origin.width / target.width
|
|
const scaleY = origin.height / target.height
|
|
const screenScaleX = target.width / screen.clientWidth
|
|
const screenScaleY = target.height / screen.clientHeight
|
|
const iconRadius = Number.parseFloat(getComputedStyle(icon).borderRadius)
|
|
phone.setLaunchOrigin({
|
|
borderRadius: iconRadius / Math.min(scaleX, scaleY),
|
|
scaleX,
|
|
scaleY,
|
|
x: (origin.left - target.left) / screenScaleX,
|
|
y: (origin.top - target.top) / screenScaleY,
|
|
})
|
|
} else {
|
|
phone.setLaunchOrigin(null)
|
|
}
|
|
|
|
void router.push(props.app.route)
|
|
}
|
|
const removeBadgeColors = {
|
|
bg: 'bg-[#8e8e93]',
|
|
text: 'text-black',
|
|
}
|
|
|
|
function clearHold(): void {
|
|
if (holdTimer !== undefined) window.clearTimeout(holdTimer)
|
|
holdTimer = undefined
|
|
}
|
|
|
|
function onPointerDown(event: PointerEvent): void {
|
|
if (props.compact || event.button !== 0) return
|
|
suppressClick.value = false
|
|
pointerTarget = event.currentTarget as HTMLElement
|
|
pointerId = event.pointerId
|
|
pointerTarget.setPointerCapture(pointerId)
|
|
pointerStart = { x: event.clientX, y: event.clientY }
|
|
clearHold()
|
|
if (props.editMode) {
|
|
beginPointerDrag(event)
|
|
return
|
|
}
|
|
holdTimer = window.setTimeout(() => {
|
|
suppressClick.value = true
|
|
emit('edit')
|
|
beginPointerDrag(event)
|
|
holdTimer = undefined
|
|
}, 520)
|
|
}
|
|
|
|
function onPointerMove(event: PointerEvent): void {
|
|
if (isDragging.value) {
|
|
dragOffset.value = {
|
|
x: event.clientX - pointerStart.x,
|
|
y: event.clientY - pointerStart.y,
|
|
}
|
|
emit('dragmove', event)
|
|
return
|
|
}
|
|
if (
|
|
springboardSwipeIntent(
|
|
event.clientX - pointerStart.x,
|
|
event.clientY - pointerStart.y,
|
|
) !== 'pending'
|
|
) {
|
|
suppressClick.value = true
|
|
clearHold()
|
|
}
|
|
}
|
|
|
|
function beginPointerDrag(event: PointerEvent): void {
|
|
dragStartPage = phone.currentPage
|
|
dragPageWidth =
|
|
(event.target as HTMLElement)
|
|
.closest<HTMLElement>('.springboard-page')
|
|
?.getBoundingClientRect().width ?? 0
|
|
isDragging.value = true
|
|
emit('dragstart', event)
|
|
}
|
|
|
|
function onPointerUp(event: PointerEvent): void {
|
|
clearHold()
|
|
if (isDragging.value) {
|
|
suppressClick.value = true
|
|
isDragging.value = false
|
|
dragOffset.value = { x: 0, y: 0 }
|
|
releasePointerCapture()
|
|
emit('dragend', event)
|
|
return
|
|
}
|
|
releasePointerCapture()
|
|
}
|
|
|
|
function cancelPointerDrag(): void {
|
|
clearHold()
|
|
const wasDragging = isDragging.value
|
|
isDragging.value = false
|
|
dragOffset.value = { x: 0, y: 0 }
|
|
releasePointerCapture()
|
|
if (wasDragging) emit('dragcancel')
|
|
}
|
|
|
|
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(() => {
|
|
if (props.app.id !== 'calendar') return
|
|
calendarTimer = window.setInterval(() => {
|
|
calendarToday.value = new Date()
|
|
}, 60_000)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
clearHold()
|
|
if (calendarTimer !== undefined) window.clearInterval(calendarTimer)
|
|
cancelPointerDrag()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="app-icon-item"
|
|
:class="{
|
|
'app-icon-item--compact': compact,
|
|
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
|
'app-icon-item--dragging': isDragging,
|
|
'app-icon-item--editing': editMode,
|
|
}"
|
|
:style="dragStyle"
|
|
>
|
|
<button
|
|
class="app-icon-button"
|
|
:class="{ 'app-icon-button--compact': compact }"
|
|
type="button"
|
|
:aria-label="getPhoneAppLabel(app, phone.t)"
|
|
:aria-disabled="!app.route"
|
|
:aria-keyshortcuts="
|
|
editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
|
|
"
|
|
:style="dragPointerStyle"
|
|
@click="launch"
|
|
@contextmenu.prevent
|
|
@keydown="onKeydown"
|
|
@pointercancel="cancelPointerDrag"
|
|
@pointerdown="onPointerDown"
|
|
@lostpointercapture="cancelPointerDrag"
|
|
@pointerleave="isDragging || clearHold()"
|
|
@pointermove="onPointerMove"
|
|
@pointerup="onPointerUp"
|
|
>
|
|
<span class="app-icon-anchor" aria-hidden="true">
|
|
<span
|
|
class="app-icon"
|
|
:class="[
|
|
app.iconClass,
|
|
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
|
|
]"
|
|
:style="iconStyle"
|
|
>
|
|
<span v-if="app.id === 'calendar'" class="app-icon-calendar">
|
|
<strong>{{ calendarWeekday }}</strong>
|
|
<b>{{ calendarDay }}</b>
|
|
</span>
|
|
<img
|
|
v-else-if="!iconFailed"
|
|
:src="app.iconImage"
|
|
alt=""
|
|
draggable="false"
|
|
@error="iconFailed = true"
|
|
/>
|
|
<component
|
|
:is="app.icon"
|
|
v-else
|
|
:size="compact ? 18 : 28"
|
|
:stroke-width="2"
|
|
/>
|
|
</span>
|
|
<k-badge
|
|
v-if="unreadCount"
|
|
class="app-icon-badge"
|
|
:small="compact"
|
|
:colors="notificationBadgeColors"
|
|
>
|
|
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
|
</k-badge>
|
|
</span>
|
|
<span v-if="showLabel" class="app-icon-label">{{
|
|
getPhoneAppLabel(app, phone.t)
|
|
}}</span>
|
|
</button>
|
|
<button
|
|
v-if="editMode && isPhoneAppRemovable(app)"
|
|
class="app-icon-remove"
|
|
type="button"
|
|
:aria-label="
|
|
phone.t('Home.removeApp', {
|
|
app: getPhoneAppLabel(app, phone.t),
|
|
})
|
|
"
|
|
:style="dragPointerStyle"
|
|
@click.stop="emit('remove')"
|
|
@pointerdown.stop
|
|
>
|
|
<k-badge class="app-icon-remove__badge" :colors="removeBadgeColors">
|
|
<Minus :size="15" :stroke-width="3" aria-hidden="true" />
|
|
</k-badge>
|
|
</button>
|
|
</div>
|
|
</template>
|