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:
1139
docs/plans/2026-02-18-motion-system-implementation.md
Normal file
1139
docs/plans/2026-02-18-motion-system-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,11 @@ watch(() => [settingsStore.settings.shortcut_toggle_timer, settingsStore.setting
|
|||||||
<div class="flex-1 flex overflow-hidden">
|
<div class="flex-1 flex overflow-hidden">
|
||||||
<NavRail />
|
<NavRail />
|
||||||
<main class="flex-1 overflow-auto">
|
<main class="flex-1 overflow-auto">
|
||||||
<router-view />
|
<router-view v-slot="{ Component }">
|
||||||
|
<Transition name="page" mode="out-in">
|
||||||
|
<component :is="Component" :key="$route.path" />
|
||||||
|
</Transition>
|
||||||
|
</router-view>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
423
src/components/AppColorPicker.vue
Normal file
423
src/components/AppColorPicker.vue
Normal 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>
|
||||||
@@ -51,29 +51,106 @@ const displayText = computed(() => {
|
|||||||
return datePart
|
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 internalHour = ref(props.hour)
|
||||||
const internalMinute = ref(props.minute)
|
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.hour, (v) => { internalHour.value = v })
|
||||||
watch(() => props.minute, (v) => { internalMinute.value = v })
|
watch(() => props.minute, (v) => { internalMinute.value = v })
|
||||||
|
|
||||||
function onHourInput(e: Event) {
|
// Debounced scroll handler to read the current value
|
||||||
const val = parseInt((e.target as HTMLInputElement).value)
|
let hourScrollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
if (!isNaN(val)) {
|
let minuteScrollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const clamped = Math.min(23, Math.max(0, val))
|
|
||||||
|
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
|
internalHour.value = clamped
|
||||||
emit('update:hour', clamped)
|
emit('update:hour', clamped)
|
||||||
}
|
}
|
||||||
|
}, 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMinuteInput(e: Event) {
|
function onMinuteScroll() {
|
||||||
const val = parseInt((e.target as HTMLInputElement).value)
|
if (minuteScrollTimer) clearTimeout(minuteScrollTimer)
|
||||||
if (!isNaN(val)) {
|
minuteScrollTimer = setTimeout(() => {
|
||||||
const clamped = Math.min(59, Math.max(0, val))
|
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
|
internalMinute.value = clamped
|
||||||
emit('update:minute', 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewMonthLabel = computed(() => {
|
const viewMonthLabel = computed(() => {
|
||||||
@@ -106,9 +183,7 @@ const dayCells = computed<DayCell[]>(() => {
|
|||||||
const y = viewYear.value
|
const y = viewYear.value
|
||||||
const m = viewMonth.value
|
const m = viewMonth.value
|
||||||
|
|
||||||
// First day of the month (0=Sun, 1=Mon, ...)
|
|
||||||
const firstDayOfWeek = new Date(y, m, 1).getDay()
|
const firstDayOfWeek = new Date(y, m, 1).getDay()
|
||||||
// Shift so Monday=0
|
|
||||||
const startOffset = (firstDayOfWeek + 6) % 7
|
const startOffset = (firstDayOfWeek + 6) % 7
|
||||||
|
|
||||||
const daysInMonth = new Date(y, m + 1, 0).getDate()
|
const daysInMonth = new Date(y, m + 1, 0).getDate()
|
||||||
@@ -116,7 +191,6 @@ const dayCells = computed<DayCell[]>(() => {
|
|||||||
|
|
||||||
const cells: DayCell[] = []
|
const cells: DayCell[] = []
|
||||||
|
|
||||||
// Previous month padding
|
|
||||||
const prevMonth = m === 0 ? 11 : m - 1
|
const prevMonth = m === 0 ? 11 : m - 1
|
||||||
const prevYear = m === 0 ? y - 1 : y
|
const prevYear = m === 0 ? y - 1 : y
|
||||||
for (let i = startOffset - 1; i >= 0; i--) {
|
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++) {
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
cells.push({
|
cells.push({
|
||||||
date: d,
|
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 nextMonth = m === 11 ? 0 : m + 1
|
||||||
const nextYear = m === 11 ? y + 1 : y
|
const nextYear = m === 11 ? y + 1 : y
|
||||||
let nextDay = 1
|
let nextDay = 1
|
||||||
@@ -173,10 +245,9 @@ function updatePosition() {
|
|||||||
if (!triggerRef.value) return
|
if (!triggerRef.value) return
|
||||||
const rect = triggerRef.value.getBoundingClientRect()
|
const rect = triggerRef.value.getBoundingClientRect()
|
||||||
const zoom = getZoomFactor()
|
const zoom = getZoomFactor()
|
||||||
const panelWidth = 280
|
const panelWidth = props.showTime ? 390 : 280
|
||||||
const renderedWidth = panelWidth * zoom
|
const renderedWidth = panelWidth * zoom
|
||||||
|
|
||||||
// Compute left in viewport space, then convert to panel's zoomed coordinate space
|
|
||||||
let leftViewport = rect.left
|
let leftViewport = rect.left
|
||||||
if (leftViewport + renderedWidth > window.innerWidth) {
|
if (leftViewport + renderedWidth > window.innerWidth) {
|
||||||
leftViewport = window.innerWidth - renderedWidth - 8
|
leftViewport = window.innerWidth - renderedWidth - 8
|
||||||
@@ -203,7 +274,6 @@ function toggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
// Sync view to modelValue or today
|
|
||||||
if (props.modelValue) {
|
if (props.modelValue) {
|
||||||
const [y, m] = props.modelValue.split('-').map(Number)
|
const [y, m] = props.modelValue.split('-').map(Number)
|
||||||
viewYear.value = y
|
viewYear.value = y
|
||||||
@@ -221,6 +291,10 @@ function open() {
|
|||||||
document.addEventListener('click', onClickOutside, true)
|
document.addEventListener('click', onClickOutside, true)
|
||||||
document.addEventListener('scroll', onScrollOrResize, true)
|
document.addEventListener('scroll', onScrollOrResize, true)
|
||||||
window.addEventListener('resize', onScrollOrResize)
|
window.addEventListener('resize', onScrollOrResize)
|
||||||
|
|
||||||
|
if (props.showTime) {
|
||||||
|
scrollWheelsToTime()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,13 +328,17 @@ function nextMonthNav() {
|
|||||||
function selectDay(cell: DayCell) {
|
function selectDay(cell: DayCell) {
|
||||||
if (!cell.isCurrentMonth) return
|
if (!cell.isCurrentMonth) return
|
||||||
emit('update:modelValue', cell.dateString)
|
emit('update:modelValue', cell.dateString)
|
||||||
|
if (!props.showTime) {
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function selectToday() {
|
function selectToday() {
|
||||||
emit('update:modelValue', todayString())
|
emit('update:modelValue', todayString())
|
||||||
|
if (!props.showTime) {
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Event handlers ──────────────────────────────────────────────────
|
// ── Event handlers ──────────────────────────────────────────────────
|
||||||
function onClickOutside(e: MouseEvent) {
|
function onClickOutside(e: MouseEvent) {
|
||||||
@@ -332,11 +410,12 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<!-- Calendar popover -->
|
<!-- Calendar popover -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
|
<Transition name="dropdown">
|
||||||
<div
|
<div
|
||||||
v-if="isOpen"
|
v-if="isOpen"
|
||||||
ref="panelRef"
|
ref="panelRef"
|
||||||
:style="panelStyle"
|
: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 -->
|
<!-- Month/year header -->
|
||||||
<div class="flex items-center justify-between px-3 py-2.5">
|
<div class="flex items-center justify-between px-3 py-2.5">
|
||||||
@@ -359,6 +438,10 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar + Time wheels side by side -->
|
||||||
|
<div class="flex">
|
||||||
|
<!-- Calendar column -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
<!-- Day-of-week headers -->
|
<!-- Day-of-week headers -->
|
||||||
<div class="grid grid-cols-7 px-2">
|
<div class="grid grid-cols-7 px-2">
|
||||||
<div
|
<div
|
||||||
@@ -392,27 +475,84 @@ onBeforeUnmount(() => {
|
|||||||
{{ cell.date }}
|
{{ cell.date }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Time inputs (when showTime is true) -->
|
<!-- Time wheels column (to the right of calendar) -->
|
||||||
<div v-if="showTime" class="border-t border-border-subtle px-3 py-2.5 flex items-center justify-center gap-2">
|
<div v-if="showTime" class="border-l border-border-subtle flex flex-col items-center justify-center px-3">
|
||||||
<span class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em]">Time</span>
|
<div class="flex items-center gap-1.5">
|
||||||
<input
|
<!-- Hour wheel -->
|
||||||
:value="String(internalHour).padStart(2, '0')"
|
<div
|
||||||
type="number"
|
class="relative overflow-hidden rounded-lg"
|
||||||
min="0"
|
:style="{ height: WHEEL_HEIGHT + 'px', width: '42px' }"
|
||||||
max="23"
|
>
|
||||||
@input="onHourInput"
|
<!-- Highlight band (behind scroll content via DOM order) -->
|
||||||
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"
|
<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' }"
|
||||||
/>
|
/>
|
||||||
<span class="text-text-tertiary text-sm font-mono">:</span>
|
<!-- Scrollable wheel -->
|
||||||
<input
|
<div
|
||||||
:value="String(internalMinute).padStart(2, '0')"
|
ref="hourWheelRef"
|
||||||
type="number"
|
class="absolute inset-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden cursor-grab active:cursor-grabbing"
|
||||||
min="0"
|
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%);"
|
||||||
max="59"
|
@scroll="onHourScroll"
|
||||||
@input="onMinuteInput"
|
@wheel.prevent="onHourWheel"
|
||||||
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"
|
@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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Today shortcut -->
|
<!-- Today shortcut -->
|
||||||
@@ -426,6 +566,7 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { ChevronDown, Check } from 'lucide-vue-next'
|
import { ChevronDown, Check } from 'lucide-vue-next'
|
||||||
|
import { computeDropdownPosition } from '../utils/dropdown'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: any
|
modelValue: any
|
||||||
@@ -77,25 +78,9 @@ function isSelected(item: any): boolean {
|
|||||||
return val === props.modelValue
|
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() {
|
function updatePosition() {
|
||||||
if (!triggerRef.value) return
|
if (!triggerRef.value) return
|
||||||
const rect = triggerRef.value.getBoundingClientRect()
|
panelStyle.value = computeDropdownPosition(triggerRef.value, { estimatedHeight: 280 })
|
||||||
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}%`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
@@ -268,11 +253,12 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<!-- Dropdown panel -->
|
<!-- Dropdown panel -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
|
<Transition name="dropdown">
|
||||||
<div
|
<div
|
||||||
v-if="isOpen"
|
v-if="isOpen"
|
||||||
ref="panelRef"
|
ref="panelRef"
|
||||||
:style="panelStyle"
|
: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">
|
<div v-if="searchable" class="px-2 pt-2 pb-1">
|
||||||
<input
|
<input
|
||||||
@@ -307,6 +293,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div ref="triggerRef" class="relative">
|
<div ref="triggerRef" class="relative">
|
||||||
<!-- Selected tags + add button -->
|
<!-- 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
|
<span
|
||||||
v-for="tag in selectedTags"
|
v-for="tag in selectedTags"
|
||||||
:key="tag.id"
|
:key="tag.id"
|
||||||
@@ -123,6 +123,7 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
key="__add_btn__"
|
||||||
type="button"
|
type="button"
|
||||||
@click="isOpen ? close() : open()"
|
@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"
|
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" />
|
<Plus class="w-3 h-3" />
|
||||||
Tag
|
Tag
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</TransitionGroup>
|
||||||
|
|
||||||
<!-- Dropdown -->
|
<!-- Dropdown -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
|
<Transition name="dropdown">
|
||||||
<div
|
<div
|
||||||
v-if="isOpen"
|
v-if="isOpen"
|
||||||
ref="panelRef"
|
ref="panelRef"
|
||||||
:style="panelStyle"
|
: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">
|
<div class="px-2 pt-2 pb-1">
|
||||||
<input
|
<input
|
||||||
@@ -172,6 +174,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ const navItems = [
|
|||||||
|
|
||||||
const currentPath = computed(() => route.path)
|
const currentPath = computed(() => route.path)
|
||||||
|
|
||||||
|
const activeIndex = computed(() => {
|
||||||
|
return navItems.findIndex(item => item.path === currentPath.value)
|
||||||
|
})
|
||||||
|
|
||||||
function navigate(path: string) {
|
function navigate(path: string) {
|
||||||
router.push(path)
|
router.push(path)
|
||||||
}
|
}
|
||||||
@@ -41,24 +45,25 @@ function navigate(path: string) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="w-12 flex flex-col items-center bg-bg-surface border-r border-border-subtle shrink-0">
|
<nav class="w-12 flex flex-col items-center bg-bg-surface border-r border-border-subtle shrink-0">
|
||||||
<!-- Navigation icons -->
|
<div class="relative flex-1 flex flex-col items-center pt-2 gap-1">
|
||||||
<div class="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
|
<button
|
||||||
v-for="item in navItems"
|
v-for="item in navItems"
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
@click="navigate(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
|
:class="currentPath === item.path
|
||||||
? 'text-text-primary'
|
? 'text-text-primary'
|
||||||
: 'text-text-tertiary hover:text-text-secondary'"
|
: 'text-text-tertiary hover:text-text-secondary'"
|
||||||
:title="item.name"
|
: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" />
|
<component :is="item.icon" class="w-[18px] h-[18px]" :stroke-width="1.5" />
|
||||||
|
|
||||||
<!-- Tooltip -->
|
<!-- Tooltip -->
|
||||||
|
|||||||
212
src/components/RunningAppsPicker.vue
Normal file
212
src/components/RunningAppsPicker.vue
Normal 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>
|
||||||
@@ -34,7 +34,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Calendar Grid -->
|
<!-- Calendar Grid -->
|
||||||
<div class="flex-1 bg-bg-surface rounded-lg border border-border-subtle overflow-hidden flex flex-col min-h-0">
|
<Transition name="fade" mode="out-in">
|
||||||
|
<div :key="weekStart.getTime()" class="flex-1 bg-bg-surface rounded-lg border border-border-subtle overflow-hidden flex flex-col min-h-0">
|
||||||
<!-- Day column headers -->
|
<!-- Day column headers -->
|
||||||
<div class="grid shrink-0 border-b border-border-subtle" :style="gridStyle">
|
<div class="grid shrink-0 border-b border-border-subtle" :style="gridStyle">
|
||||||
<!-- Top-left corner (hour gutter) -->
|
<!-- Top-left corner (hour gutter) -->
|
||||||
@@ -119,6 +120,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Clients Grid -->
|
<!-- Clients Grid -->
|
||||||
<div v-if="clientsStore.clients.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<TransitionGroup v-if="clientsStore.clients.length > 0" name="list" tag="div" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<div
|
<div
|
||||||
v-for="client in clientsStore.clients"
|
v-for="(client, index) in clientsStore.clients"
|
||||||
:key="client.id"
|
:key="client.id"
|
||||||
class="group bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] hover:bg-bg-elevated transition-all duration-150 cursor-pointer"
|
class="group bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] card-hover cursor-pointer"
|
||||||
|
:style="{ transitionDelay: `${index * 30}ms` }"
|
||||||
@click="openEditDialog(client)"
|
@click="openEditDialog(client)"
|
||||||
>
|
>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
@@ -49,11 +50,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TransitionGroup>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-else class="flex flex-col items-center justify-center py-16">
|
<div v-else class="flex flex-col items-center justify-center py-16">
|
||||||
<Users class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" />
|
<Users class="w-12 h-12 text-text-tertiary animate-float" :stroke-width="1.5" />
|
||||||
<p class="text-sm text-text-secondary mt-4">No clients yet</p>
|
<p class="text-sm text-text-secondary mt-4">No clients yet</p>
|
||||||
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Clients let you organize projects and generate invoices with billing details.</p>
|
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Clients let you organize projects and generate invoices with billing details.</p>
|
||||||
<button
|
<button
|
||||||
@@ -65,12 +66,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create/Edit Dialog -->
|
<!-- Create/Edit Dialog -->
|
||||||
|
<Transition name="modal">
|
||||||
<div
|
<div
|
||||||
v-if="showDialog"
|
v-if="showDialog"
|
||||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||||
@click.self="closeDialog"
|
@click.self="tryCloseDialog"
|
||||||
>
|
>
|
||||||
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6 animate-modal-enter max-h-[calc(100vh-2rem)] overflow-y-auto">
|
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||||
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
|
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
|
||||||
{{ editingClient ? 'Edit Client' : 'Create Client' }}
|
{{ editingClient ? 'Edit Client' : 'Create Client' }}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -201,14 +203,16 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- Delete Confirmation Dialog -->
|
<!-- Delete Confirmation Dialog -->
|
||||||
|
<Transition name="modal">
|
||||||
<div
|
<div
|
||||||
v-if="showDeleteDialog"
|
v-if="showDeleteDialog"
|
||||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||||
@click.self="cancelDelete"
|
@click.self="cancelDelete"
|
||||||
>
|
>
|
||||||
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6 animate-modal-enter">
|
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
|
||||||
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Delete Client</h2>
|
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Delete Client</h2>
|
||||||
<p class="text-[0.75rem] text-text-secondary mb-6">
|
<p class="text-[0.75rem] text-text-secondary mb-6">
|
||||||
Are you sure you want to delete "{{ clientToDelete?.name }}"? This action cannot be undone.
|
Are you sure you want to delete "{{ clientToDelete?.name }}"? This action cannot be undone.
|
||||||
@@ -229,16 +233,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<AppDiscardDialog :show="showDiscardDialog" @cancel="cancelDiscard" @discard="confirmDiscard" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { Users } from 'lucide-vue-next'
|
import { Users } from 'lucide-vue-next'
|
||||||
|
import AppDiscardDialog from '../components/AppDiscardDialog.vue'
|
||||||
import { useClientsStore, type Client } from '../stores/clients'
|
import { useClientsStore, type Client } from '../stores/clients'
|
||||||
|
import { useFormGuard } from '../utils/formGuard'
|
||||||
|
|
||||||
const clientsStore = useClientsStore()
|
const clientsStore = useClientsStore()
|
||||||
|
|
||||||
|
const { showDiscardDialog, snapshot: snapshotForm, tryClose: tryCloseForm, confirmDiscard, cancelDiscard } = useFormGuard()
|
||||||
|
|
||||||
|
function getFormData() {
|
||||||
|
return { name: formData.name, email: formData.email, phone: formData.phone, address: formData.address, company: formData.company, tax_id: formData.tax_id, payment_terms: formData.payment_terms, notes: formData.notes }
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryCloseDialog() {
|
||||||
|
tryCloseForm(getFormData(), closeDialog)
|
||||||
|
}
|
||||||
|
|
||||||
// Dialog state
|
// Dialog state
|
||||||
const showDialog = ref(false)
|
const showDialog = ref(false)
|
||||||
const showDeleteDialog = ref(false)
|
const showDeleteDialog = ref(false)
|
||||||
@@ -275,6 +294,7 @@ function openCreateDialog() {
|
|||||||
formData.payment_terms = undefined
|
formData.payment_terms = undefined
|
||||||
formData.notes = undefined
|
formData.notes = undefined
|
||||||
billingOpen.value = false
|
billingOpen.value = false
|
||||||
|
snapshotForm(getFormData())
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +311,7 @@ function openEditDialog(client: Client) {
|
|||||||
formData.payment_terms = client.payment_terms
|
formData.payment_terms = client.payment_terms
|
||||||
formData.notes = client.notes
|
formData.notes = client.notes
|
||||||
billingOpen.value = hasBillingData(client)
|
billingOpen.value = hasBillingData(client)
|
||||||
|
snapshotForm(getFormData())
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div v-if="isEmpty" class="flex flex-col items-center justify-center py-16">
|
<div v-if="isEmpty" class="flex flex-col items-center justify-center py-16">
|
||||||
<Clock class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" />
|
<Clock class="w-12 h-12 text-text-tertiary animate-float" :stroke-width="1.5" />
|
||||||
<p class="text-sm text-text-secondary mt-4">Start tracking your time</p>
|
<p class="text-sm text-text-secondary mt-4">Start tracking your time</p>
|
||||||
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Your dashboard will come alive with stats, charts, and recent activity once you start logging hours.</p>
|
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Your dashboard will come alive with stats, charts, and recent activity once you start logging hours.</p>
|
||||||
<router-link to="/timer" class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors">
|
<router-link to="/timer" class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors">
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<template v-else>
|
<Transition name="fade" appear>
|
||||||
|
<div v-if="!isEmpty">
|
||||||
<!-- Greeting header -->
|
<!-- Greeting header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<p class="text-xl font-[family-name:var(--font-heading)] text-text-secondary">{{ greeting }}</p>
|
<p class="text-xl font-[family-name:var(--font-heading)] text-text-secondary">{{ greeting }}</p>
|
||||||
@@ -49,7 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-bg-elevated rounded-full h-1.5">
|
<div class="w-full bg-bg-elevated rounded-full h-1.5">
|
||||||
<div
|
<div
|
||||||
class="h-1.5 rounded-full bg-accent transition-all"
|
class="h-1.5 rounded-full bg-accent progress-bar"
|
||||||
:style="{ width: Math.min(dailyPct, 100) + '%' }"
|
:style="{ width: Math.min(dailyPct, 100) + '%' }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-bg-elevated rounded-full h-1.5">
|
<div class="w-full bg-bg-elevated rounded-full h-1.5">
|
||||||
<div
|
<div
|
||||||
class="h-1.5 rounded-full bg-accent transition-all"
|
class="h-1.5 rounded-full bg-accent progress-bar"
|
||||||
:style="{ width: Math.min(weeklyPct, 100) + '%' }"
|
:style="{ width: Math.min(weeklyPct, 100) + '%' }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +131,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -70,11 +70,12 @@
|
|||||||
<th class="px-4 py-3 w-20"></th>
|
<th class="px-4 py-3 w-20"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<TransitionGroup name="list" tag="tbody">
|
||||||
<tr
|
<tr
|
||||||
v-for="entry in filteredEntries"
|
v-for="(entry, index) in filteredEntries"
|
||||||
:key="entry.id"
|
:key="entry.id"
|
||||||
class="group border-b border-border-subtle hover:bg-bg-elevated transition-colors duration-150"
|
class="group border-b border-border-subtle hover:bg-bg-elevated transition-colors duration-150"
|
||||||
|
:style="{ transitionDelay: `${index * 30}ms` }"
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3 text-[0.75rem] text-text-primary">
|
<td class="px-4 py-3 text-[0.75rem] text-text-primary">
|
||||||
{{ formatDate(entry.start_time) }}
|
{{ formatDate(entry.start_time) }}
|
||||||
@@ -138,12 +139,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</TransitionGroup>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex flex-col items-center justify-center py-16">
|
<div v-else class="flex flex-col items-center justify-center py-16">
|
||||||
<ListIcon class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" />
|
<ListIcon class="w-12 h-12 text-text-tertiary animate-float" :stroke-width="1.5" />
|
||||||
<p class="text-sm text-text-secondary mt-4">No entries found</p>
|
<p class="text-sm text-text-secondary mt-4">No entries found</p>
|
||||||
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Time entries will appear here as you track your work. Try adjusting the date range if you have existing entries.</p>
|
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Time entries will appear here as you track your work. Try adjusting the date range if you have existing entries.</p>
|
||||||
<router-link to="/timer" class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors">
|
<router-link to="/timer" class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors">
|
||||||
@@ -152,12 +153,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit Dialog -->
|
<!-- Edit Dialog -->
|
||||||
|
<Transition name="modal">
|
||||||
<div
|
<div
|
||||||
v-if="showEditDialog"
|
v-if="showEditDialog"
|
||||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||||
@click.self="tryCloseEditDialog"
|
@click.self="tryCloseEditDialog"
|
||||||
>
|
>
|
||||||
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6 animate-modal-enter max-h-[calc(100vh-2rem)] overflow-y-auto">
|
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||||
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Edit Entry</h2>
|
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Edit Entry</h2>
|
||||||
|
|
||||||
<form @submit.prevent="handleEdit" class="space-y-4">
|
<form @submit.prevent="handleEdit" class="space-y-4">
|
||||||
@@ -232,14 +234,16 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- Delete Confirmation Dialog -->
|
<!-- Delete Confirmation Dialog -->
|
||||||
|
<Transition name="modal">
|
||||||
<div
|
<div
|
||||||
v-if="showDeleteDialog"
|
v-if="showDeleteDialog"
|
||||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||||
@click.self="cancelDelete"
|
@click.self="cancelDelete"
|
||||||
>
|
>
|
||||||
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6 animate-modal-enter">
|
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
|
||||||
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Delete Entry</h2>
|
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Delete Entry</h2>
|
||||||
<p class="text-[0.75rem] text-text-secondary mb-6">
|
<p class="text-[0.75rem] text-text-secondary mb-6">
|
||||||
Are you sure you want to delete this time entry? This action cannot be undone.
|
Are you sure you want to delete this time entry? This action cannot be undone.
|
||||||
@@ -260,6 +264,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<AppDiscardDialog :show="showDiscardDialog" @cancel="cancelDiscard" @discard="confirmDiscard" />
|
<AppDiscardDialog :show="showDiscardDialog" @cancel="cancelDiscard" @discard="confirmDiscard" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,11 +12,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Projects Grid -->
|
<!-- Projects Grid -->
|
||||||
<div v-if="projectsStore.projects.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<TransitionGroup v-if="projectsStore.projects.length > 0" name="list" tag="div" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<div
|
<div
|
||||||
v-for="project in projectsStore.projects"
|
v-for="(project, index) in projectsStore.projects"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
class="group bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] hover:bg-bg-elevated transition-all duration-150 cursor-pointer"
|
class="group bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] card-hover cursor-pointer"
|
||||||
|
:style="{ transitionDelay: `${index * 30}ms` }"
|
||||||
@click="openEditDialog(project)"
|
@click="openEditDialog(project)"
|
||||||
>
|
>
|
||||||
<div class="flex border-l-[2px] hover:border-l-[3px] rounded-l-lg transition-all duration-150" :style="{ borderLeftColor: project.color }">
|
<div class="flex border-l-[2px] hover:border-l-[3px] rounded-l-lg transition-all duration-150" :style="{ borderLeftColor: project.color }">
|
||||||
@@ -54,7 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-bg-elevated rounded-full h-1">
|
<div class="w-full bg-bg-elevated rounded-full h-1">
|
||||||
<div
|
<div
|
||||||
class="h-1 rounded-full transition-all"
|
class="h-1 rounded-full progress-bar"
|
||||||
:class="getBudgetPct(project) > 90 ? 'bg-status-error' : getBudgetPct(project) > 75 ? 'bg-status-warning' : 'bg-accent'"
|
:class="getBudgetPct(project) > 90 ? 'bg-status-error' : getBudgetPct(project) > 75 ? 'bg-status-warning' : 'bg-accent'"
|
||||||
:style="{ width: Math.min(getBudgetPct(project), 100) + '%' }"
|
:style="{ width: Math.min(getBudgetPct(project), 100) + '%' }"
|
||||||
/>
|
/>
|
||||||
@@ -63,11 +64,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TransitionGroup>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-else class="flex flex-col items-center justify-center py-16">
|
<div v-else class="flex flex-col items-center justify-center py-16">
|
||||||
<FolderKanban class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" />
|
<FolderKanban class="w-12 h-12 text-text-tertiary animate-float" :stroke-width="1.5" />
|
||||||
<p class="text-sm text-text-secondary mt-4">No projects yet</p>
|
<p class="text-sm text-text-secondary mt-4">No projects yet</p>
|
||||||
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Projects organize your time entries and set billing rates for clients.</p>
|
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Projects organize your time entries and set billing rates for clients.</p>
|
||||||
<button
|
<button
|
||||||
@@ -79,12 +80,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create/Edit Dialog -->
|
<!-- Create/Edit Dialog -->
|
||||||
|
<Transition name="modal">
|
||||||
<div
|
<div
|
||||||
v-if="showDialog"
|
v-if="showDialog"
|
||||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||||
@click.self="tryCloseDialog"
|
@click.self="tryCloseDialog"
|
||||||
>
|
>
|
||||||
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6 animate-modal-enter max-h-[calc(100vh-2rem)] overflow-y-auto">
|
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||||
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
|
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
|
||||||
{{ editingProject ? 'Edit Project' : 'Create Project' }}
|
{{ editingProject ? 'Edit Project' : 'Create Project' }}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -240,14 +242,16 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- Delete Confirmation Dialog -->
|
<!-- Delete Confirmation Dialog -->
|
||||||
|
<Transition name="modal">
|
||||||
<div
|
<div
|
||||||
v-if="showDeleteDialog"
|
v-if="showDeleteDialog"
|
||||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||||
@click.self="cancelDelete"
|
@click.self="cancelDelete"
|
||||||
>
|
>
|
||||||
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6 animate-modal-enter">
|
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
|
||||||
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Delete Project</h2>
|
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Delete Project</h2>
|
||||||
<p class="text-[0.75rem] text-text-secondary mb-6">
|
<p class="text-[0.75rem] text-text-secondary mb-6">
|
||||||
Are you sure you want to delete "{{ projectToDelete?.name }}"? This action cannot be undone.
|
Are you sure you want to delete "{{ projectToDelete?.name }}"? This action cannot be undone.
|
||||||
@@ -268,6 +272,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<AppDiscardDialog :show="showDiscardDialog" @cancel="cancelDiscard" @discard="confirmDiscard" />
|
<AppDiscardDialog :show="showDiscardDialog" @cancel="cancelDiscard" @discard="confirmDiscard" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<Transition name="fade" appear>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary mb-6">Reports</h1>
|
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary mb-6">Reports</h1>
|
||||||
|
|
||||||
@@ -198,6 +199,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- Hero timer display -->
|
<!-- Hero timer display -->
|
||||||
<div class="text-center pt-4 pb-8">
|
<div class="text-center pt-4 pb-8">
|
||||||
<div class="relative inline-block">
|
<div class="relative inline-block">
|
||||||
<p class="text-[3.5rem] font-medium font-[family-name:var(--font-heading)] tracking-tighter mb-2">
|
<p class="text-[3.5rem] font-medium font-[family-name:var(--font-heading)] tracking-tighter mb-2" :class="timerPulseClass">
|
||||||
<span class="text-text-primary">{{ timerParts.hours }}</span>
|
<span class="text-text-primary">{{ timerParts.hours }}</span>
|
||||||
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : 'text-text-primary'">:</span>
|
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : 'text-text-primary'">:</span>
|
||||||
<span class="text-text-primary">{{ timerParts.minutes }}</span>
|
<span class="text-text-primary">{{ timerParts.minutes }}</span>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
@click="toggleTimer"
|
@click="toggleTimer"
|
||||||
class="px-10 py-3 text-sm font-medium rounded-lg transition-colors duration-150"
|
class="btn-primary px-10 py-3 text-sm font-medium rounded-lg transition-colors duration-150"
|
||||||
:class="buttonClass"
|
:class="buttonClass"
|
||||||
>
|
>
|
||||||
{{ buttonLabel }}
|
{{ buttonLabel }}
|
||||||
@@ -41,19 +41,20 @@
|
|||||||
|
|
||||||
<!-- Favorites strip -->
|
<!-- Favorites strip -->
|
||||||
<div v-if="favorites.length > 0" class="max-w-[36rem] mx-auto mb-4">
|
<div v-if="favorites.length > 0" class="max-w-[36rem] mx-auto mb-4">
|
||||||
<div class="flex items-center gap-2 overflow-x-auto pb-1">
|
<TransitionGroup tag="div" name="chip" class="flex items-center gap-2 overflow-x-auto pb-1">
|
||||||
<button
|
<button
|
||||||
v-for="fav in favorites"
|
v-for="(fav, favIndex) in favorites"
|
||||||
:key="fav.id"
|
:key="fav.id"
|
||||||
@click="applyFavorite(fav)"
|
@click="applyFavorite(fav)"
|
||||||
:disabled="!timerStore.isStopped"
|
:disabled="!timerStore.isStopped"
|
||||||
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border-subtle text-[0.6875rem] text-text-secondary hover:text-text-primary hover:border-border-visible transition-colors disabled:opacity-40"
|
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border-subtle text-[0.6875rem] text-text-secondary hover:text-text-primary hover:border-border-visible transition-colors disabled:opacity-40"
|
||||||
|
:style="{ transitionDelay: `${favIndex * 50}ms` }"
|
||||||
>
|
>
|
||||||
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: getProjectColor(fav.project_id) }" />
|
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: getProjectColor(fav.project_id) }" />
|
||||||
{{ getProjectName(fav.project_id) }}
|
{{ getProjectName(fav.project_id) }}
|
||||||
<span v-if="fav.description" class="text-text-tertiary">· {{ fav.description }}</span>
|
<span v-if="fav.description" class="text-text-tertiary">· {{ fav.description }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Inputs -->
|
<!-- Inputs -->
|
||||||
@@ -117,12 +118,13 @@
|
|||||||
<router-link v-if="recentEntries.length > 0" to="/entries" class="text-xs text-text-tertiary hover:text-text-secondary transition-colors">View all</router-link>
|
<router-link v-if="recentEntries.length > 0" to="/entries" class="text-xs text-text-tertiary hover:text-text-secondary transition-colors">View all</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="recentEntries.length > 0">
|
<TransitionGroup v-if="recentEntries.length > 0" name="list" tag="div">
|
||||||
<div
|
<div
|
||||||
v-for="(entry, index) in recentEntries"
|
v-for="(entry, index) in recentEntries"
|
||||||
:key="entry.id"
|
:key="entry.id"
|
||||||
class="flex items-center justify-between py-3 border-b border-border-subtle last:border-0"
|
class="flex items-center justify-between py-3 border-b border-border-subtle last:border-0"
|
||||||
:class="index === 0 ? 'border-l-2 border-l-accent pl-3' : ''"
|
:class="index === 0 ? 'border-l-2 border-l-accent pl-3' : ''"
|
||||||
|
:style="{ transitionDelay: `${index * 40}ms` }"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -149,11 +151,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TransitionGroup>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div v-else class="flex flex-col items-center py-8">
|
<div v-else class="flex flex-col items-center py-8">
|
||||||
<TimerIcon class="w-10 h-10 text-text-tertiary" :stroke-width="1.5" />
|
<TimerIcon class="w-10 h-10 text-text-tertiary animate-float" :stroke-width="1.5" />
|
||||||
<p class="text-sm text-text-secondary mt-3">No entries today</p>
|
<p class="text-sm text-text-secondary mt-3">No entries today</p>
|
||||||
<p class="text-xs text-text-tertiary mt-1">Select a project and hit Start to begin tracking</p>
|
<p class="text-xs text-text-tertiary mt-1">Select a project and hit Start to begin tracking</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,6 +211,7 @@ const selectedTask = ref<number | null>(timerStore.selectedTaskId)
|
|||||||
const description = ref(timerStore.description)
|
const description = ref(timerStore.description)
|
||||||
const selectedTags = ref<number[]>([])
|
const selectedTags = ref<number[]>([])
|
||||||
const projectTasks = ref<Task[]>([])
|
const projectTasks = ref<Task[]>([])
|
||||||
|
const timerPulseClass = ref('')
|
||||||
|
|
||||||
// Split timer into parts for colon animation
|
// Split timer into parts for colon animation
|
||||||
const timerParts = computed(() => {
|
const timerParts = computed(() => {
|
||||||
@@ -243,6 +246,21 @@ const buttonClass = computed(() => {
|
|||||||
return 'bg-status-error text-white hover:bg-status-error/80'
|
return 'bg-status-error text-white hover:bg-status-error/80'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Timer start/stop pulse animation
|
||||||
|
watch(() => timerStore.isRunning, (isRunning, wasRunning) => {
|
||||||
|
if (isRunning && !wasRunning) {
|
||||||
|
timerPulseClass.value = 'animate-timer-pulse'
|
||||||
|
setTimeout(() => { timerPulseClass.value = '' }, 300)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => timerStore.isStopped, (isStopped, wasStopped) => {
|
||||||
|
if (isStopped && !wasStopped) {
|
||||||
|
timerPulseClass.value = 'animate-timer-glow'
|
||||||
|
setTimeout(() => { timerPulseClass.value = '' }, 600)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Watch project selection and fetch tasks
|
// Watch project selection and fetch tasks
|
||||||
watch(selectedProject, async (newProjectId) => {
|
watch(selectedProject, async (newProjectId) => {
|
||||||
timerStore.setProject(newProjectId)
|
timerStore.setProject(newProjectId)
|
||||||
|
|||||||
@@ -35,7 +35,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Timesheet Table -->
|
<!-- Timesheet Table -->
|
||||||
<div class="bg-bg-surface rounded-lg overflow-hidden">
|
<Transition name="fade" mode="out-in">
|
||||||
|
<div :key="weekStart" class="bg-bg-surface rounded-lg overflow-hidden">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-border-subtle">
|
<tr class="border-b border-border-subtle">
|
||||||
@@ -156,6 +157,7 @@
|
|||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- Add Row Button -->
|
<!-- Add Row Button -->
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user