mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
7cdfac3285
Introduces Graphics Mode settings to toggle between "Performance" (blur-free) and "Ultimate" (full effects) to optimize CEF rendering. Expands device customization with ten new phone frames, standardizes 24-hour time formatting across all apps, and updates the build target to Chrome 103 for better compatibility.
2093 lines
49 KiB
Vue
2093 lines
49 KiB
Vue
<script setup lang="ts">
|
||
import {
|
||
Bell,
|
||
CalendarDays,
|
||
Check,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Circle,
|
||
Clock3,
|
||
FileText,
|
||
LayoutGrid,
|
||
List,
|
||
Plus,
|
||
Rows3,
|
||
Search,
|
||
Trash2,
|
||
UserRound,
|
||
X,
|
||
} from 'lucide-vue-next'
|
||
import { kFab } from 'konsta/vue'
|
||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||
|
||
import { useAccountStore } from '@/stores/account'
|
||
import { useCalendarStore } from '@/stores/calendar'
|
||
import { usePhoneStore } from '@/stores/phone'
|
||
import type { CalendarEvent, CalendarEventDraft } from '@/types/calendar'
|
||
|
||
type CalendarScreen = 'detail' | 'form' | 'main'
|
||
type CalendarViewMode = 'compact' | 'details' | 'list' | 'stacked'
|
||
type TimeField = 'endTime' | 'startTime'
|
||
type YearMonth = {
|
||
cells: Array<Date | null>
|
||
date: Date
|
||
key: string
|
||
label: string
|
||
}
|
||
type YearOverview = {
|
||
months: YearMonth[]
|
||
year: number
|
||
}
|
||
|
||
const phone = usePhoneStore()
|
||
const account = useAccountStore()
|
||
const calendar = useCalendarStore()
|
||
const today = new Date()
|
||
const visibleYear = ref(today.getFullYear())
|
||
const selectedDate = ref(dateKey(today))
|
||
const selectedEvent = ref<CalendarEvent | null>(null)
|
||
const editingEvent = ref<CalendarEvent | null>(null)
|
||
const screen = ref<CalendarScreen>('main')
|
||
const viewMode = ref<CalendarViewMode>('details')
|
||
const viewMenuOpen = ref(false)
|
||
const yearOverviewOpen = ref(false)
|
||
const searchOpen = ref(false)
|
||
const searchQuery = ref('')
|
||
const reminderOpen = ref(false)
|
||
const saving = ref(false)
|
||
const formError = ref('')
|
||
const timePickerField = ref<TimeField | null>(null)
|
||
const pickerHour = ref('09')
|
||
const pickerMinute = ref('00')
|
||
const hourColumn = ref<HTMLElement | null>(null)
|
||
const minuteColumn = ref<HTMLElement | null>(null)
|
||
const calendarScroll = ref<HTMLElement | null>(null)
|
||
const yearOverviewScroll = ref<HTMLElement | null>(null)
|
||
const draft = reactive({
|
||
date: selectedDate.value,
|
||
endTime: '10:00',
|
||
note: '',
|
||
reminderMinutes: 60 as number | null,
|
||
startTime: '09:00',
|
||
title: '',
|
||
})
|
||
const reminderOptions: Array<number | null> = [null, 0, 10, 30, 60, 1440]
|
||
const hourOptions = Array.from({ length: 24 }, (_, index) =>
|
||
String(index).padStart(2, '0'),
|
||
)
|
||
const minuteOptions = Array.from({ length: 12 }, (_, index) =>
|
||
String(index * 5).padStart(2, '0'),
|
||
)
|
||
const cornerButtonColors = {
|
||
bgIos: 'bg-ios-light-glass/75 dark:bg-ios-dark-glass/75',
|
||
activeBgIos: 'active:bg-white/90 dark:active:bg-white/20',
|
||
textIos: 'text-black/80 dark:text-white/80',
|
||
}
|
||
const viewModes: Array<{
|
||
icon: typeof LayoutGrid
|
||
id: CalendarViewMode
|
||
labelKey: string
|
||
}> = [
|
||
{
|
||
icon: LayoutGrid,
|
||
id: 'compact',
|
||
labelKey: 'Apps.calendar.views.compact',
|
||
},
|
||
{
|
||
icon: Rows3,
|
||
id: 'stacked',
|
||
labelKey: 'Apps.calendar.views.stacked',
|
||
},
|
||
{
|
||
icon: CalendarDays,
|
||
id: 'details',
|
||
labelKey: 'Apps.calendar.views.details',
|
||
},
|
||
{
|
||
icon: List,
|
||
id: 'list',
|
||
labelKey: 'Apps.calendar.views.list',
|
||
},
|
||
]
|
||
|
||
const isAuthenticated = computed(() => account.email !== '')
|
||
const timePickerTitle = computed(() =>
|
||
timePickerField.value === 'startTime'
|
||
? phone.t('Apps.calendar.starts')
|
||
: phone.t('Apps.calendar.ends'),
|
||
)
|
||
const weekdayLabels = computed(() => {
|
||
const monday = new Date(2026, 0, 5)
|
||
return Array.from({ length: 7 }, (_, index) =>
|
||
new Intl.DateTimeFormat(phone.lang, { weekday: 'narrow' }).format(
|
||
addDays(monday, index),
|
||
),
|
||
)
|
||
})
|
||
const yearMonths = computed<YearMonth[]>(() =>
|
||
Array.from({ length: 12 }, (_, monthIndex) => {
|
||
const date = new Date(visibleYear.value, monthIndex, 1)
|
||
return {
|
||
cells: monthCells(date),
|
||
date,
|
||
key: `${visibleYear.value}-${String(monthIndex + 1).padStart(2, '0')}`,
|
||
label: new Intl.DateTimeFormat(phone.lang, { month: 'long' }).format(
|
||
date,
|
||
),
|
||
}
|
||
}),
|
||
)
|
||
const yearOverview = computed<YearOverview[]>(() =>
|
||
Array.from({ length: 11 }, (_, yearIndex) => {
|
||
const year = visibleYear.value - 5 + yearIndex
|
||
return {
|
||
months: Array.from({ length: 12 }, (_, monthIndex) => {
|
||
const date = new Date(year, monthIndex, 1)
|
||
return {
|
||
cells: monthCells(date),
|
||
date,
|
||
key: `${year}-${String(monthIndex + 1).padStart(2, '0')}`,
|
||
label: new Intl.DateTimeFormat(phone.lang, { month: 'long' }).format(
|
||
date,
|
||
),
|
||
}
|
||
}),
|
||
year,
|
||
}
|
||
}),
|
||
)
|
||
const normalizedSearch = computed(() => searchQuery.value.trim().toLowerCase())
|
||
const eventsByDate = computed(() => {
|
||
const groups = new Map<string, CalendarEvent[]>()
|
||
for (const event of [...calendar.events]
|
||
.filter(matchesSearch)
|
||
.sort((left, right) => left.startsAt - right.startsAt)) {
|
||
const key = dateKey(event.startsAt)
|
||
const events = groups.get(key) ?? []
|
||
events.push(event)
|
||
groups.set(key, events)
|
||
}
|
||
return groups
|
||
})
|
||
const monthEventGroups = computed(() => {
|
||
return [...eventsByDate.value.entries()].map(([key, events]) => ({
|
||
events,
|
||
key,
|
||
label: new Intl.DateTimeFormat(phone.lang, {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
weekday: 'long',
|
||
}).format(new Date(`${key}T12:00:00`)),
|
||
}))
|
||
})
|
||
|
||
function addDays(date: Date, amount: number): Date {
|
||
const next = new Date(date)
|
||
next.setDate(next.getDate() + amount)
|
||
return next
|
||
}
|
||
|
||
function monthCells(month: Date): Array<Date | null> {
|
||
const first = new Date(month.getFullYear(), month.getMonth(), 1)
|
||
const leading = (first.getDay() + 6) % 7
|
||
const daysInMonth = new Date(
|
||
month.getFullYear(),
|
||
month.getMonth() + 1,
|
||
0,
|
||
).getDate()
|
||
const cellCount = Math.ceil((leading + daysInMonth) / 7) * 7
|
||
return Array.from({ length: cellCount }, (_, index) => {
|
||
const day = index - leading + 1
|
||
return day > 0 && day <= daysInMonth
|
||
? new Date(month.getFullYear(), month.getMonth(), day)
|
||
: null
|
||
})
|
||
}
|
||
|
||
function dateKey(value: Date | number): string {
|
||
const date = value instanceof Date ? value : new Date(value)
|
||
return [
|
||
date.getFullYear(),
|
||
String(date.getMonth() + 1).padStart(2, '0'),
|
||
String(date.getDate()).padStart(2, '0'),
|
||
].join('-')
|
||
}
|
||
|
||
function timeValue(value: number): string {
|
||
const date = new Date(value)
|
||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||
}
|
||
|
||
function combineDateAndTime(date: string, time: string): number {
|
||
return new Date(`${date}T${time}:00`).getTime()
|
||
}
|
||
|
||
function matchesSearch(event: CalendarEvent): boolean {
|
||
if (!normalizedSearch.value) return true
|
||
return `${event.title} ${event.note}`
|
||
.toLowerCase()
|
||
.includes(normalizedSearch.value)
|
||
}
|
||
|
||
function eventsForDay(day: Date): CalendarEvent[] {
|
||
return eventsByDate.value.get(dateKey(day)) ?? []
|
||
}
|
||
|
||
function formatTime(value: number): string {
|
||
return new Intl.DateTimeFormat(phone.lang, {
|
||
hour: '2-digit',
|
||
hourCycle: 'h23',
|
||
minute: '2-digit',
|
||
}).format(value)
|
||
}
|
||
|
||
function formatLongDate(value: number): string {
|
||
return new Intl.DateTimeFormat(phone.lang, {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
weekday: 'long',
|
||
year: 'numeric',
|
||
}).format(value)
|
||
}
|
||
|
||
function reminderLabel(minutes: number | null): string {
|
||
if (minutes === null) return phone.t('Apps.calendar.reminders.none')
|
||
if (minutes === 0) return phone.t('Apps.calendar.reminders.atStart')
|
||
if (minutes === 10) return phone.t('Apps.calendar.reminders.tenMinutes')
|
||
if (minutes === 30) return phone.t('Apps.calendar.reminders.thirtyMinutes')
|
||
if (minutes === 60) return phone.t('Apps.calendar.reminders.oneHour')
|
||
return phone.t('Apps.calendar.reminders.oneDay')
|
||
}
|
||
|
||
async function loadYear(): Promise<void> {
|
||
if (!isAuthenticated.value) {
|
||
calendar.events = []
|
||
return
|
||
}
|
||
const start = new Date(visibleYear.value, 0, 1)
|
||
const end = new Date(visibleYear.value + 1, 0, 1)
|
||
await calendar.load(start.getTime(), end.getTime())
|
||
}
|
||
|
||
async function moveYear(amount: number): Promise<void> {
|
||
visibleYear.value += amount
|
||
selectedDate.value = `${visibleYear.value}-01-01`
|
||
await loadYear()
|
||
await nextTick()
|
||
calendarScroll.value?.scrollTo({ behavior: 'smooth', top: 0 })
|
||
}
|
||
|
||
async function goToday(): Promise<void> {
|
||
const current = new Date()
|
||
visibleYear.value = current.getFullYear()
|
||
selectedDate.value = dateKey(current)
|
||
await loadYear()
|
||
await nextTick()
|
||
if (yearOverviewOpen.value) {
|
||
yearOverviewScroll.value
|
||
?.querySelector<HTMLElement>(`[data-year="${visibleYear.value}"]`)
|
||
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
} else {
|
||
scrollToMonth(current)
|
||
}
|
||
}
|
||
|
||
function selectDay(day: Date): void {
|
||
selectedDate.value = dateKey(day)
|
||
}
|
||
|
||
function selectView(mode: CalendarViewMode): void {
|
||
viewMode.value = mode
|
||
viewMenuOpen.value = false
|
||
}
|
||
|
||
async function openYearOverview(): Promise<void> {
|
||
viewMenuOpen.value = false
|
||
yearOverviewOpen.value = true
|
||
await nextTick()
|
||
yearOverviewScroll.value
|
||
?.querySelector<HTMLElement>(`[data-year="${visibleYear.value}"]`)
|
||
?.scrollIntoView({ block: 'start' })
|
||
}
|
||
|
||
async function selectOverviewDay(day: Date): Promise<void> {
|
||
visibleYear.value = day.getFullYear()
|
||
selectedDate.value = dateKey(day)
|
||
yearOverviewOpen.value = false
|
||
await loadYear()
|
||
await nextTick()
|
||
scrollToMonth(day, 'auto')
|
||
}
|
||
|
||
function scrollToMonth(date: Date, behavior: ScrollBehavior = 'smooth'): void {
|
||
calendarScroll.value
|
||
?.querySelector<HTMLElement>(`[data-month="${dateKey(date).slice(0, 7)}"]`)
|
||
?.scrollIntoView({ behavior, block: 'start' })
|
||
}
|
||
|
||
function toggleSearch(): void {
|
||
searchOpen.value = !searchOpen.value
|
||
if (!searchOpen.value) searchQuery.value = ''
|
||
}
|
||
|
||
function resetDraft(event?: CalendarEvent): void {
|
||
editingEvent.value = event ?? null
|
||
draft.title = event?.title ?? ''
|
||
draft.note = event?.note ?? ''
|
||
draft.date = event ? dateKey(event.startsAt) : selectedDate.value
|
||
draft.startTime = event ? timeValue(event.startsAt) : '09:00'
|
||
draft.endTime = event ? timeValue(event.endsAt) : '10:00'
|
||
draft.reminderMinutes = event?.reminderMinutes ?? 60
|
||
reminderOpen.value = false
|
||
formError.value = ''
|
||
}
|
||
|
||
function openCreate(): void {
|
||
resetDraft()
|
||
screen.value = 'form'
|
||
}
|
||
|
||
function openDetail(event: CalendarEvent): void {
|
||
selectedEvent.value = event
|
||
screen.value = 'detail'
|
||
}
|
||
|
||
function openEdit(): void {
|
||
if (!selectedEvent.value) return
|
||
resetDraft(selectedEvent.value)
|
||
screen.value = 'form'
|
||
}
|
||
|
||
function closeForm(): void {
|
||
timePickerField.value = null
|
||
screen.value = editingEvent.value ? 'detail' : 'main'
|
||
}
|
||
|
||
async function openTimePicker(field: TimeField): Promise<void> {
|
||
const [hour, minute] = draft[field].split(':')
|
||
timePickerField.value = field
|
||
pickerHour.value = hour ?? '09'
|
||
pickerMinute.value = minute ?? '00'
|
||
await nextTick()
|
||
hourColumn.value
|
||
?.querySelector(`[data-value="${pickerHour.value}"]`)
|
||
?.scrollIntoView({ block: 'center' })
|
||
minuteColumn.value
|
||
?.querySelector(`[data-value="${pickerMinute.value}"]`)
|
||
?.scrollIntoView({ block: 'center' })
|
||
}
|
||
|
||
function confirmTimePicker(): void {
|
||
if (!timePickerField.value) return
|
||
draft[timePickerField.value] = `${pickerHour.value}:${pickerMinute.value}`
|
||
timePickerField.value = null
|
||
}
|
||
|
||
function selectPickerValue(
|
||
column: 'hour' | 'minute',
|
||
value: string,
|
||
target: EventTarget | null,
|
||
): void {
|
||
if (column === 'hour') pickerHour.value = value
|
||
else pickerMinute.value = value
|
||
if (target instanceof HTMLElement) {
|
||
target.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||
}
|
||
}
|
||
|
||
async function saveEvent(): Promise<void> {
|
||
const startsAt = combineDateAndTime(draft.date, draft.startTime)
|
||
const endsAt = combineDateAndTime(draft.date, draft.endTime)
|
||
if (!draft.title.trim() || endsAt <= startsAt) {
|
||
formError.value = phone.t('Apps.calendar.errors.invalid_event')
|
||
return
|
||
}
|
||
const payload: CalendarEventDraft = {
|
||
endsAt,
|
||
note: draft.note.trim(),
|
||
reminderMinutes: draft.reminderMinutes,
|
||
startsAt,
|
||
title: draft.title.trim(),
|
||
}
|
||
saving.value = true
|
||
const success = editingEvent.value
|
||
? await calendar.update(editingEvent.value, payload)
|
||
: await calendar.create(payload)
|
||
saving.value = false
|
||
if (!success) {
|
||
formError.value = phone.t(
|
||
`Apps.calendar.errors.${calendar.error || 'default'}`,
|
||
)
|
||
return
|
||
}
|
||
selectedDate.value = draft.date
|
||
visibleYear.value = new Date(startsAt).getFullYear()
|
||
await loadYear()
|
||
screen.value = 'main'
|
||
}
|
||
|
||
async function deleteEvent(): Promise<void> {
|
||
if (
|
||
!selectedEvent.value ||
|
||
!(await calendar.deleteEvent(selectedEvent.value.id))
|
||
) {
|
||
return
|
||
}
|
||
selectedEvent.value = null
|
||
screen.value = 'main'
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await loadYear()
|
||
await nextTick()
|
||
scrollToMonth(today, 'auto')
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<main class="calendar" :class="{ 'calendar--light': !phone.isDarkMode }">
|
||
<template v-if="screen === 'main'">
|
||
<header
|
||
class="calendar__toolbar"
|
||
:class="{ 'calendar__toolbar--year': yearOverviewOpen }"
|
||
>
|
||
<k-fab
|
||
v-if="!yearOverviewOpen"
|
||
component="button"
|
||
type="button"
|
||
:text="String(visibleYear)"
|
||
text-position="after"
|
||
:colors="cornerButtonColors"
|
||
:aria-label="String(visibleYear)"
|
||
@click="openYearOverview"
|
||
>
|
||
<template #icon>
|
||
<ChevronLeft :size="22" />
|
||
</template>
|
||
</k-fab>
|
||
<div class="calendar__toolbar-actions">
|
||
<k-fab
|
||
v-if="!yearOverviewOpen"
|
||
component="button"
|
||
:colors="cornerButtonColors"
|
||
:aria-label="phone.t(`Apps.calendar.views.${viewMode}`)"
|
||
type="button"
|
||
@click="viewMenuOpen = !viewMenuOpen"
|
||
>
|
||
<template #icon>
|
||
<component
|
||
:is="viewModes.find((mode) => mode.id === viewMode)?.icon"
|
||
:size="19"
|
||
/>
|
||
</template>
|
||
</k-fab>
|
||
<k-fab
|
||
component="button"
|
||
:colors="cornerButtonColors"
|
||
:aria-label="phone.t('Common.search')"
|
||
type="button"
|
||
@click="toggleSearch"
|
||
>
|
||
<template #icon>
|
||
<X v-if="searchOpen" :size="19" />
|
||
<Search v-else :size="19" />
|
||
</template>
|
||
</k-fab>
|
||
<k-fab
|
||
v-if="isAuthenticated"
|
||
component="button"
|
||
:colors="cornerButtonColors"
|
||
:aria-label="phone.t('Apps.calendar.newEvent')"
|
||
type="button"
|
||
@click="openCreate"
|
||
>
|
||
<template #icon>
|
||
<Plus :size="22" />
|
||
</template>
|
||
</k-fab>
|
||
</div>
|
||
|
||
<Transition name="calendar-popover">
|
||
<div v-if="viewMenuOpen" class="calendar__view-menu">
|
||
<button
|
||
v-for="mode in viewModes"
|
||
:key="mode.id"
|
||
:class="{ active: viewMode === mode.id }"
|
||
type="button"
|
||
@click="selectView(mode.id)"
|
||
>
|
||
<component :is="mode.icon" :size="19" />
|
||
<span>{{ phone.t(mode.labelKey) }}</span>
|
||
<Check v-if="viewMode === mode.id" :size="17" />
|
||
</button>
|
||
</div>
|
||
</Transition>
|
||
</header>
|
||
|
||
<Transition name="calendar-search">
|
||
<div v-if="searchOpen" class="calendar__search">
|
||
<Search :size="17" />
|
||
<input
|
||
v-model="searchQuery"
|
||
autofocus
|
||
type="search"
|
||
:placeholder="phone.t('Apps.calendar.searchPlaceholder')"
|
||
/>
|
||
</div>
|
||
</Transition>
|
||
|
||
<section v-if="!isAuthenticated" class="calendar__auth">
|
||
<span><UserRound :size="33" /></span>
|
||
<h1>{{ phone.t('Apps.calendar.signInTitle') }}</h1>
|
||
<p>{{ phone.t('Apps.calendar.signInBody') }}</p>
|
||
</section>
|
||
|
||
<div
|
||
v-else-if="yearOverviewOpen"
|
||
ref="yearOverviewScroll"
|
||
class="calendar__year-overview"
|
||
>
|
||
<section
|
||
v-for="overview in yearOverview"
|
||
:key="overview.year"
|
||
class="calendar__overview-year"
|
||
:data-year="overview.year"
|
||
>
|
||
<h1>{{ overview.year }}</h1>
|
||
<div class="calendar__overview-months">
|
||
<section
|
||
v-for="month in overview.months"
|
||
:key="month.key"
|
||
class="calendar__overview-month"
|
||
>
|
||
<h2
|
||
:class="{
|
||
current:
|
||
month.date.getFullYear() === today.getFullYear() &&
|
||
month.date.getMonth() === today.getMonth(),
|
||
}"
|
||
>
|
||
{{ month.label }}
|
||
</h2>
|
||
<div>
|
||
<template
|
||
v-for="(day, cellIndex) in month.cells"
|
||
:key="day ? dateKey(day) : `${month.key}-${cellIndex}`"
|
||
>
|
||
<button
|
||
v-if="day"
|
||
type="button"
|
||
:class="{
|
||
selected: dateKey(day) === selectedDate,
|
||
today: dateKey(day) === dateKey(today),
|
||
}"
|
||
@click="selectOverviewDay(day)"
|
||
>
|
||
{{ day.getDate() }}
|
||
</button>
|
||
<span v-else />
|
||
</template>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div
|
||
v-else
|
||
ref="calendarScroll"
|
||
class="calendar__content"
|
||
:class="`calendar__content--${viewMode}`"
|
||
>
|
||
<template v-if="viewMode !== 'list'">
|
||
<section
|
||
v-for="month in yearMonths"
|
||
:key="month.key"
|
||
class="calendar__year-month"
|
||
:data-month="month.key"
|
||
>
|
||
<h1>{{ month.label }}</h1>
|
||
<div class="calendar__weekdays">
|
||
<span
|
||
v-for="(weekday, weekdayIndex) in weekdayLabels"
|
||
:key="`${month.key}-${weekdayIndex}`"
|
||
:class="{ weekend: weekdayIndex > 4 }"
|
||
>
|
||
{{ weekday }}
|
||
</span>
|
||
</div>
|
||
<div
|
||
class="calendar__year-grid"
|
||
:class="`calendar__year-grid--${viewMode}`"
|
||
>
|
||
<template
|
||
v-for="(day, cellIndex) in month.cells"
|
||
:key="day ? dateKey(day) : `${month.key}-blank-${cellIndex}`"
|
||
>
|
||
<button
|
||
v-if="day"
|
||
type="button"
|
||
:class="{
|
||
selected: dateKey(day) === selectedDate,
|
||
today: dateKey(day) === dateKey(today),
|
||
weekend: cellIndex % 7 > 4,
|
||
}"
|
||
@click="selectDay(day)"
|
||
>
|
||
<span class="calendar__day-number">{{ day.getDate() }}</span>
|
||
<span
|
||
v-if="
|
||
viewMode === 'compact' && eventsForDay(day).length
|
||
"
|
||
class="calendar__compact-indicator"
|
||
/>
|
||
<span
|
||
v-else-if="
|
||
viewMode === 'stacked' && eventsForDay(day).length
|
||
"
|
||
class="calendar__event-bars"
|
||
>
|
||
<i
|
||
v-for="(event, eventIndex) in eventsForDay(day).slice(0, 3)"
|
||
:key="event.id"
|
||
:class="`calendar__event-dot--${eventIndex % 3}`"
|
||
/>
|
||
</span>
|
||
<span
|
||
v-else-if="
|
||
viewMode === 'details' && eventsForDay(day).length
|
||
"
|
||
class="calendar__event-titles"
|
||
>
|
||
<i
|
||
v-for="event in eventsForDay(day).slice(0, 2)"
|
||
:key="event.id"
|
||
>
|
||
{{ event.title }}
|
||
</i>
|
||
</span>
|
||
</button>
|
||
<span v-else class="calendar__blank-day" />
|
||
</template>
|
||
</div>
|
||
</section>
|
||
|
||
<button
|
||
class="calendar__next-year"
|
||
type="button"
|
||
@click="moveYear(1)"
|
||
>
|
||
<span>{{ visibleYear + 1 }}</span>
|
||
<ChevronRight :size="19" />
|
||
</button>
|
||
</template>
|
||
|
||
<section v-else class="calendar__list-view">
|
||
<div v-if="calendar.loading" class="calendar__empty-copy">
|
||
{{ phone.t('Common.loading') }}
|
||
</div>
|
||
<div
|
||
v-else-if="!monthEventGroups.length"
|
||
class="calendar__empty-copy calendar__empty-copy--list"
|
||
>
|
||
<CalendarDays :size="38" />
|
||
<strong>{{ phone.t('Apps.calendar.noEvents') }}</strong>
|
||
<span>{{ phone.t('Apps.calendar.noEventsBody') }}</span>
|
||
</div>
|
||
<section
|
||
v-for="group in monthEventGroups"
|
||
v-else
|
||
:key="group.key"
|
||
class="calendar__list-group"
|
||
>
|
||
<header>
|
||
<strong>{{ group.label }}</strong>
|
||
<span>{{ group.events.length }}</span>
|
||
</header>
|
||
<button
|
||
v-for="event in group.events"
|
||
:key="event.id"
|
||
type="button"
|
||
@click="openDetail(event)"
|
||
>
|
||
<i />
|
||
<span>
|
||
<strong>{{ event.title }}</strong>
|
||
<small>
|
||
{{ formatTime(event.startsAt) }} –
|
||
{{ formatTime(event.endsAt) }}
|
||
</small>
|
||
</span>
|
||
<ChevronRight :size="17" />
|
||
</button>
|
||
</section>
|
||
</section>
|
||
</div>
|
||
|
||
<footer v-if="isAuthenticated" class="calendar__footer">
|
||
<k-fab
|
||
component="button"
|
||
type="button"
|
||
:colors="cornerButtonColors"
|
||
:text="phone.t('Apps.calendar.today')"
|
||
@click="goToday"
|
||
/>
|
||
</footer>
|
||
</template>
|
||
|
||
<section
|
||
v-else-if="screen === 'detail' && selectedEvent"
|
||
class="calendar__detail"
|
||
>
|
||
<header class="calendar__sheet-header">
|
||
<button type="button" @click="screen = 'main'">
|
||
<ChevronLeft :size="20" />
|
||
{{ phone.t('Apps.calendar.name') }}
|
||
</button>
|
||
<strong>{{ phone.t('Apps.calendar.event') }}</strong>
|
||
<button type="button" @click="openEdit">
|
||
{{ phone.t('Common.edit') }}
|
||
</button>
|
||
</header>
|
||
<div class="calendar__sheet-scroll">
|
||
<section class="calendar__detail-title">
|
||
<i />
|
||
<div>
|
||
<h1>{{ selectedEvent.title }}</h1>
|
||
<span>{{ phone.t('Apps.calendar.calendarName') }}</span>
|
||
</div>
|
||
</section>
|
||
<section class="calendar__group">
|
||
<div class="calendar__group-row calendar__group-row--tall">
|
||
<Clock3 :size="20" />
|
||
<div>
|
||
<small>{{ phone.t('Apps.calendar.date') }}</small>
|
||
<strong>{{ formatLongDate(selectedEvent.startsAt) }}</strong>
|
||
<span>
|
||
{{ formatTime(selectedEvent.startsAt) }} –
|
||
{{ formatTime(selectedEvent.endsAt) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="calendar__group-row">
|
||
<Bell :size="20" />
|
||
<span>{{ phone.t('Apps.calendar.reminder') }}</span>
|
||
<strong>{{ reminderLabel(selectedEvent.reminderMinutes) }}</strong>
|
||
</div>
|
||
</section>
|
||
<section v-if="selectedEvent.note" class="calendar__group">
|
||
<div class="calendar__group-row calendar__group-row--note">
|
||
<FileText :size="20" />
|
||
<p>{{ selectedEvent.note }}</p>
|
||
</div>
|
||
</section>
|
||
<button class="calendar__delete" type="button" @click="deleteEvent">
|
||
<Trash2 :size="18" />
|
||
{{ phone.t('Apps.calendar.deleteEvent') }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-else class="calendar__form">
|
||
<header class="calendar__sheet-header">
|
||
<button type="button" @click="closeForm">
|
||
{{ phone.t('Common.cancel') }}
|
||
</button>
|
||
<strong>
|
||
{{
|
||
editingEvent
|
||
? phone.t('Apps.calendar.editEvent')
|
||
: phone.t('Apps.calendar.newEvent')
|
||
}}
|
||
</strong>
|
||
<button :disabled="saving" type="button" @click="saveEvent">
|
||
{{ editingEvent ? phone.t('Common.save') : phone.t('Common.add') }}
|
||
</button>
|
||
</header>
|
||
<form class="calendar__sheet-scroll" @submit.prevent="saveEvent">
|
||
<section class="calendar__group calendar__group--fields">
|
||
<input
|
||
v-model="draft.title"
|
||
maxlength="120"
|
||
:placeholder="phone.t('Apps.calendar.title')"
|
||
/>
|
||
<textarea
|
||
v-model="draft.note"
|
||
maxlength="2000"
|
||
:placeholder="phone.t('Apps.calendar.notePlaceholder')"
|
||
/>
|
||
</section>
|
||
|
||
<section class="calendar__group calendar__group--fields">
|
||
<label>
|
||
<span>{{ phone.t('Apps.calendar.date') }}</span>
|
||
<input v-model="draft.date" type="date" />
|
||
</label>
|
||
<button
|
||
class="calendar__date-time-row"
|
||
type="button"
|
||
@click="openTimePicker('startTime')"
|
||
>
|
||
<span>{{ phone.t('Apps.calendar.starts') }}</span>
|
||
<strong>{{ draft.startTime }}</strong>
|
||
<ChevronRight :size="17" />
|
||
</button>
|
||
<button
|
||
class="calendar__date-time-row"
|
||
type="button"
|
||
@click="openTimePicker('endTime')"
|
||
>
|
||
<span>{{ phone.t('Apps.calendar.ends') }}</span>
|
||
<strong>{{ draft.endTime }}</strong>
|
||
<ChevronRight :size="17" />
|
||
</button>
|
||
</section>
|
||
|
||
<section class="calendar__group calendar__group--fields">
|
||
<button
|
||
class="calendar__form-row"
|
||
type="button"
|
||
@click="reminderOpen = !reminderOpen"
|
||
>
|
||
<Bell :size="19" />
|
||
<span>{{ phone.t('Apps.calendar.reminder') }}</span>
|
||
<strong>{{ reminderLabel(draft.reminderMinutes) }}</strong>
|
||
<ChevronRight :size="17" :class="{ open: reminderOpen }" />
|
||
</button>
|
||
<div v-if="reminderOpen" class="calendar__reminders">
|
||
<button
|
||
v-for="option in reminderOptions"
|
||
:key="String(option)"
|
||
type="button"
|
||
:class="{ active: draft.reminderMinutes === option }"
|
||
@click="draft.reminderMinutes = option"
|
||
>
|
||
<span>{{ reminderLabel(option) }}</span>
|
||
<Check v-if="draft.reminderMinutes === option" :size="18" />
|
||
</button>
|
||
</div>
|
||
<div class="calendar__form-row">
|
||
<Circle :size="17" fill="#ff3b30" color="#ff3b30" />
|
||
<span>{{ phone.t('Apps.calendar.calendar') }}</span>
|
||
<strong>{{ phone.t('Apps.calendar.calendarName') }}</strong>
|
||
</div>
|
||
</section>
|
||
|
||
<p v-if="formError" class="calendar__error">{{ formError }}</p>
|
||
</form>
|
||
</section>
|
||
|
||
<Transition name="calendar-picker">
|
||
<div
|
||
v-if="timePickerField"
|
||
class="calendar__picker-backdrop"
|
||
role="presentation"
|
||
@click.self="timePickerField = null"
|
||
>
|
||
<section
|
||
class="calendar__time-picker"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
:aria-label="timePickerTitle"
|
||
>
|
||
<header>
|
||
<button type="button" @click="timePickerField = null">
|
||
{{ phone.t('Common.cancel') }}
|
||
</button>
|
||
<strong>{{ timePickerTitle }}</strong>
|
||
<button type="button" @click="confirmTimePicker">
|
||
{{ phone.t('Common.done') }}
|
||
</button>
|
||
</header>
|
||
|
||
<div class="calendar__time-preview">
|
||
{{ pickerHour }}<span>:</span>{{ pickerMinute }}
|
||
</div>
|
||
|
||
<div class="calendar__time-wheels">
|
||
<div ref="hourColumn" class="calendar__time-column">
|
||
<button
|
||
v-for="hour in hourOptions"
|
||
:key="hour"
|
||
:data-value="hour"
|
||
:class="{ selected: pickerHour === hour }"
|
||
type="button"
|
||
@click="selectPickerValue('hour', hour, $event.currentTarget)"
|
||
>
|
||
{{ hour }}
|
||
</button>
|
||
</div>
|
||
<strong>:</strong>
|
||
<div ref="minuteColumn" class="calendar__time-column">
|
||
<button
|
||
v-for="minute in minuteOptions"
|
||
:key="minute"
|
||
:data-value="minute"
|
||
:class="{ selected: pickerMinute === minute }"
|
||
type="button"
|
||
@click="
|
||
selectPickerValue('minute', minute, $event.currentTarget)
|
||
"
|
||
>
|
||
{{ minute }}
|
||
</button>
|
||
</div>
|
||
<i aria-hidden="true" />
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</Transition>
|
||
</main>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.calendar {
|
||
--color-primary: transparent;
|
||
--accent: #ff3b30;
|
||
--bg: #000;
|
||
--elevated: #1c1c1e;
|
||
--field: #2c2c2e;
|
||
--glass: rgb(58 58 60 / 76%);
|
||
--label: #fff;
|
||
--line: rgb(255 255 255 / 10%);
|
||
--muted: #98989f;
|
||
position: absolute;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
padding: 47px 0 24px;
|
||
background: var(--bg);
|
||
color: var(--label);
|
||
font-family:
|
||
Inter,
|
||
-apple-system,
|
||
BlinkMacSystemFont,
|
||
'SF Pro Display',
|
||
system-ui,
|
||
sans-serif;
|
||
}
|
||
|
||
.calendar--light {
|
||
--bg: #f2f2f7;
|
||
--elevated: #fff;
|
||
--field: #f2f2f7;
|
||
--glass: rgb(250 250 252 / 78%);
|
||
--label: #111114;
|
||
--line: rgb(60 60 67 / 13%);
|
||
--muted: #6e6e73;
|
||
}
|
||
|
||
.calendar button:not(.k-fab),
|
||
.calendar input,
|
||
.calendar textarea {
|
||
border: 0;
|
||
color: inherit;
|
||
font: inherit;
|
||
}
|
||
|
||
.calendar button:not(.k-fab) {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.calendar__toolbar {
|
||
position: relative;
|
||
z-index: 8;
|
||
height: 68px;
|
||
padding: 11px 14px 9px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
}
|
||
|
||
.calendar__toolbar-actions {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.calendar__view-menu {
|
||
position: absolute;
|
||
z-index: 12;
|
||
top: 62px;
|
||
right: 91px;
|
||
width: 190px;
|
||
padding: 8px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 18px;
|
||
background: var(--glass);
|
||
box-shadow: 0 16px 44px rgb(0 0 0 / 38%);
|
||
backdrop-filter: blur(28px) saturate(180%);
|
||
-webkit-backdrop-filter: blur(28px) saturate(180%);
|
||
}
|
||
|
||
.calendar__view-menu button {
|
||
width: 100%;
|
||
min-height: 42px;
|
||
padding: 7px 9px;
|
||
border-radius: 11px;
|
||
display: grid;
|
||
grid-template-columns: 22px 1fr 18px;
|
||
align-items: center;
|
||
gap: 7px;
|
||
background: transparent;
|
||
font-size: 14px;
|
||
text-align: left;
|
||
}
|
||
|
||
.calendar__view-menu button.active {
|
||
background: rgb(255 59 48 / 14%);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.calendar__search {
|
||
height: 46px;
|
||
margin: 0 14px 7px;
|
||
padding: 0 10px;
|
||
border-radius: 13px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
background: var(--elevated);
|
||
color: var(--muted);
|
||
}
|
||
|
||
.calendar__search input {
|
||
min-width: 0;
|
||
flex: 1;
|
||
outline: 0;
|
||
background: transparent;
|
||
color: var(--label);
|
||
font-size: 15px;
|
||
}
|
||
|
||
.calendar__content {
|
||
height: calc(100% - 68px);
|
||
padding: 4px 0 100px;
|
||
overflow-y: auto;
|
||
overscroll-behavior: contain;
|
||
scroll-behavior: smooth;
|
||
scrollbar-width: none;
|
||
}
|
||
|
||
.calendar__search + .calendar__content {
|
||
height: calc(100% - 121px);
|
||
}
|
||
|
||
.calendar__year-overview {
|
||
height: calc(100% - 68px);
|
||
padding: 4px 14px 100px;
|
||
overflow-y: auto;
|
||
overscroll-behavior: contain;
|
||
scroll-behavior: smooth;
|
||
scrollbar-width: none;
|
||
}
|
||
|
||
.calendar__search + .calendar__year-overview {
|
||
height: calc(100% - 121px);
|
||
}
|
||
|
||
.calendar__year-overview::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
|
||
.calendar__overview-year {
|
||
scroll-margin-top: 4px;
|
||
padding-bottom: 36px;
|
||
}
|
||
|
||
.calendar__overview-year > h1 {
|
||
margin: 0 0 14px;
|
||
padding: 10px 2px 9px;
|
||
border-bottom: 1px solid var(--line);
|
||
color: var(--accent);
|
||
font-size: 40px;
|
||
font-weight: 780;
|
||
line-height: 1;
|
||
letter-spacing: -1.4px;
|
||
}
|
||
|
||
.calendar__overview-months {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 24px 11px;
|
||
}
|
||
|
||
.calendar__overview-month {
|
||
min-width: 0;
|
||
}
|
||
|
||
.calendar__overview-month h2 {
|
||
margin: 0 0 7px;
|
||
overflow: hidden;
|
||
color: var(--label);
|
||
font-size: 16px;
|
||
font-weight: 680;
|
||
line-height: 1.1;
|
||
text-overflow: ellipsis;
|
||
text-transform: capitalize;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.calendar__overview-month h2.current {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.calendar__overview-month > div {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||
}
|
||
|
||
.calendar__overview-month button,
|
||
.calendar__overview-month > div > span {
|
||
min-width: 0;
|
||
height: 17px;
|
||
}
|
||
|
||
.calendar__overview-month button {
|
||
padding: 0;
|
||
border-radius: 50%;
|
||
display: grid;
|
||
place-items: center;
|
||
background: transparent;
|
||
color: var(--label);
|
||
font-size: 10px;
|
||
font-weight: 540;
|
||
}
|
||
|
||
.calendar__overview-month button.today,
|
||
.calendar__overview-month button.selected {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
font-weight: 750;
|
||
}
|
||
|
||
.calendar__content::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
|
||
.calendar__year-month {
|
||
scroll-margin-top: 8px;
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.calendar__year-month h1 {
|
||
margin: 0;
|
||
padding: 13px 18px 16px;
|
||
font-size: 34px;
|
||
font-weight: 780;
|
||
line-height: 1;
|
||
letter-spacing: -1.2px;
|
||
text-transform: capitalize;
|
||
}
|
||
|
||
.calendar__weekdays,
|
||
.calendar__year-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||
}
|
||
|
||
.calendar__weekdays {
|
||
height: 28px;
|
||
padding: 0 7px;
|
||
align-items: center;
|
||
color: var(--label);
|
||
font-size: 10px;
|
||
font-weight: 680;
|
||
text-align: center;
|
||
}
|
||
|
||
.calendar__weekdays span.weekend {
|
||
color: var(--muted);
|
||
}
|
||
|
||
.calendar__year-grid {
|
||
padding: 0 7px;
|
||
}
|
||
|
||
.calendar__year-grid button,
|
||
.calendar__blank-day {
|
||
height: 92px;
|
||
border-top: 1px solid var(--line) !important;
|
||
}
|
||
|
||
.calendar__year-grid--compact > * {
|
||
height: 72px;
|
||
}
|
||
|
||
.calendar__year-grid--stacked > * {
|
||
height: 88px;
|
||
}
|
||
|
||
.calendar__year-grid--details > * {
|
||
height: 112px;
|
||
}
|
||
|
||
.calendar__year-grid button {
|
||
min-width: 0;
|
||
padding: 11px 1px 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
overflow: hidden;
|
||
background: transparent;
|
||
color: var(--label);
|
||
}
|
||
|
||
.calendar__year-grid button.weekend {
|
||
color: var(--muted);
|
||
}
|
||
|
||
.calendar__day-number {
|
||
width: 34px;
|
||
height: 34px;
|
||
flex: none;
|
||
border-radius: 50%;
|
||
display: grid;
|
||
place-items: center;
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.calendar__year-grid button.today .calendar__day-number {
|
||
color: var(--accent);
|
||
font-weight: 800;
|
||
}
|
||
|
||
.calendar__year-grid button.selected .calendar__day-number {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.calendar__compact-indicator {
|
||
width: 5px;
|
||
height: 5px;
|
||
margin-top: 2px;
|
||
border-radius: 50%;
|
||
background: #a855f7;
|
||
}
|
||
|
||
.calendar__event-bars {
|
||
width: 74%;
|
||
margin-top: 3px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
}
|
||
|
||
.calendar__event-bars i {
|
||
height: 4px;
|
||
min-width: 0;
|
||
flex: 1;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.calendar__event-dot--0 {
|
||
background: #a855f7;
|
||
}
|
||
|
||
.calendar__event-dot--1 {
|
||
background: #0ea5e9;
|
||
}
|
||
|
||
.calendar__event-dot--2 {
|
||
background: #f59e0b;
|
||
}
|
||
|
||
.calendar__event-titles {
|
||
width: 100%;
|
||
padding: 0 2px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
}
|
||
|
||
.calendar__event-titles i {
|
||
overflow: hidden;
|
||
color: var(--accent);
|
||
font-size: 8px;
|
||
font-style: normal;
|
||
font-weight: 650;
|
||
line-height: 10px;
|
||
text-align: left;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.calendar__next-year {
|
||
width: calc(100% - 28px);
|
||
height: 48px;
|
||
margin: 0 14px 20px;
|
||
border-top: 1px solid var(--line) !important;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
background: transparent;
|
||
color: var(--accent) !important;
|
||
font-size: 15px !important;
|
||
font-weight: 650 !important;
|
||
}
|
||
|
||
.calendar__footer {
|
||
position: absolute;
|
||
z-index: 9;
|
||
bottom: 29px;
|
||
left: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.calendar__list-view {
|
||
padding: 0 14px;
|
||
}
|
||
|
||
.calendar__agenda {
|
||
padding: 16px 1px 22px;
|
||
}
|
||
|
||
.calendar__agenda > header {
|
||
min-height: 44px;
|
||
padding: 0 3px 9px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.calendar__agenda > header div {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.calendar__agenda > header strong {
|
||
font-size: 18px;
|
||
text-transform: capitalize;
|
||
}
|
||
|
||
.calendar__agenda > header span {
|
||
min-width: 28px;
|
||
height: 28px;
|
||
padding: 0 6px;
|
||
border-radius: 10px;
|
||
display: grid;
|
||
place-items: center;
|
||
background: var(--elevated);
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.calendar__agenda > header button {
|
||
width: 34px;
|
||
height: 34px;
|
||
padding: 0;
|
||
border-radius: 50%;
|
||
display: grid;
|
||
place-items: center;
|
||
background: rgb(255 59 48 / 12%);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.calendar__event-row {
|
||
width: 100%;
|
||
min-height: 74px;
|
||
padding: 11px 2px;
|
||
border-bottom: 1px solid var(--line) !important;
|
||
display: grid;
|
||
grid-template-columns: 56px 4px 1fr 18px;
|
||
align-items: center;
|
||
gap: 8px;
|
||
background: transparent;
|
||
text-align: left;
|
||
}
|
||
|
||
.calendar__event-time strong,
|
||
.calendar__event-time small,
|
||
.calendar__event-copy strong,
|
||
.calendar__event-copy small {
|
||
display: block;
|
||
}
|
||
|
||
.calendar__event-time {
|
||
text-align: right;
|
||
}
|
||
|
||
.calendar__event-time strong {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.calendar__event-time small,
|
||
.calendar__event-copy small {
|
||
color: var(--muted);
|
||
font-size: 10px;
|
||
}
|
||
|
||
.calendar__event-row > i,
|
||
.calendar__list-group button > i {
|
||
width: 4px;
|
||
height: 40px;
|
||
border-radius: 3px;
|
||
background: var(--accent);
|
||
}
|
||
|
||
.calendar__event-copy {
|
||
min-width: 0;
|
||
}
|
||
|
||
.calendar__event-copy strong {
|
||
overflow: hidden;
|
||
font-size: 14px;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.calendar__event-copy small {
|
||
margin-top: 2px;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.calendar__event-row > svg,
|
||
.calendar__list-group button > svg {
|
||
color: var(--muted);
|
||
}
|
||
|
||
.calendar__empty-copy {
|
||
min-height: 70px;
|
||
padding: 14px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 3px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
text-align: center;
|
||
}
|
||
|
||
.calendar__empty-copy strong {
|
||
color: var(--label);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.calendar__empty-copy--list {
|
||
height: 250px;
|
||
}
|
||
|
||
.calendar__list-view {
|
||
padding-top: 4px;
|
||
}
|
||
|
||
.calendar__list-group {
|
||
margin-bottom: 13px;
|
||
}
|
||
|
||
.calendar__list-group > header {
|
||
height: 42px;
|
||
padding: 0 5px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.calendar__list-group > header strong {
|
||
font-size: 16px;
|
||
text-transform: capitalize;
|
||
}
|
||
|
||
.calendar__list-group > header span {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.calendar__list-group button {
|
||
width: 100%;
|
||
min-height: 70px;
|
||
padding: 10px 5px;
|
||
border-bottom: 1px solid var(--line) !important;
|
||
display: grid;
|
||
grid-template-columns: 3px 1fr 15px;
|
||
align-items: center;
|
||
gap: 9px;
|
||
background: transparent;
|
||
text-align: left;
|
||
}
|
||
|
||
.calendar__list-group button span {
|
||
min-width: 0;
|
||
}
|
||
|
||
.calendar__list-group button strong,
|
||
.calendar__list-group button small {
|
||
display: block;
|
||
}
|
||
|
||
.calendar__list-group button strong {
|
||
overflow: hidden;
|
||
font-size: 14px;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.calendar__list-group button small {
|
||
margin-top: 2px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.calendar__auth {
|
||
height: calc(100% - 52px);
|
||
padding: 37px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--muted);
|
||
text-align: center;
|
||
}
|
||
|
||
.calendar__auth > span {
|
||
width: 58px;
|
||
height: 58px;
|
||
border-radius: 18px;
|
||
display: grid;
|
||
place-items: center;
|
||
background: var(--elevated);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.calendar__auth h1 {
|
||
margin: 14px 0 5px;
|
||
color: var(--label);
|
||
font-size: 22px;
|
||
}
|
||
|
||
.calendar__auth p {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.calendar__detail,
|
||
.calendar__form {
|
||
position: absolute;
|
||
inset: 0;
|
||
padding-top: 47px;
|
||
background: var(--bg);
|
||
}
|
||
|
||
.calendar__sheet-header {
|
||
height: 60px;
|
||
padding: 0 16px;
|
||
display: grid;
|
||
grid-template-columns: 1fr auto 1fr;
|
||
align-items: center;
|
||
border-bottom: 1px solid var(--line);
|
||
background: var(--bg);
|
||
background: color-mix(in srgb, var(--bg) 82%, transparent);
|
||
backdrop-filter: blur(20px);
|
||
-webkit-backdrop-filter: blur(20px);
|
||
}
|
||
|
||
.calendar__sheet-header button {
|
||
padding: 6px 0;
|
||
display: flex;
|
||
align-items: center;
|
||
background: transparent;
|
||
color: var(--accent);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.calendar__sheet-header button:last-child {
|
||
justify-self: end;
|
||
}
|
||
|
||
.calendar__sheet-header button:disabled {
|
||
opacity: 0.4;
|
||
}
|
||
|
||
.calendar__sheet-header > strong {
|
||
font-size: 15px;
|
||
}
|
||
|
||
.calendar__sheet-scroll {
|
||
height: calc(100% - 60px);
|
||
padding: 20px 16px 48px;
|
||
overflow-y: auto;
|
||
scrollbar-width: none;
|
||
}
|
||
|
||
.calendar__detail-title {
|
||
padding: 9px 5px 18px;
|
||
display: grid;
|
||
grid-template-columns: 9px 1fr;
|
||
gap: 10px;
|
||
}
|
||
|
||
.calendar__detail-title > i {
|
||
width: 9px;
|
||
height: 9px;
|
||
margin-top: 7px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
}
|
||
|
||
.calendar__detail-title h1 {
|
||
margin: 0;
|
||
font-size: 29px;
|
||
line-height: 1.12;
|
||
}
|
||
|
||
.calendar__detail-title span {
|
||
display: block;
|
||
margin-top: 5px;
|
||
color: var(--accent);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.calendar__group {
|
||
margin-bottom: 13px;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
background: var(--elevated);
|
||
}
|
||
|
||
.calendar__group-row {
|
||
min-height: 58px;
|
||
padding: 13px 15px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
border-bottom: 1px solid var(--line);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.calendar__group-row:last-child {
|
||
border-bottom: 0;
|
||
}
|
||
|
||
.calendar__group-row > svg {
|
||
flex: none;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.calendar__group-row > span {
|
||
flex: 1;
|
||
}
|
||
|
||
.calendar__group-row > strong {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.calendar__group-row--tall {
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.calendar__group-row--tall div {
|
||
min-width: 0;
|
||
}
|
||
|
||
.calendar__group-row--tall small,
|
||
.calendar__group-row--tall strong,
|
||
.calendar__group-row--tall span {
|
||
display: block;
|
||
}
|
||
|
||
.calendar__group-row--tall small {
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.calendar__group-row--tall strong {
|
||
margin-top: 2px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.calendar__group-row--tall span {
|
||
margin-top: 3px;
|
||
color: var(--accent);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.calendar__group-row--note {
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.calendar__group-row--note p {
|
||
margin: 0;
|
||
color: var(--label);
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.calendar__delete {
|
||
width: 100%;
|
||
min-height: 58px;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
background: var(--elevated);
|
||
color: var(--accent) !important;
|
||
font-size: 14px !important;
|
||
}
|
||
|
||
.calendar__group--fields input,
|
||
.calendar__group--fields textarea {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
outline: 0;
|
||
background: transparent;
|
||
}
|
||
|
||
.calendar__group--fields > input {
|
||
height: 56px;
|
||
padding: 0 12px;
|
||
border-bottom: 1px solid var(--line);
|
||
font-size: 18px;
|
||
}
|
||
|
||
.calendar__group--fields > textarea {
|
||
height: 88px;
|
||
padding: 14px;
|
||
resize: none;
|
||
font-size: 14px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.calendar__group--fields label {
|
||
min-height: 58px;
|
||
padding: 0 15px;
|
||
display: flex;
|
||
align-items: center;
|
||
border-bottom: 1px solid var(--line);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.calendar__group--fields label:last-child {
|
||
border-bottom: 0;
|
||
}
|
||
|
||
.calendar__date-time-row {
|
||
width: 100%;
|
||
min-height: 58px;
|
||
padding: 0 15px;
|
||
border-bottom: 1px solid var(--line) !important;
|
||
display: grid;
|
||
grid-template-columns: 1fr auto 18px;
|
||
align-items: center;
|
||
gap: 10px;
|
||
background: transparent;
|
||
font-size: 13px;
|
||
text-align: left;
|
||
}
|
||
|
||
.calendar__date-time-row span {
|
||
min-width: 0;
|
||
}
|
||
|
||
.calendar__date-time-row strong {
|
||
color: var(--accent);
|
||
font-size: 14px;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.calendar__date-time-row svg {
|
||
color: var(--muted);
|
||
}
|
||
|
||
.calendar__group--fields label span {
|
||
flex: 1;
|
||
}
|
||
|
||
.calendar__group--fields label input {
|
||
width: auto;
|
||
min-width: 112px;
|
||
color-scheme: dark;
|
||
font-size: 13px;
|
||
text-align: right;
|
||
}
|
||
|
||
.calendar--light .calendar__group--fields label input {
|
||
color-scheme: light;
|
||
}
|
||
|
||
.calendar__form-row {
|
||
width: 100%;
|
||
min-height: 58px;
|
||
padding: 0 15px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
border-bottom: 1px solid var(--line) !important;
|
||
background: transparent;
|
||
font-size: 13px;
|
||
text-align: left;
|
||
}
|
||
|
||
.calendar__form-row > svg:first-child {
|
||
flex: none;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.calendar__form-row > span {
|
||
flex: 1;
|
||
}
|
||
|
||
.calendar__form-row > strong {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.calendar__form-row > svg:last-child {
|
||
color: var(--muted);
|
||
transition: transform 0.18s ease;
|
||
}
|
||
|
||
.calendar__form-row > svg:last-child.open {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.calendar__reminders {
|
||
padding-left: 35px;
|
||
background: var(--field);
|
||
}
|
||
|
||
.calendar__reminders button {
|
||
width: 100%;
|
||
min-height: 48px;
|
||
padding: 0 11px 0 0;
|
||
border-bottom: 1px solid var(--line) !important;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
text-align: left;
|
||
}
|
||
|
||
.calendar__reminders button.active {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.calendar__error {
|
||
margin: 3px 5px 0;
|
||
color: var(--accent);
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.calendar__picker-backdrop {
|
||
position: absolute;
|
||
z-index: 30;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
background: rgb(0 0 0 / 52%);
|
||
backdrop-filter: blur(4px);
|
||
-webkit-backdrop-filter: blur(4px);
|
||
}
|
||
|
||
.calendar__time-picker {
|
||
width: 100%;
|
||
padding: 0 14px 31px;
|
||
border: 1px solid var(--line);
|
||
border-bottom: 0;
|
||
border-radius: 28px 28px 0 0;
|
||
overflow: hidden;
|
||
background: var(--elevated);
|
||
background: color-mix(in srgb, var(--elevated) 94%, transparent);
|
||
box-shadow: 0 -18px 60px rgb(0 0 0 / 38%);
|
||
backdrop-filter: blur(30px) saturate(180%);
|
||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||
}
|
||
|
||
.calendar__time-picker > header {
|
||
height: 62px;
|
||
display: grid;
|
||
grid-template-columns: 1fr auto 1fr;
|
||
align-items: center;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
|
||
.calendar__time-picker > header strong {
|
||
font-size: 16px;
|
||
}
|
||
|
||
.calendar__time-picker > header button {
|
||
padding: 10px 0;
|
||
background: transparent;
|
||
color: var(--accent);
|
||
font-size: 14px;
|
||
font-weight: 650;
|
||
}
|
||
|
||
.calendar__time-picker > header button:last-child {
|
||
justify-self: end;
|
||
}
|
||
|
||
.calendar__time-preview {
|
||
height: 64px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--label);
|
||
font-size: 34px;
|
||
font-variant-numeric: tabular-nums;
|
||
font-weight: 720;
|
||
letter-spacing: -1px;
|
||
}
|
||
|
||
.calendar__time-preview span {
|
||
margin: 0 5px;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.calendar__time-wheels {
|
||
position: relative;
|
||
height: 232px;
|
||
display: grid;
|
||
grid-template-columns: 1fr 28px 1fr;
|
||
align-items: center;
|
||
overflow: hidden;
|
||
mask-image: linear-gradient(
|
||
to bottom,
|
||
transparent,
|
||
#000 25%,
|
||
#000 75%,
|
||
transparent
|
||
);
|
||
}
|
||
|
||
.calendar__time-wheels > strong {
|
||
position: relative;
|
||
z-index: 2;
|
||
color: var(--accent);
|
||
font-size: 28px;
|
||
text-align: center;
|
||
}
|
||
|
||
.calendar__time-wheels > i {
|
||
position: absolute;
|
||
z-index: 0;
|
||
top: 87px;
|
||
right: 0;
|
||
left: 0;
|
||
height: 58px;
|
||
border-top: 1px solid var(--line);
|
||
border-bottom: 1px solid var(--line);
|
||
border-radius: 14px;
|
||
background: rgb(255 255 255 / 7%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.calendar--light .calendar__time-wheels > i {
|
||
background: rgb(0 0 0 / 5%);
|
||
}
|
||
|
||
.calendar__time-column {
|
||
position: relative;
|
||
z-index: 1;
|
||
height: 232px;
|
||
padding: 87px 4px;
|
||
overflow-y: auto;
|
||
scroll-snap-type: y mandatory;
|
||
scrollbar-width: none;
|
||
}
|
||
|
||
.calendar__time-column::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
|
||
.calendar__time-column button {
|
||
width: 100%;
|
||
height: 58px;
|
||
padding: 0;
|
||
scroll-snap-align: center;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
font-size: 23px;
|
||
font-variant-numeric: tabular-nums;
|
||
font-weight: 560;
|
||
transition:
|
||
color 0.16s ease,
|
||
transform 0.16s ease;
|
||
}
|
||
|
||
.calendar__time-column button.selected {
|
||
color: var(--label);
|
||
font-size: 27px;
|
||
font-weight: 750;
|
||
transform: scale(1.06);
|
||
}
|
||
|
||
.calendar-popover-enter-active,
|
||
.calendar-popover-leave-active,
|
||
.calendar-search-enter-active,
|
||
.calendar-search-leave-active {
|
||
transition:
|
||
opacity 0.16s ease,
|
||
transform 0.16s ease;
|
||
}
|
||
|
||
.calendar-popover-enter-from,
|
||
.calendar-popover-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-5px) scale(0.96);
|
||
}
|
||
|
||
.calendar-search-enter-from,
|
||
.calendar-search-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-4px);
|
||
}
|
||
|
||
.calendar-picker-enter-active,
|
||
.calendar-picker-leave-active {
|
||
transition: opacity 0.22s ease;
|
||
}
|
||
|
||
.calendar-picker-enter-active .calendar__time-picker,
|
||
.calendar-picker-leave-active .calendar__time-picker {
|
||
transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1);
|
||
}
|
||
|
||
.calendar-picker-enter-from,
|
||
.calendar-picker-leave-to {
|
||
opacity: 0;
|
||
}
|
||
|
||
.calendar-picker-enter-from .calendar__time-picker,
|
||
.calendar-picker-leave-to .calendar__time-picker {
|
||
transform: translateY(100%);
|
||
}
|
||
|
||
.calendar__agenda > header span,
|
||
.calendar__list-group > header span {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.calendar__event-time strong {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.calendar__event-time small,
|
||
.calendar__event-copy small {
|
||
font-size: 12px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.calendar__empty-copy {
|
||
font-size: 13px;
|
||
}
|
||
</style>
|