mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
Merge branch 'dev' into feature/funk
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { kBadge } from 'konsta/vue'
|
||||
import { Minus } from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
|
||||
@@ -27,6 +27,7 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
dragcancel: []
|
||||
dragend: [event: PointerEvent]
|
||||
dragmove: [event: PointerEvent]
|
||||
dragstart: [event: PointerEvent]
|
||||
edit: []
|
||||
remove: []
|
||||
@@ -39,16 +40,21 @@ 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
|
||||
? {
|
||||
transform: `translate(${dragOffset.value.x}px, ${dragOffset.value.y}px)`,
|
||||
transform: `translateX(${(phone.currentPage - dragStartPage) * dragPageWidth}px)`,
|
||||
translate: `${dragOffset.value.x}px ${dragOffset.value.y}px`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const suppressClick = ref(false)
|
||||
let holdTimer: number | undefined
|
||||
let calendarTimer: number | undefined
|
||||
let pointerStart = { x: 0, y: 0 }
|
||||
const unreadCount = computed(() => {
|
||||
if (props.app.id === 'mail') return mail.counts.unread
|
||||
@@ -60,6 +66,12 @@ 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) {
|
||||
@@ -124,6 +136,7 @@ function onPointerMove(event: PointerEvent): void {
|
||||
x: event.clientX - pointerStart.x,
|
||||
y: event.clientY - pointerStart.y,
|
||||
}
|
||||
emit('dragmove', event)
|
||||
return
|
||||
}
|
||||
if (
|
||||
@@ -135,6 +148,11 @@ function onPointerMove(event: PointerEvent): void {
|
||||
}
|
||||
|
||||
function beginPointerDrag(event: PointerEvent): void {
|
||||
dragStartPage = phone.currentPage
|
||||
dragPageWidth =
|
||||
(event.target as HTMLElement)
|
||||
.closest<HTMLElement>('.springboard-page')
|
||||
?.getBoundingClientRect().width ?? 0
|
||||
isDragging.value = true
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
@@ -167,8 +185,16 @@ function removeDragListeners(): void {
|
||||
window.removeEventListener('pointercancel', cancelPointerDrag)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.app.id !== 'calendar') return
|
||||
calendarTimer = window.setInterval(() => {
|
||||
calendarToday.value = new Date()
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearHold()
|
||||
if (calendarTimer !== undefined) window.clearInterval(calendarTimer)
|
||||
removeDragListeners()
|
||||
})
|
||||
</script>
|
||||
@@ -200,10 +226,17 @@ onBeforeUnmount(() => {
|
||||
<span class="app-icon-anchor" aria-hidden="true">
|
||||
<span
|
||||
class="app-icon"
|
||||
:class="[app.iconClass, { 'app-icon--image': !iconFailed }]"
|
||||
:class="[
|
||||
app.iconClass,
|
||||
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
|
||||
]"
|
||||
>
|
||||
<span v-if="app.id === 'calendar'" class="app-icon-calendar">
|
||||
<strong>{{ calendarWeekday }}</strong>
|
||||
<b>{{ calendarDay }}</b>
|
||||
</span>
|
||||
<img
|
||||
v-if="!iconFailed"
|
||||
v-else-if="!iconFailed"
|
||||
:src="app.iconImage"
|
||||
alt=""
|
||||
draggable="false"
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import { Delete } from 'lucide-vue-next'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
busy?: boolean
|
||||
cancelable?: boolean
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
length: 4 | 6
|
||||
resetKey?: number
|
||||
subtitle?: string
|
||||
title: string
|
||||
}>(),
|
||||
{
|
||||
busy: false,
|
||||
cancelable: true,
|
||||
disabled: false,
|
||||
error: '',
|
||||
resetKey: 0,
|
||||
subtitle: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
complete: [passcode: string]
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const digits = ref('')
|
||||
const keypad = [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
const inputDisabled = computed(() => props.busy || props.disabled)
|
||||
|
||||
function enterDigit(digit: number): void {
|
||||
if (inputDisabled.value || digits.value.length >= props.length) return
|
||||
digits.value += String(digit)
|
||||
if (digits.value.length === props.length) emit('complete', digits.value)
|
||||
}
|
||||
|
||||
function removeDigit(): void {
|
||||
if (inputDisabled.value) return
|
||||
digits.value = digits.value.slice(0, -1)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.resetKey,
|
||||
() => {
|
||||
digits.value = ''
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="passcode-screen" :aria-label="title">
|
||||
<header class="passcode-screen__header">
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="subtitle">{{ subtitle }}</p>
|
||||
<div class="passcode-screen__dots" aria-hidden="true">
|
||||
<span
|
||||
v-for="index in length"
|
||||
:key="index"
|
||||
:class="{ 'passcode-screen__dot--filled': digits.length >= index }"
|
||||
></span>
|
||||
</div>
|
||||
<p
|
||||
v-if="error"
|
||||
class="passcode-screen__error"
|
||||
role="alert"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="passcode-screen__keypad">
|
||||
<button
|
||||
v-for="digit in keypad"
|
||||
:key="digit"
|
||||
type="button"
|
||||
:disabled="inputDisabled"
|
||||
@click="enterDigit(digit)"
|
||||
>
|
||||
{{ digit }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="passcode-screen__action"
|
||||
:disabled="!cancelable || busy"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ cancelable ? phone.t('LockScreen.passcode.cancel') : '' }}
|
||||
</button>
|
||||
<button type="button" :disabled="inputDisabled" @click="enterDigit(0)">
|
||||
0
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="passcode-screen__action"
|
||||
:aria-label="phone.t('LockScreen.passcode.delete')"
|
||||
:disabled="inputDisabled || digits.length === 0"
|
||||
@click="removeDigit"
|
||||
>
|
||||
<Delete :size="25" :stroke-width="1.7" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.passcode-screen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 76px 28px 30px;
|
||||
color: white;
|
||||
background:
|
||||
radial-gradient(circle at 50% 16%, rgb(76 92 132 / 46%), transparent 35%),
|
||||
linear-gradient(160deg, #182139, #080b12 72%);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.passcode-screen__header {
|
||||
min-height: 170px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.passcode-screen__header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.passcode-screen__header p {
|
||||
max-width: 270px;
|
||||
margin: 8px auto 0;
|
||||
color: rgb(255 255 255 / 72%);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.passcode-screen__dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.passcode-screen__dots span {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid rgb(255 255 255 / 78%);
|
||||
border-radius: 50%;
|
||||
transition: background-color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.passcode-screen__dots .passcode-screen__dot--filled {
|
||||
background: white;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.passcode-screen__header .passcode-screen__error {
|
||||
color: #ff9b93;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 72px);
|
||||
gap: 15px 20px;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad button:not(.passcode-screen__action) {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
background: rgb(255 255 255 / 16%);
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
backdrop-filter: blur(18px);
|
||||
transition: background-color 100ms ease, transform 100ms ease;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad button:not(.passcode-screen__action):active {
|
||||
background: rgb(255 255 255 / 34%);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.passcode-screen__keypad button:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad .passcode-screen__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 72px;
|
||||
min-height: 48px;
|
||||
border: 0;
|
||||
color: white;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,831 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Banknote,
|
||||
Cloud,
|
||||
CloudFog,
|
||||
CloudLightning,
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
MessageCircle,
|
||||
MoonStar,
|
||||
Pause,
|
||||
Phone,
|
||||
Play,
|
||||
SkipForward,
|
||||
Snowflake,
|
||||
Sun,
|
||||
WalletCards,
|
||||
} from 'lucide-vue-next'
|
||||
import { kBadge, kGlass } from 'konsta/vue'
|
||||
import { computed, onBeforeUnmount, ref, type Component } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
useBankService,
|
||||
useClockService,
|
||||
useContactsService,
|
||||
useMusicService,
|
||||
useWeatherService,
|
||||
} from '@/services/widgetServices'
|
||||
import { useCallsStore } from '@/stores/calls'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { WidgetInstance } from '@/types/widgets'
|
||||
import type { WeatherConditionId } from '@/types/weather'
|
||||
import { WIDGET_SPANS } from '@/utils/widgetLayout'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
editMode?: boolean
|
||||
instance: WidgetInstance
|
||||
interactive?: boolean
|
||||
preview?: boolean
|
||||
}>(),
|
||||
{ editMode: false, interactive: true, preview: false },
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
dragcancel: []
|
||||
dragend: [event: PointerEvent]
|
||||
dragmove: [event: PointerEvent]
|
||||
dragstart: [event: PointerEvent]
|
||||
menu: []
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const calls = useCallsStore()
|
||||
const messages = useMessagesStore()
|
||||
const router = useRouter()
|
||||
const clock = useClockService()
|
||||
const weather = useWeatherService()
|
||||
const music = useMusicService()
|
||||
const bank = useBankService()
|
||||
const contactsService = useContactsService()
|
||||
const isDragging = ref(false)
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
const suppressClick = ref(false)
|
||||
let holdTimer: number | undefined
|
||||
let pointerStart = { x: 0, y: 0 }
|
||||
let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
|
||||
const weatherIcons: Record<WeatherConditionId, Component> = {
|
||||
sunny: Sun,
|
||||
clear: MoonStar,
|
||||
partly_cloudy: CloudSun,
|
||||
cloudy: Cloud,
|
||||
rain: CloudRain,
|
||||
thunder: CloudLightning,
|
||||
fog: CloudFog,
|
||||
snow: Snowflake,
|
||||
}
|
||||
const span = computed(() => WIDGET_SPANS[props.instance.size])
|
||||
const placementStyle = computed(() =>
|
||||
props.preview
|
||||
? undefined
|
||||
: {
|
||||
gridColumn: `${props.instance.column + 1} / span ${span.value.columns}`,
|
||||
gridRow: `${props.instance.row + 1} / span ${span.value.rows}`,
|
||||
translate: isDragging.value
|
||||
? `${dragOffset.value.x}px ${dragOffset.value.y}px`
|
||||
: undefined,
|
||||
transform: isDragging.value
|
||||
? `translateX(${(phone.currentPage - dragStartPage) * dragPageWidth}px) scale(1.035)`
|
||||
: undefined,
|
||||
},
|
||||
)
|
||||
const forecastHigh = computed(() =>
|
||||
weather.forecast.value?.hourly.length
|
||||
? Math.max(
|
||||
...weather.forecast.value.hourly.map((entry) => entry.temperature),
|
||||
)
|
||||
: null,
|
||||
)
|
||||
const forecastLow = computed(() =>
|
||||
weather.forecast.value?.hourly.length
|
||||
? Math.min(
|
||||
...weather.forecast.value.hourly.map((entry) => entry.temperature),
|
||||
)
|
||||
: null,
|
||||
)
|
||||
const weatherIcon = computed(
|
||||
() => weatherIcons[weather.forecast.value?.condition ?? 'partly_cloudy'],
|
||||
)
|
||||
const balance = computed(() =>
|
||||
props.instance.settings.balanceSource === 'cash'
|
||||
? bank.overview.value.cash
|
||||
: bank.overview.value.bank,
|
||||
)
|
||||
const favoriteContacts = computed(() => {
|
||||
const selected = props.instance.settings.contactIds ?? []
|
||||
const ordered = selected.length
|
||||
? selected
|
||||
.map((id) =>
|
||||
contactsService.contacts.value.find((contact) => contact.id === id),
|
||||
)
|
||||
.filter((contact) => contact !== undefined)
|
||||
: contactsService.contacts.value
|
||||
return ordered.slice(0, props.instance.size === 'large' ? 6 : 4)
|
||||
})
|
||||
const visibleTransactions = computed(() =>
|
||||
bank.overview.value.transactions.slice(
|
||||
0,
|
||||
props.instance.size === 'large' ? 5 : 2,
|
||||
),
|
||||
)
|
||||
const removeBadgeColors = {
|
||||
bg: 'bg-[#d1d1d6]',
|
||||
text: 'text-black',
|
||||
}
|
||||
|
||||
function formatMoney(value: number): string {
|
||||
return `${bank.overview.value.currency}${new Intl.NumberFormat(phone.lang, {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value)}`
|
||||
}
|
||||
|
||||
function avatar(name: string): string {
|
||||
return name.trim().charAt(0).toLocaleUpperCase(phone.lang) || '?'
|
||||
}
|
||||
|
||||
function clearHold(): void {
|
||||
if (holdTimer !== undefined) window.clearTimeout(holdTimer)
|
||||
holdTimer = undefined
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
if (
|
||||
!props.interactive ||
|
||||
props.preview ||
|
||||
event.button !== 0 ||
|
||||
(event.target as HTMLElement).closest('[data-widget-control]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
pointerStart = { x: event.clientX, y: event.clientY }
|
||||
clearHold()
|
||||
if (props.editMode) {
|
||||
beginDrag(event)
|
||||
return
|
||||
}
|
||||
holdTimer = window.setTimeout(() => {
|
||||
suppressClick.value = true
|
||||
emit('menu')
|
||||
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 (
|
||||
Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >
|
||||
8
|
||||
) {
|
||||
clearHold()
|
||||
}
|
||||
}
|
||||
|
||||
function beginDrag(event: PointerEvent): void {
|
||||
dragStartPage = phone.currentPage
|
||||
dragPageWidth =
|
||||
(event.currentTarget as HTMLElement)
|
||||
.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()
|
||||
}
|
||||
|
||||
function cancelDrag(): void {
|
||||
clearHold()
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
removeDragListeners()
|
||||
emit('dragcancel')
|
||||
}
|
||||
|
||||
function removeDragListeners(): void {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', cancelDrag)
|
||||
}
|
||||
|
||||
function openWidget(): void {
|
||||
if (props.editMode || suppressClick.value || !props.interactive) {
|
||||
suppressClick.value = false
|
||||
return
|
||||
}
|
||||
const routes: Partial<Record<WidgetInstance['kind'], string>> = {
|
||||
clock: '/apps/clock',
|
||||
contacts: '/apps/phone',
|
||||
date: '/apps/calendar',
|
||||
transactions: '/apps/banking',
|
||||
wallet: '/apps/banking',
|
||||
weather: '/apps/weather',
|
||||
}
|
||||
const route = routes[props.instance.kind]
|
||||
if (route) {
|
||||
phone.setLaunchOrigin(null)
|
||||
void router.push(route)
|
||||
}
|
||||
}
|
||||
|
||||
async function callContact(phoneNumber: string): Promise<void> {
|
||||
await calls.dial(phoneNumber)
|
||||
phone.setLaunchOrigin(null)
|
||||
void router.push('/apps/phone')
|
||||
}
|
||||
|
||||
async function messageContact(phoneNumber: string): Promise<void> {
|
||||
await messages.openThread(phoneNumber)
|
||||
phone.setLaunchOrigin(null)
|
||||
void router.push('/apps/messages')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearHold()
|
||||
removeDragListeners()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="home-widget-shell"
|
||||
:class="[
|
||||
`home-widget-shell--${instance.size}`,
|
||||
{
|
||||
'home-widget-shell--dragging': isDragging,
|
||||
'home-widget-shell--editing': editMode,
|
||||
'home-widget-shell--preview': preview,
|
||||
},
|
||||
]"
|
||||
:style="placementStyle"
|
||||
:data-widget-id="instance.id"
|
||||
>
|
||||
<k-glass
|
||||
component="article"
|
||||
class="home-widget"
|
||||
:class="`home-widget--${instance.kind}`"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openWidget"
|
||||
@contextmenu.prevent
|
||||
@keydown.enter="openWidget"
|
||||
@pointercancel="cancelDrag"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointerleave="isDragging || clearHold()"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
>
|
||||
<template v-if="instance.kind === 'clock'">
|
||||
<span class="widget-eyebrow">{{ clock.weekday.value }}</span>
|
||||
<strong class="widget-clock">{{ clock.time.value }}</strong>
|
||||
<small v-if="instance.settings.showDate !== false">{{
|
||||
clock.date.value
|
||||
}}</small>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.kind === 'date'">
|
||||
<span class="widget-date-month">{{ clock.month.value }}</span>
|
||||
<strong class="widget-date-day">{{ clock.day.value }}</strong>
|
||||
<small>{{ clock.weekday.value }}</small>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.kind === 'weather'">
|
||||
<div class="widget-weather-top">
|
||||
<div>
|
||||
<span class="widget-eyebrow">{{ weather.location.value }}</span>
|
||||
<strong>{{ weather.forecast.value?.temperature ?? '--' }}°</strong>
|
||||
</div>
|
||||
<component
|
||||
:is="weatherIcon"
|
||||
:size="instance.size === 'small' ? 30 : 40"
|
||||
/>
|
||||
</div>
|
||||
<small>{{ weather.condition.value }}</small>
|
||||
<small v-if="instance.size !== 'small'" class="widget-weather-range">
|
||||
H: {{ forecastHigh ?? '--' }}° L: {{ forecastLow ?? '--' }}°
|
||||
</small>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.kind === 'music'">
|
||||
<div class="widget-album" aria-hidden="true">
|
||||
<span>SKY</span>
|
||||
</div>
|
||||
<div class="widget-music-copy">
|
||||
<strong>{{ music.current.value.title }}</strong>
|
||||
<small>{{ music.current.value.artist }}</small>
|
||||
</div>
|
||||
<div class="widget-music-controls" data-widget-control>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
music.playing.value
|
||||
? 'Home.widgets.media.pause'
|
||||
: 'Home.widgets.media.play',
|
||||
)
|
||||
"
|
||||
@click.stop="music.toggle"
|
||||
>
|
||||
<Pause v-if="music.playing.value" :size="20" fill="currentColor" />
|
||||
<Play v-else :size="20" fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('ControlCenter.next')"
|
||||
@click.stop="music.next"
|
||||
>
|
||||
<SkipForward :size="19" fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.kind === 'wallet'">
|
||||
<div class="widget-wallet-icon"><WalletCards :size="22" /></div>
|
||||
<span class="widget-eyebrow">{{
|
||||
phone.t(
|
||||
instance.settings.balanceSource === 'cash'
|
||||
? 'Home.widgetSystem.wallet.cash'
|
||||
: 'Home.widgetSystem.wallet.bank',
|
||||
)
|
||||
}}</span>
|
||||
<strong class="widget-balance">{{ formatMoney(balance) }}</strong>
|
||||
<small>{{ bank.overview.value.playerName }}</small>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.kind === 'transactions'">
|
||||
<header class="widget-list-header">
|
||||
<span>{{ phone.t('Home.widgetSystem.transactions.name') }}</span>
|
||||
<Banknote :size="19" />
|
||||
</header>
|
||||
<button
|
||||
v-for="transaction in visibleTransactions"
|
||||
:key="transaction.id"
|
||||
type="button"
|
||||
class="widget-transaction"
|
||||
data-widget-control
|
||||
@click.stop="openWidget"
|
||||
>
|
||||
<span>
|
||||
<strong>{{ transaction.label }}</strong>
|
||||
<small>{{ transaction.reference }}</small>
|
||||
</span>
|
||||
<b
|
||||
:class="{
|
||||
positive:
|
||||
transaction.kind === 'deposit' ||
|
||||
transaction.kind === 'transfer_in',
|
||||
}"
|
||||
>{{
|
||||
transaction.kind === 'deposit' ||
|
||||
transaction.kind === 'transfer_in'
|
||||
? '+'
|
||||
: '−'
|
||||
}}{{ formatMoney(transaction.amount) }}</b
|
||||
>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.kind === 'contacts'">
|
||||
<header class="widget-list-header">
|
||||
<span>{{ phone.t('Home.widgetSystem.contacts.name') }}</span>
|
||||
</header>
|
||||
<div class="widget-contacts">
|
||||
<article v-for="contact in favoriteContacts" :key="contact.id">
|
||||
<span class="widget-contact-avatar">{{
|
||||
avatar(contact.name)
|
||||
}}</span>
|
||||
<strong>{{ contact.name }}</strong>
|
||||
<div data-widget-control>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.messages.call')"
|
||||
@click.stop="callContact(contact.phone_number)"
|
||||
>
|
||||
<Phone :size="15" fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.messages.messageAction')"
|
||||
@click.stop="messageContact(contact.phone_number)"
|
||||
>
|
||||
<MessageCircle :size="15" fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</k-glass>
|
||||
|
||||
<button
|
||||
v-if="editMode && !preview"
|
||||
class="home-widget-remove"
|
||||
type="button"
|
||||
:aria-label="phone.t('Home.widgetSystem.remove')"
|
||||
@click.stop="emit('remove')"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<k-badge :colors="removeBadgeColors">−</k-badge>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-widget-shell {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
transition:
|
||||
transform 0.32s cubic-bezier(0.32, 0.72, 0, 1),
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.home-widget-shell--dragging {
|
||||
z-index: 40;
|
||||
opacity: 0.86;
|
||||
transition:
|
||||
transform var(--springboard-page-duration) var(--springboard-page-easing),
|
||||
opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-widget-shell--editing:not(.home-widget-shell--dragging) {
|
||||
animation: widget-wobble 0.17s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.home-widget-shell--preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 1;
|
||||
}
|
||||
|
||||
.home-widget-shell--preview.home-widget-shell--small {
|
||||
max-width: 150px;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.home-widget-shell--preview.home-widget-shell--large {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.home-widget {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
border: 0.5px solid rgb(255 255 255 / 18%);
|
||||
border-radius: 22px;
|
||||
outline: none;
|
||||
color: #fff;
|
||||
background: rgb(28 28 30 / 76%);
|
||||
box-shadow:
|
||||
0 8px 24px rgb(0 0 0 / 24%),
|
||||
inset 0 0.5px rgb(255 255 255 / 18%);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.home-widget:active {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.home-widget small,
|
||||
.widget-eyebrow {
|
||||
color: rgb(255 255 255 / 68%);
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.home-widget--clock,
|
||||
.home-widget--date,
|
||||
.home-widget--wallet {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.widget-clock {
|
||||
margin: 1px 0;
|
||||
font-size: 31px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -1.6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.home-widget-shell--medium .widget-clock {
|
||||
font-size: 45px;
|
||||
}
|
||||
|
||||
.home-widget--date {
|
||||
background: rgb(247 247 247 / 88%);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.home-widget--date small {
|
||||
color: #6e6e73;
|
||||
}
|
||||
|
||||
.widget-date-month {
|
||||
color: #ff3b30;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.widget-date-day {
|
||||
font-size: 48px;
|
||||
font-weight: 300;
|
||||
letter-spacing: -2px;
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.home-widget--weather {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
background: rgb(25 69 112 / 82%);
|
||||
}
|
||||
|
||||
.widget-weather-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.widget-weather-top > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.widget-weather-top strong {
|
||||
font-size: 34px;
|
||||
font-weight: 300;
|
||||
letter-spacing: -1.5px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.widget-weather-range {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.home-widget--music {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.home-widget-shell--large .home-widget--music {
|
||||
align-content: center;
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.widget-album {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
border-radius: 13px;
|
||||
color: #fff;
|
||||
background: #b33a3a;
|
||||
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 22%);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
|
||||
.home-widget-shell--large .widget-album {
|
||||
width: 118px;
|
||||
height: 118px;
|
||||
margin: 0 auto;
|
||||
border-radius: 24px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.widget-music-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.widget-music-copy strong,
|
||||
.widget-music-copy small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.widget-music-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.widget-music-controls button,
|
||||
.widget-contacts button {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: rgb(255 255 255 / 13%);
|
||||
}
|
||||
|
||||
.widget-wallet-icon {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
right: 13px;
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: #0a84ff;
|
||||
background: rgb(10 132 255 / 15%);
|
||||
}
|
||||
|
||||
.widget-balance {
|
||||
margin: 2px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -1.2px;
|
||||
}
|
||||
|
||||
.home-widget--transactions,
|
||||
.home-widget--contacts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.widget-list-header {
|
||||
display: flex;
|
||||
margin-bottom: 7px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: rgb(255 255 255 / 72%);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-transaction {
|
||||
display: flex;
|
||||
min-height: 43px;
|
||||
padding: 5px 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
border-top: 0.5px solid rgb(255 255 255 / 12%);
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.widget-transaction > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.widget-transaction strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.widget-transaction b {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-transaction b.positive {
|
||||
color: #30d158;
|
||||
}
|
||||
|
||||
.widget-contacts {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.home-widget-shell--large .widget-contacts {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.widget-contacts article {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.widget-contact-avatar {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: #5e5ce6;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.widget-contacts article:nth-child(2n) .widget-contact-avatar {
|
||||
background: #ff9f0a;
|
||||
}
|
||||
|
||||
.widget-contacts article:nth-child(3n) .widget-contact-avatar {
|
||||
background: #34c759;
|
||||
}
|
||||
|
||||
.widget-contacts strong {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.widget-contacts article > div {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.widget-contacts button {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
color: #0a84ff;
|
||||
background: rgb(10 132 255 / 14%);
|
||||
}
|
||||
|
||||
.home-widget-remove {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
display: grid;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.home-widget-remove :deep(.k-badge) {
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
min-width: 23px;
|
||||
padding: 0;
|
||||
border: 0.5px solid rgb(255 255 255 / 55%);
|
||||
box-shadow: 0 1px 5px rgb(0 0 0 / 55%);
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@keyframes widget-wobble {
|
||||
from {
|
||||
transform: rotate(-0.7deg) translateY(-0.5px);
|
||||
}
|
||||
to {
|
||||
transform: rotate(0.7deg) translateY(0.5px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.home-widget-shell--editing {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SpringboardWidget from '@/components/SpringboardWidget.vue'
|
||||
import { useWidgetsStore } from '@/stores/widgets'
|
||||
import { WIDGET_HOME_ROWS, WIDGET_PAGE_ROWS } from '@/utils/widgetLayout'
|
||||
|
||||
const props = defineProps<{
|
||||
draggingWidgetId?: string | null
|
||||
editMode: boolean
|
||||
page: number
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
dragcancel: []
|
||||
dragend: [event: PointerEvent]
|
||||
dragmove: [event: PointerEvent]
|
||||
dragstart: [id: string, event: PointerEvent]
|
||||
menu: [id: string]
|
||||
remove: [id: string]
|
||||
}>()
|
||||
const widgets = useWidgetsStore()
|
||||
const rows = computed(() =>
|
||||
props.page === 0 ? WIDGET_PAGE_ROWS : WIDGET_HOME_ROWS,
|
||||
)
|
||||
const instances = computed(() =>
|
||||
widgets.layout.instances.filter((instance) => instance.page === props.page),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="springboard-widget-grid"
|
||||
:class="{
|
||||
'springboard-widget-grid--editing': editMode,
|
||||
'springboard-widget-grid--page': page === 0,
|
||||
'springboard-widget-grid--drag-source': instances.some(
|
||||
(instance) => instance.id === draggingWidgetId,
|
||||
),
|
||||
}"
|
||||
:style="{ '--widget-grid-rows': rows }"
|
||||
:data-widget-page="page"
|
||||
>
|
||||
<SpringboardWidget
|
||||
v-for="instance in instances"
|
||||
:key="instance.id"
|
||||
:instance="instance"
|
||||
:edit-mode="editMode"
|
||||
@dragcancel="emit('dragcancel')"
|
||||
@dragend="emit('dragend', $event)"
|
||||
@dragmove="emit('dragmove', $event)"
|
||||
@dragstart="emit('dragstart', instance.id, $event)"
|
||||
@menu="emit('menu', instance.id)"
|
||||
@remove="emit('remove', instance.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.springboard-widget-grid {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 82px;
|
||||
right: 22px;
|
||||
left: 22px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(
|
||||
var(--widget-grid-rows),
|
||||
var(--springboard-grid-row)
|
||||
);
|
||||
grid-auto-rows: var(--springboard-grid-row);
|
||||
gap: var(--springboard-grid-row-gap) var(--springboard-grid-column-gap);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.springboard-widget-grid--page {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.springboard-widget-grid--drag-source {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.springboard-widget-grid :deep(.home-widget-shell) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import {
|
||||
kLink,
|
||||
kList,
|
||||
kListItem,
|
||||
kNavbar,
|
||||
kPage,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
kSheet,
|
||||
kToggle,
|
||||
} from 'konsta/vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import SpringboardWidget from '@/components/SpringboardWidget.vue'
|
||||
import { WIDGET_REGISTRY_BY_KIND } from '@/config/widgets'
|
||||
import { useContactsService } from '@/services/widgetServices'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type {
|
||||
WidgetInstance,
|
||||
WidgetSettings,
|
||||
WidgetSize,
|
||||
} from '@/types/widgets'
|
||||
|
||||
const props = defineProps<{
|
||||
instance: WidgetInstance | null
|
||||
opened: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
save: [size: WidgetSize, settings: WidgetSettings]
|
||||
}>()
|
||||
const phone = usePhoneStore()
|
||||
const contactsService = useContactsService()
|
||||
const size = ref<WidgetSize>('small')
|
||||
const showDate = ref(true)
|
||||
const balanceSource = ref<'bank' | 'cash'>('bank')
|
||||
const contactIds = ref<string[]>([])
|
||||
const sheetColors = {
|
||||
bgIos: 'bg-[#f2f2f7] dark:bg-black',
|
||||
}
|
||||
|
||||
function toggleContact(id: string): void {
|
||||
const index = contactIds.value.indexOf(id)
|
||||
if (index !== -1) contactIds.value.splice(index, 1)
|
||||
else if (contactIds.value.length < 6) contactIds.value.push(id)
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
emit('save', size.value, {
|
||||
balanceSource: balanceSource.value,
|
||||
contactIds: contactIds.value,
|
||||
showDate: showDate.value,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.opened, () => props.instance],
|
||||
([opened, instance]) => {
|
||||
if (!opened || !instance) return
|
||||
size.value = instance.size
|
||||
showDate.value = instance.settings.showDate !== false
|
||||
balanceSource.value = instance.settings.balanceSource ?? 'bank'
|
||||
contactIds.value = [...(instance.settings.contactIds ?? [])]
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-sheet
|
||||
:opened="opened"
|
||||
class="widget-config-sheet"
|
||||
:colors="sheetColors"
|
||||
@backdropclick="emit('close')"
|
||||
>
|
||||
<k-page
|
||||
v-if="instance"
|
||||
class="widget-config-page"
|
||||
:class="{ 'widget-config-page--dark': phone.isDarkMode }"
|
||||
>
|
||||
<k-navbar :title="phone.t('Home.widgetSystem.configure')">
|
||||
<template #left>
|
||||
<k-link component="button" type="button" @click="emit('close')">
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</k-link>
|
||||
</template>
|
||||
<template #right>
|
||||
<k-link component="button" type="button" @click="save">
|
||||
{{ phone.t('Common.done') }}
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
|
||||
<div class="widget-config-scroll">
|
||||
<div class="widget-config-preview">
|
||||
<SpringboardWidget
|
||||
:instance="{ ...instance, size }"
|
||||
preview
|
||||
:interactive="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section class="widget-config-size">
|
||||
<span>{{ phone.t('Home.widgetSystem.size') }}</span>
|
||||
<k-segmented raised>
|
||||
<k-segmented-button
|
||||
v-for="supportedSize in WIDGET_REGISTRY_BY_KIND.get(instance.kind)
|
||||
?.supportedSizes"
|
||||
:key="supportedSize"
|
||||
:active="size === supportedSize"
|
||||
@click="size = supportedSize"
|
||||
>
|
||||
{{ phone.t(`Home.widgetSystem.sizes.${supportedSize}`) }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</section>
|
||||
|
||||
<k-list
|
||||
v-if="instance.kind === 'clock'"
|
||||
inset
|
||||
strong
|
||||
class="widget-config-list"
|
||||
>
|
||||
<k-list-item :title="phone.t('Home.widgetSystem.clock.showDate')">
|
||||
<template #after>
|
||||
<k-toggle :checked="showDate" @change="showDate = !showDate" />
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
|
||||
<k-list
|
||||
v-if="instance.kind === 'wallet'"
|
||||
inset
|
||||
strong
|
||||
class="widget-config-list"
|
||||
>
|
||||
<k-list-item :title="phone.t('Home.widgetSystem.wallet.balance')">
|
||||
<template #after>
|
||||
<k-segmented class="widget-config-balance">
|
||||
<k-segmented-button
|
||||
:active="balanceSource === 'bank'"
|
||||
@click="balanceSource = 'bank'"
|
||||
>
|
||||
{{ phone.t('Home.widgetSystem.wallet.bank') }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
:active="balanceSource === 'cash'"
|
||||
@click="balanceSource = 'cash'"
|
||||
>
|
||||
{{ phone.t('Home.widgetSystem.wallet.cash') }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
|
||||
<section v-if="instance.kind === 'contacts'">
|
||||
<h3>{{ phone.t('Home.widgetSystem.contacts.choose') }}</h3>
|
||||
<k-list inset strong class="widget-config-list">
|
||||
<k-list-item
|
||||
v-for="contact in contactsService.contacts.value"
|
||||
:key="contact.id"
|
||||
link
|
||||
link-component="button"
|
||||
:title="contact.name"
|
||||
:subtitle="contact.phone_number"
|
||||
@click="toggleContact(contact.id)"
|
||||
>
|
||||
<template #media>
|
||||
<span class="widget-config-avatar">{{
|
||||
contact.name.charAt(0).toUpperCase()
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-if="contactIds.includes(contact.id)" #after>
|
||||
<Check :size="20" class="widget-config-check" />
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
</section>
|
||||
</div>
|
||||
</k-page>
|
||||
</k-sheet>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.widget-config-sheet) {
|
||||
z-index: 115;
|
||||
height: calc(100% - 22px);
|
||||
overflow: hidden;
|
||||
border-radius: 28px 28px 0 0;
|
||||
}
|
||||
|
||||
.widget-config-page {
|
||||
height: 100%;
|
||||
color: #111;
|
||||
background: #f2f2f7;
|
||||
}
|
||||
|
||||
.widget-config-page--dark {
|
||||
color: #fff;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.widget-config-scroll {
|
||||
height: calc(100% - 54px);
|
||||
padding: 8px 12px 42px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.widget-config-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.widget-config-preview {
|
||||
display: flex;
|
||||
min-height: 215px;
|
||||
padding: 20px 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.widget-config-preview :deep(.home-widget-shell) {
|
||||
max-height: 188px;
|
||||
}
|
||||
|
||||
.widget-config-size {
|
||||
display: grid;
|
||||
margin: 0 4px 16px;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.widget-config-size > span,
|
||||
.widget-config-scroll h3 {
|
||||
color: #6e6e73;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-config-scroll h3 {
|
||||
margin: 18px 14px 7px;
|
||||
}
|
||||
|
||||
.widget-config-list :deep([class*='title']) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.widget-config-balance {
|
||||
width: 138px;
|
||||
}
|
||||
|
||||
.widget-config-avatar {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: #5e5ce6;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.widget-config-check {
|
||||
color: #0a84ff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,319 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CalendarDays,
|
||||
Check,
|
||||
Clock3,
|
||||
CloudSun,
|
||||
Music,
|
||||
ReceiptText,
|
||||
Users,
|
||||
WalletCards,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
kButton,
|
||||
kLink,
|
||||
kList,
|
||||
kListItem,
|
||||
kNavbar,
|
||||
kPage,
|
||||
kSearchbar,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
kSheet,
|
||||
} from 'konsta/vue'
|
||||
import { computed, ref, watch, type Component } from 'vue'
|
||||
|
||||
import SpringboardWidget from '@/components/SpringboardWidget.vue'
|
||||
import { WIDGET_REGISTRY } from '@/config/widgets'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type {
|
||||
WidgetDefinition,
|
||||
WidgetInstance,
|
||||
WidgetKind,
|
||||
WidgetSize,
|
||||
} from '@/types/widgets'
|
||||
|
||||
const props = defineProps<{ opened: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
add: [kind: WidgetKind, size: WidgetSize]
|
||||
close: []
|
||||
}>()
|
||||
const phone = usePhoneStore()
|
||||
const query = ref('')
|
||||
const selectedKind = ref<WidgetKind>('clock')
|
||||
const selectedSize = ref<WidgetSize>('small')
|
||||
|
||||
const icons: Record<WidgetKind, Component> = {
|
||||
clock: Clock3,
|
||||
date: CalendarDays,
|
||||
weather: CloudSun,
|
||||
music: Music,
|
||||
wallet: WalletCards,
|
||||
transactions: ReceiptText,
|
||||
contacts: Users,
|
||||
}
|
||||
const filteredWidgets = computed(() => {
|
||||
const search = query.value.trim().toLocaleLowerCase(phone.lang)
|
||||
if (!search) return WIDGET_REGISTRY
|
||||
return WIDGET_REGISTRY.filter((definition) =>
|
||||
`${phone.t(definition.labelKey)} ${phone.t(definition.descriptionKey)}`
|
||||
.toLocaleLowerCase(phone.lang)
|
||||
.includes(search),
|
||||
)
|
||||
})
|
||||
const categories = computed(() => {
|
||||
const groups = new Map<string, WidgetDefinition[]>()
|
||||
for (const definition of filteredWidgets.value) {
|
||||
const group = groups.get(definition.categoryKey) ?? []
|
||||
group.push(definition)
|
||||
groups.set(definition.categoryKey, group)
|
||||
}
|
||||
return [...groups.entries()].map(([key, widgets]) => ({ key, widgets }))
|
||||
})
|
||||
const selectedDefinition = computed(
|
||||
() =>
|
||||
WIDGET_REGISTRY.find(
|
||||
(definition) => definition.kind === selectedKind.value,
|
||||
) ?? WIDGET_REGISTRY[0],
|
||||
)
|
||||
const previewInstance = computed<WidgetInstance>(() => ({
|
||||
column: 0,
|
||||
id: 'widget-preview',
|
||||
kind: selectedDefinition.value.kind,
|
||||
page: 0,
|
||||
row: 0,
|
||||
settings: {
|
||||
...(selectedDefinition.value.kind === 'clock' ? { showDate: true } : {}),
|
||||
...(selectedDefinition.value.kind === 'wallet'
|
||||
? { balanceSource: 'bank' as const }
|
||||
: {}),
|
||||
},
|
||||
size: selectedSize.value,
|
||||
}))
|
||||
const sheetColors = {
|
||||
bgIos: 'bg-[#f2f2f7] dark:bg-black',
|
||||
}
|
||||
|
||||
function inputValue(event: Event): string {
|
||||
return (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
function selectWidget(definition: WidgetDefinition): void {
|
||||
selectedKind.value = definition.kind
|
||||
if (!definition.supportedSizes.includes(selectedSize.value))
|
||||
selectedSize.value = definition.defaultSize
|
||||
}
|
||||
|
||||
function addWidget(): void {
|
||||
emit('add', selectedKind.value, selectedSize.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.opened,
|
||||
(opened) => {
|
||||
if (!opened) query.value = ''
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-sheet
|
||||
:opened="opened"
|
||||
class="widget-picker-sheet"
|
||||
:colors="sheetColors"
|
||||
@backdropclick="emit('close')"
|
||||
>
|
||||
<k-page
|
||||
class="widget-picker-page"
|
||||
:class="{ 'widget-picker-page--dark': phone.isDarkMode }"
|
||||
>
|
||||
<k-navbar :title="phone.t('Home.widgetSystem.galleryTitle')">
|
||||
<template #right>
|
||||
<k-link component="button" type="button" @click="emit('close')">
|
||||
{{ phone.t('Common.done') }}
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
|
||||
<div class="widget-picker-scroll">
|
||||
<k-searchbar
|
||||
:value="query"
|
||||
:placeholder="phone.t('Home.widgetSystem.search')"
|
||||
@input="query = inputValue($event)"
|
||||
@clear="query = ''"
|
||||
/>
|
||||
|
||||
<section class="widget-picker-preview">
|
||||
<SpringboardWidget
|
||||
:instance="previewInstance"
|
||||
preview
|
||||
:interactive="false"
|
||||
/>
|
||||
<h2>{{ phone.t(selectedDefinition.labelKey) }}</h2>
|
||||
<p>{{ phone.t(selectedDefinition.descriptionKey) }}</p>
|
||||
</section>
|
||||
|
||||
<section class="widget-picker-size">
|
||||
<span>{{ phone.t('Home.widgetSystem.size') }}</span>
|
||||
<k-segmented raised>
|
||||
<k-segmented-button
|
||||
v-for="size in selectedDefinition.supportedSizes"
|
||||
:key="size"
|
||||
:active="selectedSize === size"
|
||||
@click="selectedSize = size"
|
||||
>
|
||||
{{ phone.t(`Home.widgetSystem.sizes.${size}`) }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</section>
|
||||
|
||||
<k-button large rounded class="widget-picker-add" @click="addWidget">
|
||||
{{ phone.t('Home.widgetSystem.addWidget') }}
|
||||
</k-button>
|
||||
|
||||
<section
|
||||
v-for="category in categories"
|
||||
:key="category.key"
|
||||
class="widget-picker-category"
|
||||
>
|
||||
<h3>{{ phone.t(category.key) }}</h3>
|
||||
<k-list inset strong>
|
||||
<k-list-item
|
||||
v-for="definition in category.widgets"
|
||||
:key="definition.kind"
|
||||
link
|
||||
link-component="button"
|
||||
:title="phone.t(definition.labelKey)"
|
||||
:subtitle="phone.t(definition.descriptionKey)"
|
||||
@click="selectWidget(definition)"
|
||||
>
|
||||
<template #media>
|
||||
<span class="widget-picker-icon">
|
||||
<component :is="icons[definition.kind]" :size="21" />
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="selectedKind === definition.kind" #after>
|
||||
<Check :size="20" class="widget-picker-check" />
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
</section>
|
||||
|
||||
<p v-if="filteredWidgets.length === 0" class="widget-picker-empty">
|
||||
{{ phone.t('Home.widgetSystem.noResults') }}
|
||||
</p>
|
||||
</div>
|
||||
</k-page>
|
||||
</k-sheet>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.widget-picker-sheet) {
|
||||
z-index: 110;
|
||||
height: calc(100% - 22px);
|
||||
overflow: hidden;
|
||||
border-radius: 28px 28px 0 0;
|
||||
}
|
||||
|
||||
.widget-picker-page {
|
||||
height: 100%;
|
||||
color: #111;
|
||||
background: #f2f2f7;
|
||||
}
|
||||
|
||||
.widget-picker-page--dark {
|
||||
color: #fff;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.widget-picker-scroll {
|
||||
height: calc(100% - 54px);
|
||||
padding: 8px 13px 42px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.widget-picker-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.widget-picker-preview {
|
||||
display: flex;
|
||||
min-height: 235px;
|
||||
padding: 22px 14px 14px;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.widget-picker-preview :deep(.home-widget-shell) {
|
||||
max-height: 188px;
|
||||
}
|
||||
|
||||
.widget-picker-preview h2 {
|
||||
margin: 14px 0 3px;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.4px;
|
||||
}
|
||||
|
||||
.widget-picker-preview p {
|
||||
max-width: 285px;
|
||||
margin: 0;
|
||||
color: #6e6e73;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.widget-picker-page--dark .widget-picker-preview p {
|
||||
color: #98989d;
|
||||
}
|
||||
|
||||
.widget-picker-size {
|
||||
display: grid;
|
||||
margin: 0 4px 14px;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.widget-picker-size > span {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-picker-add {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.widget-picker-category h3 {
|
||||
margin: 18px 12px 7px;
|
||||
color: #6e6e73;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.widget-picker-category :deep([class*='title']) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.widget-picker-icon {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
background: #2c2c2e;
|
||||
}
|
||||
|
||||
.widget-picker-check {
|
||||
color: #0a84ff;
|
||||
}
|
||||
|
||||
.widget-picker-empty {
|
||||
padding: 40px 20px;
|
||||
color: #8e8e93;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -65,4 +65,7 @@ const formattedAmount = computed(() =>
|
||||
<style scoped>
|
||||
.citymarkt-offer{width:92%;padding:11px;border:1px solid #ffc92842;border-radius:14px;align-self:flex-start;background:linear-gradient(145deg,#332d19,var(--panel));box-shadow:0 7px 18px #0003}.citymarkt-offer--own{align-self:flex-end}.citymarkt-offer header{display:flex;align-items:center;gap:8px}.citymarkt-offer header>span{width:32px;height:32px;flex:none;border-radius:10px;display:grid;place-items:center;background:var(--yellow);color:#171816}.citymarkt-offer header>div{min-width:0;flex:1}.citymarkt-offer header small,.citymarkt-offer header strong{display:block}.citymarkt-offer header small{color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.02em;text-transform:uppercase}.citymarkt-offer header strong{margin-top:1px;font-size:18px}.citymarkt-offer header i{padding:5px 7px;border-radius:7px;background:#ffc92817;color:var(--yellow);font-size:8px;font-style:normal;font-weight:900;text-transform:uppercase}.citymarkt-offer>p{margin:7px 0 0;color:var(--muted);font-size:9px;line-height:1.35}.citymarkt-offer--accepted{border-color:#54d68173;background:linear-gradient(145deg,#193526,var(--panel))}.citymarkt-offer--accepted header>span{background:#54d681}.citymarkt-offer--accepted header i{background:#54d6811c;color:#67e494}.citymarkt-offer--rejected,.citymarkt-offer--countered{border-color:#ffffff14;filter:saturate(.65)}.citymarkt-offer--rejected header>span,.citymarkt-offer--countered header>span{background:#555750;color:#ddd}.citymarkt-offer--rejected header i,.citymarkt-offer--countered header i{background:#ffffff0c;color:var(--muted)}.citymarkt-offer__actions{margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:6px}.citymarkt-offer__actions button{min-height:35px;padding:7px 6px;border:1px solid #ffffff12;border-radius:10px;display:flex;align-items:center;justify-content:center;gap:4px;background:#ffffff09;color:inherit;font-size:9.5px;font-weight:850;line-height:1.1}.citymarkt-offer__actions button.accept{border:0;background:#54d681;color:#102319}.citymarkt-offer__actions button.reject{grid-column:1/-1;color:#ff8078}:global(.citymarkt--light) .citymarkt-offer{background:linear-gradient(145deg,#fff8d9,#fff);box-shadow:0 7px 18px #0001}:global(.citymarkt--light) .citymarkt-offer--accepted{background:linear-gradient(145deg,#e6faed,#fff)}
|
||||
.citymarkt-offer{padding:12px}.citymarkt-offer header>span{width:35px;height:35px}.citymarkt-offer header small{font-size:10.5px}.citymarkt-offer header strong{font-size:20px}.citymarkt-offer header i{padding:5px 8px;font-size:9.5px}.citymarkt-offer>p{margin-top:8px;font-size:11.5px}.citymarkt-offer__actions{margin-top:11px;gap:7px}.citymarkt-offer__actions button{min-height:40px;padding:8px;font-size:12px;font-weight:850;gap:5px}.citymarkt-offer__actions button svg{width:15px;height:15px}
|
||||
.citymarkt-offer:not(.citymarkt-offer--accepted) {
|
||||
border-color: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown } from 'lucide-vue-next'
|
||||
import { kGlass } from 'konsta/vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
type SelectOption = {
|
||||
@@ -20,7 +21,9 @@ const root = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const highlightedIndex = ref(0)
|
||||
const selectedLabel = computed(
|
||||
() => props.options.find((option) => option.value === props.modelValue)?.label ?? '',
|
||||
() =>
|
||||
props.options.find((option) => option.value === props.modelValue)?.label ??
|
||||
'',
|
||||
)
|
||||
|
||||
function open(): void {
|
||||
@@ -44,7 +47,8 @@ function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
if (!isOpen.value) open()
|
||||
else select(props.options[highlightedIndex.value]?.value ?? props.modelValue)
|
||||
else
|
||||
select(props.options[highlightedIndex.value]?.value ?? props.modelValue)
|
||||
return
|
||||
}
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
|
||||
@@ -52,7 +56,8 @@ function handleKeydown(event: KeyboardEvent): void {
|
||||
if (!isOpen.value) open()
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1
|
||||
highlightedIndex.value =
|
||||
(highlightedIndex.value + direction + props.options.length) % props.options.length
|
||||
(highlightedIndex.value + direction + props.options.length) %
|
||||
props.options.length
|
||||
}
|
||||
|
||||
function handleOutsidePointer(event: PointerEvent): void {
|
||||
@@ -60,12 +65,15 @@ function handleOutsidePointer(event: PointerEvent): void {
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('pointerdown', handleOutsidePointer))
|
||||
onUnmounted(() => window.removeEventListener('pointerdown', handleOutsidePointer))
|
||||
onUnmounted(() =>
|
||||
window.removeEventListener('pointerdown', handleOutsidePointer),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="citymarkt-select" @keydown="handleKeydown">
|
||||
<button
|
||||
<k-glass
|
||||
component="button"
|
||||
class="citymarkt-select__trigger"
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
@@ -74,7 +82,7 @@ onUnmounted(() => window.removeEventListener('pointerdown', handleOutsidePointer
|
||||
>
|
||||
<span>{{ selectedLabel }}</span>
|
||||
<ChevronDown :size="14" :class="{ open: isOpen }" />
|
||||
</button>
|
||||
</k-glass>
|
||||
|
||||
<Transition name="citymarkt-select">
|
||||
<div v-if="isOpen" class="citymarkt-select__menu" role="listbox">
|
||||
@@ -100,7 +108,105 @@ onUnmounted(() => window.removeEventListener('pointerdown', handleOutsidePointer
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.citymarkt-select{position:relative;min-width:0}.citymarkt-select__trigger{width:100%;height:36px;padding:0 10px;border:1px solid #ffffff0d;border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:6px;background:var(--panel);color:inherit;font-size:10px;text-align:left}.citymarkt-select__trigger span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.citymarkt-select__trigger svg{flex:none;color:var(--yellow);transition:transform .18s ease}.citymarkt-select__trigger svg.open{transform:rotate(180deg)}.citymarkt-select__menu{position:absolute;z-index:12;top:calc(100% + 5px);right:0;left:0;max-height:176px;padding:4px;border:1px solid #ffffff16;border-radius:11px;overflow-y:auto;background:#292a27;box-shadow:0 12px 28px #0009;scrollbar-width:none}:global(.citymarkt--light) .citymarkt-select__menu{border-color:#00000014;background:#fff;box-shadow:0 12px 28px #0003}.citymarkt-select__menu button{width:100%;min-height:31px;padding:6px 7px;border:0;border-radius:8px;display:flex;align-items:center;justify-content:space-between;gap:5px;background:none;color:var(--muted);font-size:10px;text-align:left}.citymarkt-select__menu button.highlighted{background:#ffffff0b;color:inherit}:global(.citymarkt--light) .citymarkt-select__menu button.highlighted{background:#0000000b}.citymarkt-select__menu button.selected{color:var(--yellow);font-weight:800}.citymarkt-select-enter-active,.citymarkt-select-leave-active{transition:opacity .15s ease,transform .15s ease}.citymarkt-select-enter-from,.citymarkt-select-leave-to{opacity:0;transform:translateY(-4px) scale(.98)}
|
||||
.citymarkt-select__trigger{height:40px;font-size:13px}
|
||||
.citymarkt-select__menu button{min-height:36px;padding:7px 8px;font-size:12px}
|
||||
.citymarkt-select {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.citymarkt-select__trigger {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.citymarkt-select__trigger span {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.citymarkt-select__trigger svg {
|
||||
flex: none;
|
||||
color: var(--yellow);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.citymarkt-select__trigger svg.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.citymarkt-select__menu {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: calc(100% + 5px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
max-height: 176px;
|
||||
padding: 4px;
|
||||
border: 1px solid #ffffff14;
|
||||
border-radius: 11px;
|
||||
overflow-y: auto;
|
||||
background: #292a27;
|
||||
box-shadow: 0 12px 28px #0009;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
:global(.citymarkt--light) .citymarkt-select__menu {
|
||||
border-color: #00000012;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 28px #0003;
|
||||
}
|
||||
.citymarkt-select__menu button {
|
||||
width: 100%;
|
||||
min-height: 31px;
|
||||
padding: 6px 7px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.citymarkt-select__menu button.highlighted {
|
||||
background: #ffffff0b;
|
||||
color: inherit;
|
||||
}
|
||||
:global(.citymarkt--light) .citymarkt-select__menu button.highlighted {
|
||||
background: #0000000b;
|
||||
}
|
||||
.citymarkt-select__menu button.selected {
|
||||
background: #ffffff14;
|
||||
color: inherit;
|
||||
font-weight: 800;
|
||||
}
|
||||
:global(.citymarkt--light) .citymarkt-select__menu button.selected {
|
||||
background: #0000000d;
|
||||
}
|
||||
.citymarkt-select-enter-active,
|
||||
.citymarkt-select-leave-active {
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
.citymarkt-select-enter-from,
|
||||
.citymarkt-select-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.98);
|
||||
}
|
||||
.citymarkt-select__trigger {
|
||||
height: 40px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.citymarkt-select__menu button {
|
||||
min-height: 36px;
|
||||
padding: 7px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user