feat: add transitions and micro-interactions across all views

- Page transitions with slide-up/fade on route changes (App.vue)
- NavRail sliding active indicator with spring-like easing
- List enter/leave/move animations on Entries, Projects, Clients, Timer
- Modal enter/leave transitions with scale+fade on all dialogs
- Dropdown transitions with overshoot on all select/picker components
- Button feedback (scale on hover/active), card hover lift effects
- Timer pulse on start, glow on stop, floating empty state icons
- Content fade-in on Dashboard, Reports, Calendar, Timesheet
- Tag chip enter/leave animations in AppTagInput
- Progress bar smooth width transitions
- Implementation plan document
This commit is contained in:
Your Name
2026-02-18 11:33:58 +02:00
parent bd0dbaf91d
commit 04d4220604
16 changed files with 2115 additions and 144 deletions

View File

@@ -0,0 +1,423 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { Pipette } from 'lucide-vue-next'
import { computeDropdownPosition } from '../utils/dropdown'
interface Props {
modelValue: string
presets?: string[]
}
const props = withDefaults(defineProps<Props>(), {
presets: () => ['#D97706', '#3B82F6', '#8B5CF6', '#EC4899', '#10B981', '#EF4444', '#06B6D4', '#6B7280'],
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const isOpen = ref(false)
const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
const panelStyle = ref<Record<string, string>>({})
const hexInput = ref('')
// HSV state for the gradient picker
const hue = ref(0)
const saturation = ref(100)
const brightness = ref(100)
// Canvas refs
const gradientRef = ref<HTMLCanvasElement | null>(null)
const hueRef = ref<HTMLCanvasElement | null>(null)
// Dragging state
const draggingGradient = ref(false)
const draggingHue = ref(false)
// ── Color Conversion ────────────────────────────────────────────────
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
s /= 100
v /= 100
const c = v * s
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
const m = v - c
let r = 0, g = 0, b = 0
if (h < 60) { r = c; g = x; b = 0 }
else if (h < 120) { r = x; g = c; b = 0 }
else if (h < 180) { r = 0; g = c; b = x }
else if (h < 240) { r = 0; g = x; b = c }
else if (h < 300) { r = x; g = 0; b = c }
else { r = c; g = 0; b = x }
return [
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255),
]
}
function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const d = max - min
let h = 0
if (d !== 0) {
if (max === r) h = 60 * (((g - b) / d) % 6)
else if (max === g) h = 60 * ((b - r) / d + 2)
else h = 60 * ((r - g) / d + 4)
}
if (h < 0) h += 360
const s = max === 0 ? 0 : (d / max) * 100
const v = max * 100
return [h, s, v]
}
function hexToRgb(hex: string): [number, number, number] | null {
const match = hex.match(/^#?([0-9a-f]{6})$/i)
if (!match) return null
const n = parseInt(match[1], 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
function rgbToHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('').toUpperCase()
}
// ── Current color ────────────────────────────────────────────────────
const currentHex = computed(() => {
const [r, g, b] = hsvToRgb(hue.value, saturation.value, brightness.value)
return rgbToHex(r, g, b)
})
// ── Sync from prop ─────────────────────────────────────────────────
function syncFromHex(hex: string) {
const rgb = hexToRgb(hex)
if (!rgb) return
const [h, s, v] = rgbToHsv(...rgb)
hue.value = h
saturation.value = s
brightness.value = v
hexInput.value = hex.toUpperCase()
}
// Initialize from prop
syncFromHex(props.modelValue || '#D97706')
watch(() => props.modelValue, (val) => {
if (val && val.toUpperCase() !== currentHex.value) {
syncFromHex(val)
}
})
// ── Emit ────────────────────────────────────────────────────────────
function emitColor() {
hexInput.value = currentHex.value
emit('update:modelValue', currentHex.value)
}
// ── Hex Input ─────────────────────────────────────────────────────
function onHexInput(e: Event) {
const val = (e.target as HTMLInputElement).value
hexInput.value = val
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
syncFromHex(val)
emit('update:modelValue', val.toUpperCase())
}
}
// ── Preset Click ──────────────────────────────────────────────────
function selectPreset(color: string) {
syncFromHex(color)
emitColor()
}
// ── Gradient Canvas (Saturation/Brightness) ────────────────────────
function drawGradient() {
const canvas = gradientRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = canvas.width
const h = canvas.height
// Base hue color
const [r, g, b] = hsvToRgb(hue.value, 100, 100)
// White to hue (horizontal)
const gradH = ctx.createLinearGradient(0, 0, w, 0)
gradH.addColorStop(0, '#FFFFFF')
gradH.addColorStop(1, `rgb(${r},${g},${b})`)
ctx.fillStyle = gradH
ctx.fillRect(0, 0, w, h)
// Black overlay (vertical)
const gradV = ctx.createLinearGradient(0, 0, 0, h)
gradV.addColorStop(0, 'rgba(0,0,0,0)')
gradV.addColorStop(1, 'rgba(0,0,0,1)')
ctx.fillStyle = gradV
ctx.fillRect(0, 0, w, h)
}
function drawHueStrip() {
const canvas = hueRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = canvas.width
const h = canvas.height
const grad = ctx.createLinearGradient(0, 0, w, 0)
for (let i = 0; i <= 360; i += 60) {
const [r, g, b] = hsvToRgb(i, 100, 100)
grad.addColorStop(i / 360, `rgb(${r},${g},${b})`)
}
ctx.fillStyle = grad
ctx.fillRect(0, 0, w, h)
}
// ── Gradient Pointer ────────────────────────────────────────────────
const gradientCursorX = computed(() => (saturation.value / 100) * 100)
const gradientCursorY = computed(() => ((100 - brightness.value) / 100) * 100)
const hueCursorX = computed(() => (hue.value / 360) * 100)
function handleGradientInteraction(e: MouseEvent | PointerEvent) {
const canvas = gradientRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
saturation.value = x * 100
brightness.value = (1 - y) * 100
emitColor()
}
function handleHueInteraction(e: MouseEvent | PointerEvent) {
const canvas = hueRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
hue.value = x * 360
nextTick(() => drawGradient())
emitColor()
}
function onGradientPointerDown(e: PointerEvent) {
draggingGradient.value = true
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
handleGradientInteraction(e)
}
function onGradientPointerMove(e: PointerEvent) {
if (!draggingGradient.value) return
handleGradientInteraction(e)
}
function onGradientPointerUp() {
draggingGradient.value = false
}
function onHuePointerDown(e: PointerEvent) {
draggingHue.value = true
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
handleHueInteraction(e)
}
function onHuePointerMove(e: PointerEvent) {
if (!draggingHue.value) return
handleHueInteraction(e)
}
function onHuePointerUp() {
draggingHue.value = false
}
// ── Positioning ─────────────────────────────────────────────────────
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 260, estimatedHeight: 330 })
}
// ── Open / Close ────────────────────────────────────────────────────
function toggle() {
if (isOpen.value) close()
else open()
}
function open() {
syncFromHex(props.modelValue || '#D97706')
isOpen.value = true
updatePosition()
nextTick(() => {
drawGradient()
drawHueStrip()
document.addEventListener('click', onClickOutside, true)
document.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
})
}
function close() {
isOpen.value = false
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
}
function onClickOutside(e: MouseEvent) {
const target = e.target as Node
if (triggerRef.value?.contains(target) || panelRef.value?.contains(target)) return
close()
}
function onScrollOrResize() {
if (isOpen.value) updatePosition()
}
// Redraw gradient when hue changes
watch(hue, () => {
if (isOpen.value) nextTick(() => drawGradient())
})
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
})
</script>
<template>
<div class="relative">
<!-- Trigger button -->
<button
ref="triggerRef"
type="button"
@click="toggle"
class="w-full flex items-center gap-2.5 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left cursor-pointer transition-colors"
:style="
isOpen
? {
borderColor: 'var(--color-accent)',
boxShadow: '0 0 0 2px var(--color-accent-muted)',
outline: 'none',
}
: {}
"
>
<span
class="w-5 h-5 rounded-md border border-border-subtle shrink-0"
:style="{ backgroundColor: modelValue }"
/>
<span class="text-text-primary font-mono text-[0.75rem] tracking-wide">{{ modelValue?.toUpperCase() || '#000000' }}</span>
<Pipette class="w-4 h-4 text-text-secondary shrink-0 ml-auto" :stroke-width="2" />
</button>
<!-- Color picker popover -->
<Teleport to="body">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<!-- Preset swatches -->
<div class="px-3 pt-3 pb-2">
<div class="flex gap-2 flex-wrap">
<button
v-for="c in presets"
:key="c"
type="button"
@click="selectPreset(c)"
class="w-6 h-6 rounded-full border-2 transition-colors cursor-pointer hover:scale-110"
:class="currentHex === c.toUpperCase() ? 'border-text-primary' : 'border-transparent'"
:style="{ backgroundColor: c }"
/>
</div>
</div>
<!-- Saturation/Brightness gradient -->
<div class="px-3 pb-2">
<div
class="relative rounded-lg overflow-hidden cursor-crosshair"
style="touch-action: none;"
@pointerdown="onGradientPointerDown"
@pointermove="onGradientPointerMove"
@pointerup="onGradientPointerUp"
>
<canvas
ref="gradientRef"
width="260"
height="150"
class="w-full h-[150px] block rounded-lg"
/>
<!-- Cursor -->
<div
class="absolute w-4 h-4 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)] pointer-events-none -translate-x-1/2 -translate-y-1/2"
:style="{
left: gradientCursorX + '%',
top: gradientCursorY + '%',
backgroundColor: currentHex,
}"
/>
</div>
</div>
<!-- Hue slider -->
<div class="px-3 pb-2">
<div
class="relative rounded-md overflow-hidden cursor-pointer"
style="touch-action: none;"
@pointerdown="onHuePointerDown"
@pointermove="onHuePointerMove"
@pointerup="onHuePointerUp"
>
<canvas
ref="hueRef"
width="260"
height="14"
class="w-full h-3.5 block rounded-md"
/>
<!-- Hue cursor -->
<div
class="absolute top-1/2 w-3.5 h-3.5 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)] pointer-events-none -translate-x-1/2 -translate-y-1/2"
:style="{
left: hueCursorX + '%',
backgroundColor: rgbToHex(...hsvToRgb(hue, 100, 100)),
}"
/>
</div>
</div>
<!-- Hex input + preview -->
<div class="px-3 pb-3 flex items-center gap-2">
<span
class="w-8 h-8 rounded-lg border border-border-subtle shrink-0"
:style="{ backgroundColor: currentHex }"
/>
<input
:value="hexInput"
@input="onHexInput"
type="text"
maxlength="7"
class="flex-1 px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary font-mono tracking-wide placeholder-text-tertiary focus:outline-none focus:border-border-visible"
placeholder="#D97706"
/>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>

View File

@@ -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<HTMLDivElement | null>(null)
const minuteWheelRef = ref<HTMLDivElement | null>(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<typeof setTimeout> | null = null
let minuteScrollTimer: ReturnType<typeof setTimeout> | 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<DayCell[]>(() => {
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<DayCell[]>(() => {
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<DayCell[]>(() => {
})
}
// Current month days
for (let d = 1; d <= daysInMonth; d++) {
cells.push({
date: d,
@@ -141,7 +214,6 @@ const dayCells = computed<DayCell[]>(() => {
})
}
// 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(() => {
<!-- Calendar popover -->
<Teleport to="body">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden animate-dropdown-enter"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<!-- Month/year header -->
<div class="flex items-center justify-between px-3 py-2.5">
@@ -359,60 +438,121 @@ onBeforeUnmount(() => {
</button>
</div>
<!-- Day-of-week headers -->
<div class="grid grid-cols-7 px-2">
<div
v-for="header in dayHeaders"
:key="header"
class="text-center text-[0.6875rem] font-medium text-text-tertiary py-1 select-none"
>
{{ header }}
<!-- Calendar + Time wheels side by side -->
<div class="flex">
<!-- Calendar column -->
<div class="flex-1 min-w-0">
<!-- Day-of-week headers -->
<div class="grid grid-cols-7 px-2">
<div
v-for="header in dayHeaders"
:key="header"
class="text-center text-[0.6875rem] font-medium text-text-tertiary py-1 select-none"
>
{{ header }}
</div>
</div>
<!-- Day grid -->
<div class="grid grid-cols-7 px-2 pb-2">
<button
v-for="(cell, index) in dayCells"
:key="index"
type="button"
:disabled="!cell.isCurrentMonth"
@click="selectDay(cell)"
class="relative flex items-center justify-center h-8 w-full text-[0.75rem] rounded-lg transition-colors select-none"
:class="[
!cell.isCurrentMonth
? 'text-text-tertiary/40 cursor-default'
: cell.dateString === modelValue
? 'bg-accent text-bg-base font-medium cursor-pointer'
: cell.dateString === todayString()
? 'ring-1 ring-accent text-accent-text cursor-pointer hover:bg-bg-elevated'
: 'text-text-primary cursor-pointer hover:bg-bg-elevated',
]"
>
{{ cell.date }}
</button>
</div>
</div>
</div>
<!-- Day grid -->
<div class="grid grid-cols-7 px-2 pb-2">
<button
v-for="(cell, index) in dayCells"
:key="index"
type="button"
:disabled="!cell.isCurrentMonth"
@click="selectDay(cell)"
class="relative flex items-center justify-center h-8 w-full text-[0.75rem] rounded-lg transition-colors select-none"
:class="[
!cell.isCurrentMonth
? 'text-text-tertiary/40 cursor-default'
: cell.dateString === modelValue
? 'bg-accent text-bg-base font-medium cursor-pointer'
: cell.dateString === todayString()
? 'ring-1 ring-accent text-accent-text cursor-pointer hover:bg-bg-elevated'
: 'text-text-primary cursor-pointer hover:bg-bg-elevated',
]"
>
{{ cell.date }}
</button>
</div>
<!-- Time wheels column (to the right of calendar) -->
<div v-if="showTime" class="border-l border-border-subtle flex flex-col items-center justify-center px-3">
<div class="flex items-center gap-1.5">
<!-- Hour wheel -->
<div
class="relative overflow-hidden rounded-lg"
:style="{ height: WHEEL_HEIGHT + 'px', width: '42px' }"
>
<!-- Highlight band (behind scroll content via DOM order) -->
<div
class="absolute inset-x-0 rounded-lg pointer-events-none bg-bg-elevated border border-border-subtle"
:style="{ top: WHEEL_PAD + 'px', height: WHEEL_ITEM_H + 'px' }"
/>
<!-- Scrollable wheel -->
<div
ref="hourWheelRef"
class="absolute inset-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden cursor-grab active:cursor-grabbing"
style="touch-action: none; -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%);"
@scroll="onHourScroll"
@wheel.prevent="onHourWheel"
@pointerdown.prevent="onWheelPointerDown"
@pointermove="onWheelPointerMove"
@pointerup="onWheelPointerUp"
>
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
<div
v-for="h in 24"
:key="h"
class="shrink-0 flex items-center justify-center text-[0.875rem] font-mono select-none"
:style="{ height: WHEEL_ITEM_H + 'px' }"
:class="internalHour === h - 1 ? 'text-text-primary font-semibold' : 'text-text-tertiary'"
>
{{ String(h - 1).padStart(2, '0') }}
</div>
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
</div>
</div>
<!-- Time inputs (when showTime is true) -->
<div v-if="showTime" class="border-t border-border-subtle px-3 py-2.5 flex items-center justify-center gap-2">
<span class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em]">Time</span>
<input
:value="String(internalHour).padStart(2, '0')"
type="number"
min="0"
max="23"
@input="onHourInput"
class="w-11 px-1.5 py-1 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary text-center font-mono focus:outline-none focus:border-border-visible [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<span class="text-text-tertiary text-sm font-mono">:</span>
<input
:value="String(internalMinute).padStart(2, '0')"
type="number"
min="0"
max="59"
@input="onMinuteInput"
class="w-11 px-1.5 py-1 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary text-center font-mono focus:outline-none focus:border-border-visible [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<span class="text-text-secondary text-sm font-mono font-semibold select-none">:</span>
<!-- Minute wheel -->
<div
class="relative overflow-hidden rounded-lg"
:style="{ height: WHEEL_HEIGHT + 'px', width: '42px' }"
>
<!-- Highlight band -->
<div
class="absolute inset-x-0 rounded-lg pointer-events-none bg-bg-elevated border border-border-subtle"
:style="{ top: WHEEL_PAD + 'px', height: WHEEL_ITEM_H + 'px' }"
/>
<!-- Scrollable wheel -->
<div
ref="minuteWheelRef"
class="absolute inset-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden cursor-grab active:cursor-grabbing"
style="touch-action: none; -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%);"
@scroll="onMinuteScroll"
@wheel.prevent="onMinuteWheel"
@pointerdown.prevent="onWheelPointerDown"
@pointermove="onWheelPointerMove"
@pointerup="onWheelPointerUp"
>
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
<div
v-for="m in 60"
:key="m"
class="shrink-0 flex items-center justify-center text-[0.875rem] font-mono select-none"
:style="{ height: WHEEL_ITEM_H + 'px' }"
:class="internalMinute === m - 1 ? 'text-text-primary font-semibold' : 'text-text-tertiary'"
>
{{ String(m - 1).padStart(2, '0') }}
</div>
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
</div>
</div>
</div>
</div>
</div>
<!-- Today shortcut -->
@@ -426,6 +566,7 @@ onBeforeUnmount(() => {
</button>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ChevronDown, Check } from 'lucide-vue-next'
import { computeDropdownPosition } from '../utils/dropdown'
interface Props {
modelValue: any
@@ -77,25 +78,9 @@ function isSelected(item: any): boolean {
return val === props.modelValue
}
function getZoomFactor(): number {
const app = document.getElementById('app')
if (!app) return 1
const zoom = (app.style as any).zoom
return zoom ? parseFloat(zoom) / 100 : 1
}
function updatePosition() {
if (!triggerRef.value) return
const rect = triggerRef.value.getBoundingClientRect()
const zoom = getZoomFactor()
panelStyle.value = {
position: 'fixed',
top: `${(rect.bottom + 4) / zoom}px`,
left: `${rect.left / zoom}px`,
width: `${rect.width / zoom}px`,
zIndex: '9999',
zoom: `${zoom * 100}%`,
}
panelStyle.value = computeDropdownPosition(triggerRef.value, { estimatedHeight: 280 })
}
function toggle() {
@@ -268,11 +253,12 @@ onBeforeUnmount(() => {
<!-- Dropdown panel -->
<Teleport to="body">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden animate-dropdown-enter"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<div v-if="searchable" class="px-2 pt-2 pb-1">
<input
@@ -307,6 +293,7 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>

View File

@@ -109,7 +109,7 @@ onBeforeUnmount(() => {
<template>
<div ref="triggerRef" class="relative">
<!-- Selected tags + add button -->
<div class="flex flex-wrap items-center gap-1.5">
<TransitionGroup tag="div" name="chip" class="flex flex-wrap items-center gap-1.5">
<span
v-for="tag in selectedTags"
:key="tag.id"
@@ -123,6 +123,7 @@ onBeforeUnmount(() => {
</button>
</span>
<button
key="__add_btn__"
type="button"
@click="isOpen ? close() : open()"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[0.6875rem] text-text-tertiary border border-border-subtle hover:text-text-secondary hover:border-border-visible transition-colors"
@@ -130,15 +131,16 @@ onBeforeUnmount(() => {
<Plus class="w-3 h-3" />
Tag
</button>
</div>
</TransitionGroup>
<!-- Dropdown -->
<Teleport to="body">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden animate-dropdown-enter"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<div class="px-2 pt-2 pb-1">
<input
@@ -172,6 +174,7 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>

View File

@@ -34,6 +34,10 @@ const navItems = [
const currentPath = computed(() => route.path)
const activeIndex = computed(() => {
return navItems.findIndex(item => item.path === currentPath.value)
})
function navigate(path: string) {
router.push(path)
}
@@ -41,24 +45,25 @@ function navigate(path: string) {
<template>
<nav class="w-12 flex flex-col items-center bg-bg-surface border-r border-border-subtle shrink-0">
<!-- Navigation icons -->
<div class="flex-1 flex flex-col items-center pt-2 gap-1">
<div class="relative flex-1 flex flex-col items-center pt-2 gap-1">
<!-- Sliding active indicator -->
<div
v-if="activeIndex >= 0"
class="absolute left-0 w-[2px] bg-accent transition-all duration-300"
:style="{ top: `${activeIndex * 52 + 8 + 8}px`, height: '36px' }"
style="transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);"
/>
<button
v-for="item in navItems"
:key="item.path"
@click="navigate(item.path)"
class="relative w-12 h-12 flex items-center justify-center transition-colors duration-150 group"
class="relative w-12 h-[52px] flex items-center justify-center transition-colors duration-150 group"
:class="currentPath === item.path
? 'text-text-primary'
: 'text-text-tertiary hover:text-text-secondary'"
:title="item.name"
>
<!-- Active indicator (left border) -->
<div
v-if="currentPath === item.path"
class="absolute left-0 top-2 bottom-2 w-[2px] bg-accent"
/>
<component :is="item.icon" class="w-[18px] h-[18px]" :stroke-width="1.5" />
<!-- Tooltip -->

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import { ref, computed, onBeforeUnmount, nextTick } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { Monitor, RefreshCw } from 'lucide-vue-next'
import { computeDropdownPosition } from '../utils/dropdown'
interface WindowInfo {
exe_name: string
exe_path: string
title: string
display_name: string
icon: string | null
}
interface Props {
excludePaths?: string[]
}
const props = withDefaults(defineProps<Props>(), {
excludePaths: () => [],
})
const emit = defineEmits<{
select: [app: WindowInfo]
}>()
const isOpen = ref(false)
const loading = ref(false)
const processes = ref<WindowInfo[]>([])
const searchQuery = ref('')
const highlightedIndex = ref(-1)
const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
const panelStyle = ref<Record<string, string>>({})
const searchInputRef = ref<HTMLInputElement | null>(null)
const filteredProcesses = computed(() => {
let list = processes.value.filter(
p => !props.excludePaths.includes(p.exe_path.toLowerCase())
)
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
list = list.filter(p =>
p.display_name.toLowerCase().includes(q) ||
p.exe_name.toLowerCase().includes(q)
)
}
return list
})
async function fetchProcesses() {
loading.value = true
try {
processes.value = await invoke<WindowInfo[]>('get_running_processes')
} catch (e) {
console.error('Failed to fetch running processes:', e)
} finally {
loading.value = false
}
}
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 280, estimatedHeight: 290 })
}
function toggle() {
if (isOpen.value) {
close()
} else {
open()
}
}
async function open() {
isOpen.value = true
updatePosition()
highlightedIndex.value = 0
searchQuery.value = ''
await fetchProcesses()
nextTick(() => searchInputRef.value?.focus())
document.addEventListener('click', onClickOutside, true)
document.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
}
function close() {
isOpen.value = false
highlightedIndex.value = -1
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
}
function selectApp(app: WindowInfo) {
emit('select', app)
close()
}
function onClickOutside(e: MouseEvent) {
const target = e.target as Node
if (triggerRef.value?.contains(target) || panelRef.value?.contains(target)) return
close()
}
function onScrollOrResize() {
if (isOpen.value) updatePosition()
}
function onSearchKeydown(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredProcesses.value.length - 1)
break
case 'ArrowUp':
e.preventDefault()
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
break
case 'Enter':
e.preventDefault()
if (highlightedIndex.value >= 0 && filteredProcesses.value[highlightedIndex.value]) {
selectApp(filteredProcesses.value[highlightedIndex.value])
}
break
case 'Escape':
e.preventDefault()
close()
triggerRef.value?.focus()
break
}
}
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
})
</script>
<template>
<div class="relative">
<button
ref="triggerRef"
type="button"
@click="toggle"
class="flex items-center gap-1.5 px-2.5 py-1.5 border border-border-subtle text-text-secondary text-[0.75rem] rounded-lg hover:bg-bg-elevated hover:text-text-primary transition-colors"
>
<Monitor class="w-3.5 h-3.5" :stroke-width="1.5" />
From Running Apps
</button>
<Teleport to="body">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<div class="px-2 pt-2 pb-1 flex items-center gap-1.5">
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
class="flex-1 px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible"
placeholder="Search apps..."
@keydown="onSearchKeydown"
/>
<button
type="button"
@click="fetchProcesses"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors"
title="Refresh"
>
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" :stroke-width="1.5" />
</button>
</div>
<div class="max-h-[240px] overflow-y-auto py-1">
<div v-if="loading && filteredProcesses.length === 0" class="px-3 py-4 text-center text-[0.75rem] text-text-tertiary">
Loading...
</div>
<div v-else-if="filteredProcesses.length === 0" class="px-3 py-4 text-center text-[0.75rem] text-text-tertiary">
No apps found
</div>
<div
v-for="(app, index) in filteredProcesses"
:key="app.exe_path"
@click="selectApp(app)"
@mouseenter="highlightedIndex = index"
class="flex items-center gap-2.5 px-3 py-2 cursor-pointer transition-colors"
:class="{ 'bg-bg-elevated': highlightedIndex === index }"
>
<img
v-if="app.icon"
:src="app.icon"
class="w-5 h-5 shrink-0"
alt=""
/>
<div v-else class="w-5 h-5 shrink-0 rounded bg-bg-elevated" />
<div class="flex-1 min-w-0">
<p class="text-[0.8125rem] text-text-primary truncate">{{ app.display_name }}</p>
<p class="text-[0.6875rem] text-text-tertiary truncate">{{ app.exe_name }}</p>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>