vue #1
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
.*
|
||||
!.gitignore
|
||||
*.lock
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
@ -1,6 +0,0 @@
|
||||
/* Calendar CSS - Main file with imports */
|
||||
@import url('colors.css');
|
||||
@import url('layout.css');
|
||||
@import url('cells.css');
|
||||
@import url('events.css');
|
||||
@import url('utilities.css');
|
484
calendar.js
484
calendar.js
@ -1,484 +0,0 @@
|
||||
// calendar.js — Infinite scrolling week-by-week with overlay event rendering
|
||||
import {
|
||||
monthAbbr,
|
||||
DAY_MS,
|
||||
WEEK_MS,
|
||||
isoWeekInfo,
|
||||
toLocalString,
|
||||
fromLocalString,
|
||||
mondayIndex,
|
||||
pad,
|
||||
addDaysStr,
|
||||
getLocalizedWeekdayNames,
|
||||
getLocalizedMonthName,
|
||||
formatDateRange,
|
||||
lunarPhaseSymbol
|
||||
} from './date-utils.js'
|
||||
import { EventManager } from './event-manager.js'
|
||||
import { JogwheelManager } from './jogwheel.js'
|
||||
|
||||
class InfiniteCalendar {
|
||||
constructor(config = {}) {
|
||||
this.config = {
|
||||
select_days: 0,
|
||||
min_year: 1900,
|
||||
max_year: 2100,
|
||||
...config
|
||||
}
|
||||
|
||||
this.weekend = [true, false, false, false, false, false, true]
|
||||
|
||||
// Initialize event manager
|
||||
this.eventManager = new EventManager(this)
|
||||
|
||||
this.viewport = document.getElementById('calendar-viewport')
|
||||
this.content = document.getElementById('calendar-content')
|
||||
this.header = document.getElementById('calendar-header')
|
||||
this.selectedDateInput = document.getElementById('selected-date')
|
||||
|
||||
// Initialize jogwheel manager after DOM elements are available
|
||||
this.jogwheelManager = new JogwheelManager(this)
|
||||
|
||||
this.rowHeight = this.computeRowHeight()
|
||||
this.visibleWeeks = new Map()
|
||||
this.baseDate = new Date(2024, 0, 1) // 2024 begins with Monday
|
||||
|
||||
this.init()
|
||||
} init() {
|
||||
this.createHeader()
|
||||
this.setupScrollListener()
|
||||
this.setupYearScroll()
|
||||
this.setupSelectionInput()
|
||||
this.setupCurrentDate()
|
||||
this.setupInitialView()
|
||||
}
|
||||
|
||||
setupInitialView() {
|
||||
const minYearDate = new Date(this.config.min_year, 0, 1)
|
||||
const maxYearLastDay = new Date(this.config.max_year, 11, 31)
|
||||
const lastWeekMonday = new Date(maxYearLastDay)
|
||||
lastWeekMonday.setDate(maxYearLastDay.getDate() - mondayIndex(maxYearLastDay))
|
||||
|
||||
this.minVirtualWeek = this.getWeekIndex(minYearDate)
|
||||
this.maxVirtualWeek = this.getWeekIndex(lastWeekMonday)
|
||||
this.totalVirtualWeeks = this.maxVirtualWeek - this.minVirtualWeek + 1
|
||||
|
||||
this.content.style.height = `${this.totalVirtualWeeks * this.rowHeight}px`
|
||||
this.jogwheelManager.updateHeight(this.totalVirtualWeeks, this.rowHeight)
|
||||
const initial = this.config.initial || this.today
|
||||
this.navigateTo(fromLocalString(initial))
|
||||
}
|
||||
|
||||
setupCurrentDate() {
|
||||
const updateDate = () => {
|
||||
this.now = new Date()
|
||||
const today = toLocalString(this.now)
|
||||
if (this.today === today) return
|
||||
this.today = today
|
||||
for (const cell of this.content.querySelectorAll('.cell.today')) cell.classList.remove('today')
|
||||
const cell = this.content.querySelector(`.cell[data-date="${this.today}"]`)
|
||||
if (cell) cell.classList.add('today')
|
||||
const todayDateElement = document.getElementById('today-date')
|
||||
const t = this.now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric'}).replace(/,? /, "\n")
|
||||
todayDateElement.textContent = t.charAt(0).toUpperCase() + t.slice(1)
|
||||
}
|
||||
|
||||
const todayDateElement = document.getElementById('today-date')
|
||||
todayDateElement.addEventListener('click', () => this.goToToday())
|
||||
// Day selection drag functionality is handled through cell event handlers in EventManager
|
||||
updateDate()
|
||||
setInterval(updateDate, 1000)
|
||||
}
|
||||
|
||||
setupYearScroll() {
|
||||
let throttled = false
|
||||
const handleWheel = e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const currentYear = parseInt(this.yearLabel.textContent)
|
||||
const topDisplayIndex = Math.floor(this.viewport.scrollTop / this.rowHeight)
|
||||
const currentWeekIndex = topDisplayIndex + this.minVirtualWeek
|
||||
const sensitivity = 1/3
|
||||
const delta = Math.round(e.deltaY * sensitivity)
|
||||
if (!delta) return
|
||||
const newYear = Math.max(this.config.min_year, Math.min(this.config.max_year, currentYear + delta))
|
||||
if (newYear === currentYear || throttled) return
|
||||
throttled = true
|
||||
this.navigateToYear(newYear, currentWeekIndex)
|
||||
setTimeout(() => throttled = false, 100)
|
||||
}
|
||||
this.yearLabel.addEventListener('wheel', handleWheel, { passive: false })
|
||||
}
|
||||
|
||||
navigateTo(date) {
|
||||
const targetWeekIndex = this.getWeekIndex(date) - 3
|
||||
return this.scrollToTarget(targetWeekIndex)
|
||||
}
|
||||
|
||||
navigateToYear(targetYear, weekIndex) {
|
||||
const monday = this.getMondayForVirtualWeek(weekIndex)
|
||||
const { week } = isoWeekInfo(monday)
|
||||
const jan4 = new Date(targetYear, 0, 4)
|
||||
const jan4Monday = new Date(jan4)
|
||||
jan4Monday.setDate(jan4.getDate() - mondayIndex(jan4))
|
||||
const targetMonday = new Date(jan4Monday)
|
||||
targetMonday.setDate(jan4Monday.getDate() + (week - 1) * 7)
|
||||
this.scrollToTarget(targetMonday)
|
||||
}
|
||||
|
||||
scrollToTarget(target, options = {}) {
|
||||
const { smooth = false, forceUpdate = true, allowFloating = false } = options
|
||||
let targetWeekIndex
|
||||
if (target instanceof Date) {
|
||||
if (!this.isDateInAllowedRange(target)) return false
|
||||
targetWeekIndex = this.getWeekIndex(target)
|
||||
} else if (typeof target === 'number') {
|
||||
targetWeekIndex = target
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
if (allowFloating) {
|
||||
if (targetWeekIndex < this.minVirtualWeek - 0.5 || targetWeekIndex > this.maxVirtualWeek + 0.5) return false
|
||||
} else {
|
||||
if (targetWeekIndex < this.minVirtualWeek || targetWeekIndex > this.maxVirtualWeek) return false
|
||||
}
|
||||
|
||||
const targetScrollTop = (targetWeekIndex - this.minVirtualWeek) * this.rowHeight
|
||||
|
||||
if (smooth) this.viewport.scrollTo({ top: targetScrollTop, behavior: 'smooth' })
|
||||
else this.viewport.scrollTop = targetScrollTop
|
||||
|
||||
const mainScrollable = Math.max(0, this.content.scrollHeight - this.viewport.clientHeight)
|
||||
this.jogwheelManager.scrollTo(targetScrollTop, mainScrollable, smooth)
|
||||
|
||||
if (forceUpdate) this.updateVisibleWeeks()
|
||||
return true
|
||||
}
|
||||
|
||||
computeRowHeight() {
|
||||
const el = document.createElement('div')
|
||||
el.style.position = 'absolute'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.height = 'var(--cell-h)'
|
||||
document.body.appendChild(el)
|
||||
const h = el.getBoundingClientRect().height || 64
|
||||
el.remove()
|
||||
return Math.round(h)
|
||||
}
|
||||
|
||||
createHeader() {
|
||||
this.yearLabel = document.createElement('div')
|
||||
this.yearLabel.className = 'year-label'
|
||||
this.yearLabel.textContent = isoWeekInfo(new Date()).year
|
||||
this.header.appendChild(this.yearLabel)
|
||||
|
||||
const names = getLocalizedWeekdayNames()
|
||||
names.forEach((name, i) => {
|
||||
const c = document.createElement('div')
|
||||
c.classList.add('dow')
|
||||
const dayIdx = (i + 1) % 7
|
||||
if (this.weekend[dayIdx]) c.classList.add('weekend')
|
||||
c.textContent = name
|
||||
this.header.appendChild(c)
|
||||
})
|
||||
|
||||
const spacer = document.createElement('div')
|
||||
spacer.className = 'overlay-header-spacer'
|
||||
this.header.appendChild(spacer)
|
||||
}
|
||||
|
||||
getWeekIndex(date) {
|
||||
const monday = new Date(date)
|
||||
monday.setDate(date.getDate() - mondayIndex(date))
|
||||
return Math.floor((monday - this.baseDate) / WEEK_MS)
|
||||
}
|
||||
|
||||
getMondayForVirtualWeek(virtualWeek) {
|
||||
const monday = new Date(this.baseDate)
|
||||
monday.setDate(monday.getDate() + virtualWeek * 7)
|
||||
return monday
|
||||
}
|
||||
|
||||
isDateInAllowedRange(date) {
|
||||
const y = date.getFullYear()
|
||||
return y >= this.config.min_year && y <= this.config.max_year
|
||||
}
|
||||
|
||||
setupScrollListener() {
|
||||
let t
|
||||
this.viewport.addEventListener('scroll', () => {
|
||||
this.updateVisibleWeeks()
|
||||
clearTimeout(t)
|
||||
t = setTimeout(() => this.updateVisibleWeeks(), 8)
|
||||
})
|
||||
}
|
||||
|
||||
updateVisibleWeeks() {
|
||||
const scrollTop = this.viewport.scrollTop
|
||||
const viewportH = this.viewport.clientHeight
|
||||
|
||||
this.updateYearLabel(scrollTop)
|
||||
|
||||
const buffer = 10
|
||||
const startIdx = Math.floor((scrollTop - buffer * this.rowHeight) / this.rowHeight)
|
||||
const endIdx = Math.ceil((scrollTop + viewportH + buffer * this.rowHeight) / this.rowHeight)
|
||||
|
||||
const startVW = Math.max(this.minVirtualWeek, startIdx + this.minVirtualWeek)
|
||||
const endVW = Math.min(this.maxVirtualWeek, endIdx + this.minVirtualWeek)
|
||||
|
||||
for (const [vw, el] of this.visibleWeeks) {
|
||||
if (vw < startVW || vw > endVW) {
|
||||
el.remove()
|
||||
this.visibleWeeks.delete(vw)
|
||||
}
|
||||
}
|
||||
|
||||
for (let vw = startVW; vw <= endVW; vw++) {
|
||||
if (this.visibleWeeks.has(vw)) continue
|
||||
const weekEl = this.createWeekElement(vw)
|
||||
weekEl.style.position = 'absolute'
|
||||
weekEl.style.left = '0'
|
||||
const displayIndex = vw - this.minVirtualWeek
|
||||
weekEl.style.top = `${displayIndex * this.rowHeight}px`
|
||||
weekEl.style.width = '100%'
|
||||
weekEl.style.height = `${this.rowHeight}px`
|
||||
this.content.appendChild(weekEl)
|
||||
this.visibleWeeks.set(vw, weekEl)
|
||||
this.addEventsToWeek(weekEl, vw)
|
||||
}
|
||||
|
||||
this.eventManager.applySelectionToVisible()
|
||||
}
|
||||
|
||||
updateYearLabel(scrollTop) {
|
||||
const topDisplayIndex = Math.floor(scrollTop / this.rowHeight)
|
||||
const topVW = topDisplayIndex + this.minVirtualWeek
|
||||
const monday = this.getMondayForVirtualWeek(topVW)
|
||||
const { year } = isoWeekInfo(monday)
|
||||
if (this.yearLabel.textContent !== String(year)) this.yearLabel.textContent = year
|
||||
}
|
||||
|
||||
createWeekElement(virtualWeek) {
|
||||
const weekDiv = document.createElement('div')
|
||||
weekDiv.className = 'week-row'
|
||||
const monday = this.getMondayForVirtualWeek(virtualWeek)
|
||||
|
||||
const wkLabel = document.createElement('div')
|
||||
wkLabel.className = 'week-label'
|
||||
wkLabel.textContent = `W${pad(isoWeekInfo(monday).week)}`
|
||||
weekDiv.appendChild(wkLabel)
|
||||
|
||||
// days grid container to host cells and overlay
|
||||
const daysGrid = document.createElement('div')
|
||||
daysGrid.className = 'days-grid'
|
||||
daysGrid.style.position = 'relative'
|
||||
daysGrid.style.display = 'grid'
|
||||
daysGrid.style.gridTemplateColumns = 'repeat(7, 1fr)'
|
||||
daysGrid.style.gridAutoRows = '1fr'
|
||||
daysGrid.style.height = '100%'
|
||||
daysGrid.style.width = '100%'
|
||||
weekDiv.appendChild(daysGrid)
|
||||
|
||||
// overlay positioned above cells, same 7-col grid
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'week-overlay'
|
||||
overlay.style.position = 'absolute'
|
||||
overlay.style.inset = '0'
|
||||
overlay.style.pointerEvents = 'none'
|
||||
overlay.style.display = 'grid'
|
||||
overlay.style.gridTemplateColumns = 'repeat(7, 1fr)'
|
||||
overlay.style.gridAutoRows = '1fr'
|
||||
overlay.style.zIndex = '15'
|
||||
daysGrid.appendChild(overlay)
|
||||
weekDiv._overlay = overlay
|
||||
weekDiv._daysGrid = daysGrid
|
||||
|
||||
const cur = new Date(monday)
|
||||
let hasFirst = false
|
||||
let monthToLabel = null
|
||||
let labelYear = null
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const cell = document.createElement('div')
|
||||
cell.className = 'cell'
|
||||
const dateStr = toLocalString(cur)
|
||||
cell.setAttribute('data-date', dateStr)
|
||||
|
||||
const dow = cur.getDay()
|
||||
if (this.weekend[dow]) cell.classList.add('weekend')
|
||||
|
||||
const m = cur.getMonth()
|
||||
cell.classList.add(monthAbbr[m])
|
||||
|
||||
const isFirst = cur.getDate() === 1
|
||||
if (isFirst) {
|
||||
hasFirst = true
|
||||
monthToLabel = m
|
||||
labelYear = cur.getFullYear()
|
||||
}
|
||||
|
||||
const day = document.createElement('h1')
|
||||
day.textContent = String(cur.getDate())
|
||||
|
||||
const date = toLocalString(cur)
|
||||
cell.dataset.date = date
|
||||
if (this.today && date === this.today) cell.classList.add('today')
|
||||
|
||||
if (this.config.select_days > 0) {
|
||||
cell.addEventListener('mousedown', e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
this.eventManager.startDrag(dateStr)
|
||||
})
|
||||
cell.addEventListener('touchstart', e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
this.eventManager.startDrag(dateStr)
|
||||
})
|
||||
cell.addEventListener('mouseenter', () => {
|
||||
if (this.eventManager.isDragging) this.eventManager.updateDrag(dateStr)
|
||||
})
|
||||
cell.addEventListener('mouseup', e => {
|
||||
e.stopPropagation()
|
||||
if (this.eventManager.isDragging) this.eventManager.endDrag(dateStr)
|
||||
})
|
||||
cell.addEventListener('touchmove', e => {
|
||||
if (this.eventManager.isDragging) {
|
||||
e.preventDefault()
|
||||
const touch = e.touches[0]
|
||||
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY)
|
||||
if (elementBelow && elementBelow.closest('.cell[data-date]')) {
|
||||
const cellBelow = elementBelow.closest('.cell[data-date]')
|
||||
const touchDateStr = cellBelow.dataset.date
|
||||
if (touchDateStr) this.eventManager.updateDrag(touchDateStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
cell.addEventListener('touchend', e => {
|
||||
e.stopPropagation()
|
||||
if (this.eventManager.isDragging) this.eventManager.endDrag(dateStr)
|
||||
})
|
||||
}
|
||||
|
||||
if (isFirst) {
|
||||
cell.classList.add('firstday')
|
||||
day.textContent = cur.getMonth() ? monthAbbr[m].slice(0,3).toUpperCase() : cur.getFullYear()
|
||||
}
|
||||
|
||||
cell.appendChild(day)
|
||||
const luna = lunarPhaseSymbol(cur)
|
||||
if (luna) {
|
||||
const moon = document.createElement('span')
|
||||
moon.className = 'lunar-phase'
|
||||
moon.textContent = luna
|
||||
cell.appendChild(moon)
|
||||
}
|
||||
daysGrid.appendChild(cell)
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
}
|
||||
|
||||
if (hasFirst && monthToLabel !== null) {
|
||||
if (labelYear && labelYear > this.config.max_year) return weekDiv
|
||||
const overlayCell = document.createElement('div')
|
||||
overlayCell.className = 'month-name-label'
|
||||
|
||||
let weeksSpan = 0
|
||||
const d = new Date(cur)
|
||||
d.setDate(cur.getDate() - 1)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
d.setDate(cur.getDate() - 1 + i * 7)
|
||||
if (d.getMonth() === monthToLabel) weeksSpan++
|
||||
}
|
||||
|
||||
const remainingWeeks = Math.max(1, this.maxVirtualWeek - virtualWeek + 1)
|
||||
weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks))
|
||||
|
||||
overlayCell.style.height = `${weeksSpan * this.rowHeight}px`
|
||||
overlayCell.style.display = 'flex'
|
||||
overlayCell.style.alignItems = 'center'
|
||||
overlayCell.style.justifyContent = 'center'
|
||||
overlayCell.style.zIndex = '15'
|
||||
overlayCell.style.pointerEvents = 'none'
|
||||
|
||||
const label = document.createElement('span')
|
||||
const year = String((labelYear ?? monday.getFullYear())).slice(-2)
|
||||
label.textContent = `${getLocalizedMonthName(monthToLabel)} '${year}`
|
||||
overlayCell.appendChild(label)
|
||||
weekDiv.appendChild(overlayCell)
|
||||
weekDiv.style.zIndex = '18'
|
||||
}
|
||||
|
||||
return weekDiv
|
||||
}
|
||||
|
||||
setupSelectionInput() {
|
||||
if (this.config.select_days === 0) {
|
||||
this.selectedDateInput.style.display = 'none'
|
||||
} else {
|
||||
this.selectedDateInput.style.display = 'block'
|
||||
this.selectedDateInput.classList.add('clean-input')
|
||||
}
|
||||
}
|
||||
|
||||
goToToday() {
|
||||
const top = new Date(this.now)
|
||||
top.setDate(top.getDate() - 21)
|
||||
this.scrollToTarget(top, { smooth: true })
|
||||
}
|
||||
|
||||
// -------- Event Rendering (overlay-based) --------
|
||||
|
||||
refreshEvents() {
|
||||
for (const [, weekEl] of this.visibleWeeks) {
|
||||
this.addEventsToWeek(weekEl)
|
||||
}
|
||||
}
|
||||
|
||||
forceUpdateVisibleWeeks() {
|
||||
// Force complete re-render of all visible weeks by clearing overlays first
|
||||
for (const [, weekEl] of this.visibleWeeks) {
|
||||
const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay')
|
||||
if (overlay) {
|
||||
while (overlay.firstChild) overlay.removeChild(overlay.firstChild)
|
||||
}
|
||||
this.addEventsToWeek(weekEl)
|
||||
}
|
||||
}
|
||||
|
||||
addEventsToWeek(weekEl) {
|
||||
this.eventManager.addEventsToWeek(weekEl)
|
||||
}
|
||||
|
||||
getDateUnderPointer(clientX, clientY) {
|
||||
const el = document.elementFromPoint(clientX, clientY)
|
||||
if (!el) return null
|
||||
// Fast path: directly find the cell under the pointer
|
||||
const directCell = el.closest && el.closest('.cell[data-date]')
|
||||
if (directCell) {
|
||||
const weekEl = directCell.closest('.week-row')
|
||||
return weekEl ? { weekEl, overlay: (weekEl._overlay || weekEl.querySelector('.week-overlay')), col: -1, date: directCell.dataset.date } : null
|
||||
}
|
||||
const weekEl = el.closest && el.closest('.week-row')
|
||||
if (!weekEl) return null
|
||||
const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay')
|
||||
const daysGrid = weekEl._daysGrid || weekEl.querySelector('.days-grid')
|
||||
if (!overlay || !daysGrid) return null
|
||||
const rect = overlay.getBoundingClientRect()
|
||||
if (rect.width <= 0) return null
|
||||
const colWidth = rect.width / 7
|
||||
let col = Math.floor((clientX - rect.left) / colWidth)
|
||||
if (clientX < rect.left) col = 0
|
||||
if (clientX > rect.right) col = 6
|
||||
col = Math.max(0, Math.min(6, col))
|
||||
const cells = Array.from(daysGrid.querySelectorAll('.cell[data-date]'))
|
||||
const cell = cells[col]
|
||||
return cell ? { weekEl, overlay, col, date: cell.dataset.date } : null
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new InfiniteCalendar({
|
||||
select_days: 1000
|
||||
})
|
||||
})
|
38
cells.css
38
cells.css
@ -1,38 +0,0 @@
|
||||
/* Day cells */
|
||||
.dow { text-transform: uppercase }
|
||||
.cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: .25em;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: var(--cell-h);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background-color .15s ease;
|
||||
}
|
||||
.cell h1 {
|
||||
top: .25em;
|
||||
right: .25em;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-width: 1.5em;
|
||||
transition: background-color .15s ease;
|
||||
font-size: 1em;
|
||||
}
|
||||
.cell.today h1 {
|
||||
border-radius: 2em;
|
||||
background: var(--today);
|
||||
border: .2em solid var(--today);
|
||||
margin: -.2em;
|
||||
}
|
||||
.cell:hover h1 { text-shadow: 0 0 .2em var(--shadow); }
|
||||
|
||||
/* Fixed heights for cells and labels */
|
||||
.week-row .cell, .week-row .week-label { height: var(--cell-h) }
|
||||
|
||||
.weekend { color: var(--weekend) }
|
||||
.firstday { color: var(--firstday); text-shadow: 0 0 .1em var(--strong); }
|
77
colors.css
77
colors.css
@ -1,77 +0,0 @@
|
||||
/* Color tokens */
|
||||
:root {
|
||||
--panel: #fff;
|
||||
--today: #f83;
|
||||
--ink: #222;
|
||||
--strong: #000;
|
||||
--muted: #888;
|
||||
--weekend: #888;
|
||||
--firstday: #000;
|
||||
--select: #aaf;
|
||||
--shadow: #fff;
|
||||
--label-bg: #fafbfe;
|
||||
--label-bg-rgb: 250, 251, 254;
|
||||
}
|
||||
|
||||
/* Month tints (light) */
|
||||
.dec { background: hsl(220 50% 95%) }
|
||||
.jan { background: hsl(220 50% 92%) }
|
||||
.feb { background: hsl(220 50% 95%) }
|
||||
.mar { background: hsl(125 60% 92%) }
|
||||
.apr { background: hsl(125 60% 95%) }
|
||||
.may { background: hsl(125 60% 92%) }
|
||||
.jun { background: hsl(45 85% 95%) }
|
||||
.jul { background: hsl(45 85% 92%) }
|
||||
.aug { background: hsl(45 85% 95%) }
|
||||
.sep { background: hsl(18 78% 92%) }
|
||||
.oct { background: hsl(18 78% 95%) }
|
||||
.nov { background: hsl(18 78% 92%) }
|
||||
|
||||
/* Light mode — gray shades and colors */
|
||||
.event-color-0 { background: hsl(0, 0%, 85%) } /* lightest grey */
|
||||
.event-color-1 { background: hsl(0, 0%, 75%) } /* light grey */
|
||||
.event-color-2 { background: hsl(0, 0%, 65%) } /* medium grey */
|
||||
.event-color-3 { background: hsl(0, 0%, 55%) } /* dark grey */
|
||||
.event-color-4 { background: hsl(0, 80%, 70%) } /* red */
|
||||
.event-color-5 { background: hsl(40, 80%, 70%) } /* orange */
|
||||
.event-color-6 { background: hsl(200, 80%, 70%) } /* green */
|
||||
.event-color-7 { background: hsl(280, 80%, 70%) } /* purple */
|
||||
|
||||
/* Color tokens (dark) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--panel: #111318;
|
||||
--today: #f83;
|
||||
--ink: #ddd;
|
||||
--strong: #fff;
|
||||
--muted: #888;
|
||||
--weekend: #999;
|
||||
--firstday: #fff;
|
||||
--select: #22a;
|
||||
--shadow: #888;
|
||||
--label-bg: #1a1d25;
|
||||
--label-bg-rgb: 26, 29, 37;
|
||||
}
|
||||
|
||||
.dec { background: hsl(220 50% 8%) }
|
||||
.jan { background: hsl(220 50% 6%) }
|
||||
.feb { background: hsl(220 50% 8%) }
|
||||
.mar { background: hsl(125 60% 6%) }
|
||||
.apr { background: hsl(125 60% 8%) }
|
||||
.may { background: hsl(125 60% 6%) }
|
||||
.jun { background: hsl(45 85% 8%) }
|
||||
.jul { background: hsl(45 85% 6%) }
|
||||
.aug { background: hsl(45 85% 8%) }
|
||||
.sep { background: hsl(18 78% 6%) }
|
||||
.oct { background: hsl(18 78% 8%) }
|
||||
.nov { background: hsl(18 78% 6%) }
|
||||
|
||||
.event-color-0 { background: hsl(0, 0%, 20%) } /* lightest grey */
|
||||
.event-color-1 { background: hsl(0, 0%, 30%) } /* light grey */
|
||||
.event-color-2 { background: hsl(0, 0%, 40%) } /* medium grey */
|
||||
.event-color-3 { background: hsl(0, 0%, 50%) } /* dark grey */
|
||||
.event-color-4 { background: hsl(0, 70%, 50%) } /* red */
|
||||
.event-color-5 { background: hsl(40, 70%, 50%) } /* orange */
|
||||
.event-color-6 { background: hsl(200, 70%, 50%) } /* green */
|
||||
.event-color-7 { background: hsl(280, 70%, 50%) } /* purple */
|
||||
}
|
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import globals from 'globals'
|
||||
import js from '@eslint/js'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{js,mjs,jsx,vue}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
...pluginVue.configs['flat/essential'],
|
||||
...pluginOxlint.configs['flat/recommended'],
|
||||
skipFormatting,
|
||||
])
|
851
event-manager.js
851
event-manager.js
@ -1,851 +0,0 @@
|
||||
// event-manager.js — Event creation, editing, drag/drop, and selection logic
|
||||
import {
|
||||
toLocalString,
|
||||
fromLocalString,
|
||||
daysInclusive,
|
||||
addDaysStr,
|
||||
formatDateRange
|
||||
} from './date-utils.js'
|
||||
|
||||
export class EventManager {
|
||||
constructor(calendar) {
|
||||
this.calendar = calendar
|
||||
this.events = new Map() // Map of date strings to arrays of events
|
||||
this.eventIdCounter = 1
|
||||
|
||||
// Selection state
|
||||
this.selStart = null
|
||||
this.selEnd = null
|
||||
this.isDragging = false
|
||||
this.dragAnchor = null
|
||||
|
||||
// Event drag state
|
||||
this.dragEventState = null
|
||||
this.dragPreview = null
|
||||
this.justDragged = false
|
||||
this._eventDragMoved = false
|
||||
this._installedEventDrag = false
|
||||
|
||||
this.setupEventDialog()
|
||||
}
|
||||
|
||||
// -------- Selection Logic --------
|
||||
|
||||
clampRange(anchorStr, otherStr) {
|
||||
if (this.calendar.config.select_days <= 1) return [otherStr, otherStr]
|
||||
const limit = this.calendar.config.select_days
|
||||
const forward = fromLocalString(otherStr) >= fromLocalString(anchorStr)
|
||||
const span = daysInclusive(anchorStr, otherStr)
|
||||
if (span <= limit) {
|
||||
const a = [anchorStr, otherStr].sort()
|
||||
return [a[0], a[1]]
|
||||
}
|
||||
if (forward) return [anchorStr, addDaysStr(anchorStr, limit - 1)]
|
||||
return [addDaysStr(anchorStr, -(limit - 1)), anchorStr]
|
||||
}
|
||||
|
||||
setSelection(aStr, bStr) {
|
||||
const [start, end] = this.clampRange(aStr, bStr)
|
||||
this.selStart = start
|
||||
this.selEnd = end
|
||||
this.applySelectionToVisible()
|
||||
this.calendar.selectedDateInput.value = formatDateRange(fromLocalString(start), fromLocalString(end))
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.selStart = null
|
||||
this.selEnd = null
|
||||
for (const [, weekEl] of this.calendar.visibleWeeks) {
|
||||
weekEl.querySelectorAll('.cell.selected').forEach(c => c.classList.remove('selected'))
|
||||
}
|
||||
this.calendar.selectedDateInput.value = ''
|
||||
}
|
||||
|
||||
applySelectionToVisible() {
|
||||
for (const [, weekEl] of this.calendar.visibleWeeks) {
|
||||
const cells = weekEl.querySelectorAll('.cell[data-date]')
|
||||
for (const cell of cells) {
|
||||
if (!this.selStart || !this.selEnd) {
|
||||
cell.classList.remove('selected')
|
||||
continue
|
||||
}
|
||||
const ds = cell.dataset.date
|
||||
const inRange = ds >= this.selStart && ds <= this.selEnd
|
||||
cell.classList.toggle('selected', inRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startDrag(dateStr) {
|
||||
if (this.calendar.config.select_days === 0) return
|
||||
this.isDragging = true
|
||||
this.dragAnchor = dateStr
|
||||
this.setSelection(dateStr, dateStr)
|
||||
}
|
||||
|
||||
updateDrag(dateStr) {
|
||||
if (!this.isDragging) return
|
||||
this.setSelection(this.dragAnchor, dateStr)
|
||||
document.body.style.cursor = 'default'
|
||||
}
|
||||
|
||||
endDrag(dateStr) {
|
||||
if (!this.isDragging) return
|
||||
this.isDragging = false
|
||||
this.setSelection(this.dragAnchor, dateStr)
|
||||
document.body.style.cursor = 'default'
|
||||
if (this.selStart && this.selEnd) {
|
||||
setTimeout(() => this.showEventDialog('create'), 50)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Event Management --------
|
||||
|
||||
createEvent(eventData) {
|
||||
const singleDay = eventData.startDate === eventData.endDate
|
||||
const event = {
|
||||
id: this.eventIdCounter++,
|
||||
title: eventData.title,
|
||||
startDate: eventData.startDate,
|
||||
endDate: eventData.endDate,
|
||||
colorId: eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate),
|
||||
startTime: singleDay ? (eventData.startTime || '09:00') : null,
|
||||
durationMinutes: singleDay ? (eventData.durationMinutes || 60) : null
|
||||
}
|
||||
|
||||
const startDate = new Date(fromLocalString(event.startDate))
|
||||
const endDate = new Date(fromLocalString(event.endDate))
|
||||
|
||||
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = toLocalString(d)
|
||||
if (!this.events.has(dateStr)) this.events.set(dateStr, [])
|
||||
this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate })
|
||||
}
|
||||
|
||||
this.calendar.forceUpdateVisibleWeeks()
|
||||
}
|
||||
|
||||
createEventWithRepeat(eventData) {
|
||||
const { repeat, repeatCount, ...baseEventData } = eventData
|
||||
|
||||
if (repeat === 'none') {
|
||||
// Single event
|
||||
this.createEvent(baseEventData)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate dates for repeating events
|
||||
const startDate = new Date(fromLocalString(baseEventData.startDate))
|
||||
const endDate = new Date(fromLocalString(baseEventData.endDate))
|
||||
const spanDays = Math.floor((endDate - startDate) / (24 * 60 * 60 * 1000))
|
||||
|
||||
const maxOccurrences = repeatCount === 'unlimited' ? 104 : parseInt(repeatCount, 10) // Cap unlimited at ~2 years
|
||||
const dates = []
|
||||
|
||||
for (let i = 0; i < maxOccurrences; i++) {
|
||||
const currentStart = new Date(startDate)
|
||||
|
||||
switch (repeat) {
|
||||
case 'daily':
|
||||
currentStart.setDate(startDate.getDate() + i)
|
||||
break
|
||||
case 'weekly':
|
||||
currentStart.setDate(startDate.getDate() + i * 7)
|
||||
break
|
||||
case 'biweekly':
|
||||
currentStart.setDate(startDate.getDate() + i * 14)
|
||||
break
|
||||
case 'monthly':
|
||||
currentStart.setMonth(startDate.getMonth() + i)
|
||||
break
|
||||
case 'yearly':
|
||||
currentStart.setFullYear(startDate.getFullYear() + i)
|
||||
break
|
||||
}
|
||||
|
||||
const currentEnd = new Date(currentStart)
|
||||
currentEnd.setDate(currentStart.getDate() + spanDays)
|
||||
|
||||
dates.push({
|
||||
startDate: toLocalString(currentStart),
|
||||
endDate: toLocalString(currentEnd)
|
||||
})
|
||||
}
|
||||
|
||||
// Create events for all dates
|
||||
dates.forEach(({ startDate, endDate }) => {
|
||||
this.createEvent({
|
||||
...baseEventData,
|
||||
startDate,
|
||||
endDate
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
getEventById(id) {
|
||||
for (const [, list] of this.events) {
|
||||
const found = list.find(e => e.id === id)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
selectEventColorId(startDateStr, endDateStr) {
|
||||
const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0]
|
||||
const startDate = new Date(fromLocalString(startDateStr))
|
||||
const endDate = new Date(fromLocalString(endDateStr))
|
||||
|
||||
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = toLocalString(d)
|
||||
const dayEvents = this.events.get(dateStr) || []
|
||||
for (const event of dayEvents) {
|
||||
if (event.colorId >= 0 && event.colorId < 8) {
|
||||
colorCounts[event.colorId]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let minCount = colorCounts[0]
|
||||
let selectedColor = 0
|
||||
|
||||
for (let colorId = 1; colorId < 8; colorId++) {
|
||||
if (colorCounts[colorId] < minCount) {
|
||||
minCount = colorCounts[colorId]
|
||||
selectedColor = colorId
|
||||
}
|
||||
}
|
||||
|
||||
return selectedColor
|
||||
}
|
||||
|
||||
applyEventEdit(eventId, data) {
|
||||
const current = this.getEventById(eventId)
|
||||
if (!current) return
|
||||
const newStart = data.startDate || current.startDate
|
||||
const newEnd = data.endDate || current.endDate
|
||||
const datesChanged = (newStart !== current.startDate) || (newEnd !== current.endDate)
|
||||
if (datesChanged) {
|
||||
const multi = daysInclusive(newStart, newEnd) > 1
|
||||
const payload = {
|
||||
...current,
|
||||
title: data.title.trim(),
|
||||
colorId: data.colorId,
|
||||
startDate: newStart,
|
||||
endDate: newEnd,
|
||||
startTime: multi ? null : (data.startTime ?? current.startTime),
|
||||
durationMinutes: multi ? null : (data.duration ?? current.durationMinutes)
|
||||
}
|
||||
this.updateEventDatesAndReindex(eventId, payload)
|
||||
this.calendar.forceUpdateVisibleWeeks()
|
||||
return
|
||||
}
|
||||
// No date change: update in place across instances
|
||||
for (const [, list] of this.events) {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i].id === eventId) {
|
||||
const isMulti = list[i].startDate !== list[i].endDate
|
||||
list[i] = {
|
||||
...list[i],
|
||||
title: data.title.trim(),
|
||||
colorId: data.colorId,
|
||||
startTime: isMulti ? null : data.startTime,
|
||||
durationMinutes: isMulti ? null : data.duration
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.calendar.forceUpdateVisibleWeeks()
|
||||
}
|
||||
|
||||
updateEventDatesAndReindex(eventId, updated) {
|
||||
// Remove old instances
|
||||
for (const [date, list] of this.events) {
|
||||
const idx = list.findIndex(e => e.id === eventId)
|
||||
if (idx !== -1) list.splice(idx, 1)
|
||||
if (list.length === 0) this.events.delete(date)
|
||||
}
|
||||
// Re-add across new range
|
||||
const start = new Date(fromLocalString(updated.startDate))
|
||||
const end = new Date(fromLocalString(updated.endDate))
|
||||
const base = {
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
colorId: updated.colorId,
|
||||
startDate: updated.startDate,
|
||||
endDate: updated.endDate,
|
||||
startTime: updated.startTime,
|
||||
durationMinutes: updated.durationMinutes
|
||||
}
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
const ds = toLocalString(d)
|
||||
if (!this.events.has(ds)) this.events.set(ds, [])
|
||||
this.events.get(ds).push({ ...base, isSpanning: start < end })
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Event Dialog --------
|
||||
|
||||
setupEventDialog() {
|
||||
const tpl = document.createElement('template')
|
||||
tpl.innerHTML = `
|
||||
<div class="ec-modal-backdrop" part="backdrop" hidden>
|
||||
<div class="ec-modal" role="dialog" aria-modal="true" aria-labelledby="ec-modal-title">
|
||||
<form class="ec-form" novalidate>
|
||||
<header class="ec-header">
|
||||
<h2 id="ec-modal-title">Event</h2>
|
||||
</header>
|
||||
<div class="ec-body">
|
||||
<label class="ec-field">
|
||||
<span>Title</span>
|
||||
<input type="text" name="title" autocomplete="off" required />
|
||||
</label>
|
||||
<label class="ec-field">
|
||||
<span>Repeat</span>
|
||||
<select name="repeat">
|
||||
<option value="none">No repeat</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="biweekly">Every 2 weeks</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="ec-repeat-count-row" style="display: none;">
|
||||
<label class="ec-field">
|
||||
<span>Number of occurrences</span>
|
||||
<select name="repeatCount">
|
||||
<option value="2">2 times</option>
|
||||
<option value="3">3 times</option>
|
||||
<option value="4">4 times</option>
|
||||
<option value="5">5 times</option>
|
||||
<option value="10">10 times</option>
|
||||
<option value="52">52 times (1 year)</option>
|
||||
<option value="unlimited">Unlimited</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ec-color-swatches">
|
||||
${Array.from({ length: 8 }, (_, i) => `
|
||||
<input class="swatch event-color-${i}" type="radio" name="colorId" value="${i}">
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<footer class="ec-footer">
|
||||
<button type="button" class="ec-btn" data-action="cancel">Cancel</button>
|
||||
<button type="submit" class="ec-btn primary">Save</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
document.body.appendChild(tpl.content)
|
||||
this.eventModal = document.querySelector('.ec-modal-backdrop')
|
||||
this.eventForm = this.eventModal.querySelector('form.ec-form')
|
||||
this.eventTitleInput = this.eventForm.elements['title']
|
||||
this.eventRepeatInput = this.eventForm.elements['repeat']
|
||||
this.eventRepeatCountInput = this.eventForm.elements['repeatCount']
|
||||
this.eventRepeatCountRow = this.eventForm.querySelector('.ec-repeat-count-row')
|
||||
this.eventColorInputs = Array.from(this.eventForm.querySelectorAll('input[name="colorId"]'))
|
||||
|
||||
// Repeat change toggles repeat count visibility
|
||||
this.eventRepeatInput.addEventListener('change', () => {
|
||||
const showRepeatCount = this.eventRepeatInput.value !== 'none'
|
||||
this.eventRepeatCountRow.style.display = showRepeatCount ? 'block' : 'none'
|
||||
})
|
||||
|
||||
// Color selection visual state
|
||||
this.eventColorInputs.forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
const swatches = this.eventForm.querySelectorAll('.ec-color-swatches .swatch')
|
||||
swatches.forEach(s => s.classList.toggle('selected', s.checked))
|
||||
})
|
||||
})
|
||||
|
||||
this.eventForm.addEventListener('submit', e => {
|
||||
e.preventDefault()
|
||||
const data = this.readEventForm()
|
||||
if (!data.title.trim()) return
|
||||
|
||||
if (this._dialogMode === 'create') {
|
||||
this.createEventWithRepeat({
|
||||
title: data.title.trim(),
|
||||
startDate: this.selStart,
|
||||
endDate: this.selEnd,
|
||||
colorId: data.colorId,
|
||||
repeat: data.repeat,
|
||||
repeatCount: data.repeatCount
|
||||
})
|
||||
this.clearSelection()
|
||||
} else if (this._dialogMode === 'edit' && this._editingEventId != null) {
|
||||
this.applyEventEdit(this._editingEventId, {
|
||||
title: data.title.trim(),
|
||||
colorId: data.colorId,
|
||||
repeat: data.repeat,
|
||||
repeatCount: data.repeatCount
|
||||
})
|
||||
}
|
||||
this.hideEventDialog()
|
||||
})
|
||||
|
||||
this.eventForm.querySelector('[data-action="cancel"]').addEventListener('click', () => {
|
||||
this.hideEventDialog()
|
||||
if (this._dialogMode === 'create') this.clearSelection()
|
||||
})
|
||||
|
||||
this.eventModal.addEventListener('click', e => {
|
||||
if (e.target === this.eventModal) this.hideEventDialog()
|
||||
})
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (this.eventModal.hidden) return
|
||||
if (e.key === 'Escape') {
|
||||
this.hideEventDialog()
|
||||
if (this._dialogMode === 'create') this.clearSelection()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
showEventDialog(mode, opts = {}) {
|
||||
this._dialogMode = mode
|
||||
this._editingEventId = null
|
||||
|
||||
if (mode === 'create') {
|
||||
this.eventTitleInput.value = ''
|
||||
this.eventRepeatInput.value = 'none'
|
||||
this.eventRepeatCountInput.value = '5'
|
||||
this.eventRepeatCountRow.style.display = 'none'
|
||||
const suggested = this.selectEventColorId(this.selStart, this.selEnd)
|
||||
this.eventColorInputs.forEach(r => r.checked = Number(r.value) === suggested)
|
||||
} else if (mode === 'edit') {
|
||||
const ev = this.getEventById(opts.id)
|
||||
if (!ev) return
|
||||
this._editingEventId = ev.id
|
||||
this.eventTitleInput.value = ev.title || ''
|
||||
this.eventRepeatInput.value = ev.repeat || 'none'
|
||||
this.eventRepeatCountInput.value = ev.repeatCount || '5'
|
||||
this.eventRepeatCountRow.style.display = (ev.repeat && ev.repeat !== 'none') ? 'block' : 'none'
|
||||
this.eventColorInputs.forEach(r => r.checked = Number(r.value) === (ev.colorId ?? 0))
|
||||
}
|
||||
this.eventModal.hidden = false
|
||||
setTimeout(() => this.eventTitleInput.focus(), 0)
|
||||
}
|
||||
|
||||
hideEventDialog() {
|
||||
this.eventModal.hidden = true
|
||||
}
|
||||
|
||||
readEventForm() {
|
||||
const colorId = Number(this.eventForm.querySelector('input[name="colorId"]:checked')?.value ?? 0)
|
||||
return {
|
||||
title: this.eventTitleInput.value,
|
||||
repeat: this.eventRepeatInput.value,
|
||||
repeatCount: this.eventRepeatCountInput.value,
|
||||
colorId
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Event Drag & Drop --------
|
||||
|
||||
installGlobalEventDragHandlers() {
|
||||
if (this._installedEventDrag) return
|
||||
this._installedEventDrag = true
|
||||
this._onMouseMoveEventDrag = e => this.onEventDragMove(e)
|
||||
this._onMouseUpEventDrag = e => this.onEventDragEnd(e)
|
||||
document.addEventListener('mousemove', this._onMouseMoveEventDrag)
|
||||
document.addEventListener('mouseup', this._onMouseUpEventDrag)
|
||||
|
||||
this._onTouchMoveEventDrag = e => this.onEventDragMove(e)
|
||||
this._onTouchEndEventDrag = e => this.onEventDragEnd(e)
|
||||
document.addEventListener('touchmove', this._onTouchMoveEventDrag, { passive: false })
|
||||
document.addEventListener('touchend', this._onTouchEndEventDrag)
|
||||
|
||||
this._onPointerMoveEventDrag = e => this.onEventDragMove(e)
|
||||
this._onPointerUpEventDrag = e => this.onEventDragEnd(e)
|
||||
this._onPointerCancelEventDrag = e => this.onEventDragEnd(e)
|
||||
window.addEventListener('pointermove', this._onPointerMoveEventDrag)
|
||||
window.addEventListener('pointerup', this._onPointerUpEventDrag)
|
||||
window.addEventListener('pointercancel', this._onPointerCancelEventDrag)
|
||||
|
||||
this._onWindowMouseUpEventDrag = e => this.onEventDragEnd(e)
|
||||
this._onWindowBlurEventDrag = () => this.onEventDragEnd()
|
||||
window.addEventListener('mouseup', this._onWindowMouseUpEventDrag)
|
||||
window.addEventListener('blur', this._onWindowBlurEventDrag)
|
||||
}
|
||||
|
||||
removeGlobalEventDragHandlers() {
|
||||
if (!this._installedEventDrag) return
|
||||
document.removeEventListener('mousemove', this._onMouseMoveEventDrag)
|
||||
document.removeEventListener('mouseup', this._onMouseUpEventDrag)
|
||||
document.removeEventListener('touchmove', this._onTouchMoveEventDrag)
|
||||
document.removeEventListener('touchend', this._onTouchEndEventDrag)
|
||||
window.removeEventListener('pointermove', this._onPointerMoveEventDrag)
|
||||
window.removeEventListener('pointerup', this._onPointerUpEventDrag)
|
||||
window.removeEventListener('pointercancel', this._onPointerCancelEventDrag)
|
||||
window.removeEventListener('mouseup', this._onWindowMouseUpEventDrag)
|
||||
window.removeEventListener('blur', this._onWindowBlurEventDrag)
|
||||
this._installedEventDrag = false
|
||||
}
|
||||
|
||||
onEventDragMove(e) {
|
||||
if (!this.dragEventState) return
|
||||
if (this.dragEventState.usingPointer && !(e && e.type && e.type.startsWith('pointer'))) return
|
||||
|
||||
const pt = e.touches ? e.touches[0] : e
|
||||
|
||||
// Check if we've moved far enough to consider this a real drag
|
||||
if (!this._eventDragMoved) {
|
||||
const dx = pt.clientX - this.dragEventState.pointerStartX
|
||||
const dy = pt.clientY - this.dragEventState.pointerStartY
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
const minDragDistance = 5 // pixels
|
||||
|
||||
if (distance < minDragDistance) {
|
||||
return // Don't start dragging yet
|
||||
}
|
||||
// Only prevent default when we actually start dragging
|
||||
if (e && e.cancelable) e.preventDefault()
|
||||
this._eventDragMoved = true
|
||||
} else {
|
||||
// Already dragging, continue to prevent default
|
||||
if (e && e.cancelable) e.preventDefault()
|
||||
}
|
||||
|
||||
const hit = pt ? this.calendar.getDateUnderPointer(pt.clientX, pt.clientY) : null
|
||||
if (hit && hit.date) {
|
||||
const [s, en] = this.computeTentativeRangeFromPointer(hit.date)
|
||||
this.dragPreview = { id: this.dragEventState.id, startDate: s, endDate: en }
|
||||
} else {
|
||||
this.dragPreview = null
|
||||
}
|
||||
this.calendar.forceUpdateVisibleWeeks()
|
||||
}
|
||||
|
||||
onEventDragEnd(e) {
|
||||
if (!this.dragEventState) return
|
||||
if (this.dragEventState.usingPointer && e && !(e.type && e.type.startsWith('pointer'))) {
|
||||
return
|
||||
}
|
||||
|
||||
const st = this.dragEventState
|
||||
|
||||
let startDateStr = this.dragPreview?.startDate
|
||||
let endDateStr = this.dragPreview?.endDate
|
||||
|
||||
if (!startDateStr || !endDateStr) {
|
||||
const getPoint = ev => (ev && ev.changedTouches && ev.changedTouches[0]) ? ev.changedTouches[0] : (ev && ev.touches ? ev.touches[0] : ev)
|
||||
const pt = getPoint(e)
|
||||
const drop = pt ? this.calendar.getDateUnderPointer(pt.clientX, pt.clientY) : null
|
||||
if (drop && drop.date) {
|
||||
const pair = this.computeTentativeRangeFromPointer(drop.date)
|
||||
startDateStr = pair[0]
|
||||
endDateStr = pair[1]
|
||||
} else {
|
||||
startDateStr = st.startDate
|
||||
endDateStr = st.endDate
|
||||
}
|
||||
}
|
||||
|
||||
const ev = this.getEventById(st.id)
|
||||
if (ev) {
|
||||
const updated = { ...ev }
|
||||
if (st.mode === 'move') {
|
||||
const spanDays = daysInclusive(ev.startDate, ev.endDate)
|
||||
updated.startDate = startDateStr
|
||||
updated.endDate = addDaysStr(startDateStr, spanDays - 1)
|
||||
} else {
|
||||
if (startDateStr <= endDateStr) {
|
||||
updated.startDate = startDateStr
|
||||
updated.endDate = endDateStr
|
||||
}
|
||||
}
|
||||
|
||||
let [ns, ne] = this.normalizeDateOrder(updated.startDate, updated.endDate)
|
||||
updated.startDate = ns
|
||||
updated.endDate = ne
|
||||
const multi = daysInclusive(updated.startDate, updated.endDate) > 1
|
||||
if (multi) {
|
||||
updated.startTime = null
|
||||
updated.durationMinutes = null
|
||||
} else {
|
||||
if (!updated.startTime) updated.startTime = '09:00'
|
||||
if (!updated.durationMinutes) updated.durationMinutes = 60
|
||||
}
|
||||
|
||||
this.updateEventDatesAndReindex(ev.id, updated)
|
||||
}
|
||||
|
||||
try {
|
||||
if (st.usingPointer && st.element && st.element.releasePointerCapture && e && e.pointerId != null) {
|
||||
st.element.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
this.dragEventState = null
|
||||
|
||||
// Only set justDragged if we actually moved and dragged
|
||||
this.justDragged = !!this._eventDragMoved
|
||||
|
||||
this._eventDragMoved = false
|
||||
this.removeGlobalEventDragHandlers()
|
||||
|
||||
// Only update visible weeks if we actually dragged
|
||||
if (this.justDragged) {
|
||||
this.calendar.forceUpdateVisibleWeeks()
|
||||
}
|
||||
|
||||
// Clear justDragged flag after a short delay to allow click events to process
|
||||
if (this.justDragged) {
|
||||
setTimeout(() => {
|
||||
this.justDragged = false
|
||||
}, 100)
|
||||
}
|
||||
this.dragPreview = null
|
||||
}
|
||||
|
||||
computeTentativeRangeFromPointer(dropDateStr) {
|
||||
const st = this.dragEventState
|
||||
if (!st) return [null, null]
|
||||
const anchorOffset = st.anchorOffset || 0
|
||||
const spanDays = st.originSpanDays || daysInclusive(st.startDate, st.endDate)
|
||||
let startStr = st.startDate
|
||||
let endStr = st.endDate
|
||||
if (st.mode === 'move') {
|
||||
startStr = addDaysStr(dropDateStr, -anchorOffset)
|
||||
endStr = addDaysStr(startStr, spanDays - 1)
|
||||
} else if (st.mode === 'resize-left') {
|
||||
startStr = dropDateStr
|
||||
endStr = st.originalEndDate || st.endDate
|
||||
} else if (st.mode === 'resize-right') {
|
||||
startStr = st.originalStartDate || st.startDate
|
||||
endStr = dropDateStr
|
||||
}
|
||||
const [ns, ne] = this.normalizeDateOrder(startStr, endStr)
|
||||
return [ns, ne]
|
||||
}
|
||||
|
||||
normalizeDateOrder(aStr, bStr) {
|
||||
if (!aStr) return [bStr, bStr]
|
||||
if (!bStr) return [aStr, aStr]
|
||||
return aStr <= bStr ? [aStr, bStr] : [bStr, aStr]
|
||||
}
|
||||
|
||||
addEventsToWeek(weekEl) {
|
||||
const daysGrid = weekEl._daysGrid || weekEl.querySelector('.days-grid')
|
||||
const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay')
|
||||
if (!daysGrid || !overlay) return
|
||||
|
||||
const cells = Array.from(daysGrid.querySelectorAll('.cell[data-date]'))
|
||||
|
||||
while (overlay.firstChild) overlay.removeChild(overlay.firstChild)
|
||||
|
||||
const weekEvents = new Map()
|
||||
for (const cell of cells) {
|
||||
const dateStr = cell.dataset.date
|
||||
const events = this.events.get(dateStr) || []
|
||||
for (const ev of events) {
|
||||
if (!weekEvents.has(ev.id)) {
|
||||
weekEvents.set(ev.id, {
|
||||
...ev,
|
||||
startDateInWeek: dateStr,
|
||||
endDateInWeek: dateStr,
|
||||
startIdx: cells.indexOf(cell),
|
||||
endIdx: cells.indexOf(cell)
|
||||
})
|
||||
} else {
|
||||
const w = weekEvents.get(ev.id)
|
||||
w.endDateInWeek = dateStr
|
||||
w.endIdx = cells.indexOf(cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If dragging, hide the original of the dragged event and inject preview if it intersects this week
|
||||
if (this.dragPreview && this.dragPreview.id != null) {
|
||||
const pv = this.dragPreview
|
||||
// Remove original entries of the dragged event for this week to prevent ghosts
|
||||
if (weekEvents.has(pv.id)) weekEvents.delete(pv.id)
|
||||
// Determine week range
|
||||
const weekStart = cells[0]?.dataset?.date
|
||||
const weekEnd = cells[cells.length - 1]?.dataset?.date
|
||||
if (weekStart && weekEnd) {
|
||||
const s = pv.startDate
|
||||
const e = pv.endDate
|
||||
// Intersect preview with this week
|
||||
const startInWeek = s <= weekEnd && e >= weekStart ? (s < weekStart ? weekStart : s) : null
|
||||
const endInWeek = s <= weekEnd && e >= weekStart ? (e > weekEnd ? weekEnd : e) : null
|
||||
if (startInWeek && endInWeek) {
|
||||
// Compute indices
|
||||
let sIdx = cells.findIndex(c => c.dataset.date === startInWeek)
|
||||
if (sIdx === -1) sIdx = cells.findIndex(c => c.dataset.date > startInWeek)
|
||||
if (sIdx === -1) sIdx = 0
|
||||
let eIdx = -1
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
if (cells[i].dataset.date <= endInWeek) eIdx = i
|
||||
}
|
||||
if (eIdx === -1) eIdx = cells.length - 1
|
||||
|
||||
// Build/override entry
|
||||
const baseEv = this.getEventById(pv.id)
|
||||
if (baseEv) {
|
||||
const entry = {
|
||||
...baseEv,
|
||||
startDateInWeek: startInWeek,
|
||||
endDateInWeek: endInWeek,
|
||||
startIdx: sIdx,
|
||||
endIdx: eIdx
|
||||
}
|
||||
weekEvents.set(pv.id, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const timeToMin = t => {
|
||||
if (typeof t !== 'string') return 1e9
|
||||
const m = t.match(/^(\d{2}):(\d{2})/)
|
||||
if (!m) return 1e9
|
||||
return Number(m[1]) * 60 + Number(m[2])
|
||||
}
|
||||
|
||||
const spans = Array.from(weekEvents.values()).sort((a, b) => {
|
||||
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
|
||||
// Prefer longer spans to be placed first for packing
|
||||
const aLen = a.endIdx - a.startIdx
|
||||
const bLen = b.endIdx - b.startIdx
|
||||
if (aLen !== bLen) return bLen - aLen
|
||||
// Within the same day and same span length, order by start time
|
||||
const at = timeToMin(a.startTime)
|
||||
const bt = timeToMin(b.startTime)
|
||||
if (at !== bt) return at - bt
|
||||
// Stable fallback by id
|
||||
return (a.id || 0) - (b.id || 0)
|
||||
})
|
||||
|
||||
const rowsLastEnd = []
|
||||
for (const w of spans) {
|
||||
let placedRow = 0
|
||||
while (placedRow < rowsLastEnd.length && !(w.startIdx > rowsLastEnd[placedRow])) placedRow++
|
||||
if (placedRow === rowsLastEnd.length) rowsLastEnd.push(-1)
|
||||
rowsLastEnd[placedRow] = w.endIdx
|
||||
w._row = placedRow + 1
|
||||
}
|
||||
|
||||
const numRows = Math.max(1, rowsLastEnd.length)
|
||||
|
||||
// Decide between "comfortable" layout (with gaps, not stretched)
|
||||
// and "compressed" layout (fractional rows, no gaps) based on fit.
|
||||
const cs = getComputedStyle(overlay)
|
||||
const overlayHeight = overlay.getBoundingClientRect().height
|
||||
const marginTopPx = parseFloat(cs.marginTop) || 0
|
||||
const available = Math.max(0, overlayHeight - marginTopPx)
|
||||
const baseEm = parseFloat(cs.fontSize) || 16
|
||||
const rowPx = 1.2 * baseEm // preferred row height ~ 1.2em
|
||||
const gapPx = 0.2 * baseEm // preferred gap ~ .2em
|
||||
const needed = numRows * rowPx + (numRows - 1) * gapPx
|
||||
|
||||
if (needed <= available) {
|
||||
// Comfortable: keep gaps and do not stretch rows to fill
|
||||
overlay.style.gridTemplateRows = `repeat(${numRows}, ${rowPx}px)`
|
||||
overlay.style.rowGap = `${gapPx}px`
|
||||
} else {
|
||||
// Compressed: use fractional rows so everything fits; remove gaps
|
||||
overlay.style.gridTemplateRows = `repeat(${numRows}, 1fr)`
|
||||
overlay.style.rowGap = '0'
|
||||
}
|
||||
|
||||
// Create the spans
|
||||
for (const w of spans) this.createOverlaySpan(overlay, w, weekEl)
|
||||
}
|
||||
|
||||
createOverlaySpan(overlay, w, weekEl) {
|
||||
const span = document.createElement('div')
|
||||
span.className = `event-span event-color-${w.colorId}`
|
||||
span.style.gridColumn = `${w.startIdx + 1} / ${w.endIdx + 2}`
|
||||
span.style.gridRow = `${w._row}`
|
||||
span.textContent = w.title
|
||||
span.title = `${w.title} (${w.startDate === w.endDate ? w.startDate : w.startDate + ' - ' + w.endDate})`
|
||||
span.dataset.eventId = String(w.id)
|
||||
if (this.dragEventState && this.dragEventState.id === w.id) span.classList.add('dragging')
|
||||
|
||||
// Click opens edit if not dragging
|
||||
span.addEventListener('click', e => {
|
||||
e.stopPropagation()
|
||||
|
||||
// Only block if we actually dragged (moved the mouse)
|
||||
if (this.justDragged) return
|
||||
|
||||
this.showEventDialog('edit', { id: w.id })
|
||||
})
|
||||
|
||||
// Add resize handles
|
||||
const left = document.createElement('div')
|
||||
left.className = 'resize-handle left'
|
||||
const right = document.createElement('div')
|
||||
right.className = 'resize-handle right'
|
||||
span.appendChild(left)
|
||||
span.appendChild(right)
|
||||
|
||||
// Pointer down handlers
|
||||
const onPointerDown = (mode, ev) => {
|
||||
// Prevent duplicate handling if we already have a drag state
|
||||
if (this.dragEventState) return
|
||||
|
||||
// Don't prevent default immediately - let click events through
|
||||
ev.stopPropagation()
|
||||
const point = ev.touches ? ev.touches[0] : ev
|
||||
const hitAtStart = this.calendar.getDateUnderPointer(point.clientX, point.clientY)
|
||||
this.dragEventState = {
|
||||
mode,
|
||||
id: w.id,
|
||||
originWeek: weekEl,
|
||||
originStartIdx: w.startIdx,
|
||||
originEndIdx: w.endIdx,
|
||||
pointerStartX: point.clientX,
|
||||
pointerStartY: point.clientY,
|
||||
startDate: w.startDate,
|
||||
endDate: w.endDate,
|
||||
usingPointer: ev.type && ev.type.startsWith('pointer')
|
||||
}
|
||||
// compute anchor offset within the event based on where the pointer is
|
||||
const spanDays = daysInclusive(w.startDate, w.endDate)
|
||||
let anchorOffset = 0
|
||||
if (hitAtStart && hitAtStart.date) {
|
||||
const anchorDate = hitAtStart.date
|
||||
// clamp anchorDate to within event span
|
||||
if (anchorDate < w.startDate) anchorOffset = 0
|
||||
else if (anchorDate > w.endDate) anchorOffset = spanDays - 1
|
||||
else anchorOffset = daysInclusive(w.startDate, anchorDate) - 1
|
||||
}
|
||||
this.dragEventState.anchorOffset = anchorOffset
|
||||
this.dragEventState.originSpanDays = spanDays
|
||||
this.dragEventState.originalStartDate = w.startDate
|
||||
this.dragEventState.originalEndDate = w.endDate
|
||||
// capture pointer to ensure we receive the up even if cursor leaves element
|
||||
if (this.dragEventState.usingPointer && span.setPointerCapture && ev.pointerId != null) {
|
||||
try { span.setPointerCapture(ev.pointerId) } catch {}
|
||||
}
|
||||
this.dragEventState.element = span
|
||||
this.dragEventState.currentOverlay = overlay
|
||||
this._eventDragMoved = false
|
||||
span.classList.add('dragging')
|
||||
this.installGlobalEventDragHandlers()
|
||||
}
|
||||
|
||||
// Use pointer events (supported by all modern browsers)
|
||||
left.addEventListener('pointerdown', e => onPointerDown('resize-left', e))
|
||||
right.addEventListener('pointerdown', e => onPointerDown('resize-right', e))
|
||||
span.addEventListener('pointerdown', e => {
|
||||
if ((e.target).classList && (e.target).classList.contains('resize-handle')) return
|
||||
onPointerDown('move', e)
|
||||
})
|
||||
|
||||
// Touch support (for compatibility with older mobile browsers)
|
||||
left.addEventListener('touchstart', e => onPointerDown('resize-left', e), { passive: false })
|
||||
right.addEventListener('touchstart', e => onPointerDown('resize-right', e), { passive: false })
|
||||
span.addEventListener('touchstart', e => {
|
||||
if ((e.target).classList && (e.target).classList.contains('resize-handle')) return
|
||||
onPointerDown('move', e)
|
||||
}, { passive: false })
|
||||
overlay.appendChild(span)
|
||||
}
|
||||
}
|
69
events.css
69
events.css
@ -1,69 +0,0 @@
|
||||
.event-span {
|
||||
font-size: clamp(.45em, 1.8vh, .75em);
|
||||
padding: 0 .5em;
|
||||
border-radius: .4em;
|
||||
color: var(--strong);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1; /* let the track height define the visual height */
|
||||
height: 100%; /* fill the grid row height */
|
||||
align-self: stretch; /* fill row vertically */
|
||||
justify-self: stretch; /* stretch across chosen grid columns */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto; /* clickable despite overlay having none */
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
/* Selection styles */
|
||||
.cell.selected {
|
||||
background: var(--select);
|
||||
box-shadow: 0 0 .1em var(--muted) inset;
|
||||
}
|
||||
.cell.selected .event { opacity: .7 }
|
||||
|
||||
/* Dragging state */
|
||||
.event-span.dragging {
|
||||
opacity: .9;
|
||||
cursor: grabbing;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
/* Resize handles */
|
||||
.event-span .resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
background: transparent;
|
||||
z-index: 2;
|
||||
}
|
||||
.event-span .resize-handle.left {
|
||||
left: 0;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.event-span .resize-handle.right {
|
||||
right: 0;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
/* Live preview ghost while dragging */
|
||||
.event-preview {
|
||||
pointer-events: none;
|
||||
opacity: .6;
|
||||
outline: 2px dashed currentColor;
|
||||
outline-offset: -2px;
|
||||
border-radius: .4em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: clamp(.45em, 1.8vh, .75em);
|
||||
line-height: 1;
|
||||
height: 100%;
|
||||
z-index: 3;
|
||||
}
|
25
index.html
25
index.html
@ -4,26 +4,9 @@
|
||||
<meta charset="utf-8">
|
||||
<title>Calendar</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="stylesheet" href="calendar.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1 id="title">Calendar</h1>
|
||||
<div class="header-controls">
|
||||
<input type="text" id="selected-date" class="date-input" readonly>
|
||||
<div id="today-date" class="today-date"></div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="calendar-header" id="calendar-header"></div>
|
||||
<div class="calendar-container" id="calendar-container">
|
||||
<div class="calendar-viewport" id="calendar-viewport">
|
||||
<div class="calendar-content" id="calendar-content"></div>
|
||||
</div>
|
||||
<div class="jogwheel-viewport" id="jogwheel-viewport">
|
||||
<div class="jogwheel-content" id="jogwheel-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="calendar.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
79
jogwheel.js
79
jogwheel.js
@ -1,79 +0,0 @@
|
||||
// jogwheel.js — Jogwheel synchronization for calendar scrolling
|
||||
|
||||
export class JogwheelManager {
|
||||
constructor(calendar) {
|
||||
this.calendar = calendar
|
||||
this.viewport = null
|
||||
this.content = null
|
||||
this.syncLock = null
|
||||
this.init()
|
||||
}
|
||||
|
||||
init() {
|
||||
this.viewport = document.getElementById('jogwheel-viewport')
|
||||
this.content = document.getElementById('jogwheel-content')
|
||||
|
||||
if (!this.viewport || !this.content) {
|
||||
console.warn('Jogwheel elements not found - jogwheel functionality disabled')
|
||||
return
|
||||
}
|
||||
|
||||
this.setupScrollSync()
|
||||
}
|
||||
|
||||
setupScrollSync() {
|
||||
// Check if calendar viewport is available
|
||||
if (!this.calendar.viewport || !this.calendar.content) {
|
||||
console.warn('Calendar viewport not available - jogwheel sync disabled')
|
||||
return
|
||||
}
|
||||
|
||||
// Bind sync function to maintain proper context
|
||||
const sync = this.sync.bind(this)
|
||||
|
||||
this.viewport.addEventListener('scroll', () =>
|
||||
sync(this.viewport, this.calendar.viewport, this.content, this.calendar.content)
|
||||
)
|
||||
|
||||
this.calendar.viewport.addEventListener('scroll', () =>
|
||||
sync(this.calendar.viewport, this.viewport, this.calendar.content, this.content)
|
||||
)
|
||||
}
|
||||
|
||||
sync(fromEl, toEl, fromContent, toContent) {
|
||||
if (this.syncLock === toEl) return
|
||||
this.syncLock = fromEl
|
||||
|
||||
const fromScrollable = Math.max(0, fromContent.scrollHeight - fromEl.clientHeight)
|
||||
const toScrollable = Math.max(0, toContent.scrollHeight - toEl.clientHeight)
|
||||
const ratio = fromScrollable > 0 ? fromEl.scrollTop / fromScrollable : 0
|
||||
toEl.scrollTop = ratio * toScrollable
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.syncLock === fromEl) this.syncLock = null
|
||||
}, 50)
|
||||
}
|
||||
|
||||
updateHeight(totalVirtualWeeks, rowHeight) {
|
||||
if (this.content) {
|
||||
this.content.style.height = `${(totalVirtualWeeks * rowHeight) / 10}px`
|
||||
}
|
||||
}
|
||||
|
||||
scrollTo(targetScrollTop, mainScrollable, smooth = false) {
|
||||
if (!this.viewport || !this.content) return
|
||||
|
||||
const jogScrollable = Math.max(0, this.content.scrollHeight - this.viewport.clientHeight)
|
||||
const jogwheelTarget = mainScrollable > 0 ? (targetScrollTop / mainScrollable) * jogScrollable : 0
|
||||
|
||||
if (smooth) {
|
||||
this.viewport.scrollTo({ top: jogwheelTarget, behavior: 'smooth' })
|
||||
} else {
|
||||
this.viewport.scrollTop = jogwheelTarget
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable() {
|
||||
return !!(this.viewport && this.content)
|
||||
}
|
||||
}
|
8
jsconfig.json
Normal file
8
jsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
37
package.json
Normal file
37
package.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "calendar",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
|
||||
"lint:eslint": "eslint . --fix",
|
||||
"lint": "run-s lint:*",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.31.0",
|
||||
"@prettier/plugin-oxc": "^0.0.4",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"eslint": "^9.31.0",
|
||||
"eslint-plugin-oxlint": "~1.8.0",
|
||||
"eslint-plugin-vue": "~10.3.0",
|
||||
"globals": "^16.3.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.8.0",
|
||||
"prettier": "3.6.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vite-plugin-vue-devtools": "^8.0.0"
|
||||
}
|
||||
}
|
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
36
src/App.vue
Normal file
36
src/App.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import CalendarView from './components/CalendarView.vue'
|
||||
import EventDialog from './components/EventDialog.vue'
|
||||
|
||||
const eventDialog = ref(null)
|
||||
|
||||
const handleCreateEvent = (eventData) => {
|
||||
if (eventDialog.value) {
|
||||
const selectionData = {
|
||||
startDate: eventData.startDate,
|
||||
dayCount: eventData.dayCount,
|
||||
}
|
||||
setTimeout(() => eventDialog.value.openCreateDialog(selectionData), 50)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditEvent = (eventInstanceId) => {
|
||||
if (eventDialog.value) {
|
||||
eventDialog.value.openEditDialog(eventInstanceId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearSelection = () => {}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarView @create-event="handleCreateEvent" @edit-event="handleEditEvent" />
|
||||
<EventDialog
|
||||
ref="eventDialog"
|
||||
:selection="{ startDate: null, dayCount: 0 }"
|
||||
@clear-selection="handleClearSelection"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
3
src/assets/calendar.css
Normal file
3
src/assets/calendar.css
Normal file
@ -0,0 +1,3 @@
|
||||
/* Calendar CSS - Main file with imports */
|
||||
@import url('./colors.css');
|
||||
@import url('./layout.css');
|
116
src/assets/colors.css
Normal file
116
src/assets/colors.css
Normal file
@ -0,0 +1,116 @@
|
||||
/* Color tokens */
|
||||
:root {
|
||||
--panel: #ffffff;
|
||||
--panel-alt: #f6f8fa;
|
||||
--panel-accent: #eef4ff;
|
||||
--today: #f83;
|
||||
--ink: #222;
|
||||
--strong: #000;
|
||||
--muted: #6a6f76;
|
||||
--muted-alt: #9aa2ad;
|
||||
--accent: #2563eb; /* blue */
|
||||
--accent-soft: #dbeafe;
|
||||
--accent-hover: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--danger-hover: #b91c1c;
|
||||
--weekend: #888;
|
||||
--firstday: #000;
|
||||
--select: #aaf;
|
||||
--shadow: #fff;
|
||||
--label-bg: #fafbfe;
|
||||
--label-bg-rgb: 250, 251, 254;
|
||||
|
||||
/* Input / recurrence tokens */
|
||||
--input-border: var(--muted-alt);
|
||||
--input-focus: var(--accent);
|
||||
--pill-bg: var(--panel-alt);
|
||||
--pill-active-bg: var(--accent);
|
||||
--pill-active-ink: #fff;
|
||||
--pill-hover-bg: var(--accent-soft);
|
||||
|
||||
/* Vue component color mappings */
|
||||
--bg: var(--panel);
|
||||
--border-color: #ddd;
|
||||
}
|
||||
|
||||
/* Month tints (light) */
|
||||
.dec { background: hsl(220 50% 95%) }
|
||||
.jan { background: hsl(220 50% 92%) }
|
||||
.feb { background: hsl(220 50% 95%) }
|
||||
.mar { background: hsl(125 60% 92%) }
|
||||
.apr { background: hsl(125 60% 95%) }
|
||||
.may { background: hsl(125 60% 92%) }
|
||||
.jun { background: hsl(45 85% 95%) }
|
||||
.jul { background: hsl(45 85% 92%) }
|
||||
.aug { background: hsl(45 85% 95%) }
|
||||
.sep { background: hsl(18 78% 92%) }
|
||||
.oct { background: hsl(18 78% 95%) }
|
||||
.nov { background: hsl(18 78% 92%) }
|
||||
|
||||
/* Light mode — gray shades and colors */
|
||||
.event-color-0 { background: hsl(0, 0%, 85%) } /* lightest grey */
|
||||
.event-color-1 { background: hsl(0, 0%, 75%) } /* light grey */
|
||||
.event-color-2 { background: hsl(0, 0%, 65%) } /* medium grey */
|
||||
.event-color-3 { background: hsl(0, 0%, 55%) } /* dark grey */
|
||||
.event-color-4 { background: hsl(0, 70%, 70%) } /* red */
|
||||
.event-color-5 { background: hsl(90, 70%, 70%) } /* green */
|
||||
.event-color-6 { background: hsl(230, 70%, 70%) } /* blue */
|
||||
.event-color-7 { background: hsl(280, 70%, 70%) } /* purple */
|
||||
|
||||
/* Color tokens (dark) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--panel: #121417;
|
||||
--panel-alt: #1d2228;
|
||||
--panel-accent: #1a2634;
|
||||
--today: #f83;
|
||||
--ink: #e5e7eb;
|
||||
--strong: #fff;
|
||||
--muted: #7d8691;
|
||||
--muted-alt: #5d646d;
|
||||
--accent: #3b82f6;
|
||||
--accent-soft: rgba(59,130,246,0.15);
|
||||
--accent-hover: #2563eb;
|
||||
--danger: #ef4444;
|
||||
--danger-hover: #dc2626;
|
||||
--workday: var(--ink);
|
||||
--weekend: #999;
|
||||
--firstday: #fff;
|
||||
--select: #3355ff;
|
||||
--shadow: #000;
|
||||
--label-bg: #1a1d25;
|
||||
--label-bg-rgb: 26, 29, 37;
|
||||
--input-border: var(--muted-alt);
|
||||
--input-focus: var(--accent);
|
||||
--pill-bg: #222a32;
|
||||
--pill-active-bg: var(--accent);
|
||||
--pill-active-ink: #fff;
|
||||
--pill-hover-bg: rgba(255,255,255,0.08);
|
||||
|
||||
/* Vue component color mappings (dark) */
|
||||
--bg: var(--panel);
|
||||
--border-color: #333;
|
||||
}
|
||||
|
||||
.dec { background: hsl(220 50% 8%) }
|
||||
.jan { background: hsl(220 50% 6%) }
|
||||
.feb { background: hsl(220 50% 8%) }
|
||||
.mar { background: hsl(125 60% 6%) }
|
||||
.apr { background: hsl(125 60% 8%) }
|
||||
.may { background: hsl(125 60% 6%) }
|
||||
.jun { background: hsl(45 85% 8%) }
|
||||
.jul { background: hsl(45 85% 6%) }
|
||||
.aug { background: hsl(45 85% 8%) }
|
||||
.sep { background: hsl(18 78% 6%) }
|
||||
.oct { background: hsl(18 78% 8%) }
|
||||
.nov { background: hsl(18 78% 6%) }
|
||||
|
||||
.event-color-0 { background: hsl(0, 0%, 50%) } /* lightest grey */
|
||||
.event-color-1 { background: hsl(0, 0%, 40%) } /* light grey */
|
||||
.event-color-2 { background: hsl(0, 0%, 30%) } /* medium grey */
|
||||
.event-color-3 { background: hsl(0, 0%, 20%) } /* dark grey */
|
||||
.event-color-4 { background: hsl(0, 70%, 40%) } /* red */
|
||||
.event-color-5 { background: hsl(90, 70%, 30%) } /* green - darker for perceptional purposes */
|
||||
.event-color-6 { background: hsl(230, 70%, 40%) } /* blue */
|
||||
.event-color-7 { background: hsl(280, 70%, 40%) } /* purple */
|
||||
}
|
@ -18,17 +18,6 @@ body {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
background: var(--panel);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@ -129,19 +118,6 @@ header {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Overlay sitting above the day cells, same 7-col grid */
|
||||
.week-row > .days-grid > .week-overlay {
|
||||
margin-top: 1.5em;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-auto-rows: 1fr;
|
||||
row-gap: 0; /* eliminate gaps so space is fully usable */
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.month-name-label {
|
||||
grid-column: -2 / -1;
|
||||
font-size: 2em;
|
35
src/components/Calendar.vue
Normal file
35
src/components/Calendar.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<AppHeader />
|
||||
<div class="calendar-container" ref="containerEl">
|
||||
<CalendarGrid />
|
||||
<Jogwheel />
|
||||
</div>
|
||||
<EventDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import CalendarGrid from './CalendarGrid.vue'
|
||||
import Jogwheel from './Jogwheel.vue'
|
||||
import EventDialog from './EventDialog.vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
|
||||
const calendarStore = useCalendarStore()
|
||||
const containerEl = ref(null)
|
||||
|
||||
let intervalId
|
||||
|
||||
onMounted(() => {
|
||||
calendarStore.setToday()
|
||||
intervalId = setInterval(() => {
|
||||
calendarStore.setToday()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(intervalId)
|
||||
})
|
||||
</script>
|
110
src/components/CalendarDay.vue
Normal file
110
src/components/CalendarDay.vue
Normal file
@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
day: Object,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['event-click'])
|
||||
|
||||
const handleEventClick = (eventId) => {
|
||||
emit('event-click', eventId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="cell"
|
||||
:class="[
|
||||
props.day.monthClass,
|
||||
{
|
||||
today: props.day.isToday,
|
||||
weekend: props.day.isWeekend,
|
||||
firstday: props.day.isFirstDay,
|
||||
selected: props.day.isSelected,
|
||||
},
|
||||
]"
|
||||
:data-date="props.day.date"
|
||||
>
|
||||
<h1>{{ props.day.displayText }}</h1>
|
||||
<span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span>
|
||||
|
||||
<!-- Simple event display for now -->
|
||||
<div v-if="props.day.events && props.day.events.length > 0" class="day-events">
|
||||
<div
|
||||
v-for="event in props.day.events.slice(0, 3)"
|
||||
:key="event.id"
|
||||
class="event-dot"
|
||||
:class="`event-color-${event.colorId}`"
|
||||
:title="event.title"
|
||||
@click.stop="handleEventClick(event.id)"
|
||||
></div>
|
||||
<div v-if="props.day.events.length > 3" class="event-more">
|
||||
+{{ props.day.events.length - 3 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cell {
|
||||
position: relative;
|
||||
border-right: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0.25em;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: var(--cell-h);
|
||||
font-weight: 700;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.cell h1 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 1.5em;
|
||||
font-size: 1em;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.cell.today h1 {
|
||||
border-radius: 2em;
|
||||
background: var(--today);
|
||||
border: 0.2em solid var(--today);
|
||||
margin: -0.2em;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cell:hover h1 {
|
||||
text-shadow: 0 0 0.2em var(--shadow);
|
||||
}
|
||||
|
||||
.cell.weekend h1 {
|
||||
color: var(--weekend);
|
||||
}
|
||||
.cell.firstday h1 {
|
||||
color: var(--firstday);
|
||||
text-shadow: 0 0 0.1em var(--strong);
|
||||
}
|
||||
.cell.selected {
|
||||
filter: hue-rotate(180deg);
|
||||
}
|
||||
.cell.selected h1 {
|
||||
color: var(--strong);
|
||||
}
|
||||
|
||||
.lunar-phase {
|
||||
position: absolute;
|
||||
top: 0.1em;
|
||||
right: 0.1em;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
184
src/components/CalendarGrid.vue
Normal file
184
src/components/CalendarGrid.vue
Normal file
@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="calendar-header">
|
||||
<div class="year-label" @wheel.prevent="handleWheel">{{ calendarStore.viewYear }}</div>
|
||||
<div v-for="day in weekdayNames" :key="day" class="dow" :class="{ weekend: isWeekend(day) }">
|
||||
{{ day }}
|
||||
</div>
|
||||
<div class="overlay-header-spacer"></div>
|
||||
</div>
|
||||
<div class="calendar-viewport" ref="viewportEl" @scroll="handleScroll">
|
||||
<div class="calendar-content" :style="{ height: `${totalVirtualWeeks * rowHeight}px` }">
|
||||
<WeekRow
|
||||
v.for="week in visibleWeeks"
|
||||
:key="week.virtualWeek"
|
||||
:week="week"
|
||||
:style="{ top: `${(week.virtualWeek - minVirtualWeek) * rowHeight}px` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import {
|
||||
getLocalizedWeekdayNames,
|
||||
getLocaleWeekendDays,
|
||||
getLocaleFirstDay,
|
||||
isoWeekInfo,
|
||||
fromLocalString,
|
||||
toLocalString,
|
||||
mondayIndex,
|
||||
} from '@/utils/date'
|
||||
import WeekRow from './WeekRow.vue'
|
||||
|
||||
const calendarStore = useCalendarStore()
|
||||
const viewportEl = ref(null)
|
||||
const rowHeight = ref(64) // Default value, will be computed
|
||||
const totalVirtualWeeks = ref(0)
|
||||
const minVirtualWeek = ref(0)
|
||||
const visibleWeeks = ref([])
|
||||
|
||||
const config = {
|
||||
min_year: 1900,
|
||||
max_year: 2100,
|
||||
weekend: getLocaleWeekendDays(),
|
||||
}
|
||||
|
||||
const baseDate = new Date(1970, 0, 4 + getLocaleFirstDay())
|
||||
const WEEK_MS = 7 * 86400000
|
||||
|
||||
const weekdayNames = getLocalizedWeekdayNames()
|
||||
|
||||
const isWeekend = (day) => {
|
||||
const dayIndex = weekdayNames.indexOf(day)
|
||||
return config.weekend[(dayIndex + 1) % 7]
|
||||
}
|
||||
|
||||
const getWeekIndex = (date) => {
|
||||
const monday = new Date(date)
|
||||
monday.setDate(date.getDate() - mondayIndex(date))
|
||||
return Math.floor((monday - baseDate) / WEEK_MS)
|
||||
}
|
||||
|
||||
const getMondayForVirtualWeek = (virtualWeek) => {
|
||||
const monday = new Date(baseDate)
|
||||
monday.setDate(monday.getDate() + virtualWeek * 7)
|
||||
return monday
|
||||
}
|
||||
|
||||
const computeRowHeight = () => {
|
||||
const el = document.createElement('div')
|
||||
el.style.position = 'absolute'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.height = 'var(--cell-h)'
|
||||
document.body.appendChild(el)
|
||||
const h = el.getBoundingClientRect().height || 64
|
||||
el.remove()
|
||||
return Math.round(h)
|
||||
}
|
||||
|
||||
const updateVisibleWeeks = () => {
|
||||
if (!viewportEl.value) return
|
||||
|
||||
const scrollTop = viewportEl.value.scrollTop
|
||||
const viewportH = viewportEl.value.clientHeight
|
||||
|
||||
const topDisplayIndex = Math.floor(scrollTop / rowHeight.value)
|
||||
const topVW = topDisplayIndex + minVirtualWeek.value
|
||||
const monday = getMondayForVirtualWeek(topVW)
|
||||
const { year } = isoWeekInfo(monday)
|
||||
if (calendarStore.viewYear !== year) {
|
||||
calendarStore.setViewYear(year)
|
||||
}
|
||||
|
||||
const buffer = 10
|
||||
const startIdx = Math.floor((scrollTop - buffer * rowHeight.value) / rowHeight.value)
|
||||
const endIdx = Math.ceil((scrollTop + viewportH + buffer * rowHeight.value) / rowHeight.value)
|
||||
|
||||
const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value)
|
||||
const endVW = Math.min(
|
||||
totalVirtualWeeks.value + minVirtualWeek.value - 1,
|
||||
endIdx + minVirtualWeek.value,
|
||||
)
|
||||
|
||||
const newVisibleWeeks = []
|
||||
for (let vw = startVW; vw <= endVW; vw++) {
|
||||
newVisibleWeeks.push({
|
||||
virtualWeek: vw,
|
||||
monday: getMondayForVirtualWeek(vw),
|
||||
})
|
||||
}
|
||||
visibleWeeks.value = newVisibleWeeks
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
requestAnimationFrame(updateVisibleWeeks)
|
||||
}
|
||||
|
||||
const handleWheel = (e) => {
|
||||
const currentYear = calendarStore.viewYear
|
||||
const delta = Math.round(e.deltaY * (1 / 3))
|
||||
if (!delta) return
|
||||
const newYear = Math.max(config.min_year, Math.min(config.max_year, currentYear + delta))
|
||||
if (newYear === currentYear) return
|
||||
|
||||
const topDisplayIndex = Math.floor(viewportEl.value.scrollTop / rowHeight.value)
|
||||
const currentWeekIndex = topDisplayIndex + minVirtualWeek.value
|
||||
|
||||
navigateToYear(newYear, currentWeekIndex)
|
||||
}
|
||||
|
||||
const navigateToYear = (targetYear, weekIndex) => {
|
||||
const monday = getMondayForVirtualWeek(weekIndex)
|
||||
const { week } = isoWeekInfo(monday)
|
||||
const jan4 = new Date(targetYear, 0, 4)
|
||||
const jan4Monday = new Date(jan4)
|
||||
jan4Monday.setDate(jan4.getDate() - mondayIndex(jan4))
|
||||
const targetMonday = new Date(jan4Monday)
|
||||
targetMonday.setDate(jan4Monday.getDate() + (week - 1) * 7)
|
||||
scrollToTarget(targetMonday)
|
||||
}
|
||||
|
||||
const scrollToTarget = (target) => {
|
||||
let targetWeekIndex
|
||||
if (target instanceof Date) {
|
||||
targetWeekIndex = getWeekIndex(target)
|
||||
} else {
|
||||
targetWeekIndex = target
|
||||
}
|
||||
|
||||
const targetScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
viewportEl.value.scrollTop = targetScrollTop
|
||||
updateVisibleWeeks()
|
||||
}
|
||||
|
||||
const goToTodayHandler = () => {
|
||||
const today = new Date()
|
||||
const top = new Date(today)
|
||||
top.setDate(top.getDate() - 21)
|
||||
scrollToTarget(top)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
rowHeight.value = computeRowHeight()
|
||||
|
||||
const minYearDate = new Date(config.min_year, 0, 1)
|
||||
const maxYearLastDay = new Date(config.max_year, 11, 31)
|
||||
const lastWeekMonday = new Date(maxYearLastDay)
|
||||
lastWeekMonday.setDate(maxYearLastDay.getDate() - mondayIndex(maxYearLastDay))
|
||||
|
||||
minVirtualWeek.value = getWeekIndex(minYearDate)
|
||||
const maxVirtualWeek = getWeekIndex(lastWeekMonday)
|
||||
totalVirtualWeeks.value = maxVirtualWeek - minVirtualWeek.value + 1
|
||||
|
||||
const initialDate = fromLocalString(calendarStore.today)
|
||||
scrollToTarget(initialDate)
|
||||
|
||||
document.addEventListener('goToToday', goToTodayHandler)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('goToToday', goToTodayHandler)
|
||||
})
|
||||
</script>
|
92
src/components/CalendarHeader.vue
Normal file
92
src/components/CalendarHeader.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import { getLocalizedWeekdayNames, reorderByFirstDay, isoWeekInfo } from '@/utils/date'
|
||||
|
||||
const props = defineProps({
|
||||
scrollTop: { type: Number, default: 0 },
|
||||
rowHeight: { type: Number, default: 64 },
|
||||
minVirtualWeek: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const calendarStore = useCalendarStore()
|
||||
|
||||
const yearLabel = computed(() => {
|
||||
const topDisplayIndex = Math.floor(props.scrollTop / props.rowHeight)
|
||||
const topVW = topDisplayIndex + props.minVirtualWeek
|
||||
const baseDate = new Date(1970, 0, 4 + calendarStore.config.first_day)
|
||||
const firstDay = new Date(baseDate)
|
||||
firstDay.setDate(firstDay.getDate() + topVW * 7)
|
||||
return isoWeekInfo(firstDay).year
|
||||
})
|
||||
|
||||
const weekdayNames = computed(() => {
|
||||
// Get Monday-first names, then reorder by first day, then add weekend info
|
||||
const mondayFirstNames = getLocalizedWeekdayNames()
|
||||
const sundayFirstNames = [mondayFirstNames[6], ...mondayFirstNames.slice(0, 6)]
|
||||
const reorderedNames = reorderByFirstDay(sundayFirstNames, calendarStore.config.first_day)
|
||||
const reorderedWeekend = reorderByFirstDay(calendarStore.weekend, calendarStore.config.first_day)
|
||||
|
||||
return reorderedNames.map((name, i) => ({
|
||||
name,
|
||||
isWeekend: reorderedWeekend[i],
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar-header">
|
||||
<div class="year-label">{{ yearLabel }}</div>
|
||||
<div
|
||||
v-for="day in weekdayNames"
|
||||
:key="day.name"
|
||||
class="dow"
|
||||
:class="{ workday: !day.isWeekend, weekend: day.isWeekend }"
|
||||
>
|
||||
{{ day.name }}
|
||||
</div>
|
||||
<div class="overlay-header-spacer"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-header {
|
||||
display: grid;
|
||||
grid-template-columns: var(--label-w) repeat(7, 1fr) 3rem;
|
||||
border-bottom: 2px solid var(--muted);
|
||||
align-items: last baseline;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
/* Prevent text selection */
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.year-label {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
color: var(--muted);
|
||||
font-size: 1.2em;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.dow {
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.dow.weekend {
|
||||
color: var(--weekend);
|
||||
}
|
||||
.dow.workday {
|
||||
color: var(--workday);
|
||||
}
|
||||
.overlay-header-spacer {
|
||||
grid-area: auto;
|
||||
}
|
||||
</style>
|
494
src/components/CalendarView.vue
Normal file
494
src/components/CalendarView.vue
Normal file
@ -0,0 +1,494 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import CalendarHeader from '@/components/CalendarHeader.vue'
|
||||
import CalendarWeek from '@/components/CalendarWeek.vue'
|
||||
import Jogwheel from '@/components/Jogwheel.vue'
|
||||
import {
|
||||
isoWeekInfo,
|
||||
getLocalizedMonthName,
|
||||
monthAbbr,
|
||||
lunarPhaseSymbol,
|
||||
pad,
|
||||
daysInclusive,
|
||||
addDaysStr,
|
||||
formatDateRange,
|
||||
} from '@/utils/date'
|
||||
import { toLocalString, fromLocalString } from '@/utils/date'
|
||||
|
||||
const calendarStore = useCalendarStore()
|
||||
const viewport = ref(null)
|
||||
|
||||
const emit = defineEmits(['create-event', 'edit-event'])
|
||||
|
||||
function createEventFromSelection() {
|
||||
if (!selection.value.startDate || selection.value.dayCount === 0) return null
|
||||
|
||||
return {
|
||||
startDate: selection.value.startDate,
|
||||
dayCount: selection.value.dayCount,
|
||||
endDate: addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
|
||||
}
|
||||
}
|
||||
|
||||
const scrollTop = ref(0)
|
||||
const viewportHeight = ref(600)
|
||||
const rowHeight = ref(64)
|
||||
const baseDate = new Date(1970, 0, 4 + calendarStore.config.first_day)
|
||||
|
||||
const selection = ref({ startDate: null, dayCount: 0 })
|
||||
const isDragging = ref(false)
|
||||
const dragAnchor = ref(null)
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
const minVirtualWeek = computed(() => {
|
||||
const date = new Date(calendarStore.minYear, 0, 1)
|
||||
const firstDayOfWeek = new Date(date)
|
||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||
firstDayOfWeek.setDate(date.getDate() - dayOffset)
|
||||
return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS)
|
||||
})
|
||||
|
||||
const maxVirtualWeek = computed(() => {
|
||||
const date = new Date(calendarStore.maxYear, 11, 31)
|
||||
const firstDayOfWeek = new Date(date)
|
||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||
firstDayOfWeek.setDate(date.getDate() - dayOffset)
|
||||
return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS)
|
||||
})
|
||||
|
||||
const totalVirtualWeeks = computed(() => {
|
||||
return maxVirtualWeek.value - minVirtualWeek.value + 1
|
||||
})
|
||||
|
||||
const initialScrollTop = computed(() => {
|
||||
const targetWeekIndex = getWeekIndex(calendarStore.now) - 3
|
||||
return (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
})
|
||||
|
||||
const selectedDateRange = computed(() => {
|
||||
if (!selection.value.start || !selection.value.end) return ''
|
||||
return formatDateRange(
|
||||
fromLocalString(selection.value.start),
|
||||
fromLocalString(selection.value.end),
|
||||
)
|
||||
})
|
||||
|
||||
const todayString = computed(() => {
|
||||
const t = calendarStore.now
|
||||
.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
|
||||
.replace(/,? /, '\n')
|
||||
return t.charAt(0).toUpperCase() + t.slice(1)
|
||||
})
|
||||
|
||||
const visibleWeeks = computed(() => {
|
||||
const buffer = 10
|
||||
const startIdx = Math.floor((scrollTop.value - buffer * rowHeight.value) / rowHeight.value)
|
||||
const endIdx = Math.ceil(
|
||||
(scrollTop.value + viewportHeight.value + buffer * rowHeight.value) / rowHeight.value,
|
||||
)
|
||||
|
||||
const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value)
|
||||
const endVW = Math.min(maxVirtualWeek.value, endIdx + minVirtualWeek.value)
|
||||
|
||||
const weeks = []
|
||||
for (let vw = startVW; vw <= endVW; vw++) {
|
||||
weeks.push(createWeek(vw))
|
||||
}
|
||||
return weeks
|
||||
})
|
||||
|
||||
const contentHeight = computed(() => {
|
||||
return totalVirtualWeeks.value * rowHeight.value
|
||||
})
|
||||
|
||||
function computeRowHeight() {
|
||||
const el = document.createElement('div')
|
||||
el.style.position = 'absolute'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.height = 'var(--cell-h)'
|
||||
document.body.appendChild(el)
|
||||
const h = el.getBoundingClientRect().height || 64
|
||||
el.remove()
|
||||
rowHeight.value = Math.round(h)
|
||||
return rowHeight.value
|
||||
}
|
||||
|
||||
function getWeekIndex(date) {
|
||||
const firstDayOfWeek = new Date(date)
|
||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||
firstDayOfWeek.setDate(date.getDate() - dayOffset)
|
||||
return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS)
|
||||
}
|
||||
|
||||
function getFirstDayForVirtualWeek(virtualWeek) {
|
||||
const firstDay = new Date(baseDate)
|
||||
firstDay.setDate(firstDay.getDate() + virtualWeek * 7)
|
||||
return firstDay
|
||||
}
|
||||
|
||||
function createWeek(virtualWeek) {
|
||||
const firstDay = getFirstDayForVirtualWeek(virtualWeek)
|
||||
const weekNumber = isoWeekInfo(firstDay).week
|
||||
const days = []
|
||||
const cur = new Date(firstDay)
|
||||
let hasFirst = false
|
||||
let monthToLabel = null
|
||||
let labelYear = null
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const dateStr = toLocalString(cur)
|
||||
const eventsForDay = calendarStore.events.get(dateStr) || []
|
||||
const dow = cur.getDay()
|
||||
const isFirst = cur.getDate() === 1
|
||||
|
||||
if (isFirst) {
|
||||
hasFirst = true
|
||||
monthToLabel = cur.getMonth()
|
||||
labelYear = cur.getFullYear()
|
||||
}
|
||||
|
||||
let displayText = String(cur.getDate())
|
||||
if (isFirst) {
|
||||
if (cur.getMonth() === 0) {
|
||||
displayText = cur.getFullYear()
|
||||
} else {
|
||||
displayText = monthAbbr[cur.getMonth()].slice(0, 3).toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
days.push({
|
||||
date: dateStr,
|
||||
dayOfMonth: cur.getDate(),
|
||||
displayText,
|
||||
monthClass: monthAbbr[cur.getMonth()],
|
||||
isToday: dateStr === calendarStore.today,
|
||||
isWeekend: calendarStore.weekend[dow],
|
||||
isFirstDay: isFirst,
|
||||
lunarPhase: lunarPhaseSymbol(cur),
|
||||
isSelected:
|
||||
selection.value.startDate &&
|
||||
selection.value.dayCount > 0 &&
|
||||
dateStr >= selection.value.startDate &&
|
||||
dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
|
||||
events: eventsForDay,
|
||||
})
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
}
|
||||
|
||||
let monthLabel = null
|
||||
if (hasFirst && monthToLabel !== null) {
|
||||
if (labelYear && labelYear <= calendarStore.config.max_year) {
|
||||
let weeksSpan = 0
|
||||
const d = new Date(cur)
|
||||
d.setDate(cur.getDate() - 1)
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
d.setDate(cur.getDate() - 1 + i * 7)
|
||||
if (d.getMonth() === monthToLabel) weeksSpan++
|
||||
}
|
||||
|
||||
const remainingWeeks = Math.max(1, maxVirtualWeek.value - virtualWeek + 1)
|
||||
weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks))
|
||||
|
||||
const year = String(labelYear).slice(-2)
|
||||
monthLabel = {
|
||||
text: `${getLocalizedMonthName(monthToLabel)} '${year}`,
|
||||
month: monthToLabel,
|
||||
weeksSpan: weeksSpan,
|
||||
height: weeksSpan * rowHeight.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
virtualWeek,
|
||||
weekNumber: pad(weekNumber),
|
||||
days,
|
||||
monthLabel,
|
||||
top: (virtualWeek - minVirtualWeek.value) * rowHeight.value,
|
||||
}
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
const top = new Date(calendarStore.now)
|
||||
top.setDate(top.getDate() - 21)
|
||||
const targetWeekIndex = getWeekIndex(top)
|
||||
scrollTop.value = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
if (viewport.value) {
|
||||
viewport.value.scrollTop = scrollTop.value
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selection.value = { startDate: null, dayCount: 0 }
|
||||
}
|
||||
|
||||
function startDrag(dateStr) {
|
||||
if (calendarStore.config.select_days === 0) return
|
||||
isDragging.value = true
|
||||
dragAnchor.value = dateStr
|
||||
selection.value = { startDate: dateStr, dayCount: 1 }
|
||||
}
|
||||
|
||||
function updateDrag(dateStr) {
|
||||
if (!isDragging.value) return
|
||||
const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr)
|
||||
selection.value = { startDate, dayCount }
|
||||
}
|
||||
|
||||
function endDrag(dateStr) {
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr)
|
||||
selection.value = { startDate, dayCount }
|
||||
}
|
||||
|
||||
function calculateSelection(anchorStr, otherStr) {
|
||||
const limit = calendarStore.config.select_days
|
||||
const anchorDate = fromLocalString(anchorStr)
|
||||
const otherDate = fromLocalString(otherStr)
|
||||
const forward = otherDate >= anchorDate
|
||||
const span = daysInclusive(anchorStr, otherStr)
|
||||
|
||||
if (span <= limit) {
|
||||
const startDate = forward ? anchorStr : otherStr
|
||||
return { startDate, dayCount: span }
|
||||
}
|
||||
|
||||
if (forward) {
|
||||
return { startDate: anchorStr, dayCount: limit }
|
||||
} else {
|
||||
const startDate = addDaysStr(anchorStr, -(limit - 1))
|
||||
return { startDate, dayCount: limit }
|
||||
}
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
if (viewport.value) {
|
||||
scrollTop.value = viewport.value.scrollTop
|
||||
}
|
||||
}
|
||||
|
||||
const handleJogwheelScrollTo = (newScrollTop) => {
|
||||
if (viewport.value) {
|
||||
viewport.value.scrollTop = newScrollTop
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
computeRowHeight()
|
||||
calendarStore.updateCurrentDate()
|
||||
|
||||
if (viewport.value) {
|
||||
viewportHeight.value = viewport.value.clientHeight
|
||||
viewport.value.scrollTop = initialScrollTop.value
|
||||
viewport.value.addEventListener('scroll', onScroll)
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
calendarStore.updateCurrentDate()
|
||||
}, 60000)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer)
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (viewport.value) {
|
||||
viewport.value.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
})
|
||||
|
||||
const handleDayMouseDown = (dateStr) => {
|
||||
startDrag(dateStr)
|
||||
}
|
||||
|
||||
const handleDayMouseEnter = (dateStr) => {
|
||||
if (isDragging.value) {
|
||||
updateDrag(dateStr)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDayMouseUp = (dateStr) => {
|
||||
if (isDragging.value) {
|
||||
endDrag(dateStr)
|
||||
const eventData = createEventFromSelection()
|
||||
if (eventData) {
|
||||
clearSelection()
|
||||
emit('create-event', eventData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDayTouchStart = (dateStr) => {
|
||||
startDrag(dateStr)
|
||||
}
|
||||
|
||||
const handleDayTouchMove = (dateStr) => {
|
||||
if (isDragging.value) {
|
||||
updateDrag(dateStr)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDayTouchEnd = (dateStr) => {
|
||||
if (isDragging.value) {
|
||||
endDrag(dateStr)
|
||||
const eventData = createEventFromSelection()
|
||||
if (eventData) {
|
||||
clearSelection()
|
||||
emit('create-event', eventData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleEventClick = (eventInstanceId) => {
|
||||
emit('edit-event', eventInstanceId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1>Calendar</h1>
|
||||
<div class="header-controls">
|
||||
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
||||
</div>
|
||||
</header>
|
||||
<CalendarHeader
|
||||
:scroll-top="scrollTop"
|
||||
:row-height="rowHeight"
|
||||
:min-virtual-week="minVirtualWeek"
|
||||
/>
|
||||
<div class="calendar-container">
|
||||
<div class="calendar-viewport" ref="viewport">
|
||||
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
|
||||
<CalendarWeek
|
||||
v-for="week in visibleWeeks"
|
||||
:key="week.virtualWeek"
|
||||
:week="week"
|
||||
:style="{ top: week.top + 'px' }"
|
||||
@day-mousedown="handleDayMouseDown"
|
||||
@day-mouseenter="handleDayMouseEnter"
|
||||
@day-mouseup="handleDayMouseUp"
|
||||
@day-touchstart="handleDayTouchStart"
|
||||
@day-touchmove="handleDayTouchMove"
|
||||
@day-touchend="handleDayTouchEnd"
|
||||
@event-click="handleEventClick"
|
||||
/>
|
||||
<!-- Month labels positioned absolutely -->
|
||||
<div
|
||||
v-for="week in visibleWeeks"
|
||||
:key="`month-${week.virtualWeek}`"
|
||||
v-show="week.monthLabel"
|
||||
class="month-name-label"
|
||||
:style="{
|
||||
top: week.top + 'px',
|
||||
height: week.monthLabel?.height + 'px',
|
||||
}"
|
||||
>
|
||||
<span>{{ week.monthLabel?.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Jogwheel as sibling to calendar-viewport -->
|
||||
<Jogwheel
|
||||
:total-virtual-weeks="totalVirtualWeeks"
|
||||
:row-height="rowHeight"
|
||||
:viewport-height="viewportHeight"
|
||||
:scroll-top="scrollTop"
|
||||
@scroll-to="handleJogwheelScrollTo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.today-date {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
background: var(--today-btn-bg);
|
||||
color: var(--today-btn-text);
|
||||
border-radius: 4px;
|
||||
white-space: pre-line;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.today-date:hover {
|
||||
background: var(--today-btn-hover-bg);
|
||||
}
|
||||
|
||||
.calendar-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
position: relative;
|
||||
/* Prevent text selection in calendar */
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.calendar-viewport {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.month-name-label {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 3rem; /* Match jogwheel width */
|
||||
font-size: 2em;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 15;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.month-name-label > span {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
transform: rotate(180deg);
|
||||
transform-origin: center;
|
||||
}
|
||||
</style>
|
105
src/components/CalendarWeek.vue
Normal file
105
src/components/CalendarWeek.vue
Normal file
@ -0,0 +1,105 @@
|
||||
<script setup>
|
||||
import CalendarDay from './CalendarDay.vue'
|
||||
import EventOverlay from './EventOverlay.vue'
|
||||
|
||||
const props = defineProps({
|
||||
week: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['day-mousedown', 'day-mouseenter', 'day-mouseup', 'day-touchstart', 'day-touchmove', 'day-touchend', 'event-click'])
|
||||
|
||||
const handleDayMouseDown = (dateStr) => {
|
||||
emit('day-mousedown', dateStr)
|
||||
}
|
||||
|
||||
const handleDayMouseEnter = (dateStr) => {
|
||||
emit('day-mouseenter', dateStr)
|
||||
}
|
||||
|
||||
const handleDayMouseUp = (dateStr) => {
|
||||
emit('day-mouseup', dateStr)
|
||||
}
|
||||
|
||||
const handleDayTouchStart = (dateStr) => {
|
||||
emit('day-touchstart', dateStr)
|
||||
}
|
||||
|
||||
const handleDayTouchMove = (dateStr) => {
|
||||
emit('day-touchmove', dateStr)
|
||||
}
|
||||
|
||||
const handleDayTouchEnd = (dateStr) => {
|
||||
emit('day-touchend', dateStr)
|
||||
}
|
||||
|
||||
const handleEventClick = (eventId) => {
|
||||
emit('event-click', eventId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="week-row"
|
||||
:style="{ top: `${props.week.top}px` }"
|
||||
>
|
||||
<div class="week-label">W{{ props.week.weekNumber }}</div>
|
||||
<div class="days-grid">
|
||||
<CalendarDay
|
||||
v-for="day in props.week.days"
|
||||
:key="day.date"
|
||||
:day="day"
|
||||
@mousedown="handleDayMouseDown(day.date)"
|
||||
@mouseenter="handleDayMouseEnter(day.date)"
|
||||
@mouseup="handleDayMouseUp(day.date)"
|
||||
@touchstart="handleDayTouchStart(day.date)"
|
||||
@touchmove="handleDayTouchMove(day.date)"
|
||||
@touchend="handleDayTouchEnd(day.date)"
|
||||
@event-click="handleEventClick"
|
||||
/>
|
||||
<EventOverlay
|
||||
:week="props.week"
|
||||
@event-click="handleEventClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.week-row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--label-w) repeat(7, 1fr) 3rem;
|
||||
position: absolute;
|
||||
height: var(--cell-h);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.week-label {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
color: var(--muted);
|
||||
font-size: 1.2em;
|
||||
font-weight: 500;
|
||||
/* Prevent text selection */
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.days-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Fixed heights for cells and labels (from cells.css) */
|
||||
.week-row :deep(.cell),
|
||||
.week-label {
|
||||
height: var(--cell-h);
|
||||
}
|
||||
</style>
|
27
src/components/DayCell.vue
Normal file
27
src/components/DayCell.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="cell" :class="cellClasses" :data-date="day.date">
|
||||
<h1>{{ day.displayText }}</h1>
|
||||
<span v-if="day.lunarPhase" class="lunar-phase">{{ day.lunarPhase }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
day: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const cellClasses = computed(() => {
|
||||
return {
|
||||
[props.day.monthClass]: true,
|
||||
today: props.day.isToday,
|
||||
selected: props.day.isSelected,
|
||||
weekend: props.day.isWeekend,
|
||||
firstday: props.day.isFirstDay
|
||||
}
|
||||
})
|
||||
</script>
|
933
src/components/EventDialog.vue
Normal file
933
src/components/EventDialog.vue
Normal file
@ -0,0 +1,933 @@
|
||||
<script setup>
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import WeekdaySelector from './WeekdaySelector.vue'
|
||||
import Numeric from './Numeric.vue'
|
||||
import { addDaysStr } from '@/utils/date'
|
||||
|
||||
const props = defineProps({
|
||||
selection: { type: Object, default: () => ({ startDate: null, dayCount: 0 }) },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['clear-selection'])
|
||||
|
||||
const calendarStore = useCalendarStore()
|
||||
|
||||
const showDialog = ref(false)
|
||||
const dialogMode = ref('create') // 'create' or 'edit'
|
||||
const editingEventId = ref(null) // base event id if repeating occurrence clicked
|
||||
const occurrenceContext = ref(null) // { baseId, occurrenceIndex, weekday, occurrenceDate }
|
||||
const title = ref('')
|
||||
const recurrenceEnabled = ref(false)
|
||||
const recurrenceInterval = ref(1) // N in "Every N weeks/months"
|
||||
const recurrenceFrequency = ref('weeks') // 'weeks' | 'months'
|
||||
const recurrenceWeekdays = ref([false, false, false, false, false, false, false])
|
||||
const recurrenceOccurrences = ref(0) // 0 = unlimited
|
||||
const colorId = ref(0)
|
||||
const eventSaved = ref(false)
|
||||
const titleInput = ref(null)
|
||||
|
||||
// Helper to get starting weekday (Sunday-first index)
|
||||
function getStartingWeekday(selectionData = null) {
|
||||
const currentSelection = selectionData || props.selection
|
||||
if (!currentSelection.start) return 0 // Default to Sunday
|
||||
const date = new Date(currentSelection.start + 'T00:00:00')
|
||||
const dayOfWeek = date.getDay() // 0=Sunday, 1=Monday, ...
|
||||
return dayOfWeek // Keep Sunday-first (0=Sunday, 6=Saturday)
|
||||
}
|
||||
|
||||
// Computed property for fallback weekdays - true for the initial day of the event, false for others
|
||||
const fallbackWeekdays = computed(() => {
|
||||
const startingDay = getStartingWeekday()
|
||||
const fallback = [false, false, false, false, false, false, false]
|
||||
fallback[startingDay] = true
|
||||
return fallback
|
||||
})
|
||||
|
||||
// Repeat mapping uses 'weeks' | 'months' | 'none' directly (legacy 'weekly'/'monthly' accepted on load)
|
||||
const repeat = computed({
|
||||
get() {
|
||||
if (!recurrenceEnabled.value) return 'none'
|
||||
return recurrenceFrequency.value // 'weeks' | 'months'
|
||||
},
|
||||
set(val) {
|
||||
if (val === 'none') {
|
||||
recurrenceEnabled.value = false
|
||||
return
|
||||
}
|
||||
recurrenceEnabled.value = true
|
||||
if (val === 'weeks' || val === 'weekly') recurrenceFrequency.value = 'weeks'
|
||||
else if (val === 'months' || val === 'monthly') recurrenceFrequency.value = 'months'
|
||||
},
|
||||
})
|
||||
|
||||
// Convert Sunday-first recurrenceWeekdays to Sunday-first pattern for store
|
||||
function buildStoreWeekdayPattern() {
|
||||
// store expects Sun..Sat; we have Sun..Sat
|
||||
// Direct mapping: recurrenceWeekdays indices 0..6 (Sun..Sat) -> store array [Sun,Mon,Tue,Wed,Thu,Fri,Sat]
|
||||
let sunFirst = [...recurrenceWeekdays.value]
|
||||
|
||||
// Ensure at least one day is selected - fallback to starting day
|
||||
if (!sunFirst.some(Boolean)) {
|
||||
const startingDay = getStartingWeekday()
|
||||
sunFirst[startingDay] = true
|
||||
}
|
||||
|
||||
return sunFirst
|
||||
}
|
||||
|
||||
function loadWeekdayPatternFromStore(storePattern) {
|
||||
if (!Array.isArray(storePattern) || storePattern.length !== 7) return
|
||||
// store: Sun..Sat -> keep as Sun..Sat
|
||||
recurrenceWeekdays.value = [...storePattern]
|
||||
}
|
||||
|
||||
const selectedColor = computed({
|
||||
get: () => colorId.value,
|
||||
set: (val) => {
|
||||
colorId.value = parseInt(val)
|
||||
// Update the event immediately when color changes
|
||||
if (editingEventId.value) {
|
||||
updateEventInStore()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function openCreateDialog(selectionData = null) {
|
||||
const currentSelection = selectionData || props.selection
|
||||
|
||||
// Convert new format to start/end for compatibility with existing logic
|
||||
let start, end
|
||||
if (currentSelection.startDate && currentSelection.dayCount) {
|
||||
start = currentSelection.startDate
|
||||
end = addDaysStr(currentSelection.startDate, currentSelection.dayCount - 1)
|
||||
} else if (currentSelection.start && currentSelection.end) {
|
||||
// Fallback for old format
|
||||
start = currentSelection.start
|
||||
end = currentSelection.end
|
||||
} else {
|
||||
start = null
|
||||
end = null
|
||||
}
|
||||
|
||||
occurrenceContext.value = null
|
||||
dialogMode.value = 'create'
|
||||
title.value = ''
|
||||
recurrenceEnabled.value = false
|
||||
recurrenceInterval.value = 1
|
||||
recurrenceFrequency.value = 'weeks'
|
||||
recurrenceWeekdays.value = [false, false, false, false, false, false, false]
|
||||
recurrenceOccurrences.value = 0
|
||||
colorId.value = calendarStore.selectEventColorId(start, end)
|
||||
eventSaved.value = false
|
||||
|
||||
const startingDay = getStartingWeekday({ start, end })
|
||||
recurrenceWeekdays.value[startingDay] = true
|
||||
|
||||
editingEventId.value = calendarStore.createEvent({
|
||||
title: '',
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
colorId: colorId.value,
|
||||
repeat: repeat.value,
|
||||
repeatInterval: recurrenceInterval.value,
|
||||
repeatCount:
|
||||
recurrenceOccurrences.value === 0 ? 'for now' : String(recurrenceOccurrences.value),
|
||||
repeatWeekdays: buildStoreWeekdayPattern(),
|
||||
})
|
||||
|
||||
showDialog.value = true
|
||||
|
||||
// Focus and select text after dialog is shown
|
||||
nextTick(() => {
|
||||
if (titleInput.value) {
|
||||
titleInput.value.focus()
|
||||
if (title.value) {
|
||||
titleInput.value.select()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function openEditDialog(eventInstanceId) {
|
||||
occurrenceContext.value = null
|
||||
let baseId = eventInstanceId
|
||||
let occurrenceIndex = 0
|
||||
let weekday = null
|
||||
let occurrenceDate = null
|
||||
if (typeof eventInstanceId === 'string' && eventInstanceId.includes('_repeat_')) {
|
||||
const [bid, suffix] = eventInstanceId.split('_repeat_')
|
||||
baseId = bid
|
||||
const parts = suffix.split('_')
|
||||
occurrenceIndex = parseInt(parts[0], 10) || 0
|
||||
if (parts.length > 1) weekday = parseInt(parts[1], 10)
|
||||
}
|
||||
const event = calendarStore.getEventById(baseId)
|
||||
if (!event) return
|
||||
// Derive occurrence date if weekly occurrence
|
||||
if (weekday != null) {
|
||||
// Recompute occurrence date: iterate days accumulating selected weekdays
|
||||
const repeatWeekdaysLocal = event.repeatWeekdays
|
||||
let idx = 0
|
||||
let cur = new Date(event.startDate + 'T00:00:00')
|
||||
while (idx < occurrenceIndex && idx < 10000) {
|
||||
// safety bound
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
if (repeatWeekdaysLocal[cur.getDay()]) idx++
|
||||
}
|
||||
occurrenceDate = cur
|
||||
}
|
||||
dialogMode.value = 'edit'
|
||||
editingEventId.value = baseId
|
||||
title.value = event.title
|
||||
loadWeekdayPatternFromStore(event.repeatWeekdays)
|
||||
repeat.value = event.repeat // triggers setter mapping into recurrence state
|
||||
if (event.repeatInterval) recurrenceInterval.value = event.repeatInterval
|
||||
// Map repeatCount
|
||||
const rc = event.repeatCount ?? 'unlimited'
|
||||
recurrenceOccurrences.value = rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
|
||||
colorId.value = event.colorId
|
||||
eventSaved.value = false
|
||||
if (event.isRepeating && occurrenceIndex >= 0 && weekday != null) {
|
||||
occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate }
|
||||
}
|
||||
showDialog.value = true
|
||||
|
||||
// Focus and select text after dialog is shown
|
||||
nextTick(() => {
|
||||
if (titleInput.value) {
|
||||
titleInput.value.focus()
|
||||
if (title.value) {
|
||||
titleInput.value.select()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
showDialog.value = false
|
||||
// If we were creating a new event and user cancels (didn't save), delete it
|
||||
if (dialogMode.value === 'create' && editingEventId.value && !eventSaved.value) {
|
||||
calendarStore.deleteEvent(editingEventId.value)
|
||||
}
|
||||
}
|
||||
|
||||
function updateEventInStore() {
|
||||
if (!editingEventId.value) return
|
||||
|
||||
// For simple property updates (title, color, repeat), update all instances directly
|
||||
// This avoids the expensive remove/re-add cycle
|
||||
for (const [, eventList] of calendarStore.events) {
|
||||
for (const event of eventList) {
|
||||
if (event.id === editingEventId.value) {
|
||||
event.title = title.value
|
||||
event.colorId = colorId.value
|
||||
event.repeat = repeat.value
|
||||
event.repeatInterval = recurrenceInterval.value
|
||||
event.repeatWeekdays = buildStoreWeekdayPattern()
|
||||
event.repeatCount =
|
||||
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value)
|
||||
event.isRepeating = recurrenceEnabled.value && repeat.value !== 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function saveEvent() {
|
||||
if (editingEventId.value) {
|
||||
updateEventInStore()
|
||||
}
|
||||
|
||||
eventSaved.value = true
|
||||
|
||||
if (dialogMode.value === 'create') {
|
||||
emit('clear-selection')
|
||||
}
|
||||
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
function deleteEventAll() {
|
||||
if (editingEventId.value) calendarStore.deleteEvent(editingEventId.value)
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
function deleteEventOne() {
|
||||
if (occurrenceContext.value) {
|
||||
calendarStore.deleteSingleOccurrence(occurrenceContext.value)
|
||||
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
|
||||
calendarStore.deleteFirstOccurrence(editingEventId.value)
|
||||
}
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
function deleteEventFrom() {
|
||||
if (!occurrenceContext.value) return
|
||||
calendarStore.deleteFromOccurrence(occurrenceContext.value)
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
function toggleWeekday(index) {
|
||||
recurrenceWeekdays.value[index] = !recurrenceWeekdays.value[index]
|
||||
}
|
||||
|
||||
// Watch for title changes and update the event immediately
|
||||
watch(title, (newTitle) => {
|
||||
if (editingEventId.value && showDialog.value) {
|
||||
updateEventInStore()
|
||||
}
|
||||
})
|
||||
|
||||
watch([recurrenceEnabled, recurrenceInterval, recurrenceFrequency], () => {
|
||||
if (editingEventId.value && showDialog.value) updateEventInStore()
|
||||
})
|
||||
watch(
|
||||
recurrenceWeekdays,
|
||||
() => {
|
||||
if (editingEventId.value && showDialog.value && repeat.value === 'weeks') updateEventInStore()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
watch(recurrenceOccurrences, () => {
|
||||
if (editingEventId.value && showDialog.value) updateEventInStore()
|
||||
})
|
||||
|
||||
// Handle Esc key to close dialog
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'Escape' && showDialog.value) {
|
||||
closeDialog()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
openCreateDialog,
|
||||
openEditDialog,
|
||||
})
|
||||
|
||||
// Computed helpers for delete UI
|
||||
const isRepeatingEdit = computed(
|
||||
() => dialogMode.value === 'edit' && recurrenceEnabled.value && repeat.value !== 'none',
|
||||
)
|
||||
const showDeleteVariants = computed(() => isRepeatingEdit.value && occurrenceContext.value)
|
||||
const isRepeatingBaseEdit = computed(() => isRepeatingEdit.value && !occurrenceContext.value)
|
||||
const formattedOccurrenceShort = computed(() => {
|
||||
if (occurrenceContext.value?.occurrenceDate) {
|
||||
try {
|
||||
return occurrenceContext.value.occurrenceDate
|
||||
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
.replace(/, /, ' ')
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
if (isRepeatingBaseEdit.value && editingEventId.value) {
|
||||
const ev = calendarStore.getEventById(editingEventId.value)
|
||||
if (ev?.startDate) {
|
||||
try {
|
||||
return new Date(ev.startDate + 'T00:00:00')
|
||||
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
.replace(/, /, ' ')
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const finalOccurrenceDate = computed(() => {
|
||||
if (!recurrenceEnabled.value) return null
|
||||
const count = recurrenceOccurrences.value
|
||||
if (!count || count < 1) return null // unlimited or invalid
|
||||
// Need start date
|
||||
const base = editingEventId.value ? calendarStore.getEventById(editingEventId.value) : null
|
||||
if (!base) return null
|
||||
const start = new Date(base.startDate + 'T00:00:00')
|
||||
if (recurrenceFrequency.value === 'weeks') {
|
||||
// iterate days until we count 'count-1' additional occurrences (first is base if selected weekday)
|
||||
const pattern = buildStoreWeekdayPattern() // Sun..Sat
|
||||
// Build Monday-first pattern again for selection clarity
|
||||
const monFirst = recurrenceWeekdays.value
|
||||
const selectedCount = monFirst.some(Boolean)
|
||||
if (!selectedCount) return null
|
||||
let occs = 0
|
||||
// Determine if the start day counts
|
||||
const startWeekdaySun = start.getDay()
|
||||
// Convert to Monday-first index
|
||||
// We'll just check store pattern
|
||||
if (pattern[startWeekdaySun]) occs = 1
|
||||
let cursor = new Date(start)
|
||||
while (occs < count && occs < 10000) {
|
||||
cursor.setDate(cursor.getDate() + 1)
|
||||
if (pattern[cursor.getDay()]) occs++
|
||||
}
|
||||
if (occs === count) return cursor
|
||||
return null
|
||||
} else if (recurrenceFrequency.value === 'months') {
|
||||
const monthsToAdd = recurrenceInterval.value * (count - 1)
|
||||
const d = new Date(start)
|
||||
d.setMonth(d.getMonth() + monthsToAdd)
|
||||
return d
|
||||
}
|
||||
})
|
||||
|
||||
const formattedFinalOccurrence = computed(() => {
|
||||
const d = finalOccurrenceDate.value
|
||||
if (!d) return ''
|
||||
const now = new Date()
|
||||
const includeYear =
|
||||
d.getFullYear() !== now.getFullYear() ||
|
||||
d.getTime() - now.getTime() >= 1000 * 60 * 60 * 24 * 365
|
||||
const opts = {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(includeYear ? { year: 'numeric' } : {}),
|
||||
}
|
||||
try {
|
||||
return d.toLocaleDateString(undefined, opts)
|
||||
} catch {
|
||||
return d.toDateString()
|
||||
}
|
||||
})
|
||||
|
||||
const recurrenceSummary = computed(() => {
|
||||
if (!recurrenceEnabled.value) return 'Does not recur'
|
||||
if (recurrenceFrequency.value === 'weeks') {
|
||||
return recurrenceInterval.value === 1 ? 'Weekly' : `Every ${recurrenceInterval.value} weeks`
|
||||
}
|
||||
// months frequency
|
||||
if (recurrenceInterval.value % 12 === 0) {
|
||||
const years = recurrenceInterval.value / 12
|
||||
return years === 1 ? 'Annually' : `Every ${years} years`
|
||||
}
|
||||
return recurrenceInterval.value === 1 ? 'Monthly' : `Every ${recurrenceInterval.value} months`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ec-modal-backdrop" v-if="showDialog" @click.self="closeDialog">
|
||||
<div class="ec-modal">
|
||||
<form class="ec-form" @submit.prevent="saveEvent">
|
||||
<header class="ec-header">
|
||||
<h2 id="ec-modal-title">{{ dialogMode === 'create' ? 'Create Event' : 'Edit Event' }}</h2>
|
||||
</header>
|
||||
<div class="ec-body">
|
||||
<label class="ec-field">
|
||||
<span>Title</span>
|
||||
<input type="text" v-model="title" autocomplete="off" ref="titleInput" />
|
||||
</label>
|
||||
<div class="ec-color-swatches">
|
||||
<label v-for="i in 8" :key="i - 1" class="swatch-label">
|
||||
<input
|
||||
class="swatch"
|
||||
:class="'event-color-' + (i - 1)"
|
||||
type="radio"
|
||||
name="colorId"
|
||||
:value="i - 1"
|
||||
v-model="selectedColor"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="recurrence-block">
|
||||
<div class="recurrence-header">
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="recurrenceEnabled" />
|
||||
<span>Repeat</span>
|
||||
</label>
|
||||
<span class="recurrence-summary" v-if="recurrenceEnabled">
|
||||
{{ recurrenceSummary }}
|
||||
<template v-if="recurrenceOccurrences > 0">
|
||||
until {{ formattedFinalOccurrence }}</template
|
||||
>
|
||||
</span>
|
||||
<span class="recurrence-summary muted" v-else>Does not recur</span>
|
||||
</div>
|
||||
<div v-if="recurrenceEnabled" class="recurrence-form">
|
||||
<div class="line compact">
|
||||
<Numeric
|
||||
v-model="recurrenceInterval"
|
||||
:prefix-values="[{ value: 1, display: 'Every' }]"
|
||||
:min="2"
|
||||
number-prefix="Every "
|
||||
aria-label="Interval"
|
||||
/>
|
||||
<select v-model="recurrenceFrequency" class="freq-select">
|
||||
<option value="weeks">{{ recurrenceInterval === 1 ? 'week' : 'weeks' }}</option>
|
||||
<option value="months">
|
||||
{{ recurrenceInterval === 1 ? 'month' : 'months' }}
|
||||
</option>
|
||||
</select>
|
||||
<Numeric
|
||||
class="occ-stepper"
|
||||
v-model="recurrenceOccurrences"
|
||||
:min="2"
|
||||
:prefix-values="[{ value: 0, display: '∞' }]"
|
||||
number-postfix=" times"
|
||||
aria-label="Occurrences (0 = no end)"
|
||||
extra-class="occ"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="recurrenceFrequency === 'weeks'" @click.stop>
|
||||
<WeekdaySelector v-model="recurrenceWeekdays" :fallback="fallbackWeekdays" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="ec-footer">
|
||||
<template v-if="dialogMode === 'create'">
|
||||
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">Delete</button>
|
||||
<button type="submit" class="ec-btn save-btn">Save</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="showDeleteVariants">
|
||||
<div class="ec-delete-group">
|
||||
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne">
|
||||
Delete {{ formattedOccurrenceShort }}
|
||||
</button>
|
||||
<button type="button" class="ec-btn delete-btn" @click="deleteEventFrom">
|
||||
Rest
|
||||
</button>
|
||||
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="isRepeatingBaseEdit">
|
||||
<div class="ec-delete-group">
|
||||
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne">
|
||||
Delete {{ formattedOccurrenceShort }}
|
||||
</button>
|
||||
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">
|
||||
Delete
|
||||
</button>
|
||||
</template>
|
||||
<button type="button" class="ec-btn close-btn" @click="closeDialog">Close</button>
|
||||
</template>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Modal dialog */
|
||||
.ec-modal-backdrop[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ec-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--strong) 30%, transparent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.ec-modal {
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
border-radius: 0.6rem;
|
||||
min-width: 320px;
|
||||
max-width: min(520px, 90vw);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.ec-form {
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.ec-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.ec-body {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.ec-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.ec-field {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.ec-field > span {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ec-field input[type='text'],
|
||||
.ec-field input[type='time'],
|
||||
.ec-field input[type='number'],
|
||||
.ec-field select {
|
||||
border: 1px solid var(--muted);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.ec-color-swatches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ec-color-swatches .swatch {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.25rem;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
width: 3em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.ec-color-swatches .swatch:checked {
|
||||
outline-color: var(--ink);
|
||||
}
|
||||
|
||||
.ec-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ec-btn {
|
||||
border: 1px solid var(--muted);
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 0.4rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.ec-btn:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.ec-btn.save-btn {
|
||||
background: var(--today);
|
||||
color: #000;
|
||||
border-color: transparent;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ec-btn.save-btn:hover {
|
||||
background: color-mix(in srgb, var(--today) 90%, black);
|
||||
}
|
||||
|
||||
.ec-btn.close-btn {
|
||||
background: var(--panel);
|
||||
border-color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ec-btn.close-btn:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.ec-btn.delete-btn {
|
||||
background: hsl(0, 70%, 50%);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ec-btn.delete-btn:hover {
|
||||
background: hsl(0, 70%, 45%);
|
||||
}
|
||||
|
||||
.ec-weekday-selector {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ec-field-label {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ec-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.ec-weekday-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-radius: 0.3rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.ec-weekday-label:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.ec-weekday-checkbox {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ec-weekday-text {
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* New recurrence block */
|
||||
.recurrence-block {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.recurrence-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.recurrence-header .recurrence-summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.recurrence-header .recurrence-summary.muted {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.switch input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.recurrence-form {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.75rem 0.75rem;
|
||||
border: 1px solid var(--muted);
|
||||
border-radius: 0.5rem;
|
||||
background: color-mix(in srgb, var(--muted) 15%, transparent);
|
||||
}
|
||||
.line.compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.freq-select {
|
||||
padding: 0.4rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--input-border);
|
||||
background: var(--panel-alt);
|
||||
color: var(--ink);
|
||||
border-radius: 0.45rem;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background-color 0.18s ease;
|
||||
}
|
||||
.freq-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-focus);
|
||||
background: var(--panel-accent);
|
||||
color: var(--ink);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--input-focus),
|
||||
0 0 0 4px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
.interval-input,
|
||||
.occ-input {
|
||||
display: none;
|
||||
}
|
||||
.ec-field input[type='text'] {
|
||||
border: 1px solid var(--input-border);
|
||||
background: var(--panel-alt);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
.ec-field input[type='text']:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-focus);
|
||||
background: var(--panel-accent);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--input-focus),
|
||||
0 0 0 4px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
.mini-stepper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
font-size: 0.7rem;
|
||||
height: 1.9rem;
|
||||
}
|
||||
.mini-stepper .step {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ink);
|
||||
padding: 0 0.55rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
.mini-stepper .step:hover:not(:disabled) {
|
||||
background: var(--pill-hover-bg);
|
||||
}
|
||||
.mini-stepper .step:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
.mini-stepper .value {
|
||||
min-width: 1.6rem;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.mini-stepper:focus-within {
|
||||
border-color: var(--input-focus);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--input-focus),
|
||||
0 0 0 4px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
.mini-stepper.occ .value {
|
||||
min-width: 2rem;
|
||||
}
|
||||
.occ-stepper.mini-stepper.occ .value {
|
||||
min-width: 2rem;
|
||||
}
|
||||
.mini-stepper .step:focus-visible {
|
||||
outline: 2px solid var(--input-focus);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* Recurrence UI */
|
||||
.ec-recurrence-section {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.ec-recurrence-toggle {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--muted);
|
||||
background: var(--panel);
|
||||
border-radius: 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.ec-recurrence-toggle:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
.ec-recurrence-toggle .toggle-icon {
|
||||
transition: transform 0.2s ease;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ec-recurrence-toggle .toggle-icon.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.ec-recurrence-panel {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid var(--muted);
|
||||
border-radius: 0.4rem;
|
||||
background: color-mix(in srgb, var(--muted) 20%, transparent);
|
||||
}
|
||||
|
||||
/* Repeat modes */
|
||||
.ec-repeat-modes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.ec-repeat-modes .mode-btn {
|
||||
flex: 1 1 auto;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--muted);
|
||||
background: var(--panel);
|
||||
border-radius: 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
.ec-repeat-modes .mode-btn.active {
|
||||
background: var(--today);
|
||||
color: #000;
|
||||
border-color: var(--today);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ec-repeat-modes .mode-btn:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.ec-occurrences-field {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.ec-occurrences-field .ec-field input[type='number'] {
|
||||
max-width: 6rem;
|
||||
}
|
||||
</style>
|
620
src/components/EventOverlay.vue
Normal file
620
src/components/EventOverlay.vue
Normal file
@ -0,0 +1,620 @@
|
||||
<template>
|
||||
<div class="week-overlay">
|
||||
<div
|
||||
v-for="span in eventSpans"
|
||||
:key="span.id"
|
||||
class="event-span"
|
||||
:class="[`event-color-${span.colorId}`]"
|
||||
:style="{
|
||||
gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`,
|
||||
gridRow: `${span.row}`,
|
||||
}"
|
||||
@click="handleEventClick(span)"
|
||||
@pointerdown="handleEventPointerDown(span, $event)"
|
||||
>
|
||||
<span class="event-title">{{ span.title }}</span>
|
||||
<div
|
||||
class="resize-handle left"
|
||||
@pointerdown="handleResizePointerDown(span, 'resize-left', $event)"
|
||||
></div>
|
||||
<div
|
||||
class="resize-handle right"
|
||||
@pointerdown="handleResizePointerDown(span, 'resize-right', $event)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import { toLocalString, fromLocalString, daysInclusive, addDaysStr } from '@/utils/date'
|
||||
|
||||
const props = defineProps({
|
||||
week: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['event-click'])
|
||||
const store = useCalendarStore()
|
||||
|
||||
// Local drag state
|
||||
const dragState = ref(null)
|
||||
const justDragged = ref(false)
|
||||
|
||||
// Generate repeat occurrences for a specific date
|
||||
function generateRepeatOccurrencesForDate(targetDateStr) {
|
||||
const occurrences = []
|
||||
|
||||
// Get all events from the store and check for repeating ones
|
||||
for (const [, eventList] of store.events) {
|
||||
for (const baseEvent of eventList) {
|
||||
if (!baseEvent.isRepeating || baseEvent.repeat === 'none') {
|
||||
continue
|
||||
}
|
||||
|
||||
const targetDate = new Date(fromLocalString(targetDateStr))
|
||||
const baseStartDate = new Date(fromLocalString(baseEvent.startDate))
|
||||
const baseEndDate = new Date(fromLocalString(baseEvent.endDate))
|
||||
const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000))
|
||||
|
||||
if (baseEvent.repeat === 'weeks') {
|
||||
const repeatWeekdays = baseEvent.repeatWeekdays
|
||||
if (targetDate < baseStartDate) continue
|
||||
const maxOccurrences =
|
||||
baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
|
||||
if (maxOccurrences === 0) continue
|
||||
const interval = baseEvent.repeatInterval || 1
|
||||
const msPerDay = 24 * 60 * 60 * 1000
|
||||
|
||||
// Determine if targetDate lies within some occurrence span. We look backwards up to spanDays to find a start day.
|
||||
let occStart = null
|
||||
for (let back = 0; back <= spanDays; back++) {
|
||||
const cand = new Date(targetDate)
|
||||
cand.setDate(cand.getDate() - back)
|
||||
if (cand < baseStartDate) break
|
||||
const daysDiff = Math.floor((cand - baseStartDate) / msPerDay)
|
||||
const weeksDiff = Math.floor(daysDiff / 7)
|
||||
if (weeksDiff % interval !== 0) continue
|
||||
if (repeatWeekdays[cand.getDay()]) {
|
||||
// candidate start must produce span covering targetDate
|
||||
const candEnd = new Date(cand)
|
||||
candEnd.setDate(candEnd.getDate() + spanDays)
|
||||
if (targetDate <= candEnd) {
|
||||
occStart = cand
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!occStart) continue
|
||||
// Skip base occurrence if this is within its span (base already physically stored)
|
||||
if (occStart.getTime() === baseStartDate.getTime()) continue
|
||||
// Compute occurrence index (number of previous start days)
|
||||
let occIdx = 0
|
||||
const cursor = new Date(baseStartDate)
|
||||
while (cursor < occStart && occIdx < maxOccurrences) {
|
||||
const cDaysDiff = Math.floor((cursor - baseStartDate) / msPerDay)
|
||||
const cWeeksDiff = Math.floor(cDaysDiff / 7)
|
||||
if (cWeeksDiff % interval === 0 && repeatWeekdays[cursor.getDay()]) occIdx++
|
||||
cursor.setDate(cursor.getDate() + 1)
|
||||
}
|
||||
if (occIdx >= maxOccurrences) continue
|
||||
const occEnd = new Date(occStart)
|
||||
occEnd.setDate(occStart.getDate() + spanDays)
|
||||
const occStartStr = toLocalString(occStart)
|
||||
const occEndStr = toLocalString(occEnd)
|
||||
occurrences.push({
|
||||
...baseEvent,
|
||||
id: `${baseEvent.id}_repeat_${occIdx}_${occStart.getDay()}`,
|
||||
startDate: occStartStr,
|
||||
endDate: occEndStr,
|
||||
isRepeatOccurrence: true,
|
||||
repeatIndex: occIdx,
|
||||
})
|
||||
continue
|
||||
} else {
|
||||
// Handle other repeat types (months)
|
||||
let intervalsPassed = 0
|
||||
const timeDiff = targetDate - baseStartDate
|
||||
if (baseEvent.repeat === 'months') {
|
||||
intervalsPassed =
|
||||
(targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 +
|
||||
(targetDate.getMonth() - baseStartDate.getMonth())
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
const interval = baseEvent.repeatInterval || 1
|
||||
if (intervalsPassed < 0 || intervalsPassed % interval !== 0) continue
|
||||
|
||||
// Check a few occurrences around the target date
|
||||
const maxOccurrences =
|
||||
baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
|
||||
if (maxOccurrences === 0) continue
|
||||
const i = intervalsPassed
|
||||
if (i >= maxOccurrences) continue
|
||||
const currentStart = new Date(baseStartDate)
|
||||
currentStart.setMonth(baseStartDate.getMonth() + i)
|
||||
const currentEnd = new Date(currentStart)
|
||||
currentEnd.setDate(currentStart.getDate() + spanDays)
|
||||
// If target day lies within base (i===0) we skip because base is stored already
|
||||
if (i === 0) {
|
||||
// only skip if targetDate within base span
|
||||
if (targetDate >= baseStartDate && targetDate <= baseEndDate) continue
|
||||
}
|
||||
const currentStartStr = toLocalString(currentStart)
|
||||
const currentEndStr = toLocalString(currentEnd)
|
||||
if (currentStartStr <= targetDateStr && targetDateStr <= currentEndStr) {
|
||||
occurrences.push({
|
||||
...baseEvent,
|
||||
id: `${baseEvent.id}_repeat_${i}`,
|
||||
startDate: currentStartStr,
|
||||
endDate: currentEndStr,
|
||||
isRepeatOccurrence: true,
|
||||
repeatIndex: i,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return occurrences
|
||||
}
|
||||
|
||||
// Extract original event ID from repeat occurrence ID
|
||||
function getOriginalEventId(eventId) {
|
||||
if (typeof eventId === 'string' && eventId.includes('_repeat_')) {
|
||||
return eventId.split('_repeat_')[0]
|
||||
}
|
||||
return eventId
|
||||
}
|
||||
|
||||
// Handle event click
|
||||
function handleEventClick(span) {
|
||||
if (justDragged.value) return
|
||||
// Emit the actual span id (may include repeat suffix) so edit dialog knows occurrence context
|
||||
emit('event-click', span.id)
|
||||
}
|
||||
|
||||
// Handle event pointer down for dragging
|
||||
function handleEventPointerDown(span, event) {
|
||||
// Don't start drag if clicking on resize handle
|
||||
if (event.target.classList.contains('resize-handle')) return
|
||||
|
||||
event.stopPropagation()
|
||||
// Do not preventDefault here to allow click unless drag threshold is passed
|
||||
|
||||
// Get the date under the pointer
|
||||
const hit = getDateUnderPointer(event.clientX, event.clientY, event.currentTarget)
|
||||
const anchorDate = hit ? hit.date : span.startDate
|
||||
|
||||
startLocalDrag(
|
||||
{
|
||||
id: span.id,
|
||||
mode: 'move',
|
||||
pointerStartX: event.clientX,
|
||||
pointerStartY: event.clientY,
|
||||
anchorDate,
|
||||
startDate: span.startDate,
|
||||
endDate: span.endDate,
|
||||
},
|
||||
event,
|
||||
)
|
||||
}
|
||||
|
||||
// Handle resize handle pointer down
|
||||
function handleResizePointerDown(span, mode, event) {
|
||||
event.stopPropagation()
|
||||
// Start drag from the current edge; anchorDate not needed for resize
|
||||
startLocalDrag(
|
||||
{
|
||||
id: span.id,
|
||||
mode,
|
||||
pointerStartX: event.clientX,
|
||||
pointerStartY: event.clientY,
|
||||
anchorDate: null,
|
||||
startDate: span.startDate,
|
||||
endDate: span.endDate,
|
||||
},
|
||||
event,
|
||||
)
|
||||
}
|
||||
|
||||
// Get date under pointer coordinates
|
||||
function getDateUnderPointer(clientX, clientY, targetEl) {
|
||||
// First try to find a day cell directly under the pointer
|
||||
let element = document.elementFromPoint(clientX, clientY)
|
||||
|
||||
// If we hit an event element, temporarily hide it and try again
|
||||
const hiddenElements = []
|
||||
while (element && element.classList.contains('event-span')) {
|
||||
element.style.pointerEvents = 'none'
|
||||
hiddenElements.push(element)
|
||||
element = document.elementFromPoint(clientX, clientY)
|
||||
}
|
||||
|
||||
// Restore pointer events for hidden elements
|
||||
hiddenElements.forEach((el) => (el.style.pointerEvents = 'auto'))
|
||||
|
||||
if (element) {
|
||||
// Look for a day cell with data-date attribute
|
||||
const dayElement = element.closest('[data-date]')
|
||||
if (dayElement && dayElement.dataset.date) {
|
||||
return { date: dayElement.dataset.date }
|
||||
}
|
||||
|
||||
// Also check if we're over a week element and can calculate position
|
||||
const weekElement = element.closest('.week-row')
|
||||
if (weekElement) {
|
||||
const rect = weekElement.getBoundingClientRect()
|
||||
const relativeX = clientX - rect.left
|
||||
const dayWidth = rect.width / 7
|
||||
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
|
||||
|
||||
const daysGrid = weekElement.querySelector('.days-grid')
|
||||
if (daysGrid && daysGrid.children[dayIndex]) {
|
||||
const dayEl = daysGrid.children[dayIndex]
|
||||
const date = dayEl?.dataset?.date
|
||||
if (date) return { date }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to find the week overlay and calculate position
|
||||
const overlayEl = targetEl?.closest('.week-overlay')
|
||||
const weekElement = overlayEl ? overlayEl.parentElement : null
|
||||
if (!weekElement) {
|
||||
// If we're outside this week, try to find any week element under the pointer
|
||||
const allWeekElements = document.querySelectorAll('.week-row')
|
||||
let bestWeek = null
|
||||
let bestDistance = Infinity
|
||||
|
||||
for (const week of allWeekElements) {
|
||||
const rect = week.getBoundingClientRect()
|
||||
if (clientY >= rect.top && clientY <= rect.bottom) {
|
||||
const distance = Math.abs(clientY - (rect.top + rect.height / 2))
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance
|
||||
bestWeek = week
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestWeek) {
|
||||
const rect = bestWeek.getBoundingClientRect()
|
||||
const relativeX = clientX - rect.left
|
||||
const dayWidth = rect.width / 7
|
||||
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
|
||||
|
||||
const daysGrid = bestWeek.querySelector('.days-grid')
|
||||
if (daysGrid && daysGrid.children[dayIndex]) {
|
||||
const dayEl = daysGrid.children[dayIndex]
|
||||
const date = dayEl?.dataset?.date
|
||||
if (date) return { date }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const rect = weekElement.getBoundingClientRect()
|
||||
const relativeX = clientX - rect.left
|
||||
const dayWidth = rect.width / 7
|
||||
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
|
||||
|
||||
if (props.week.days[dayIndex]) {
|
||||
return { date: props.week.days[dayIndex].date }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Local drag handling
|
||||
function startLocalDrag(init, evt) {
|
||||
const spanDays = daysInclusive(init.startDate, init.endDate)
|
||||
let anchorOffset = 0
|
||||
if (init.mode === 'move' && init.anchorDate) {
|
||||
if (init.anchorDate < init.startDate) anchorOffset = 0
|
||||
else if (init.anchorDate > init.endDate) anchorOffset = spanDays - 1
|
||||
else anchorOffset = daysInclusive(init.startDate, init.anchorDate) - 1
|
||||
}
|
||||
|
||||
dragState.value = {
|
||||
...init,
|
||||
anchorOffset,
|
||||
originSpanDays: spanDays,
|
||||
eventMoved: false,
|
||||
}
|
||||
|
||||
// Capture pointer events globally
|
||||
if (evt.currentTarget && evt.pointerId !== undefined) {
|
||||
try {
|
||||
evt.currentTarget.setPointerCapture(evt.pointerId)
|
||||
} catch (e) {
|
||||
console.warn('Could not set pointer capture:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent default to avoid text selection and other interference
|
||||
evt.preventDefault()
|
||||
|
||||
window.addEventListener('pointermove', onDragPointerMove, { passive: false })
|
||||
window.addEventListener('pointerup', onDragPointerUp, { passive: false })
|
||||
window.addEventListener('pointercancel', onDragPointerUp, { passive: false })
|
||||
}
|
||||
|
||||
function onDragPointerMove(e) {
|
||||
const st = dragState.value
|
||||
if (!st) return
|
||||
const dx = e.clientX - st.pointerStartX
|
||||
const dy = e.clientY - st.pointerStartY
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
if (!st.eventMoved && distance < 5) return
|
||||
st.eventMoved = true
|
||||
|
||||
const hitEl = document.elementFromPoint(e.clientX, e.clientY)
|
||||
const hit = getDateUnderPointer(e.clientX, e.clientY, hitEl)
|
||||
|
||||
// If we can't find a date, don't update the range but keep the drag active
|
||||
if (!hit || !hit.date) return
|
||||
|
||||
const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date)
|
||||
if (!ns || !ne) return
|
||||
applyRangeDuringDrag(st, ns, ne)
|
||||
}
|
||||
|
||||
function onDragPointerUp(e) {
|
||||
const st = dragState.value
|
||||
if (!st) return
|
||||
|
||||
// Release pointer capture if it was set
|
||||
if (e.target && e.pointerId !== undefined) {
|
||||
try {
|
||||
e.target.releasePointerCapture(e.pointerId)
|
||||
} catch (err) {
|
||||
// Ignore errors - capture might not have been set
|
||||
}
|
||||
}
|
||||
|
||||
const moved = !!st.eventMoved
|
||||
dragState.value = null
|
||||
|
||||
window.removeEventListener('pointermove', onDragPointerMove)
|
||||
window.removeEventListener('pointerup', onDragPointerUp)
|
||||
window.removeEventListener('pointercancel', onDragPointerUp)
|
||||
|
||||
if (moved) {
|
||||
justDragged.value = true
|
||||
setTimeout(() => {
|
||||
justDragged.value = false
|
||||
}, 120)
|
||||
}
|
||||
}
|
||||
|
||||
function computeTentativeRangeFromPointer(st, dropDateStr) {
|
||||
const anchorOffset = st.anchorOffset || 0
|
||||
const spanDays = st.originSpanDays || daysInclusive(st.startDate, st.endDate)
|
||||
let startStr = st.startDate
|
||||
let endStr = st.endDate
|
||||
if (st.mode === 'move') {
|
||||
startStr = addDaysStr(dropDateStr, -anchorOffset)
|
||||
endStr = addDaysStr(startStr, spanDays - 1)
|
||||
} else if (st.mode === 'resize-left') {
|
||||
startStr = dropDateStr
|
||||
endStr = st.endDate
|
||||
} else if (st.mode === 'resize-right') {
|
||||
startStr = st.startDate
|
||||
endStr = dropDateStr
|
||||
}
|
||||
return normalizeDateOrder(startStr, endStr)
|
||||
}
|
||||
|
||||
function normalizeDateOrder(aStr, bStr) {
|
||||
if (!aStr) return [bStr, bStr]
|
||||
if (!bStr) return [aStr, aStr]
|
||||
return aStr <= bStr ? [aStr, bStr] : [bStr, aStr]
|
||||
}
|
||||
|
||||
function applyRangeDuringDrag(st, startDate, endDate) {
|
||||
let ev = store.getEventById(st.id)
|
||||
let isRepeatOccurrence = false
|
||||
let baseId = st.id
|
||||
let repeatIndex = 0
|
||||
let grabbedWeekday = null
|
||||
|
||||
// If not found (repeat occurrences aren't stored) parse synthetic id
|
||||
if (!ev && typeof st.id === 'string' && st.id.includes('_repeat_')) {
|
||||
const [bid, suffix] = st.id.split('_repeat_')
|
||||
baseId = bid
|
||||
ev = store.getEventById(baseId)
|
||||
if (ev) {
|
||||
const parts = suffix.split('_')
|
||||
repeatIndex = parseInt(parts[0], 10) || 0
|
||||
grabbedWeekday = parts.length > 1 ? parseInt(parts[1], 10) : null
|
||||
isRepeatOccurrence = repeatIndex >= 0
|
||||
}
|
||||
}
|
||||
|
||||
if (!ev) return
|
||||
|
||||
const mode = st.mode === 'resize-left' || st.mode === 'resize-right' ? st.mode : 'move'
|
||||
if (isRepeatOccurrence) {
|
||||
if (repeatIndex === 0) {
|
||||
store.setEventRange(baseId, startDate, endDate, { mode })
|
||||
} else {
|
||||
if (!st.splitNewBaseId) {
|
||||
const newId = store.splitRepeatSeries(
|
||||
baseId,
|
||||
repeatIndex,
|
||||
startDate,
|
||||
endDate,
|
||||
grabbedWeekday,
|
||||
)
|
||||
if (newId) {
|
||||
st.splitNewBaseId = newId
|
||||
st.id = newId
|
||||
st.startDate = startDate
|
||||
st.endDate = endDate
|
||||
}
|
||||
} else {
|
||||
store.setEventRange(st.splitNewBaseId, startDate, endDate, { mode })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
store.setEventRange(st.id, startDate, endDate, { mode })
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate event spans for this week
|
||||
const eventSpans = computed(() => {
|
||||
const spans = []
|
||||
const weekEvents = new Map()
|
||||
|
||||
// Collect events from all days in this week, including repeat occurrences
|
||||
props.week.days.forEach((day, dayIndex) => {
|
||||
// Get base events for this day
|
||||
day.events.forEach((event) => {
|
||||
if (!weekEvents.has(event.id)) {
|
||||
weekEvents.set(event.id, {
|
||||
...event,
|
||||
startIdx: dayIndex,
|
||||
endIdx: dayIndex,
|
||||
})
|
||||
} else {
|
||||
const existing = weekEvents.get(event.id)
|
||||
existing.endIdx = dayIndex
|
||||
}
|
||||
})
|
||||
|
||||
// Generate repeat occurrences for this day
|
||||
const repeatOccurrences = generateRepeatOccurrencesForDate(day.date)
|
||||
repeatOccurrences.forEach((event) => {
|
||||
if (!weekEvents.has(event.id)) {
|
||||
weekEvents.set(event.id, {
|
||||
...event,
|
||||
startIdx: dayIndex,
|
||||
endIdx: dayIndex,
|
||||
})
|
||||
} else {
|
||||
const existing = weekEvents.get(event.id)
|
||||
existing.endIdx = dayIndex
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Convert to array and sort
|
||||
const eventArray = Array.from(weekEvents.values())
|
||||
eventArray.sort((a, b) => {
|
||||
// Sort by span length (longer first)
|
||||
const spanA = a.endIdx - a.startIdx
|
||||
const spanB = b.endIdx - b.startIdx
|
||||
if (spanA !== spanB) return spanB - spanA
|
||||
|
||||
// Then by start position
|
||||
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
|
||||
|
||||
// Then by start time if available
|
||||
const timeA = a.startTime ? timeToMinutes(a.startTime) : 0
|
||||
const timeB = b.startTime ? timeToMinutes(b.startTime) : 0
|
||||
if (timeA !== timeB) return timeA - timeB
|
||||
|
||||
// Fallback to ID
|
||||
return String(a.id).localeCompare(String(b.id))
|
||||
})
|
||||
|
||||
// Assign rows to avoid overlaps
|
||||
const rowsLastEnd = []
|
||||
eventArray.forEach((event) => {
|
||||
let placedRow = 0
|
||||
while (placedRow < rowsLastEnd.length && !(event.startIdx > rowsLastEnd[placedRow])) {
|
||||
placedRow++
|
||||
}
|
||||
if (placedRow === rowsLastEnd.length) {
|
||||
rowsLastEnd.push(-1)
|
||||
}
|
||||
rowsLastEnd[placedRow] = event.endIdx
|
||||
event.row = placedRow + 1
|
||||
})
|
||||
|
||||
return eventArray
|
||||
})
|
||||
|
||||
function timeToMinutes(timeStr) {
|
||||
if (!timeStr) return 0
|
||||
const [hours, minutes] = timeStr.split(':').map(Number)
|
||||
return hours * 60 + minutes
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.week-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 15;
|
||||
display: grid;
|
||||
/* Prevent content from expanding tracks beyond container width */
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
grid-auto-rows: minmax(0, 1.5em);
|
||||
|
||||
row-gap: 0.05em;
|
||||
margin-top: 1.8em;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.event-span {
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 0.2em;
|
||||
font-size: clamp(0.45em, 1.8vh, 0.75em);
|
||||
font-weight: 600;
|
||||
cursor: grab;
|
||||
pointer-events: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Inner title wrapper ensures proper ellipsis within flex/grid constraints */
|
||||
.event-title {
|
||||
display: block;
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Resize handles */
|
||||
.event-span .resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
background: transparent;
|
||||
z-index: 2;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.event-span .resize-handle.left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.event-span .resize-handle.right {
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
112
src/components/Jogwheel.vue
Normal file
112
src/components/Jogwheel.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll">
|
||||
<div class="jogwheel-content" ref="jogwheelContent" :style="{ height: jogwheelHeight + 'px' }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
totalVirtualWeeks: { type: Number, required: true },
|
||||
rowHeight: { type: Number, required: true },
|
||||
viewportHeight: { type: Number, required: true },
|
||||
scrollTop: { type: Number, required: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['scroll-to'])
|
||||
|
||||
const jogwheelViewport = ref(null)
|
||||
const jogwheelContent = ref(null)
|
||||
const syncLock = ref(null)
|
||||
|
||||
// Jogwheel content height is 1/10th of main calendar
|
||||
const jogwheelHeight = computed(() => {
|
||||
return (props.totalVirtualWeeks * props.rowHeight) / 10
|
||||
})
|
||||
|
||||
const handleJogwheelScroll = () => {
|
||||
if (syncLock.value === 'jogwheel') return
|
||||
syncFromJogwheel()
|
||||
}
|
||||
|
||||
const syncFromJogwheel = () => {
|
||||
if (!jogwheelViewport.value || !jogwheelContent.value) return
|
||||
|
||||
syncLock.value = 'main'
|
||||
|
||||
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
|
||||
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
||||
|
||||
if (jogScrollable > 0) {
|
||||
const ratio = jogwheelViewport.value.scrollTop / jogScrollable
|
||||
|
||||
// Emit scroll event to parent to update main viewport
|
||||
emit('scroll-to', ratio * mainScrollable)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (syncLock.value === 'main') syncLock.value = null
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const syncFromMain = (mainScrollTop) => {
|
||||
if (!jogwheelViewport.value || !jogwheelContent.value) return
|
||||
if (syncLock.value === 'main') return
|
||||
|
||||
syncLock.value = 'jogwheel'
|
||||
|
||||
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
||||
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
|
||||
|
||||
if (mainScrollable > 0) {
|
||||
const ratio = mainScrollTop / mainScrollable
|
||||
jogwheelViewport.value.scrollTop = ratio * jogScrollable
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (syncLock.value === 'jogwheel') syncLock.value = null
|
||||
}, 50)
|
||||
}
|
||||
|
||||
// Watch for main calendar scroll changes
|
||||
watch(() => props.scrollTop, (newScrollTop) => {
|
||||
syncFromMain(newScrollTop)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
syncFromMain
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.jogwheel-viewport {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 3rem; /* Use fixed width since minmax() doesn't work for absolute positioning */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
z-index: 20;
|
||||
cursor: ns-resize;
|
||||
background: rgba(0, 0, 0, 0.05); /* Temporary background to see the area */
|
||||
/* Prevent text selection */
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.jogwheel-viewport::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jogwheel-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
251
src/components/Numeric.vue
Normal file
251
src/components/Numeric.vue
Normal file
@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="mini-stepper drag-mode"
|
||||
:class="[extraClass, { dragging }]"
|
||||
:aria-label="ariaLabel"
|
||||
role="spinbutton"
|
||||
:aria-valuemin="minValue"
|
||||
:aria-valuemax="maxValue"
|
||||
:aria-valuenow="isPrefix(current) ? undefined : current"
|
||||
:aria-valuetext="display"
|
||||
tabindex="0"
|
||||
@pointerdown="onPointerDown"
|
||||
@keydown="onKey"
|
||||
>
|
||||
<span class="value" :title="String(current)">{{ display }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const model = defineModel({ type: Number, default: 0 })
|
||||
|
||||
const props = defineProps({
|
||||
min: { type: Number, default: 0 },
|
||||
max: { type: Number, default: 999 },
|
||||
step: { type: Number, default: 1 },
|
||||
prefixValues: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
validator: (arr) =>
|
||||
arr.every((item) => typeof item === 'object' && 'value' in item && 'display' in item),
|
||||
},
|
||||
numberPrefix: { type: String, default: '' },
|
||||
numberPostfix: { type: String, default: '' },
|
||||
clamp: { type: Boolean, default: true },
|
||||
pixelsPerStep: { type: Number, default: 16 },
|
||||
// Movement now restricted to horizontal (x). Prop retained for compatibility but ignored.
|
||||
axis: { type: String, default: 'x' },
|
||||
ariaLabel: { type: String, default: '' },
|
||||
extraClass: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const minValue = computed(() => props.min)
|
||||
const maxValue = computed(() => props.max)
|
||||
|
||||
// Helper to check if a value is in the prefix values
|
||||
const isPrefix = (value) => {
|
||||
return props.prefixValues.some((prefix) => prefix.value === value)
|
||||
}
|
||||
|
||||
// Helper to get the display for a prefix value
|
||||
const getPrefixDisplay = (value) => {
|
||||
const prefix = props.prefixValues.find((p) => p.value === value)
|
||||
return prefix ? prefix.display : null
|
||||
}
|
||||
|
||||
// Get all valid values in order: prefixValues, then min to max
|
||||
const allValidValues = computed(() => {
|
||||
const prefixVals = props.prefixValues.map((p) => p.value)
|
||||
const numericVals = []
|
||||
for (let i = props.min; i <= props.max; i += props.step) {
|
||||
numericVals.push(i)
|
||||
}
|
||||
return [...prefixVals, ...numericVals]
|
||||
})
|
||||
|
||||
const current = computed({
|
||||
get() {
|
||||
return model.value
|
||||
},
|
||||
set(v) {
|
||||
if (props.clamp) {
|
||||
// If it's a prefix value, allow it
|
||||
if (isPrefix(v)) {
|
||||
model.value = v
|
||||
return
|
||||
}
|
||||
// Otherwise clamp to numeric range
|
||||
if (v < props.min) v = props.min
|
||||
if (v > props.max) v = props.max
|
||||
}
|
||||
model.value = v
|
||||
},
|
||||
})
|
||||
|
||||
const display = computed(() => {
|
||||
const prefixDisplay = getPrefixDisplay(current.value)
|
||||
if (prefixDisplay !== null) {
|
||||
// For prefix values, show only the display text without number prefix/postfix
|
||||
return prefixDisplay
|
||||
}
|
||||
// For numeric values, include prefix and postfix
|
||||
const numericValue = String(current.value)
|
||||
return `${props.numberPrefix}${numericValue}${props.numberPostfix}`
|
||||
})
|
||||
|
||||
// Drag handling
|
||||
const dragging = ref(false)
|
||||
const rootEl = ref(null)
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let startVal = 0
|
||||
|
||||
function onPointerDown(e) {
|
||||
e.preventDefault()
|
||||
startX = e.clientX
|
||||
startY = e.clientY
|
||||
startVal = current.value
|
||||
dragging.value = true
|
||||
try {
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
} catch {}
|
||||
rootEl.value?.addEventListener('pointermove', onPointerMove)
|
||||
rootEl.value?.addEventListener('pointerup', onPointerUp, { once: true })
|
||||
rootEl.value?.addEventListener('pointercancel', onPointerCancel, { once: true })
|
||||
}
|
||||
function onPointerMove(e) {
|
||||
if (!dragging.value) return
|
||||
// Prevent page scroll on touch while dragging
|
||||
if (e.pointerType === 'touch') e.preventDefault()
|
||||
const primary = e.clientX - startX // horizontal only
|
||||
const steps = Math.trunc(primary / props.pixelsPerStep)
|
||||
|
||||
// Find current value index in all valid values
|
||||
const currentIndex = allValidValues.value.indexOf(startVal)
|
||||
if (currentIndex === -1) return // shouldn't happen
|
||||
|
||||
const newIndex = currentIndex + steps
|
||||
if (props.clamp) {
|
||||
const clampedIndex = Math.max(0, Math.min(newIndex, allValidValues.value.length - 1))
|
||||
const next = allValidValues.value[clampedIndex]
|
||||
if (next !== current.value) current.value = next
|
||||
} else {
|
||||
if (newIndex >= 0 && newIndex < allValidValues.value.length) {
|
||||
const next = allValidValues.value[newIndex]
|
||||
if (next !== current.value) current.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
function endDragListeners() {
|
||||
rootEl.value?.removeEventListener('pointermove', onPointerMove)
|
||||
}
|
||||
function onPointerUp() {
|
||||
dragging.value = false
|
||||
endDragListeners()
|
||||
}
|
||||
function onPointerCancel() {
|
||||
dragging.value = false
|
||||
endDragListeners()
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
const key = e.key
|
||||
let handled = false
|
||||
let newValue = null
|
||||
|
||||
// Find current value index in all valid values
|
||||
const currentIndex = allValidValues.value.indexOf(current.value)
|
||||
|
||||
switch (key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1) {
|
||||
newValue = allValidValues.value[currentIndex + 1]
|
||||
} else if (currentIndex === -1) {
|
||||
// Current value not in list, try to increment normally
|
||||
newValue = current.value + props.step
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowDown':
|
||||
if (currentIndex !== -1 && currentIndex > 0) {
|
||||
newValue = allValidValues.value[currentIndex - 1]
|
||||
} else if (currentIndex === -1) {
|
||||
// Current value not in list, try to decrement normally
|
||||
newValue = current.value - props.step
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 'PageUp':
|
||||
if (currentIndex !== -1) {
|
||||
const newIndex = Math.min(currentIndex + 10, allValidValues.value.length - 1)
|
||||
newValue = allValidValues.value[newIndex]
|
||||
} else {
|
||||
newValue = current.value + props.step * 10
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 'PageDown':
|
||||
if (currentIndex !== -1) {
|
||||
const newIndex = Math.max(currentIndex - 10, 0)
|
||||
newValue = allValidValues.value[newIndex]
|
||||
} else {
|
||||
newValue = current.value - props.step * 10
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 'Home':
|
||||
newValue = allValidValues.value[0] || props.min
|
||||
handled = true
|
||||
break
|
||||
case 'End':
|
||||
newValue = allValidValues.value[allValidValues.value.length - 1] || props.max
|
||||
handled = true
|
||||
break
|
||||
}
|
||||
|
||||
if (newValue !== null) {
|
||||
current.value = newValue
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mini-stepper.drag-mode {
|
||||
cursor: ew-resize;
|
||||
user-select: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.4rem;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid var(--input-border, var(--muted));
|
||||
background: var(--panel-alt);
|
||||
border-radius: 0.4rem;
|
||||
min-height: 1.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
touch-action: none; /* allow custom drag without scrolling */
|
||||
}
|
||||
.mini-stepper.drag-mode:focus-visible {
|
||||
outline: 2px solid var(--input-focus, #2563eb);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.mini-stepper.drag-mode .value {
|
||||
font-weight: 600;
|
||||
min-width: 1.6rem;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mini-stepper.drag-mode.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
66
src/components/WeekRow.vue
Normal file
66
src/components/WeekRow.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="week-row">
|
||||
<div class="week-label">W{{ weekNumber }}</div>
|
||||
<div class="days-grid">
|
||||
<DayCell v-for="day in days" :key="day.dateStr" :day="day" />
|
||||
<div class="week-overlay">
|
||||
<!-- Event spans will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="monthLabel" class="month-name-label" :style="{ height: `${monthLabel.weeksSpan * 64}px` }">
|
||||
<span>{{ monthLabel.name }} '{{ monthLabel.year }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import DayCell from './DayCell.vue'
|
||||
import { isoWeekInfo, toLocalString, getLocalizedMonthName, monthAbbr } from '@/utils/date'
|
||||
|
||||
const props = defineProps({
|
||||
week: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const weekNumber = computed(() => {
|
||||
return isoWeekInfo(props.week.monday).week
|
||||
})
|
||||
|
||||
const days = computed(() => {
|
||||
const d = new Date(props.week.monday)
|
||||
const result = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const dateStr = toLocalString(d)
|
||||
result.push({
|
||||
date: new Date(d),
|
||||
dateStr,
|
||||
dayOfMonth: d.getDate(),
|
||||
month: d.getMonth(),
|
||||
isFirstDayOfMonth: d.getDate() === 1,
|
||||
monthClass: monthAbbr[d.getMonth()]
|
||||
})
|
||||
d.setDate(d.getDate() + 1)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const monthLabel = computed(() => {
|
||||
const firstDayOfMonth = days.value.find(d => d.isFirstDayOfMonth)
|
||||
if (!firstDayOfMonth) return null
|
||||
|
||||
const month = firstDayOfMonth.month
|
||||
const year = firstDayOfMonth.date.getFullYear()
|
||||
|
||||
// This is a simplified calculation for weeksSpan
|
||||
const weeksSpan = 4
|
||||
|
||||
return {
|
||||
name: getLocalizedMonthName(month),
|
||||
year: String(year).slice(-2),
|
||||
weeksSpan
|
||||
}
|
||||
})
|
||||
</script>
|
252
src/components/WeekdaySelector.vue
Normal file
252
src/components/WeekdaySelector.vue
Normal file
@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div class="weekgrid" @pointerleave="dragging = false">
|
||||
<button
|
||||
v-for="(d, di) in displayLabels"
|
||||
:key="d + di"
|
||||
type="button"
|
||||
class="day"
|
||||
:class="{
|
||||
on: anySelected && displayDisplayValues[di],
|
||||
// Show fallback styling on the reordered fallback day when none selected
|
||||
fallback: !anySelected && displayDefault[di],
|
||||
pressing: isPressing(di),
|
||||
preview: previewActive && inPreviewRange(di),
|
||||
}"
|
||||
@pointerdown="onPointerDown(di)"
|
||||
@pointerenter="onDragOver(di)"
|
||||
@pointerup="onPointerUp"
|
||||
>
|
||||
{{ d.slice(0, 3) }}
|
||||
</button>
|
||||
<button
|
||||
v-for="g in barGroups"
|
||||
:key="g.start"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
class="workday-weekend"
|
||||
:style="{ gridColumn: 'span ' + g.span }"
|
||||
@click.stop="toggleWeekend(g.type)"
|
||||
>
|
||||
<div :class="{ workday: !g.type, weekend: g.type }"></div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
getLocalizedWeekdayNames,
|
||||
getLocaleFirstDay,
|
||||
getLocaleWeekendDays,
|
||||
reorderByFirstDay,
|
||||
} from '@/utils/date'
|
||||
|
||||
const model = defineModel({
|
||||
type: Array,
|
||||
default: () => [false, false, false, false, false, false, false],
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
weekend: { type: Array, default: undefined },
|
||||
fallback: {
|
||||
type: Array,
|
||||
default: () => [false, false, false, false, false, false, false],
|
||||
},
|
||||
firstDay: { type: Number, default: null },
|
||||
})
|
||||
|
||||
// If external model provided is entirely false, keep as-is (user will see fallback styling),
|
||||
// only overwrite if null/undefined.
|
||||
if (!model.value) model.value = [...props.fallback]
|
||||
const labelsMondayFirst = getLocalizedWeekdayNames()
|
||||
const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)]
|
||||
const anySelected = computed(() => model.value.some(Boolean))
|
||||
const localeFirst = getLocaleFirstDay()
|
||||
const localeWeekend = getLocaleWeekendDays()
|
||||
const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7)
|
||||
|
||||
const weekendDays = computed(() => {
|
||||
if (props.weekend && props.weekend.length === 7) return props.weekend
|
||||
return localeWeekend
|
||||
})
|
||||
|
||||
const displayLabels = computed(() => reorderByFirstDay(labels, firstDay.value))
|
||||
const displayValuesCommitted = computed(() => reorderByFirstDay(model.value, firstDay.value))
|
||||
const displayWorking = computed(() => reorderByFirstDay(weekendDays.value, firstDay.value))
|
||||
const displayDefault = computed(() => reorderByFirstDay(props.fallback, firstDay.value))
|
||||
|
||||
// Mapping from display index to original model index
|
||||
const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7))
|
||||
|
||||
const barGroups = computed(() => {
|
||||
const arr = displayWorking.value
|
||||
const groups = []
|
||||
let type = arr[0]
|
||||
let start = 0
|
||||
for (let i = 1; i <= arr.length; i++) {
|
||||
if (i === arr.length || arr[i] !== type) {
|
||||
groups.push({ type, start, span: i - start })
|
||||
if (i < arr.length) {
|
||||
type = arr[i]
|
||||
start = i
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const dragging = ref(false)
|
||||
const previewActive = ref(false)
|
||||
const dragVal = ref(false)
|
||||
const dragStart = ref(null)
|
||||
const previewEnd = ref(null)
|
||||
let originalValues = null
|
||||
|
||||
// Preview (drag) values; when none selected, still return committed (not fallback) so 'on' class
|
||||
// is suppressed and only fallback styling applies via displayDefault
|
||||
const displayPreviewValues = computed(() => {
|
||||
if (
|
||||
!dragging.value ||
|
||||
!previewActive.value ||
|
||||
dragStart.value == null ||
|
||||
previewEnd.value == null ||
|
||||
!originalValues
|
||||
) {
|
||||
return displayValuesCommitted.value
|
||||
}
|
||||
const [s, e] =
|
||||
dragStart.value < previewEnd.value
|
||||
? [dragStart.value, previewEnd.value]
|
||||
: [previewEnd.value, dragStart.value]
|
||||
return displayValuesCommitted.value.map((v, di) => (di >= s && di <= e ? dragVal.value : v))
|
||||
})
|
||||
const displayDisplayValues = displayPreviewValues
|
||||
|
||||
function inPreviewRange(di) {
|
||||
if (!previewActive.value || dragStart.value == null || previewEnd.value == null) return false
|
||||
const [s, e] =
|
||||
dragStart.value < previewEnd.value
|
||||
? [dragStart.value, previewEnd.value]
|
||||
: [previewEnd.value, dragStart.value]
|
||||
return di >= s && di <= e
|
||||
}
|
||||
function isPressing(di) {
|
||||
return dragging.value && !previewActive.value && dragStart.value === di
|
||||
}
|
||||
|
||||
function onPointerDown(di) {
|
||||
originalValues = [...model.value]
|
||||
dragVal.value = !model.value[(di + firstDay.value) % 7]
|
||||
dragStart.value = di
|
||||
previewEnd.value = di
|
||||
dragging.value = true
|
||||
previewActive.value = false
|
||||
window.addEventListener('pointerup', onPointerUp, { once: true })
|
||||
}
|
||||
function onDragOver(di) {
|
||||
if (!dragging.value) return
|
||||
if (previewEnd.value === di) return
|
||||
if (!previewActive.value && di !== dragStart.value) previewActive.value = true
|
||||
previewEnd.value = di
|
||||
}
|
||||
function onPointerUp() {
|
||||
if (!dragging.value) return
|
||||
if (!previewActive.value) {
|
||||
// simple click: toggle single
|
||||
const next = [...originalValues]
|
||||
next[(dragStart.value + firstDay.value) % 7] = dragVal.value
|
||||
model.value = next
|
||||
cleanupDrag()
|
||||
} else {
|
||||
commitDrag()
|
||||
}
|
||||
}
|
||||
function commitDrag() {
|
||||
if (dragStart.value == null || previewEnd.value == null || !originalValues) return cancelDrag()
|
||||
const [s, e] =
|
||||
dragStart.value < previewEnd.value
|
||||
? [dragStart.value, previewEnd.value]
|
||||
: [previewEnd.value, dragStart.value]
|
||||
const next = [...originalValues]
|
||||
for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value
|
||||
model.value = next
|
||||
cleanupDrag()
|
||||
}
|
||||
function cancelDrag() {
|
||||
cleanupDrag()
|
||||
}
|
||||
function cleanupDrag() {
|
||||
dragging.value = false
|
||||
previewActive.value = false
|
||||
dragStart.value = null
|
||||
previewEnd.value = null
|
||||
originalValues = null
|
||||
}
|
||||
function toggleWeekend(work) {
|
||||
const base = weekendDays.value
|
||||
const target = work ? base : base.map((v) => !v)
|
||||
const current = model.value
|
||||
const allOn = current.every(Boolean)
|
||||
const isTargetActive = current.every((v, i) => v === target[i])
|
||||
if (allOn || isTargetActive) {
|
||||
model.value = [false, false, false, false, false, false, false]
|
||||
} else {
|
||||
model.value = [...target]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.weekgrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
.workday-weekend {
|
||||
height: 1em;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.workday-weekend div {
|
||||
height: 0.3em;
|
||||
border-radius: 0.15em;
|
||||
margin: 0.1em;
|
||||
}
|
||||
.workday {
|
||||
background: var(--workday, #888);
|
||||
}
|
||||
.weekend {
|
||||
background: var(--weekend, #f88);
|
||||
}
|
||||
.day {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
background: var(--panel-alt);
|
||||
color: var(--ink);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
padding: 0.55rem 0.35rem;
|
||||
border: none;
|
||||
margin: 0 1px;
|
||||
border-radius: 0.4rem;
|
||||
user-select: none;
|
||||
}
|
||||
.day.on {
|
||||
background: var(--pill-active-bg);
|
||||
color: var(--pill-active-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.day.pressing {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.day.preview {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.day.fallback {
|
||||
background: var(--muted-alt);
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
12
src/main.js
Normal file
12
src/main.js
Normal file
@ -0,0 +1,12 @@
|
||||
import './assets/calendar.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
|
||||
app.mount('#app')
|
503
src/stores/CalendarStore.js
Normal file
503
src/stores/CalendarStore.js
Normal file
@ -0,0 +1,503 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
toLocalString,
|
||||
fromLocalString,
|
||||
getLocaleFirstDay,
|
||||
getLocaleWeekendDays,
|
||||
} from '@/utils/date'
|
||||
|
||||
/**
|
||||
* Calendar configuration can be overridden via window.calendarConfig:
|
||||
*
|
||||
* window.calendarConfig = {
|
||||
* firstDay: 0, // 0=Sunday, 1=Monday, etc. (default: 1)
|
||||
* firstDay: 'auto', // Use locale detection
|
||||
* weekendDays: [true, false, false, false, false, false, true], // Custom weekend
|
||||
* weekendDays: 'auto' // Use locale detection (default)
|
||||
* }
|
||||
*/
|
||||
|
||||
const MIN_YEAR = 1900
|
||||
const MAX_YEAR = 2100
|
||||
|
||||
// Helper function to determine first day with config override support
|
||||
function getConfiguredFirstDay() {
|
||||
// Check for environment variable or global config
|
||||
const configOverride = window?.calendarConfig?.firstDay
|
||||
if (configOverride !== undefined) {
|
||||
return configOverride === 'auto' ? getLocaleFirstDay() : Number(configOverride)
|
||||
}
|
||||
// Default to Monday (1) instead of locale
|
||||
return 1
|
||||
}
|
||||
|
||||
// Helper function to determine weekend days with config override support
|
||||
function getConfiguredWeekendDays() {
|
||||
// Check for environment variable or global config
|
||||
const configOverride = window?.calendarConfig?.weekendDays
|
||||
if (configOverride !== undefined) {
|
||||
return configOverride === 'auto' ? getLocaleWeekendDays() : configOverride
|
||||
}
|
||||
// Default to locale-based weekend days
|
||||
return getLocaleWeekendDays()
|
||||
}
|
||||
|
||||
export const useCalendarStore = defineStore('calendar', {
|
||||
state: () => ({
|
||||
today: toLocalString(new Date()),
|
||||
now: new Date(),
|
||||
events: new Map(), // Map of date strings to arrays of events
|
||||
weekend: getConfiguredWeekendDays(),
|
||||
config: {
|
||||
select_days: 1000,
|
||||
min_year: MIN_YEAR,
|
||||
max_year: MAX_YEAR,
|
||||
first_day: getConfiguredFirstDay(),
|
||||
},
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// Basic configuration getters
|
||||
minYear: () => MIN_YEAR,
|
||||
maxYear: () => MAX_YEAR,
|
||||
},
|
||||
|
||||
actions: {
|
||||
updateCurrentDate() {
|
||||
this.now = new Date()
|
||||
const today = toLocalString(this.now)
|
||||
if (this.today !== today) {
|
||||
this.today = today
|
||||
}
|
||||
},
|
||||
|
||||
// Event management
|
||||
generateId() {
|
||||
try {
|
||||
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
|
||||
return window.crypto.randomUUID()
|
||||
}
|
||||
} catch {}
|
||||
return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36)
|
||||
},
|
||||
|
||||
createEvent(eventData) {
|
||||
const singleDay = eventData.startDate === eventData.endDate
|
||||
const event = {
|
||||
id: this.generateId(),
|
||||
title: eventData.title,
|
||||
startDate: eventData.startDate,
|
||||
endDate: eventData.endDate,
|
||||
colorId:
|
||||
eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate),
|
||||
startTime: singleDay ? eventData.startTime || '09:00' : null,
|
||||
durationMinutes: singleDay ? eventData.durationMinutes || 60 : null,
|
||||
repeat:
|
||||
(eventData.repeat === 'weekly'
|
||||
? 'weeks'
|
||||
: eventData.repeat === 'monthly'
|
||||
? 'months'
|
||||
: eventData.repeat) || 'none',
|
||||
repeatInterval: eventData.repeatInterval || 1,
|
||||
repeatCount: eventData.repeatCount || 'unlimited',
|
||||
repeatWeekdays: eventData.repeatWeekdays,
|
||||
isRepeating: eventData.repeat && eventData.repeat !== 'none',
|
||||
}
|
||||
|
||||
const startDate = new Date(fromLocalString(event.startDate))
|
||||
const endDate = new Date(fromLocalString(event.endDate))
|
||||
|
||||
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = toLocalString(d)
|
||||
if (!this.events.has(dateStr)) {
|
||||
this.events.set(dateStr, [])
|
||||
}
|
||||
this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate })
|
||||
}
|
||||
// No physical expansion; repeats are virtual
|
||||
return event.id
|
||||
},
|
||||
|
||||
getEventById(id) {
|
||||
for (const [, list] of this.events) {
|
||||
const found = list.find((e) => e.id === id)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
selectEventColorId(startDateStr, endDateStr) {
|
||||
const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0]
|
||||
const startDate = new Date(fromLocalString(startDateStr))
|
||||
const endDate = new Date(fromLocalString(endDateStr))
|
||||
|
||||
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = toLocalString(d)
|
||||
const dayEvents = this.events.get(dateStr) || []
|
||||
for (const event of dayEvents) {
|
||||
if (event.colorId >= 0 && event.colorId < 8) {
|
||||
colorCounts[event.colorId]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let minCount = colorCounts[0]
|
||||
let selectedColor = 0
|
||||
|
||||
for (let colorId = 1; colorId < 8; colorId++) {
|
||||
if (colorCounts[colorId] < minCount) {
|
||||
minCount = colorCounts[colorId]
|
||||
selectedColor = colorId
|
||||
}
|
||||
}
|
||||
|
||||
return selectedColor
|
||||
},
|
||||
|
||||
deleteEvent(eventId) {
|
||||
const datesToCleanup = []
|
||||
for (const [dateStr, eventList] of this.events) {
|
||||
const eventIndex = eventList.findIndex((event) => event.id === eventId)
|
||||
if (eventIndex !== -1) {
|
||||
eventList.splice(eventIndex, 1)
|
||||
if (eventList.length === 0) {
|
||||
datesToCleanup.push(dateStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
datesToCleanup.forEach((dateStr) => this.events.delete(dateStr))
|
||||
},
|
||||
|
||||
deleteSingleOccurrence(ctx) {
|
||||
const { baseId, occurrenceIndex } = ctx
|
||||
const base = this.getEventById(baseId)
|
||||
if (!base || base.repeat !== 'weekly') return
|
||||
if (!base || base.repeat !== 'weeks') return
|
||||
// Strategy: clone pattern into two: reduce base repeatCount to exclude this occurrence; create new series for remainder excluding this one
|
||||
// Simpler: convert base to explicit exception by creating a one-off non-repeating event? For now: create exception by inserting a blocking dummy? Instead just split by shifting repeatCount logic: decrement repeatCount and create a new series starting after this single occurrence.
|
||||
// Implementation (approx): terminate at occurrenceIndex; create a new series starting next occurrence with remaining occurrences.
|
||||
const remaining =
|
||||
base.repeatCount === 'unlimited'
|
||||
? 'unlimited'
|
||||
: String(Math.max(0, parseInt(base.repeatCount, 10) - (occurrenceIndex + 1)))
|
||||
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
||||
if (remaining === '0') return
|
||||
// Find date of next occurrence
|
||||
const startDate = new Date(base.startDate + 'T00:00:00')
|
||||
let idx = 0
|
||||
let cur = new Date(startDate)
|
||||
while (idx <= occurrenceIndex && idx < 10000) {
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
if (base.repeatWeekdays[cur.getDay()]) idx++
|
||||
}
|
||||
const nextStartStr = toLocalString(cur)
|
||||
this.createEvent({
|
||||
title: base.title,
|
||||
startDate: nextStartStr,
|
||||
endDate: nextStartStr,
|
||||
colorId: base.colorId,
|
||||
repeat: 'weeks',
|
||||
repeatCount: remaining,
|
||||
repeatWeekdays: base.repeatWeekdays,
|
||||
})
|
||||
},
|
||||
|
||||
deleteFromOccurrence(ctx) {
|
||||
const { baseId, occurrenceIndex } = ctx
|
||||
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
||||
},
|
||||
|
||||
deleteFirstOccurrence(baseId) {
|
||||
const base = this.getEventById(baseId)
|
||||
if (!base || !base.isRepeating) return
|
||||
const oldStart = new Date(fromLocalString(base.startDate))
|
||||
const oldEnd = new Date(fromLocalString(base.endDate))
|
||||
const spanDays = Math.round((oldEnd - oldStart) / (24 * 60 * 60 * 1000))
|
||||
let newStart = null
|
||||
|
||||
if (base.repeat === 'weeks' && base.repeatWeekdays) {
|
||||
const probe = new Date(oldStart)
|
||||
for (let i = 0; i < 14; i++) {
|
||||
// search ahead up to 2 weeks
|
||||
probe.setDate(probe.getDate() + 1)
|
||||
if (base.repeatWeekdays[probe.getDay()]) {
|
||||
newStart = new Date(probe)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (base.repeat === 'months') {
|
||||
newStart = new Date(oldStart)
|
||||
newStart.setMonth(newStart.getMonth() + 1)
|
||||
} else {
|
||||
// Unknown pattern: delete entire series
|
||||
this.deleteEvent(baseId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!newStart) {
|
||||
// No subsequent occurrence -> delete entire series
|
||||
this.deleteEvent(baseId)
|
||||
return
|
||||
}
|
||||
|
||||
if (base.repeatCount !== 'unlimited') {
|
||||
const rc = parseInt(base.repeatCount, 10)
|
||||
if (!isNaN(rc)) {
|
||||
const newRc = Math.max(0, rc - 1)
|
||||
if (newRc === 0) {
|
||||
this.deleteEvent(baseId)
|
||||
return
|
||||
}
|
||||
base.repeatCount = String(newRc)
|
||||
}
|
||||
}
|
||||
|
||||
const newEnd = new Date(newStart)
|
||||
newEnd.setDate(newEnd.getDate() + spanDays)
|
||||
base.startDate = toLocalString(newStart)
|
||||
base.endDate = toLocalString(newEnd)
|
||||
// old occurrence expansion removed (series handled differently now)
|
||||
const originalRepeatCount = base.repeatCount
|
||||
// Always cap original series at the split occurrence index (occurrences 0..index-1)
|
||||
// Keep its weekday pattern unchanged.
|
||||
this._terminateRepeatSeriesAtIndex(baseId, index)
|
||||
|
||||
let newRepeatCount = 'unlimited'
|
||||
if (originalRepeatCount !== 'unlimited') {
|
||||
const originalCount = parseInt(originalRepeatCount, 10)
|
||||
if (!isNaN(originalCount)) {
|
||||
const remaining = originalCount - index
|
||||
// remaining occurrences go to new series; ensure at least 1 (the dragged occurrence itself)
|
||||
newRepeatCount = remaining > 0 ? String(remaining) : '1'
|
||||
}
|
||||
} else {
|
||||
// Original was unlimited: original now capped, new stays unlimited
|
||||
newRepeatCount = 'unlimited'
|
||||
}
|
||||
|
||||
// Handle weekdays for weekly repeats
|
||||
let newRepeatWeekdays = base.repeatWeekdays
|
||||
if (base.repeat === 'weeks' && base.repeatWeekdays) {
|
||||
const newStartDate = new Date(fromLocalString(startDate))
|
||||
let dayShift = 0
|
||||
if (grabbedWeekday != null) {
|
||||
// Rotate so that the grabbed weekday maps to the new start weekday
|
||||
dayShift = newStartDate.getDay() - grabbedWeekday
|
||||
} else {
|
||||
// Fallback: rotate by difference between new and original start weekday
|
||||
const originalStartDate = new Date(fromLocalString(base.startDate))
|
||||
dayShift = newStartDate.getDay() - originalStartDate.getDay()
|
||||
}
|
||||
if (dayShift !== 0) {
|
||||
const rotatedWeekdays = [false, false, false, false, false, false, false]
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (base.repeatWeekdays[i]) {
|
||||
let nd = (i + dayShift) % 7
|
||||
if (nd < 0) nd += 7
|
||||
rotatedWeekdays[nd] = true
|
||||
}
|
||||
}
|
||||
newRepeatWeekdays = rotatedWeekdays
|
||||
}
|
||||
}
|
||||
|
||||
const newId = this.createEvent({
|
||||
title: base.title,
|
||||
startDate,
|
||||
endDate,
|
||||
colorId: base.colorId,
|
||||
repeat: base.repeat,
|
||||
repeatCount: newRepeatCount,
|
||||
repeatWeekdays: newRepeatWeekdays,
|
||||
})
|
||||
return newId
|
||||
},
|
||||
|
||||
_snapshotBaseEvent(eventId) {
|
||||
// Return a shallow snapshot of any instance for metadata
|
||||
for (const [, eventList] of this.events) {
|
||||
const e = eventList.find((x) => x.id === eventId)
|
||||
if (e) return { ...e }
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
_removeEventFromAllDatesById(eventId) {
|
||||
for (const [dateStr, list] of this.events) {
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
if (list[i].id === eventId) {
|
||||
list.splice(i, 1)
|
||||
}
|
||||
}
|
||||
if (list.length === 0) this.events.delete(dateStr)
|
||||
}
|
||||
},
|
||||
|
||||
_addEventToDateRangeWithId(eventId, baseData, startDate, endDate) {
|
||||
const s = fromLocalString(startDate)
|
||||
const e = fromLocalString(endDate)
|
||||
const multi = startDate < endDate
|
||||
const payload = {
|
||||
...baseData,
|
||||
id: eventId,
|
||||
startDate,
|
||||
endDate,
|
||||
isSpanning: multi,
|
||||
}
|
||||
// Normalize single-day time fields
|
||||
if (!multi) {
|
||||
if (!payload.startTime) payload.startTime = '09:00'
|
||||
if (!payload.durationMinutes) payload.durationMinutes = 60
|
||||
} else {
|
||||
payload.startTime = null
|
||||
payload.durationMinutes = null
|
||||
}
|
||||
const cur = new Date(s)
|
||||
while (cur <= e) {
|
||||
const dateStr = toLocalString(cur)
|
||||
if (!this.events.has(dateStr)) this.events.set(dateStr, [])
|
||||
this.events.get(dateStr).push({ ...payload })
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
}
|
||||
},
|
||||
|
||||
// expandRepeats removed: no physical occurrence expansion
|
||||
|
||||
// Adjust start/end range of a base event (non-generated) and reindex occurrences
|
||||
setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) {
|
||||
const snapshot = this._findEventInAnyList(eventId)
|
||||
if (!snapshot) return
|
||||
// Calculate current duration in days (inclusive)
|
||||
const prevStart = new Date(fromLocalString(snapshot.startDate))
|
||||
const prevEnd = new Date(fromLocalString(snapshot.endDate))
|
||||
const prevDurationDays = Math.max(
|
||||
0,
|
||||
Math.round((prevEnd - prevStart) / (24 * 60 * 60 * 1000)),
|
||||
)
|
||||
|
||||
const newStart = new Date(fromLocalString(newStartStr))
|
||||
const newEnd = new Date(fromLocalString(newEndStr))
|
||||
const proposedDurationDays = Math.max(
|
||||
0,
|
||||
Math.round((newEnd - newStart) / (24 * 60 * 60 * 1000)),
|
||||
)
|
||||
|
||||
let finalDurationDays = prevDurationDays
|
||||
if (mode === 'resize-left' || mode === 'resize-right') {
|
||||
finalDurationDays = proposedDurationDays
|
||||
}
|
||||
|
||||
snapshot.startDate = newStartStr
|
||||
snapshot.endDate = toLocalString(
|
||||
new Date(
|
||||
new Date(fromLocalString(newStartStr)).setDate(
|
||||
new Date(fromLocalString(newStartStr)).getDate() + finalDurationDays,
|
||||
),
|
||||
),
|
||||
)
|
||||
// Rotate weekly recurrence pattern when moving (not resizing) so selected weekdays track shift
|
||||
if (
|
||||
mode === 'move' &&
|
||||
snapshot.isRepeating &&
|
||||
snapshot.repeat === 'weeks' &&
|
||||
Array.isArray(snapshot.repeatWeekdays)
|
||||
) {
|
||||
const oldDow = prevStart.getDay()
|
||||
const newDow = newStart.getDay()
|
||||
const shift = newDow - oldDow
|
||||
if (shift !== 0) {
|
||||
const rotated = [false, false, false, false, false, false, false]
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (snapshot.repeatWeekdays[i]) {
|
||||
let ni = (i + shift) % 7
|
||||
if (ni < 0) ni += 7
|
||||
rotated[ni] = true
|
||||
}
|
||||
}
|
||||
snapshot.repeatWeekdays = rotated
|
||||
}
|
||||
}
|
||||
// Reindex
|
||||
this._removeEventFromAllDatesById(eventId)
|
||||
this._addEventToDateRangeWithId(eventId, snapshot, snapshot.startDate, snapshot.endDate)
|
||||
// no expansion
|
||||
},
|
||||
|
||||
// Split a repeating series at a given occurrence index; returns new series id
|
||||
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr, _grabbedWeekday) {
|
||||
const base = this._findEventInAnyList(baseId)
|
||||
if (!base || !base.isRepeating) return null
|
||||
// Capture original repeatCount BEFORE truncation
|
||||
const originalCountRaw = base.repeatCount
|
||||
// Truncate base to keep occurrences before split point (indices 0..occurrenceIndex-1)
|
||||
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
||||
// Compute new series repeatCount (remaining occurrences starting at occurrenceIndex)
|
||||
let newSeriesCount = 'unlimited'
|
||||
if (originalCountRaw !== 'unlimited') {
|
||||
const originalNum = parseInt(originalCountRaw, 10)
|
||||
if (!isNaN(originalNum)) {
|
||||
const remaining = originalNum - occurrenceIndex
|
||||
newSeriesCount = String(Math.max(1, remaining))
|
||||
}
|
||||
}
|
||||
const newId = this.createEvent({
|
||||
title: base.title,
|
||||
startDate: newStartStr,
|
||||
endDate: newEndStr,
|
||||
colorId: base.colorId,
|
||||
repeat: base.repeat,
|
||||
repeatInterval: base.repeatInterval,
|
||||
repeatCount: newSeriesCount,
|
||||
repeatWeekdays: base.repeatWeekdays,
|
||||
})
|
||||
return newId
|
||||
},
|
||||
|
||||
_reindexBaseEvent(eventId, snapshot, startDate, endDate) {
|
||||
if (!snapshot) return
|
||||
this._removeEventFromAllDatesById(eventId)
|
||||
this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate)
|
||||
},
|
||||
|
||||
_terminateRepeatSeriesAtIndex(baseId, index) {
|
||||
// Cap repeatCount to 'index' total occurrences (0-based index means index occurrences before split)
|
||||
for (const [, list] of this.events) {
|
||||
for (const ev of list) {
|
||||
if (ev.id === baseId && ev.isRepeating) {
|
||||
if (ev.repeatCount === 'unlimited') {
|
||||
ev.repeatCount = String(index)
|
||||
} else {
|
||||
const rc = parseInt(ev.repeatCount, 10)
|
||||
if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_findEventInAnyList(eventId) {
|
||||
for (const [, eventList] of this.events) {
|
||||
const found = eventList.find((e) => e.id === eventId)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
_addEventToDateRange(event) {
|
||||
const startDate = fromLocalString(event.startDate)
|
||||
const endDate = fromLocalString(event.endDate)
|
||||
const cur = new Date(startDate)
|
||||
|
||||
while (cur <= endDate) {
|
||||
const dateStr = toLocalString(cur)
|
||||
if (!this.events.has(dateStr)) {
|
||||
this.events.set(dateStr, [])
|
||||
}
|
||||
this.events.get(dateStr).push({ ...event, isSpanning: event.startDate < event.endDate })
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
}
|
||||
},
|
||||
|
||||
// NOTE: legacy dynamic getEventById for synthetic occurrences removed.
|
||||
},
|
||||
})
|
@ -1,5 +1,18 @@
|
||||
// date-utils.js — Date handling utilities for the calendar
|
||||
const monthAbbr = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec']
|
||||
const monthAbbr = [
|
||||
'jan',
|
||||
'feb',
|
||||
'mar',
|
||||
'apr',
|
||||
'may',
|
||||
'jun',
|
||||
'jul',
|
||||
'aug',
|
||||
'sep',
|
||||
'oct',
|
||||
'nov',
|
||||
'dec',
|
||||
]
|
||||
const DAY_MS = 86400000
|
||||
const WEEK_MS = 7 * DAY_MS
|
||||
|
||||
@ -8,7 +21,7 @@ const WEEK_MS = 7 * DAY_MS
|
||||
* @param {Date} date - The date to get week info for
|
||||
* @returns {Object} Object containing week number and year
|
||||
*/
|
||||
const isoWeekInfo = date => {
|
||||
const isoWeekInfo = (date) => {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
|
||||
const day = d.getUTCDay() || 7
|
||||
d.setUTCDate(d.getUTCDate() + 4 - day)
|
||||
@ -24,7 +37,7 @@ const isoWeekInfo = date => {
|
||||
* @returns {string} Date string in YYYY-MM-DD format
|
||||
*/
|
||||
function toLocalString(date = new Date()) {
|
||||
const pad = n => String(Math.floor(Math.abs(n))).padStart(2, '0')
|
||||
const pad = (n) => String(Math.floor(Math.abs(n))).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
@ -43,14 +56,14 @@ function fromLocalString(dateString) {
|
||||
* @param {Date} d - The date
|
||||
* @returns {number} Monday index (0-6)
|
||||
*/
|
||||
const mondayIndex = d => (d.getDay() + 6) % 7
|
||||
const mondayIndex = (d) => (d.getDay() + 6) % 7
|
||||
|
||||
/**
|
||||
* Pad a number with leading zeros to make it 2 digits
|
||||
* @param {number} n - Number to pad
|
||||
* @returns {string} Padded string
|
||||
*/
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
|
||||
/**
|
||||
* Calculate number of days between two date strings (inclusive)
|
||||
@ -93,6 +106,42 @@ function getLocalizedWeekdayNames() {
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the locale's first day of the week (0=Sunday, 1=Monday, etc.)
|
||||
* @returns {number} First day of the week (0-6)
|
||||
*/
|
||||
function getLocaleFirstDay() {
|
||||
try {
|
||||
return new Intl.Locale(navigator.language).weekInfo.firstDay % 7
|
||||
} catch {
|
||||
return 1 // Default to Monday if locale info not available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the locale's weekend days as an array of booleans (Sunday=index 0)
|
||||
* @returns {Array<boolean>} Array where true indicates a weekend day
|
||||
*/
|
||||
function getLocaleWeekendDays() {
|
||||
try {
|
||||
const localeWeekend = new Intl.Locale(navigator.language).weekInfo.weekend
|
||||
const dayidx = new Set(localeWeekend)
|
||||
return Array.from({ length: 7 }, (_, i) => dayidx.has(i || 7))
|
||||
} catch {
|
||||
return [true, false, false, false, false, false, true] // Default to Saturday/Sunday weekend
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder a 7-element array based on the first day of the week
|
||||
* @param {Array} days - Array of 7 elements (Sunday=index 0)
|
||||
* @param {number} firstDay - First day of the week (0=Sunday, 1=Monday, etc.)
|
||||
* @returns {Array} Reordered array
|
||||
*/
|
||||
function reorderByFirstDay(days, firstDay) {
|
||||
return Array.from({ length: 7 }, (_, i) => days[(i + firstDay) % 7])
|
||||
}
|
||||
|
||||
/**
|
||||
* Get localized month name
|
||||
* @param {number} idx - Month index (0-11)
|
||||
@ -133,12 +182,12 @@ function lunarPhaseSymbol(date) {
|
||||
// Use UTC noon of given date to reduce timezone edge effects
|
||||
const dUTC = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0)
|
||||
const daysSince = (dUTC - ref) / DAY_MS
|
||||
const phase = ((daysSince / synodic) % 1 + 1) % 1
|
||||
const phase = (((daysSince / synodic) % 1) + 1) % 1
|
||||
const phases = [
|
||||
{ t: 0.0, s: '🌑' }, // New Moon
|
||||
{ t: 0.25, s: '🌓' }, // First Quarter
|
||||
{ t: 0.5, s: '🌕' }, // Full Moon
|
||||
{ t: 0.75, s: '🌗' } // Last Quarter
|
||||
{ t: 0.75, s: '🌗' }, // Last Quarter
|
||||
]
|
||||
// threshold in days from exact phase to still count for this date
|
||||
const thresholdDays = 0.5 // ±12 hours
|
||||
@ -163,7 +212,10 @@ export {
|
||||
daysInclusive,
|
||||
addDaysStr,
|
||||
getLocalizedWeekdayNames,
|
||||
getLocaleFirstDay,
|
||||
getLocaleWeekendDays,
|
||||
reorderByFirstDay,
|
||||
getLocalizedMonthName,
|
||||
formatDateRange
|
||||
,lunarPhaseSymbol
|
||||
formatDateRange,
|
||||
lunarPhaseSymbol,
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
/* Prevent text selection in calendar */
|
||||
#calendar-viewport, #calendar-content, .week-row, .cell,
|
||||
.calendar-header, .week-label, .month-name-label,
|
||||
.calendar-container, .jogwheel-viewport, .jogwheel-content {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ink);
|
||||
width: 11em;
|
||||
}
|
||||
label:has(input[value]) { display: block }
|
||||
|
||||
/* Modal dialog */
|
||||
.ec-modal-backdrop[hidden] { display: none }
|
||||
.ec-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--strong) 30%, transparent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.ec-modal {
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
border-radius: .6rem;
|
||||
min-width: 320px;
|
||||
max-width: min(520px, 90vw);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.35);
|
||||
}
|
||||
.ec-form { padding: 1rem; display: grid; gap: .75rem }
|
||||
.ec-header h2 { margin: 0; font-size: 1.1rem }
|
||||
.ec-body { display: grid; gap: .75rem }
|
||||
.ec-row { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem }
|
||||
.ec-field { display: grid; gap: .25rem }
|
||||
.ec-field > span { font-size: .85em; color: var(--muted) }
|
||||
.ec-field input[type="text"],
|
||||
.ec-field input[type="time"],
|
||||
.ec-field input[type="number"] {
|
||||
border: 1px solid var(--muted);
|
||||
border-radius: .4rem;
|
||||
padding: .5rem .6rem;
|
||||
width: 100%;
|
||||
}
|
||||
.ec-color-swatches { display: grid; grid-template-columns: repeat(4, 1fr); gap: .3rem }
|
||||
.ec-color-swatches .swatch { display: grid; place-items: center; border-radius: .4rem; padding: .25rem; outline: 2px solid transparent; outline-offset: 2px; cursor: pointer }
|
||||
.ec-color-swatches .swatch { appearance: none; width: 3em; height: 1em; }
|
||||
.ec-color-swatches .swatch:checked { outline-color: var(--ink) }
|
||||
.ec-footer { display: flex; justify-content: end; gap: .5rem }
|
||||
.ec-btn { border: 1px solid var(--muted); background: transparent; color: var(--ink); padding: .5rem .8rem; border-radius: .4rem; cursor: pointer }
|
||||
.ec-btn.primary { background: var(--today); color: #000; border-color: transparent }
|
18
vite.config.js
Normal file
18
vite.config.js
Normal file
@ -0,0 +1,18 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user