diff --git a/src/App.vue b/src/App.vue index 84ed548..74cae74 100644 --- a/src/App.vue +++ b/src/App.vue @@ -79,7 +79,11 @@ watch(() => [settingsStore.settings.shortcut_toggle_timer, settingsStore.setting
- + + + + +
diff --git a/src/components/AppColorPicker.vue b/src/components/AppColorPicker.vue new file mode 100644 index 0000000..3c8ef5a --- /dev/null +++ b/src/components/AppColorPicker.vue @@ -0,0 +1,423 @@ + + + diff --git a/src/components/AppDatePicker.vue b/src/components/AppDatePicker.vue index 53dad42..68629c4 100644 --- a/src/components/AppDatePicker.vue +++ b/src/components/AppDatePicker.vue @@ -51,28 +51,105 @@ const displayText = computed(() => { return datePart }) -// ── Time helpers ────────────────────────────────────────────────────── +// ── Time wheel ────────────────────────────────────────────────────── +const WHEEL_ITEM_H = 36 +const WHEEL_VISIBLE = 5 +const WHEEL_HEIGHT = WHEEL_ITEM_H * WHEEL_VISIBLE // 180px +const WHEEL_PAD = WHEEL_ITEM_H * 2 // 72px spacer (2 items above/below center) + const internalHour = ref(props.hour) const internalMinute = ref(props.minute) +const hourWheelRef = ref(null) +const minuteWheelRef = ref(null) watch(() => props.hour, (v) => { internalHour.value = v }) watch(() => props.minute, (v) => { internalMinute.value = v }) -function onHourInput(e: Event) { - const val = parseInt((e.target as HTMLInputElement).value) - if (!isNaN(val)) { - const clamped = Math.min(23, Math.max(0, val)) - internalHour.value = clamped - emit('update:hour', clamped) - } +// Debounced scroll handler to read the current value +let hourScrollTimer: ReturnType | null = null +let minuteScrollTimer: ReturnType | null = null + +function onHourScroll() { + if (hourScrollTimer) clearTimeout(hourScrollTimer) + hourScrollTimer = setTimeout(() => { + if (!hourWheelRef.value) return + const index = Math.round(hourWheelRef.value.scrollTop / WHEEL_ITEM_H) + const clamped = Math.min(23, Math.max(0, index)) + if (internalHour.value !== clamped) { + internalHour.value = clamped + emit('update:hour', clamped) + } + }, 60) } -function onMinuteInput(e: Event) { - const val = parseInt((e.target as HTMLInputElement).value) - if (!isNaN(val)) { - const clamped = Math.min(59, Math.max(0, val)) - internalMinute.value = clamped - emit('update:minute', clamped) +function onMinuteScroll() { + if (minuteScrollTimer) clearTimeout(minuteScrollTimer) + minuteScrollTimer = setTimeout(() => { + if (!minuteWheelRef.value) return + const index = Math.round(minuteWheelRef.value.scrollTop / WHEEL_ITEM_H) + const clamped = Math.min(59, Math.max(0, index)) + if (internalMinute.value !== clamped) { + internalMinute.value = clamped + emit('update:minute', clamped) + } + }, 60) +} + +// Mouse wheel: one item per tick +function onHourWheel(e: WheelEvent) { + e.preventDefault() + if (!hourWheelRef.value) return + const dir = e.deltaY > 0 ? 1 : -1 + const cur = Math.round(hourWheelRef.value.scrollTop / WHEEL_ITEM_H) + const next = Math.min(23, Math.max(0, cur + dir)) + hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: 'smooth' }) +} + +function onMinuteWheel(e: WheelEvent) { + e.preventDefault() + if (!minuteWheelRef.value) return + const dir = e.deltaY > 0 ? 1 : -1 + const cur = Math.round(minuteWheelRef.value.scrollTop / WHEEL_ITEM_H) + const next = Math.min(59, Math.max(0, cur + dir)) + minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: 'smooth' }) +} + +// Click-and-drag support +let dragEl: HTMLElement | null = null +let dragStartY = 0 +let dragStartScrollTop = 0 + +function onWheelPointerDown(e: PointerEvent) { + const el = e.currentTarget as HTMLElement + dragEl = el + dragStartY = e.clientY + dragStartScrollTop = el.scrollTop + el.setPointerCapture(e.pointerId) +} + +function onWheelPointerMove(e: PointerEvent) { + if (!dragEl) return + e.preventDefault() + const delta = dragStartY - e.clientY + dragEl.scrollTop = dragStartScrollTop + delta +} + +function onWheelPointerUp(e: PointerEvent) { + if (!dragEl) return + const el = dragEl + dragEl = null + el.releasePointerCapture(e.pointerId) + // Snap to nearest item + const index = Math.round(el.scrollTop / WHEEL_ITEM_H) + el.scrollTo({ top: index * WHEEL_ITEM_H, behavior: 'smooth' }) +} + +function scrollWheelsToTime() { + if (hourWheelRef.value) { + hourWheelRef.value.scrollTop = internalHour.value * WHEEL_ITEM_H + } + if (minuteWheelRef.value) { + minuteWheelRef.value.scrollTop = internalMinute.value * WHEEL_ITEM_H } } @@ -106,9 +183,7 @@ const dayCells = computed(() => { const y = viewYear.value const m = viewMonth.value - // First day of the month (0=Sun, 1=Mon, ...) const firstDayOfWeek = new Date(y, m, 1).getDay() - // Shift so Monday=0 const startOffset = (firstDayOfWeek + 6) % 7 const daysInMonth = new Date(y, m + 1, 0).getDate() @@ -116,7 +191,6 @@ const dayCells = computed(() => { const cells: DayCell[] = [] - // Previous month padding const prevMonth = m === 0 ? 11 : m - 1 const prevYear = m === 0 ? y - 1 : y for (let i = startOffset - 1; i >= 0; i--) { @@ -130,7 +204,6 @@ const dayCells = computed(() => { }) } - // Current month days for (let d = 1; d <= daysInMonth; d++) { cells.push({ date: d, @@ -141,7 +214,6 @@ const dayCells = computed(() => { }) } - // Next month padding (fill up to 42 cells = 6 rows) const nextMonth = m === 11 ? 0 : m + 1 const nextYear = m === 11 ? y + 1 : y let nextDay = 1 @@ -173,10 +245,9 @@ function updatePosition() { if (!triggerRef.value) return const rect = triggerRef.value.getBoundingClientRect() const zoom = getZoomFactor() - const panelWidth = 280 + const panelWidth = props.showTime ? 390 : 280 const renderedWidth = panelWidth * zoom - // Compute left in viewport space, then convert to panel's zoomed coordinate space let leftViewport = rect.left if (leftViewport + renderedWidth > window.innerWidth) { leftViewport = window.innerWidth - renderedWidth - 8 @@ -203,7 +274,6 @@ function toggle() { } function open() { - // Sync view to modelValue or today if (props.modelValue) { const [y, m] = props.modelValue.split('-').map(Number) viewYear.value = y @@ -221,6 +291,10 @@ function open() { document.addEventListener('click', onClickOutside, true) document.addEventListener('scroll', onScrollOrResize, true) window.addEventListener('resize', onScrollOrResize) + + if (props.showTime) { + scrollWheelsToTime() + } }) } @@ -254,12 +328,16 @@ function nextMonthNav() { function selectDay(cell: DayCell) { if (!cell.isCurrentMonth) return emit('update:modelValue', cell.dateString) - close() + if (!props.showTime) { + close() + } } function selectToday() { emit('update:modelValue', todayString()) - close() + if (!props.showTime) { + close() + } } // ── Event handlers ────────────────────────────────────────────────── @@ -332,11 +410,12 @@ onBeforeUnmount(() => { +
@@ -359,60 +438,121 @@ onBeforeUnmount(() => {
- -
-
- {{ header }} + +
+ +
+ +
+
+ {{ header }} +
+
+ + +
+ +
-
- -
- -
+ +
+
+ +
+ +
+ +
+
+
+ {{ String(h - 1).padStart(2, '0') }} +
+
+
+
- -
- Time - - : - + : + + +
+ +
+ +
+
+
+ {{ String(m - 1).padStart(2, '0') }} +
+
+
+
+
+
@@ -426,6 +566,7 @@ onBeforeUnmount(() => {
+
diff --git a/src/components/AppSelect.vue b/src/components/AppSelect.vue index ed94b08..04f6034 100644 --- a/src/components/AppSelect.vue +++ b/src/components/AppSelect.vue @@ -1,6 +1,7 @@ + + diff --git a/src/views/CalendarView.vue b/src/views/CalendarView.vue index 4d01e24..e611772 100644 --- a/src/views/CalendarView.vue +++ b/src/views/CalendarView.vue @@ -34,7 +34,8 @@
-
+ +
@@ -119,6 +120,7 @@
+
diff --git a/src/views/Clients.vue b/src/views/Clients.vue index 6011bf0..805aa39 100644 --- a/src/views/Clients.vue +++ b/src/views/Clients.vue @@ -12,11 +12,12 @@
-
+
@@ -49,11 +50,11 @@
-
+
- +

No clients yet

Clients let you organize projects and generate invoices with billing details.