feat: tooltips, two-column timer, font selector, tray behavior, icons, readme

- Custom tooltip directive (WCAG AAA) on every button in the app
- Two-column timer layout with sticky hero and recent entries sidebar
- Timer font selector with 16 monospace Google Fonts and live preview
- UI font selector with 15+ Google Fonts
- Close-to-tray and minimize-to-tray settings
- New app icons (no-glow variants), platform icon set
- Mini timer pop-out window
- Favorites strip with drag-reorder and inline actions
- Comprehensive README with feature documentation
- Remove tracked files that belong in gitignore
This commit is contained in:
Your Name
2026-02-21 01:15:57 +02:00
parent 2608f447de
commit 514090eed4
144 changed files with 13351 additions and 3456 deletions

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { ref, watch, nextTick, onUnmounted } from 'vue'
import { AlertTriangle } from 'lucide-vue-next'
import { useFocusTrap } from '../utils/focusTrap'
const props = defineProps<{
show: boolean
entityType: string
entityName: string
impacts: { label: string; count: number }[]
}>()
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const dialogRef = ref<HTMLElement | null>(null)
const deleteReady = ref(false)
const countdown = ref(3)
const liveAnnouncement = ref('')
let countdownTimer: number | null = null
const { activate, deactivate } = useFocusTrap()
watch(() => props.show, async (val) => {
if (val) {
deleteReady.value = false
countdown.value = 3
liveAnnouncement.value = `Delete ${props.entityName}? This will also remove related data. Delete button available in 3 seconds.`
await nextTick()
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('cancel') })
countdownTimer = window.setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
deleteReady.value = true
if (countdownTimer) clearInterval(countdownTimer)
}
}, 1000)
} else {
deactivate()
if (countdownTimer) clearInterval(countdownTimer)
}
})
onUnmounted(() => {
if (countdownTimer) clearInterval(countdownTimer)
})
</script>
<template>
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-[60]"
@click.self="emit('cancel')"
>
<div
ref="dialogRef"
role="alertdialog"
aria-modal="true"
aria-labelledby="cascade-delete-title"
aria-describedby="cascade-delete-desc"
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"
>
<div class="flex items-start gap-3 mb-4">
<AlertTriangle class="w-5 h-5 text-status-error shrink-0 mt-0.5" :stroke-width="2" aria-hidden="true" />
<div>
<h2 id="cascade-delete-title" class="text-[0.875rem] font-semibold text-text-primary">
Delete {{ entityName }}?
</h2>
<p id="cascade-delete-desc" class="text-[0.75rem] text-text-secondary mt-1">
This will permanently delete the {{ entityType }} and all related data:
</p>
</div>
</div>
<ul class="space-y-1.5 mb-4 pl-8" role="list" aria-label="Data that will be deleted">
<li
v-for="impact in impacts.filter(i => i.count > 0)"
:key="impact.label"
class="text-[0.75rem] text-text-secondary"
>
{{ impact.count }} {{ impact.label }}
</li>
</ul>
<div class="flex items-center justify-end gap-2">
<button
@click="emit('cancel')"
class="px-3 py-1.5 text-[0.75rem] text-text-secondary hover:text-text-primary transition-colors duration-150 rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Cancel
</button>
<button
@click="deleteReady && emit('confirm')"
:disabled="!deleteReady"
:aria-disabled="!deleteReady"
:aria-label="'Permanently delete ' + entityName + ' and all related data'"
class="px-3 py-1.5 text-[0.75rem] font-medium rounded-md transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:class="deleteReady
? 'bg-status-error text-white hover:bg-red-600'
: 'bg-bg-elevated text-text-tertiary cursor-not-allowed'"
>
{{ deleteReady ? 'Delete Everything' : `Wait ${countdown}s...` }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<div class="sr-only" aria-live="assertive" aria-atomic="true">{{ liveAnnouncement }}</div>
</template>

View File

@@ -2,6 +2,7 @@
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { Pipette } from 'lucide-vue-next'
import { computeDropdownPosition } from '../utils/dropdown'
import { useFocusTrap } from '../utils/focusTrap'
interface Props {
modelValue: string
@@ -16,6 +17,8 @@ const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
const isOpen = ref(false)
const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
@@ -245,7 +248,7 @@ function onHuePointerUp() {
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 260, estimatedHeight: 330 })
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 260, estimatedHeight: 330, panelEl: panelRef.value })
}
// ── Open / Close ────────────────────────────────────────────────────
@@ -261,15 +264,18 @@ function open() {
updatePosition()
nextTick(() => {
updatePosition()
drawGradient()
drawHueStrip()
document.addEventListener('click', onClickOutside, true)
document.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
if (panelRef.value) activateTrap(panelRef.value)
})
}
function close() {
deactivateTrap()
isOpen.value = false
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
@@ -296,6 +302,34 @@ onBeforeUnmount(() => {
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
})
// ── Keyboard Handlers for Accessibility ─────────────────────────────
function onGradientKeydown(e: KeyboardEvent) {
const step = 5
let handled = false
if (e.key === 'ArrowRight') { saturation.value = Math.min(100, saturation.value + step); handled = true }
else if (e.key === 'ArrowLeft') { saturation.value = Math.max(0, saturation.value - step); handled = true }
else if (e.key === 'ArrowUp') { brightness.value = Math.min(100, brightness.value + step); handled = true }
else if (e.key === 'ArrowDown') { brightness.value = Math.max(0, brightness.value - step); handled = true }
if (handled) {
e.preventDefault()
emitColor()
}
}
function onHueKeydown(e: KeyboardEvent) {
const step = 5
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault()
hue.value = Math.min(360, hue.value + step)
emitColor()
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
e.preventDefault()
hue.value = Math.max(0, hue.value - step)
emitColor()
}
}
</script>
<template>
@@ -305,6 +339,9 @@ onBeforeUnmount(() => {
ref="triggerRef"
type="button"
@click="toggle"
aria-label="Color picker"
:aria-expanded="isOpen"
aria-haspopup="dialog"
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
@@ -317,20 +354,26 @@ onBeforeUnmount(() => {
"
>
<span
role="img"
:aria-label="'Current color: ' + (modelValue?.toUpperCase() || '#000000')"
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" />
<Pipette class="w-4 h-4 text-text-secondary shrink-0 ml-auto" :stroke-width="2" aria-hidden="true" />
</button>
<!-- Color picker popover -->
<Teleport to="body">
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
role="dialog"
aria-modal="true"
aria-label="Choose color"
@keydown.escape.prevent="close"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<!-- Preset swatches -->
@@ -341,7 +384,9 @@ onBeforeUnmount(() => {
:key="c"
type="button"
@click="selectPreset(c)"
class="w-6 h-6 rounded-full border-2 transition-colors cursor-pointer hover:scale-110"
:aria-label="'Color preset ' + c"
:aria-pressed="currentHex === c.toUpperCase()"
class="w-8 h-8 rounded-full border-2 transition-colors cursor-pointer hover:scale-110"
:class="currentHex === c.toUpperCase() ? 'border-text-primary' : 'border-transparent'"
:style="{ backgroundColor: c }"
/>
@@ -351,11 +396,15 @@ onBeforeUnmount(() => {
<!-- Saturation/Brightness gradient -->
<div class="px-3 pb-2">
<div
role="application"
aria-label="Saturation and brightness"
tabindex="0"
class="relative rounded-lg overflow-hidden cursor-crosshair"
style="touch-action: none;"
@pointerdown="onGradientPointerDown"
@pointermove="onGradientPointerMove"
@pointerup="onGradientPointerUp"
@keydown="onGradientKeydown"
>
<canvas
ref="gradientRef"
@@ -378,11 +427,18 @@ onBeforeUnmount(() => {
<!-- Hue slider -->
<div class="px-3 pb-2">
<div
role="slider"
aria-label="Hue"
:aria-valuenow="Math.round(hue)"
aria-valuemin="0"
aria-valuemax="360"
tabindex="0"
class="relative rounded-md overflow-hidden cursor-pointer"
style="touch-action: none;"
@pointerdown="onHuePointerDown"
@pointermove="onHuePointerMove"
@pointerup="onHuePointerUp"
@keydown="onHueKeydown"
>
<canvas
ref="hueRef"
@@ -404,6 +460,8 @@ onBeforeUnmount(() => {
<!-- Hex input + preview -->
<div class="px-3 pb-3 flex items-center gap-2">
<span
role="img"
:aria-label="'Selected color: ' + currentHex"
class="w-8 h-8 rounded-lg border border-border-subtle shrink-0"
:style="{ backgroundColor: currentHex }"
/>
@@ -412,6 +470,7 @@ onBeforeUnmount(() => {
@input="onHexInput"
type="text"
maxlength="7"
aria-label="Hex color value"
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"
/>

View File

@@ -2,6 +2,8 @@
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { getLocaleCode } from '../utils/locale'
import { getFixedPositionMapping } from '../utils/dropdown'
import { useFocusTrap } from '../utils/focusTrap'
interface Props {
modelValue: string
@@ -24,6 +26,8 @@ const emit = defineEmits<{
'update:minute': [value: number]
}>()
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
const isOpen = ref(false)
const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
@@ -51,6 +55,9 @@ const displayText = computed(() => {
return datePart
})
// ── Reduced motion check ────────────────────────────────────────────
const scrollBehavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' as const : 'smooth' as const
// ── Time wheel ──────────────────────────────────────────────────────
const WHEEL_ITEM_H = 36
const WHEEL_VISIBLE = 5
@@ -102,7 +109,7 @@ function onHourWheel(e: WheelEvent) {
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' })
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
function onMinuteWheel(e: WheelEvent) {
@@ -111,7 +118,29 @@ function onMinuteWheel(e: WheelEvent) {
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' })
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
// Keyboard support for time wheels
function onWheelKeydown(e: KeyboardEvent, type: 'hour' | 'minute') {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
e.preventDefault()
const dir = e.key === 'ArrowUp' ? -1 : 1
if (type === 'hour') {
const next = Math.min(23, Math.max(0, internalHour.value + dir))
internalHour.value = next
emit('update:hour', next)
if (hourWheelRef.value) {
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
} else {
const next = Math.min(59, Math.max(0, internalMinute.value + dir))
internalMinute.value = next
emit('update:minute', next)
if (minuteWheelRef.value) {
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
}
}
// Click-and-drag support
@@ -141,7 +170,7 @@ function onWheelPointerUp(e: PointerEvent) {
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' })
el.scrollTo({ top: index * WHEEL_ITEM_H, behavior: scrollBehavior })
}
function scrollWheelsToTime() {
@@ -234,33 +263,28 @@ const dayCells = computed<DayCell[]>(() => {
const dayHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
// ── Positioning ─────────────────────────────────────────────────────
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()
const panelWidth = props.showTime ? 390 : 280
const renderedWidth = panelWidth * zoom
const { scaleX, scaleY, offsetX, offsetY } = getFixedPositionMapping()
const gap = 4
let leftViewport = rect.left
if (leftViewport + renderedWidth > window.innerWidth) {
leftViewport = window.innerWidth - renderedWidth - 8
const panelWidth = props.showTime ? 390 : 280
const estW = panelWidth * scaleX
const vpW = window.innerWidth
let leftVP = rect.left
if (leftVP + estW > vpW - gap) {
leftVP = vpW - estW - gap
}
if (leftViewport < 0) leftViewport = 0
if (leftVP < gap) leftVP = gap
panelStyle.value = {
position: 'fixed',
top: `${(rect.bottom + 4) / zoom}px`,
left: `${leftViewport / zoom}px`,
top: `${(rect.bottom + gap - offsetY) / scaleY}px`,
left: `${(leftVP - offsetX) / scaleX}px`,
width: `${panelWidth}px`,
zIndex: '9999',
zoom: `${zoom * 100}%`,
}
}
@@ -295,10 +319,12 @@ function open() {
if (props.showTime) {
scrollWheelsToTime()
}
if (panelRef.value) activateTrap(panelRef.value)
})
}
function close() {
deactivateTrap()
isOpen.value = false
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
@@ -385,6 +411,8 @@ onBeforeUnmount(() => {
ref="triggerRef"
type="button"
@click="toggle"
:aria-expanded="isOpen"
aria-haspopup="dialog"
class="w-full flex items-center justify-between gap-2 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left cursor-pointer transition-colors"
:style="
isOpen
@@ -403,18 +431,23 @@ onBeforeUnmount(() => {
{{ displayText ?? placeholder }}
</span>
<Calendar
aria-hidden="true"
class="w-4 h-4 text-text-secondary shrink-0"
:stroke-width="2"
/>
</button>
<!-- Calendar popover -->
<Teleport to="body">
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
role="dialog"
aria-modal="true"
aria-label="Date picker"
@keydown.escape.prevent="close"
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 -->
@@ -422,9 +455,11 @@ onBeforeUnmount(() => {
<button
type="button"
@click="prevMonthNav"
aria-label="Previous month"
v-tooltip="'Previous month'"
class="p-1 rounded-lg hover:bg-bg-elevated transition-colors cursor-pointer text-text-secondary hover:text-text-primary"
>
<ChevronLeft class="w-4 h-4" :stroke-width="2" />
<ChevronLeft aria-hidden="true" class="w-4 h-4" :stroke-width="2" />
</button>
<span class="text-[0.8125rem] font-medium text-text-primary select-none">
{{ viewMonthLabel }}
@@ -432,9 +467,11 @@ onBeforeUnmount(() => {
<button
type="button"
@click="nextMonthNav"
aria-label="Next month"
v-tooltip="'Next month'"
class="p-1 rounded-lg hover:bg-bg-elevated transition-colors cursor-pointer text-text-secondary hover:text-text-primary"
>
<ChevronRight class="w-4 h-4" :stroke-width="2" />
<ChevronRight aria-hidden="true" class="w-4 h-4" :stroke-width="2" />
</button>
</div>
@@ -443,10 +480,11 @@ onBeforeUnmount(() => {
<!-- Calendar column -->
<div class="flex-1 min-w-0">
<!-- Day-of-week headers -->
<div class="grid grid-cols-7 px-2">
<div class="grid grid-cols-7 px-2" role="row">
<div
v-for="header in dayHeaders"
:key="header"
role="columnheader"
class="text-center text-[0.6875rem] font-medium text-text-tertiary py-1 select-none"
>
{{ header }}
@@ -454,13 +492,15 @@ onBeforeUnmount(() => {
</div>
<!-- Day grid -->
<div class="grid grid-cols-7 px-2 pb-2">
<div class="grid grid-cols-7 px-2 pb-2" role="grid" aria-label="Calendar days">
<button
v-for="(cell, index) in dayCells"
:key="index"
type="button"
:disabled="!cell.isCurrentMonth"
@click="selectDay(cell)"
:aria-label="new Date(cell.year, cell.month, cell.date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })"
:aria-selected="cell.isCurrentMonth ? cell.dateString === modelValue : undefined"
class="relative flex items-center justify-center h-8 w-full text-[0.75rem] rounded-lg transition-colors select-none"
:class="[
!cell.isCurrentMonth
@@ -493,10 +533,17 @@ onBeforeUnmount(() => {
<!-- Scrollable wheel -->
<div
ref="hourWheelRef"
tabindex="0"
role="spinbutton"
:aria-valuenow="internalHour"
aria-valuemin="0"
aria-valuemax="23"
aria-label="Hour"
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"
@keydown="onWheelKeydown($event, 'hour')"
@pointerdown.prevent="onWheelPointerDown"
@pointermove="onWheelPointerMove"
@pointerup="onWheelPointerUp"
@@ -530,10 +577,17 @@ onBeforeUnmount(() => {
<!-- Scrollable wheel -->
<div
ref="minuteWheelRef"
tabindex="0"
role="spinbutton"
:aria-valuenow="internalMinute"
aria-valuemin="0"
aria-valuemax="59"
aria-label="Minute"
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"
@keydown="onWheelKeydown($event, 'minute')"
@pointerdown.prevent="onWheelPointerDown"
@pointermove="onWheelPointerMove"
@pointerup="onWheelPointerUp"

View File

@@ -0,0 +1,169 @@
<template>
<div
role="group"
aria-label="Date range presets"
class="flex flex-wrap gap-1.5"
@keydown="onKeydown"
>
<button
v-for="(preset, index) in presets"
:key="preset.label"
type="button"
:aria-pressed="isActive(preset)"
:tabindex="index === focusedIndex ? 0 : -1"
:ref="(el) => { if (el) buttonRefs[index] = el as HTMLButtonElement }"
class="px-3 py-1 text-[0.6875rem] font-medium rounded-full border transition-colors duration-150"
:class="isActive(preset)
? 'bg-accent text-bg-base border-accent'
: 'border-border-subtle text-text-secondary hover:text-text-primary hover:border-border-visible'"
@click="selectPreset(preset)"
>
{{ preset.label }}
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
startDate?: string
endDate?: string
}>()
const emit = defineEmits<{
select: [payload: { start: string; end: string }]
}>()
const focusedIndex = ref(0)
const buttonRefs = ref<HTMLButtonElement[]>([])
interface Preset {
label: string
getRange: () => { start: string; end: string }
}
function fmt(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${dd}`
}
function getMonday(d: Date): Date {
const result = new Date(d)
const day = result.getDay()
const diff = day === 0 ? -6 : 1 - day
result.setDate(result.getDate() + diff)
return result
}
const presets: Preset[] = [
{
label: 'Today',
getRange: () => {
const today = fmt(new Date())
return { start: today, end: today }
},
},
{
label: 'This Week',
getRange: () => {
const now = new Date()
const monday = getMonday(now)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
return { start: fmt(monday), end: fmt(sunday) }
},
},
{
label: 'Last Week',
getRange: () => {
const now = new Date()
const thisMonday = getMonday(now)
const lastMonday = new Date(thisMonday)
lastMonday.setDate(thisMonday.getDate() - 7)
const lastSunday = new Date(lastMonday)
lastSunday.setDate(lastMonday.getDate() + 6)
return { start: fmt(lastMonday), end: fmt(lastSunday) }
},
},
{
label: 'This Month',
getRange: () => {
const now = new Date()
const first = new Date(now.getFullYear(), now.getMonth(), 1)
const last = new Date(now.getFullYear(), now.getMonth() + 1, 0)
return { start: fmt(first), end: fmt(last) }
},
},
{
label: 'Last Month',
getRange: () => {
const now = new Date()
const first = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const last = new Date(now.getFullYear(), now.getMonth(), 0)
return { start: fmt(first), end: fmt(last) }
},
},
{
label: 'This Quarter',
getRange: () => {
const now = new Date()
const qMonth = Math.floor(now.getMonth() / 3) * 3
const first = new Date(now.getFullYear(), qMonth, 1)
const last = new Date(now.getFullYear(), qMonth + 3, 0)
return { start: fmt(first), end: fmt(last) }
},
},
{
label: 'Last 30 Days',
getRange: () => {
const now = new Date()
const start = new Date(now)
start.setDate(now.getDate() - 29)
return { start: fmt(start), end: fmt(now) }
},
},
{
label: 'This Year',
getRange: () => {
const now = new Date()
const first = new Date(now.getFullYear(), 0, 1)
return { start: fmt(first), end: fmt(now) }
},
},
]
function isActive(preset: Preset): boolean {
if (!props.startDate || !props.endDate) return false
const range = preset.getRange()
return range.start === props.startDate && range.end === props.endDate
}
function selectPreset(preset: Preset) {
const range = preset.getRange()
emit('select', range)
}
function onKeydown(e: KeyboardEvent) {
let next = focusedIndex.value
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault()
next = (focusedIndex.value + 1) % presets.length
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault()
next = (focusedIndex.value - 1 + presets.length) % presets.length
} else if (e.key === 'Home') {
e.preventDefault()
next = 0
} else if (e.key === 'End') {
e.preventDefault()
next = presets.length - 1
} else {
return
}
focusedIndex.value = next
buttonRefs.value[next]?.focus()
}
</script>

View File

@@ -1,33 +1,49 @@
<script setup lang="ts">
defineProps<{ show: boolean }>()
defineEmits<{
import { watch, ref } from 'vue'
import { useFocusTrap } from '../utils/focusTrap'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{
cancel: []
discard: []
}>()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
watch(() => props.show, (val) => {
if (val) {
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('cancel') })
}, 50)
} else {
deactivate()
}
})
</script>
<template>
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-[60]"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="$emit('cancel')"
>
<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-xs p-6">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Unsaved Changes</h2>
<p class="text-[0.75rem] text-text-secondary mb-6">
<div ref="dialogRef" role="alertdialog" aria-modal="true" aria-labelledby="discard-title" aria-describedby="discard-desc" class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-xs p-6">
<h2 id="discard-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Unsaved Changes</h2>
<p id="discard-desc" class="text-[0.75rem] text-text-secondary mb-6">
You have unsaved changes. Do you want to discard them?
</p>
<div class="flex justify-end gap-3">
<button
@click="$emit('cancel')"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
class="px-4 py-2 text-[0.8125rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Keep Editing
</button>
<button
@click="$emit('discard')"
class="px-4 py-2 border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
class="px-4 py-2 text-[0.8125rem] border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Discard
</button>

View File

@@ -10,6 +10,8 @@ interface Props {
precision?: number
prefix?: string
suffix?: string
label?: string
compact?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -19,6 +21,8 @@ const props = withDefaults(defineProps<Props>(), {
precision: 0,
prefix: '',
suffix: '',
label: 'Number input',
compact: false,
})
const emit = defineEmits<{
@@ -84,24 +88,39 @@ function cancelEdit() {
</script>
<template>
<div class="flex items-center gap-2">
<div
class="flex items-center"
:class="compact ? 'gap-1' : 'gap-2'"
role="group"
:aria-label="label"
>
<button
type="button"
aria-label="Decrease value"
v-tooltip="'Decrease'"
@mousedown.prevent="startHold(decrement)"
@mouseup="stopHold"
@mouseleave="stopHold"
@touchstart.prevent="startHold(decrement)"
@touchend="stopHold"
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
class="flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
:class="compact ? 'w-6 h-6' : 'w-8 h-8'"
:disabled="modelValue <= min"
>
<Minus class="w-3.5 h-3.5" :stroke-width="2" />
<Minus :class="compact ? 'w-3 h-3' : 'w-3.5 h-3.5'" aria-hidden="true" :stroke-width="2" />
</button>
<div
v-if="!isEditing"
@click="startEdit"
class="min-w-[4rem] text-center text-[0.8125rem] font-mono text-text-primary cursor-text select-none"
@keydown.enter="startEdit"
@keydown.space.prevent="startEdit"
tabindex="0"
role="button"
:aria-label="'Edit value: ' + displayValue"
class="text-center font-mono text-text-primary cursor-text select-none"
:class="compact ? 'min-w-[2.5rem] text-[0.75rem]' : 'min-w-[4rem] text-[0.8125rem]'"
aria-live="polite"
>
<span v-if="prefix" class="text-text-tertiary">{{ prefix }}</span>
{{ displayValue }}
@@ -113,7 +132,9 @@ function cancelEdit() {
v-model="editValue"
type="text"
inputmode="decimal"
class="w-20 text-center px-1 py-0.5 bg-bg-inset border border-accent rounded-lg text-[0.8125rem] font-mono text-text-primary focus:outline-none"
:aria-label="label"
class="text-center px-1 py-0.5 bg-bg-inset border border-accent rounded-lg font-mono text-text-primary focus:outline-none"
:class="compact ? 'w-16 text-[0.75rem]' : 'w-20 text-[0.8125rem]'"
@blur="commitEdit"
@keydown.enter="commitEdit"
@keydown.escape="cancelEdit"
@@ -121,15 +142,18 @@ function cancelEdit() {
<button
type="button"
aria-label="Increase value"
v-tooltip="'Increase'"
@mousedown.prevent="startHold(increment)"
@mouseup="stopHold"
@mouseleave="stopHold"
@touchstart.prevent="startHold(increment)"
@touchend="stopHold"
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
class="flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
:class="compact ? 'w-6 h-6' : 'w-8 h-8'"
:disabled="modelValue >= max"
>
<Plus class="w-3.5 h-3.5" :stroke-width="2" />
<Plus :class="compact ? 'w-3 h-3' : 'w-3.5 h-3.5'" aria-hidden="true" :stroke-width="2" />
</button>
</div>
</template>

View File

@@ -12,6 +12,7 @@ interface Props {
disabled?: boolean
placeholderValue?: any
searchable?: boolean
ariaLabelledby?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -27,6 +28,7 @@ const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const listboxId = 'appselect-lb-' + Math.random().toString(36).slice(2, 9)
const isOpen = ref(false)
const highlightedIndex = ref(-1)
const triggerRef = ref<HTMLButtonElement | null>(null)
@@ -80,7 +82,10 @@ function isSelected(item: any): boolean {
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, { estimatedHeight: 280 })
panelStyle.value = computeDropdownPosition(triggerRef.value, {
estimatedHeight: 280,
panelEl: panelRef.value,
})
}
function toggle() {
@@ -108,6 +113,8 @@ function open() {
nextTick(() => {
scrollHighlightedIntoView()
// Reposition with actual panel height (fixes above-flip offset)
updatePosition()
})
document.addEventListener('click', onClickOutside, true)
@@ -220,6 +227,12 @@ onBeforeUnmount(() => {
<button
ref="triggerRef"
type="button"
role="combobox"
:aria-expanded="isOpen"
aria-haspopup="listbox"
:aria-activedescendant="isOpen && highlightedIndex >= 0 ? 'appselect-option-' + highlightedIndex : undefined"
:aria-labelledby="ariaLabelledby"
:aria-controls="isOpen ? listboxId : undefined"
:disabled="disabled"
@click="toggle"
@keydown="onKeydown"
@@ -238,13 +251,16 @@ onBeforeUnmount(() => {
: {}
"
>
<span
:class="isPlaceholderSelected ? 'text-text-tertiary' : 'text-text-primary'"
class="truncate"
>
{{ selectedLabel ?? placeholder }}
</span>
<slot name="selected" :label="selectedLabel ?? placeholder" :is-placeholder="isPlaceholderSelected">
<span
:class="isPlaceholderSelected ? 'text-text-tertiary' : 'text-text-primary'"
class="truncate"
>
{{ selectedLabel ?? placeholder }}
</span>
</slot>
<ChevronDown
aria-hidden="true"
class="w-4 h-4 text-text-secondary shrink-0 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"
:stroke-width="2"
@@ -252,7 +268,7 @@ onBeforeUnmount(() => {
</button>
<!-- Dropdown panel -->
<Teleport to="body">
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
@@ -265,15 +281,19 @@ onBeforeUnmount(() => {
ref="searchInputRef"
v-model="searchQuery"
type="text"
aria-label="Search options"
class="w-full 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..."
@keydown="onSearchKeydown"
/>
</div>
<div class="max-h-[240px] overflow-y-auto py-1">
<div class="max-h-[240px] overflow-y-auto py-1" role="listbox" :id="listboxId">
<div
v-for="(item, index) in filteredItems"
:key="item._isPlaceholder ? '__placeholder__' : item[valueKey]"
role="option"
:id="'appselect-option-' + index"
:aria-selected="isSelected(item)"
data-option
@click="select(item)"
@mouseenter="highlightedIndex = index"
@@ -284,9 +304,12 @@ onBeforeUnmount(() => {
'text-text-primary': !item._isPlaceholder,
}"
>
<span class="truncate">{{ getOptionLabel(item) }}</span>
<slot name="option" :item="item" :label="getOptionLabel(item)" :selected="isSelected(item)">
<span class="truncate">{{ getOptionLabel(item) }}</span>
</slot>
<Check
v-if="isSelected(item)"
aria-hidden="true"
class="w-4 h-4 text-accent shrink-0"
:stroke-width="2"
/>

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { X } from 'lucide-vue-next'
interface Props {
modelValue: string
label?: string
}
const props = withDefaults(defineProps<Props>(), {
label: '',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const recording = ref(false)
const announcement = ref('')
const recorderRef = ref<HTMLDivElement | null>(null)
const isMac = navigator.platform.toUpperCase().includes('MAC')
const keyChips = computed(() => {
if (!props.modelValue) return []
return props.modelValue.split('+').map(k =>
k === 'CmdOrCtrl' ? (isMac ? 'Cmd' : 'Ctrl') : k
)
})
function startRecording() {
recording.value = true
announcement.value = 'Recording. Press your key combination.'
nextTick(() => {
recorderRef.value?.focus()
})
}
function cancelRecording() {
recording.value = false
announcement.value = ''
}
function onKeydown(e: KeyboardEvent) {
if (!recording.value) return
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') {
cancelRecording()
return
}
// Ignore standalone modifier keys
const modifierKeys = ['Control', 'Shift', 'Alt', 'Meta']
if (modifierKeys.includes(e.key)) return
// Must have at least one modifier
const hasModifier = e.ctrlKey || e.metaKey || e.shiftKey || e.altKey
if (!hasModifier) return
// Build the shortcut string
const parts: string[] = []
if (e.ctrlKey || e.metaKey) {
parts.push('CmdOrCtrl')
}
if (e.shiftKey) {
parts.push('Shift')
}
if (e.altKey) {
parts.push('Alt')
}
// Normalize the key name
let key = e.key
if (key === ' ') {
key = 'Space'
} else if (key.length === 1) {
key = key.toUpperCase()
}
parts.push(key)
const combo = parts.join('+')
recording.value = false
emit('update:modelValue', combo)
announcement.value = `Shortcut set to ${combo}`
}
function clearShortcut() {
emit('update:modelValue', '')
announcement.value = 'Shortcut cleared'
}
</script>
<template>
<div role="group" :aria-label="label || 'Keyboard shortcut'" class="inline-flex items-center gap-2">
<!-- Key chips display -->
<div v-if="!recording && modelValue" class="flex items-center gap-1" aria-hidden="true">
<template v-for="(chip, index) in keyChips" :key="index">
<span
class="px-1.5 py-0.5 bg-bg-elevated border border-border-subtle rounded text-text-secondary font-mono text-[0.6875rem]"
>{{ chip }}</span>
<span v-if="index < keyChips.length - 1" class="text-text-tertiary text-[0.6875rem]">+</span>
</template>
</div>
<!-- Screen reader text -->
<span class="sr-only">Current shortcut: {{ modelValue || 'None' }}</span>
<!-- Recording capture area (focused div) -->
<div
v-if="recording"
ref="recorderRef"
tabindex="0"
role="application"
aria-label="Press your key combination"
@keydown="onKeydown"
@blur="cancelRecording"
class="flex items-center gap-2 px-3 py-1 border border-accent rounded-lg bg-bg-inset focus:outline-none focus:ring-1 focus:ring-accent"
>
<span class="text-[0.75rem] text-accent motion-safe:animate-pulse">Press keys...</span>
<button
type="button"
aria-label="Cancel recording"
@mousedown.prevent="cancelRecording"
class="text-[0.6875rem] text-text-tertiary hover:text-text-primary transition-colors"
>
Cancel
</button>
</div>
<!-- Record button -->
<button
v-if="!recording"
type="button"
aria-label="Record shortcut"
@click="startRecording"
class="px-2.5 py-1 text-[0.6875rem] font-medium border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated hover:text-text-primary transition-colors"
>
Record
</button>
<!-- Clear button -->
<button
v-if="!recording && modelValue"
type="button"
aria-label="Clear shortcut"
v-tooltip="'Clear shortcut'"
@click="clearShortcut"
class="w-5 h-5 flex items-center justify-center text-text-tertiary hover:text-text-primary transition-colors"
>
<X class="w-3.5 h-3.5" :stroke-width="2" aria-hidden="true" />
</button>
<!-- aria-live region for announcements -->
<div class="sr-only" aria-live="assertive" aria-atomic="true">{{ announcement }}</div>
</div>
</template>

View File

@@ -21,6 +21,7 @@ const triggerRef = ref<HTMLDivElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
const panelStyle = ref<Record<string, string>>({})
const inputRef = ref<HTMLInputElement | null>(null)
const highlightedIndex = ref(-1)
const selectedTags = computed(() => {
return tagsStore.tags.filter(t => t.id && props.modelValue.includes(t.id))
@@ -69,14 +70,17 @@ async function createAndAdd() {
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 200, estimatedHeight: 200 })
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 200, estimatedHeight: 200, panelEl: panelRef.value })
}
function open() {
isOpen.value = true
searchQuery.value = ''
updatePosition()
nextTick(() => inputRef.value?.focus())
nextTick(() => {
updatePosition()
inputRef.value?.focus()
})
document.addEventListener('click', onClickOutside, true)
document.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
@@ -99,6 +103,30 @@ function onScrollOrResize() {
if (isOpen.value) updatePosition()
}
function onSearchKeydown(e: KeyboardEvent) {
const total = filteredTags.value.length + (showCreateOption.value ? 1 : 0)
if (e.key === 'ArrowDown') {
e.preventDefault()
highlightedIndex.value = Math.min(highlightedIndex.value + 1, total - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1)
} else if (e.key === 'Enter' && highlightedIndex.value >= 0) {
e.preventDefault()
if (highlightedIndex.value < filteredTags.value.length) {
const tag = filteredTags.value[highlightedIndex.value]
toggleTag(tag.id!)
searchQuery.value = ''
} else if (showCreateOption.value) {
createAndAdd()
}
highlightedIndex.value = -1
} else if (e.key === 'Escape') {
e.preventDefault()
close()
}
}
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
@@ -116,30 +144,34 @@ onBeforeUnmount(() => {
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[0.6875rem] text-text-primary"
:style="{ backgroundColor: tag.color + '22', borderColor: tag.color + '44', border: '1px solid' }"
>
<span class="w-1.5 h-1.5 rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="w-1.5 h-1.5 rounded-full" :style="{ backgroundColor: tag.color }" aria-hidden="true" />
{{ tag.name }}
<button @click.stop="removeTag(tag.id!)" class="ml-0.5 hover:text-status-error">
<X class="w-2.5 h-2.5" />
<button @click.stop="removeTag(tag.id!)" :aria-label="'Remove tag ' + tag.name" v-tooltip="'Remove tag'" class="ml-0.5 min-w-[24px] min-h-[24px] flex items-center justify-center hover:text-status-error">
<X class="w-2.5 h-2.5" aria-hidden="true" />
</button>
</span>
<button
key="__add_btn__"
type="button"
@click="isOpen ? close() : open()"
aria-label="Add tag"
:aria-expanded="isOpen"
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"
>
<Plus class="w-3 h-3" />
<Plus class="w-3 h-3" aria-hidden="true" />
Tag
</button>
</TransitionGroup>
<!-- Dropdown -->
<Teleport to="body">
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
role="listbox"
aria-label="Tag options"
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">
@@ -147,26 +179,33 @@ onBeforeUnmount(() => {
ref="inputRef"
v-model="searchQuery"
type="text"
aria-label="Search or create tag"
@keydown="onSearchKeydown"
class="w-full 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 or create tag..."
/>
</div>
<div class="max-h-[160px] overflow-y-auto py-1">
<div
v-for="tag in filteredTags"
v-for="(tag, index) in filteredTags"
:key="tag.id"
:id="'tag-option-' + tag.id"
role="option"
@click="toggleTag(tag.id!); searchQuery = ''"
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-bg-elevated transition-colors"
:class="{ 'bg-bg-elevated': index === highlightedIndex }"
>
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: tag.color }" aria-hidden="true" />
<span class="text-[0.75rem] text-text-primary">{{ tag.name }}</span>
</div>
<div
v-if="showCreateOption"
role="option"
@click="createAndAdd"
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-bg-elevated transition-colors text-accent-text"
:class="{ 'bg-bg-elevated': filteredTags.length === highlightedIndex }"
>
<Plus class="w-3 h-3" />
<Plus class="w-3 h-3" aria-hidden="true" />
<span class="text-[0.75rem]">Create "{{ searchQuery.trim() }}"</span>
</div>
<div v-if="filteredTags.length === 0 && !showCreateOption" class="px-3 py-3 text-center text-[0.75rem] text-text-tertiary">

View File

@@ -0,0 +1,376 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { Clock } from 'lucide-vue-next'
import { getFixedPositionMapping } from '../utils/dropdown'
interface Props {
hour: number
minute: number
placeholder?: string
}
const props = withDefaults(defineProps<Props>(), {
placeholder: 'Select time',
})
const emit = defineEmits<{
'update:hour': [value: number]
'update:minute': [value: number]
}>()
const isOpen = ref(false)
const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
const panelStyle = ref<Record<string, string>>({})
// Reduced motion check
const scrollBehavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' as const : 'smooth' as const
// Time wheel constants
const WHEEL_ITEM_H = 36
const WHEEL_VISIBLE = 5
const WHEEL_HEIGHT = WHEEL_ITEM_H * WHEEL_VISIBLE
const WHEEL_PAD = WHEEL_ITEM_H * 2
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 })
const displayText = computed(() => {
const hh = String(props.hour).padStart(2, '0')
const mm = String(props.minute).padStart(2, '0')
return `${hh}:${mm}`
})
// Debounced scroll handlers
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 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: scrollBehavior })
}
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: scrollBehavior })
}
// Keyboard support
function onWheelKeydown(e: KeyboardEvent, type: 'hour' | 'minute') {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
e.preventDefault()
const dir = e.key === 'ArrowUp' ? -1 : 1
if (type === 'hour') {
const next = Math.min(23, Math.max(0, internalHour.value + dir))
internalHour.value = next
emit('update:hour', next)
if (hourWheelRef.value) {
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
} else {
const next = Math.min(59, Math.max(0, internalMinute.value + dir))
internalMinute.value = next
emit('update:minute', next)
if (minuteWheelRef.value) {
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
}
}
// 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)
const index = Math.round(el.scrollTop / WHEEL_ITEM_H)
el.scrollTo({ top: index * WHEEL_ITEM_H, behavior: scrollBehavior })
}
function scrollWheelsToTime() {
if (hourWheelRef.value) {
hourWheelRef.value.scrollTop = internalHour.value * WHEEL_ITEM_H
}
if (minuteWheelRef.value) {
minuteWheelRef.value.scrollTop = internalMinute.value * WHEEL_ITEM_H
}
}
// Positioning
function updatePosition() {
if (!triggerRef.value) return
const rect = triggerRef.value.getBoundingClientRect()
const { scaleX, scaleY, offsetX, offsetY } = getFixedPositionMapping()
const gap = 4
const panelWidth = 120
const estW = panelWidth * scaleX
const vpW = window.innerWidth
const vpH = window.innerHeight
let leftVP = rect.left
if (leftVP + estW > vpW - gap) {
leftVP = vpW - estW - gap
}
if (leftVP < gap) leftVP = gap
let topVP = rect.bottom + gap
// Use offsetHeight (unaffected by CSS transition transforms)
if (panelRef.value) {
const panelH = panelRef.value.offsetHeight * scaleY
if (topVP + panelH > vpH && rect.top - gap - panelH >= 0) {
topVP = rect.top - gap - panelH
}
}
panelStyle.value = {
position: 'fixed',
top: `${(topVP - offsetY) / scaleY}px`,
left: `${(leftVP - offsetX) / scaleX}px`,
zIndex: '9999',
}
}
// Open / Close
function toggle() {
if (isOpen.value) {
close()
} else {
open()
}
}
function open() {
isOpen.value = true
updatePosition()
nextTick(() => {
// Reposition with actual panel height (fixes above-flip offset)
updatePosition()
document.addEventListener('click', onClickOutside, true)
document.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
scrollWheelsToTime()
})
}
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()
}
}
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"
:aria-expanded="isOpen"
aria-haspopup="dialog"
class="w-full flex items-center justify-between gap-2 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="text-text-primary font-mono">
{{ displayText }}
</span>
<Clock
aria-hidden="true"
class="w-4 h-4 text-text-secondary shrink-0"
:stroke-width="2"
/>
</button>
<!-- Time picker popover -->
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
role="dialog"
aria-modal="true"
aria-label="Time picker"
@keydown.escape.prevent="close"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden p-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' }"
>
<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' }"
/>
<div
ref="hourWheelRef"
tabindex="0"
role="spinbutton"
:aria-valuenow="internalHour"
aria-valuemin="0"
aria-valuemax="23"
aria-label="Hour"
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"
@keydown="onWheelKeydown($event, 'hour')"
@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' }"
>
<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' }"
/>
<div
ref="minuteWheelRef"
tabindex="0"
role="spinbutton"
:aria-valuenow="internalMinute"
aria-valuemin="0"
aria-valuemax="59"
aria-label="Minute"
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"
@keydown="onWheelKeydown($event, 'minute')"
@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>
</Transition>
</Teleport>
</div>
</template>

View File

@@ -1,14 +1,30 @@
<script setup lang="ts">
import { watch, ref } from 'vue'
import { useFocusTrap } from '../utils/focusTrap'
interface Props {
show: boolean
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<{
continueTimer: []
stopTimer: []
}>()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
watch(() => props.show, (val) => {
if (val) {
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('stopTimer') })
}, 50)
} else {
deactivate()
}
})
</script>
<template>
@@ -17,9 +33,9 @@ const emit = defineEmits<{
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
>
<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">Tracked app not visible</h2>
<p class="text-[0.75rem] text-text-secondary mb-6">
<div ref="dialogRef" role="alertdialog" aria-modal="true" aria-labelledby="tracking-title" aria-describedby="tracking-desc" 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 id="tracking-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Tracked app not visible</h2>
<p id="tracking-desc" class="text-[0.75rem] text-text-secondary mb-6">
None of your tracked apps are currently visible on screen. The timer has been paused.
</p>
<div class="flex flex-col gap-2.5">
@@ -31,7 +47,7 @@ const emit = defineEmits<{
</button>
<button
@click="emit('stopTimer')"
class="w-full px-4 py-2.5 border border-status-error text-status-error text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
class="w-full px-4 py-2.5 border border-status-error text-status-error-text text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Stop &amp; Save
</button>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { watch, ref, computed } from 'vue'
import { useFocusTrap } from '../utils/focusTrap'
import type { TimeEntry } from '../stores/entries'
const props = defineProps<{
show: boolean
entry: TimeEntry | null
}>()
const emit = defineEmits<{
close: []
split: [payload: { splitSeconds: number; descriptionB: string }]
}>()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
const splitSeconds = ref(0)
const descriptionB = ref('')
const minSplit = 60
const maxSplit = computed(() => {
if (!props.entry) return 60
return props.entry.duration - 60
})
const durationA = computed(() => splitSeconds.value)
const durationB = computed(() => {
if (!props.entry) return 0
return props.entry.duration - splitSeconds.value
})
function formatDuration(sec: number): string {
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
watch(() => props.show, (val) => {
if (val && props.entry) {
splitSeconds.value = Math.floor(props.entry.duration / 2 / 60) * 60
descriptionB.value = props.entry.description || ''
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('close') })
}, 50)
} else {
deactivate()
}
})
function confirm() {
emit('split', { splitSeconds: splitSeconds.value, descriptionB: descriptionB.value })
}
</script>
<template>
<Transition name="modal">
<div
v-if="show && entry"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-labelledby="split-title"
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 id="split-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Split Entry</h2>
<p class="text-[0.75rem] text-text-secondary mb-4">
Total duration: <span class="font-medium text-text-primary">{{ formatDuration(entry.duration) }}</span>
</p>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Split point</label>
<input
type="range"
v-model.number="splitSeconds"
:min="minSplit"
:max="maxSplit"
:step="60"
class="w-full h-2 bg-bg-elevated rounded-lg appearance-none cursor-pointer accent-accent mb-4"
:aria-label="'Split at ' + formatDuration(splitSeconds)"
/>
<div class="grid grid-cols-2 gap-3 mb-4">
<div class="p-3 bg-bg-elevated rounded-lg">
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Entry A</p>
<p class="text-[0.9375rem] font-medium text-text-primary font-[family-name:var(--font-timer)]">{{ formatDuration(durationA) }}</p>
</div>
<div class="p-3 bg-bg-elevated rounded-lg">
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Entry B</p>
<p class="text-[0.9375rem] font-medium text-text-primary font-[family-name:var(--font-timer)]">{{ formatDuration(durationB) }}</p>
</div>
</div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description for Entry B</label>
<input
v-model="descriptionB"
type="text"
class="w-full bg-bg-base border border-border-subtle rounded-lg px-3 py-2 text-[0.8125rem] text-text-primary placeholder-text-tertiary outline-none focus:border-accent transition-colors duration-150 mb-5"
placeholder="Description..."
/>
<div class="flex justify-end gap-3">
<button
@click="$emit('close')"
class="px-4 py-2 text-[0.8125rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
@click="confirm"
class="px-4 py-2 text-[0.8125rem] bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
Split
</button>
</div>
</div>
</div>
</Transition>
</template>

View File

@@ -4,7 +4,7 @@ import { useFocusTrap } from '../utils/focusTrap'
import { useAnnouncer } from '../composables/useAnnouncer'
import { useEntryTemplatesStore, type EntryTemplate } from '../stores/entryTemplates'
import { useProjectsStore } from '../stores/projects'
import { X, FileText } from 'lucide-vue-next'
import { X, FileText, Pencil, Trash2 } from 'lucide-vue-next'
const props = defineProps<{
show: boolean
@@ -68,10 +68,46 @@ function onKeydown(e: KeyboardEvent) {
function selectTemplate(tpl: EntryTemplate) {
emit('select', tpl)
}
const editingId = ref<number | null>(null)
const editForm = ref({ name: '', project_id: 0, duration: 0 })
const confirmDeleteId = ref<number | null>(null)
function startEdit(tpl: EntryTemplate) {
editingId.value = tpl.id!
editForm.value = { name: tpl.name, project_id: tpl.project_id, duration: tpl.duration || 0 }
confirmDeleteId.value = null
}
function cancelEdit() {
editingId.value = null
}
async function saveEdit(tpl: EntryTemplate) {
await templatesStore.updateTemplate({
...tpl,
name: editForm.value.name,
project_id: editForm.value.project_id,
duration: editForm.value.duration,
})
editingId.value = null
announce('Template updated')
}
function confirmDelete(id: number) {
confirmDeleteId.value = id
editingId.value = null
}
async function executeDelete(id: number) {
await templatesStore.deleteTemplate(id)
confirmDeleteId.value = null
announce('Template deleted')
}
</script>
<template>
<Teleport to="body">
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
@@ -94,29 +130,102 @@ function selectTemplate(tpl: EntryTemplate) {
@click="$emit('cancel')"
class="p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Close"
v-tooltip="'Close'"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div v-if="templatesStore.templates.length > 0" class="space-y-1 max-h-64 overflow-y-auto" role="listbox" aria-label="Entry templates">
<button
<div
v-for="(tpl, i) in templatesStore.templates"
:key="tpl.id"
@click="selectTemplate(tpl)"
role="option"
:aria-selected="i === activeIndex"
:class="[
'w-full text-left px-3 py-2 rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
i === activeIndex ? 'bg-accent-muted' : 'hover:bg-bg-elevated'
]"
>
<p class="text-[0.8125rem] text-text-primary">{{ tpl.name }}</p>
<p class="text-[0.6875rem] text-text-tertiary">
{{ getProjectName(tpl.project_id) }}
<span v-if="tpl.duration"> - {{ formatDuration(tpl.duration) }}</span>
</p>
</button>
<!-- Delete confirmation -->
<div v-if="confirmDeleteId === tpl.id" class="px-3 py-2 rounded-lg bg-status-error/10 border border-status-error/20">
<p class="text-[0.8125rem] text-text-primary mb-2">Delete this template?</p>
<div class="flex gap-2">
<button
@click="executeDelete(tpl.id!)"
class="px-3 py-1.5 text-[0.75rem] font-medium bg-status-error text-white rounded-lg hover:bg-status-error/90 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Delete
</button>
<button
@click="confirmDeleteId = null"
class="px-3 py-1.5 text-[0.75rem] font-medium text-text-secondary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Cancel
</button>
</div>
</div>
<!-- Edit mode -->
<div v-else-if="editingId === tpl.id" class="px-3 py-2 rounded-lg bg-bg-elevated space-y-2">
<input
v-model="editForm.name"
class="w-full px-2 py-1.5 text-[0.8125rem] bg-bg-base border border-border-subtle rounded-lg text-text-primary focus:outline-2 focus:outline-accent"
placeholder="Template name"
/>
<select
v-model="editForm.project_id"
class="w-full px-2 py-1.5 text-[0.8125rem] bg-bg-base border border-border-subtle rounded-lg text-text-primary focus:outline-2 focus:outline-accent"
>
<option v-for="p in projectsStore.activeProjects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<div class="flex gap-2">
<button
@click="saveEdit(tpl)"
class="px-3 py-1.5 text-[0.75rem] font-medium bg-accent text-white rounded-lg hover:bg-accent/90 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Save
</button>
<button
@click="cancelEdit"
class="px-3 py-1.5 text-[0.75rem] font-medium text-text-secondary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Cancel
</button>
</div>
</div>
<!-- Normal display -->
<div v-else class="flex items-center group">
<button
@click="selectTemplate(tpl)"
role="option"
:aria-selected="i === activeIndex"
:class="[
'flex-1 text-left px-3 py-2 rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
i === activeIndex ? 'bg-accent-muted' : 'hover:bg-bg-elevated'
]"
>
<p class="text-[0.8125rem] text-text-primary">{{ tpl.name }}</p>
<p class="text-[0.6875rem] text-text-tertiary">
{{ getProjectName(tpl.project_id) }}
<span v-if="tpl.duration"> - {{ formatDuration(tpl.duration) }}</span>
</p>
</button>
<div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 pr-1">
<button
@click.stop="startEdit(tpl)"
class="p-1.5 text-text-tertiary hover:text-text-primary transition-colors rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Edit template"
v-tooltip="'Edit'"
>
<Pencil class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="1.5" />
</button>
<button
@click.stop="confirmDelete(tpl.id!)"
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Delete template"
v-tooltip="'Delete'"
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="1.5" />
</button>
</div>
</div>
</div>
</div>
<div v-else class="py-8 text-center">

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ChevronDown, ChevronUp, Check, ArrowRight, Eye, PartyPopper } from 'lucide-vue-next'
import { useOnboardingStore } from '../stores/onboarding'
import { useTourStore } from '../stores/tour'
import { TOURS } from '../utils/tours'
const router = useRouter()
const onboardingStore = useOnboardingStore()
const tourStore = useTourStore()
const collapsed = ref(false)
const progressPct = computed(() =>
onboardingStore.totalCount > 0
? (onboardingStore.completedCount / onboardingStore.totalCount) * 100
: 0
)
function goThere(route: string) {
router.push(route)
}
async function showMe(tourId: string, route: string) {
await router.push(route)
await nextTick()
setTimeout(() => {
const tour = TOURS[tourId]
if (tour) {
tourStore.start(tour)
}
}, 400)
}
</script>
<template>
<div
v-if="onboardingStore.isVisible"
class="mb-8 bg-bg-surface border border-border-subtle rounded-lg overflow-hidden"
role="region"
aria-labelledby="checklist-heading"
>
<!-- Header -->
<button
@click="collapsed = !collapsed"
:aria-expanded="!collapsed"
aria-controls="checklist-body"
class="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
>
<div class="flex items-center gap-3">
<h2 id="checklist-heading" class="text-[0.8125rem] font-medium text-text-primary">Getting Started</h2>
<span class="text-[0.6875rem] text-text-tertiary" aria-label="Completed {{ onboardingStore.completedCount }} of {{ onboardingStore.totalCount }} steps">
{{ onboardingStore.completedCount }} / {{ onboardingStore.totalCount }}
</span>
</div>
<component
:is="collapsed ? ChevronDown : ChevronUp"
class="w-4 h-4 text-text-tertiary transition-transform duration-200"
:stroke-width="2"
aria-hidden="true"
/>
</button>
<!-- Progress bar -->
<div class="px-4">
<div class="w-full bg-bg-elevated rounded-full h-1">
<div
class="h-1 rounded-full bg-accent progress-bar"
:style="{ width: progressPct + '%' }"
role="progressbar"
:aria-valuenow="onboardingStore.completedCount"
:aria-valuemin="0"
:aria-valuemax="onboardingStore.totalCount"
aria-label="Getting started progress"
/>
</div>
</div>
<!-- Checklist items -->
<Transition name="expand">
<div v-if="!collapsed" id="checklist-body" class="px-4 py-3">
<!-- All complete message -->
<div v-if="onboardingStore.allComplete" class="flex items-center gap-3 py-3" role="status">
<PartyPopper class="w-5 h-5 text-accent" :stroke-width="1.5" aria-hidden="true" />
<div>
<p class="text-[0.8125rem] text-text-primary font-medium">All done!</p>
<p class="text-[0.6875rem] text-text-tertiary">You have explored all the basics. Happy tracking!</p>
</div>
</div>
<!-- Items -->
<ul v-else class="space-y-1" aria-label="Onboarding steps">
<li
v-for="item in onboardingStore.items"
:key="item.key"
class="flex items-center gap-3 py-2 group"
>
<!-- Checkbox indicator -->
<div
class="w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors duration-200"
:class="item.completed
? 'border-accent bg-accent'
: 'border-border-visible'"
role="img"
:aria-label="item.completed ? 'Completed' : 'Not completed'"
>
<Check
v-if="item.completed"
class="w-3 h-3 text-bg-base"
:stroke-width="3"
aria-hidden="true"
/>
</div>
<!-- Label -->
<div class="flex-1 min-w-0">
<p
class="text-[0.8125rem] transition-colors duration-200"
:class="item.completed ? 'text-text-tertiary line-through' : 'text-text-primary'"
>
{{ item.label }}
</p>
<p class="text-[0.6875rem] text-text-tertiary">{{ item.description }}</p>
</div>
<!-- Action buttons (always focusable, visually hidden until hover/focus) -->
<div
v-if="!item.completed"
class="flex items-center gap-1.5 shrink-0"
>
<button
@click="goThere(item.route)"
:aria-label="'Go to ' + item.label"
class="flex items-center gap-1 px-2 py-1 text-[0.6875rem] text-text-secondary border border-border-subtle rounded-md hover:bg-bg-elevated hover:text-text-primary transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent focus-visible:opacity-100"
>
<ArrowRight class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
Go there
</button>
<button
@click="showMe(item.tourId, item.route)"
:aria-label="'Show me how to ' + item.label.toLowerCase()"
class="flex items-center gap-1 px-2 py-1 text-[0.6875rem] text-accent-text border border-accent/30 rounded-md hover:bg-accent/10 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent focus-visible:opacity-100"
>
<Eye class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
Show me
</button>
</div>
</li>
</ul>
<!-- Dismiss link -->
<div class="mt-3 pt-2 border-t border-border-subtle">
<button
@click="onboardingStore.dismiss()"
class="text-[0.6875rem] text-text-tertiary hover:text-text-secondary transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Dismiss checklist
</button>
</div>
</div>
</Transition>
</div>
</template>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import { watch, ref, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { invoke } from '@tauri-apps/api/core'
import { useFocusTrap } from '../utils/focusTrap'
import { useProjectsStore } from '../stores/projects'
import { useInvoicesStore } from '../stores/invoices'
import { Search, FolderKanban, Users, Clock, FileText } from 'lucide-vue-next'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: [] }>()
const router = useRouter()
const projectsStore = useProjectsStore()
const invoicesStore = useInvoicesStore()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const query = ref('')
const activeIndex = ref(0)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
interface SearchResult {
type: 'project' | 'client' | 'entry' | 'invoice'
id: number
label: string
sublabel: string
color?: string
route: string
}
const entryResults = ref<SearchResult[]>([])
const searching = ref(false)
const localResults = computed((): SearchResult[] => {
const q = query.value.toLowerCase().trim()
if (!q) return []
const results: SearchResult[] = []
for (const p of projectsStore.projects) {
if (results.length >= 5) break
if (p.name.toLowerCase().includes(q)) {
results.push({ type: 'project', id: p.id!, label: p.name, sublabel: p.archived ? 'Archived' : 'Active', color: p.color, route: '/projects' })
}
}
for (const inv of invoicesStore.invoices) {
if (results.length >= 10) break
if (inv.invoice_number.toLowerCase().includes(q)) {
results.push({ type: 'invoice', id: inv.id!, label: inv.invoice_number, sublabel: inv.status, route: '/invoices' })
}
}
return results
})
const allResults = computed(() => [...localResults.value, ...entryResults.value])
async function searchEntries(q: string) {
if (!q.trim()) {
entryResults.value = []
return
}
searching.value = true
try {
const rows = await invoke<any[]>('search_entries', { query: q, limit: 5 })
entryResults.value = rows.map(r => ({
type: 'entry' as const,
id: r.id,
label: r.description || '(no description)',
sublabel: r.project_name || 'Unknown project',
color: r.project_color,
route: '/entries',
}))
} catch {
entryResults.value = []
} finally {
searching.value = false
}
}
function onInput() {
activeIndex.value = 0
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => searchEntries(query.value), 200)
}
function navigate(result: SearchResult) {
router.push(result.route)
emit('close')
}
function onKeydown(e: KeyboardEvent) {
const total = allResults.value.length
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = (activeIndex.value + 1) % Math.max(total, 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value = (activeIndex.value - 1 + Math.max(total, 1)) % Math.max(total, 1)
} else if (e.key === 'Enter' && allResults.value[activeIndex.value]) {
e.preventDefault()
navigate(allResults.value[activeIndex.value])
} else if (e.key === 'Escape') {
emit('close')
}
}
const typeIcon: Record<string, any> = {
project: FolderKanban,
client: Users,
entry: Clock,
invoice: FileText,
}
watch(() => props.show, (val) => {
if (val) {
query.value = ''
entryResults.value = []
activeIndex.value = 0
nextTick(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('close') })
inputRef.value?.focus()
})
} else {
deactivate()
}
})
</script>
<template>
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-start justify-center pt-[15vh] p-4 z-50"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-label="Search"
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 overflow-hidden"
@keydown="onKeydown"
>
<div class="flex items-center gap-3 px-4 py-3 border-b border-border-subtle">
<Search class="w-4 h-4 text-text-tertiary shrink-0" :stroke-width="1.5" aria-hidden="true" />
<input
ref="inputRef"
v-model="query"
@input="onInput"
type="text"
class="flex-1 bg-transparent text-[0.875rem] text-text-primary placeholder-text-tertiary outline-none"
placeholder="Search projects, entries, invoices..."
aria-label="Search"
/>
<kbd class="text-[0.625rem] text-text-tertiary border border-border-subtle rounded px-1.5 py-0.5">Esc</kbd>
</div>
<div v-if="!query.trim()" class="px-4 py-8 text-center text-[0.75rem] text-text-tertiary">
Type to search...
</div>
<div v-else-if="allResults.length === 0 && !searching" class="px-4 py-8 text-center text-[0.75rem] text-text-tertiary">
No results for "{{ query }}"
</div>
<ul v-else class="max-h-80 overflow-y-auto py-2" role="listbox">
<li
v-for="(result, idx) in allResults"
:key="result.type + '-' + result.id"
role="option"
:aria-selected="idx === activeIndex"
class="flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors duration-100"
:class="idx === activeIndex ? 'bg-accent/10' : 'hover:bg-bg-elevated'"
@click="navigate(result)"
@mouseenter="activeIndex = idx"
>
<component :is="typeIcon[result.type]" class="w-4 h-4 text-text-tertiary shrink-0" :stroke-width="1.5" aria-hidden="true" />
<span v-if="result.color" class="w-2 h-2 rounded-full shrink-0" :style="{ backgroundColor: result.color }" aria-hidden="true" />
<div class="flex-1 min-w-0">
<p class="text-[0.8125rem] text-text-primary truncate">{{ result.label }}</p>
<p class="text-[0.6875rem] text-text-tertiary truncate">{{ result.sublabel }}</p>
</div>
<span class="text-[0.5625rem] text-text-tertiary uppercase tracking-wider shrink-0">{{ result.type }}</span>
</li>
</ul>
</div>
</div>
</Transition>
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, watch, ref } from 'vue'
import { useFocusTrap } from '../utils/focusTrap'
interface Props {
show: boolean
@@ -14,6 +15,9 @@ const emit = defineEmits<{
stopTimer: []
}>()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
const idleFormatted = computed(() => {
const mins = Math.floor(props.idleSeconds / 60)
const secs = props.idleSeconds % 60
@@ -22,6 +26,16 @@ const idleFormatted = computed(() => {
}
return `${secs}s`
})
watch(() => props.show, (val) => {
if (val) {
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('stopTimer') })
}, 50)
} else {
deactivate()
}
})
</script>
<template>
@@ -30,9 +44,9 @@ const idleFormatted = computed(() => {
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
>
<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">You've been idle</h2>
<p class="text-[0.75rem] text-text-secondary mb-6">
<div ref="dialogRef" role="alertdialog" aria-modal="true" aria-labelledby="idle-title" aria-describedby="idle-desc" 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 id="idle-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">You've been idle</h2>
<p id="idle-desc" class="text-[0.75rem] text-text-secondary mb-6">
No keyboard or mouse input detected for <span class="font-mono font-medium text-text-primary">{{ idleFormatted }}</span>.
</p>
<div class="flex flex-col gap-2.5">
@@ -44,13 +58,13 @@ const idleFormatted = computed(() => {
</button>
<button
@click="emit('continueSubtract')"
class="w-full px-4 py-2.5 border border-border-visible text-text-primary text-[0.8125rem] rounded-lg hover:bg-bg-elevated transition-colors duration-150"
class="w-full px-4 py-2.5 border border-border-subtle text-text-primary text-[0.8125rem] rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Continue (subtract {{ idleFormatted }})
</button>
<button
@click="emit('stopTimer')"
class="w-full px-4 py-2.5 border border-status-error text-status-error text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
class="w-full px-4 py-2.5 border border-status-error text-status-error-text text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Stop &amp; Save
</button>

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import { ref, computed, onBeforeUnmount } from 'vue'
import { useInvoicesStore, type Invoice } from '../stores/invoices'
import { useToastStore } from '../stores/toast'
import { formatCurrency, formatDate } from '../utils/locale'
import { GripVertical } from 'lucide-vue-next'
const emit = defineEmits<{ open: [id: number] }>()
const invoicesStore = useInvoicesStore()
const toastStore = useToastStore()
const columns = ['draft', 'sent', 'overdue', 'paid'] as const
const columnLabels: Record<string, string> = { draft: 'Draft', sent: 'Sent', overdue: 'Overdue', paid: 'Paid' }
function columnTotal(status: string): string {
const items = invoicesStore.groupedByStatus[status] || []
const sum = items.reduce((acc, inv) => acc + inv.total, 0)
return formatCurrency(sum)
}
// Pointer-based drag (works in Tauri webview unlike HTML5 DnD)
const dragInv = ref<Invoice | null>(null)
const dragStartX = ref(0)
const dragStartY = ref(0)
const dragX = ref(0)
const dragY = ref(0)
const isDragging = ref(false)
const dragOverCol = ref<string | null>(null)
const columnRefs = ref<Record<string, HTMLElement>>({})
const cardWidth = ref(200)
const DRAG_THRESHOLD = 6
function setColumnRef(col: string, el: HTMLElement | null) {
if (el) columnRefs.value[col] = el
}
function onPointerDown(inv: Invoice, e: PointerEvent) {
// Only primary button
if (e.button !== 0) return
dragInv.value = inv
dragStartX.value = e.clientX
dragStartY.value = e.clientY
dragX.value = e.clientX
dragY.value = e.clientY
isDragging.value = false
// Measure the source card width for the ghost
const el = (e.currentTarget as HTMLElement)
if (el) cardWidth.value = el.offsetWidth
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(e: PointerEvent) {
if (!dragInv.value) return
const dx = e.clientX - dragStartX.value
const dy = e.clientY - dragStartY.value
// Start drag only after threshold
if (!isDragging.value) {
if (Math.abs(dx) + Math.abs(dy) < DRAG_THRESHOLD) return
isDragging.value = true
}
// Track position for the ghost
dragX.value = e.clientX
dragY.value = e.clientY
// Hit-test which column the pointer is over
// The ghost has pointer-events:none so elementFromPoint sees through it
const hit = document.elementFromPoint(e.clientX, e.clientY)
if (hit) {
let found: string | null = null
for (const [col, el] of Object.entries(columnRefs.value)) {
if (el.contains(hit)) {
found = col
break
}
}
dragOverCol.value = found
}
}
async function onPointerUp() {
document.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerup', onPointerUp)
const inv = dragInv.value
const targetCol = dragOverCol.value
const wasDragging = isDragging.value
dragInv.value = null
dragOverCol.value = null
isDragging.value = false
if (!inv) return
// If we were dragging and landed on a different column, move the invoice
if (wasDragging && targetCol && targetCol !== inv.status) {
const oldStatus = inv.status
const ok = await invoicesStore.updateStatus(inv.id!, targetCol)
if (ok) {
toastStore.success(`Moved ${inv.invoice_number} to ${columnLabels[targetCol]}`, {
onUndo: async () => {
await invoicesStore.updateStatus(inv.id!, oldStatus)
}
})
}
return
}
// If we didn't drag (just clicked), open the invoice
if (!wasDragging) {
emit('open', inv.id!)
}
}
onBeforeUnmount(() => {
document.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerup', onPointerUp)
})
const reducedMotion = computed(() => window.matchMedia('(prefers-reduced-motion: reduce)').matches)
</script>
<template>
<div class="grid grid-cols-4 gap-4 select-none">
<div
v-for="col in columns"
:key="col"
:ref="(el) => setColumnRef(col, el as HTMLElement)"
class="flex flex-col min-h-[300px] bg-bg-elevated rounded-lg overflow-hidden transition-all duration-150"
:class="[
dragOverCol === col && isDragging ? 'ring-2 ring-accent bg-accent/5' : '',
col === 'overdue' ? 'border-t-2 border-status-error' : ''
]"
:aria-label="columnLabels[col] + ' invoices'"
>
<div class="px-3 py-2.5 border-b border-border-subtle">
<div class="flex items-center justify-between">
<span class="text-[0.75rem] font-medium text-text-primary">{{ columnLabels[col] }}</span>
<span class="text-[0.625rem] text-text-tertiary bg-bg-base rounded-full px-2 py-0.5">
{{ (invoicesStore.groupedByStatus[col] || []).length }}
</span>
</div>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">{{ columnTotal(col) }}</p>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-2" role="list">
<div
v-for="inv in (invoicesStore.groupedByStatus[col] || [])"
:key="inv.id"
role="listitem"
tabindex="0"
class="bg-bg-surface border border-border-subtle rounded-lg p-3 transition-all duration-150 hover:border-accent/50 group"
:class="[
isDragging && dragInv?.id === inv.id ? 'opacity-40 scale-95 cursor-grabbing' : 'cursor-grab',
!reducedMotion ? 'hover:shadow-sm' : ''
]"
@pointerdown="onPointerDown(inv, $event)"
@keydown.enter="emit('open', inv.id!)"
>
<div class="flex items-start gap-2">
<GripVertical
class="w-3.5 h-3.5 text-text-tertiary opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5"
:stroke-width="1.5"
aria-hidden="true"
/>
<div class="flex-1 min-w-0">
<p class="text-[0.8125rem] font-medium text-text-primary">{{ inv.invoice_number }}</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">{{ formatDate(inv.date) }}</p>
<p class="text-[0.8125rem] font-medium text-text-primary mt-1">
{{ formatCurrency(inv.total) }}
</p>
</div>
</div>
</div>
<!-- Drop zone placeholder when column is empty or drag active -->
<div
v-if="!(invoicesStore.groupedByStatus[col] || []).length"
class="flex items-center justify-center h-20 text-[0.6875rem] text-text-tertiary border-2 border-dashed rounded-lg transition-colors"
:class="isDragging && dragOverCol === col ? 'border-accent text-accent' : 'border-border-subtle'"
>
{{ isDragging ? 'Drop here' : 'No invoices' }}
</div>
</div>
</div>
</div>
<!-- Floating ghost tile that follows the cursor during drag -->
<Teleport to="#app">
<div
v-if="isDragging && dragInv"
class="fixed z-[200] pointer-events-none"
:style="{
left: dragX + 'px',
top: dragY + 'px',
width: cardWidth + 'px',
transform: 'translate(-50%, -60%) rotate(-2deg)',
}"
>
<div class="bg-bg-surface border-2 border-accent rounded-lg p-3 shadow-lg shadow-black/30 opacity-90">
<p class="text-[0.8125rem] font-medium text-text-primary">{{ dragInv.invoice_number }}</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">{{ formatDate(dragInv.date) }}</p>
<p class="text-[0.8125rem] font-medium text-text-primary mt-1">{{ formatCurrency(dragInv.total) }}</p>
</div>
</div>
</Teleport>
</template>

View File

@@ -58,6 +58,7 @@ void clientAddress
<img
v-if="biz?.logo"
:src="biz.logo"
alt="Business logo"
:style="{ width: '28px', height: '28px', objectFit: 'contain', marginBottom: '4px', display: 'block' }"
/>
<div :style="{ fontSize: '13px', fontWeight: '600', color: c.headerText }">
@@ -102,10 +103,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -172,7 +173,7 @@ void clientAddress
</div>
</div>
<div :style="{ textAlign: 'right' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<div :style="{ fontSize: '12px', fontWeight: '600' }">{{ biz?.name || 'Your Business' }}</div>
</div>
</div>
@@ -201,10 +202,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -268,7 +269,7 @@ void clientAddress
</div>
<!-- Biz info outside the block -->
<div :style="{ flex: 1, padding: '5% 5%', display: 'flex', flexDirection: 'column', justifyContent: 'center' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
<div :style="{ fontSize: '9px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
<div v-for="(line, i) in bizAddressLines" :key="'blf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
@@ -303,10 +304,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -364,7 +365,7 @@ void clientAddress
<div :style="{ padding: '6%' }">
<!-- Centered header -->
<div :style="{ textAlign: 'center', marginBottom: '10px' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', margin: '0 auto 6px', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', margin: '0 auto 6px', display: 'block' }" />
<div :style="{ height: '1px', backgroundColor: '#e4e4e7', marginBottom: '8px' }" />
<div :style="{ fontSize: '20px', fontWeight: '700', color: '#18181b', letterSpacing: '0.04em' }">INVOICE</div>
<div :style="{ fontSize: '9px', fontWeight: '600', color: '#18181b', marginTop: '2px' }">{{ biz?.name || 'Your Business' }}</div>
@@ -399,10 +400,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr>
<th :style="{ textAlign: 'left', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', letterSpacing: '0.03em' }">Description</th>
<th :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', letterSpacing: '0.03em' }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -461,7 +462,7 @@ void clientAddress
<!-- Traditional two-column header -->
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }">
<div>
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
<div :style="{ fontSize: '11px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
<div v-for="(line, i) in bizAddressLines" :key="'clf'+i">{{ line }}</div>
<div v-if="biz?.email">{{ biz.email }}</div>
@@ -491,10 +492,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px', border: '1px solid ' + c.tableBorder }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
</tr>
</thead>
<tbody>
@@ -560,7 +561,7 @@ void clientAddress
<div v-if="invoice.due_date" :style="{ fontSize: '7.5px', color: c.bodyText }">Due {{ formatDate(invoice.due_date) }}</div>
</div>
<div :style="{ textAlign: 'right' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<div :style="{ fontSize: '10px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
<div v-for="(line, i) in bizAddressLines" :key="'modf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
@@ -591,10 +592,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr>
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, borderBottom: '1px solid ' + c.primary }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', borderBottom: '1px solid ' + c.primary }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', borderBottom: '1px solid ' + c.primary }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', borderBottom: '1px solid ' + c.primary }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, borderBottom: '1px solid ' + c.primary }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', borderBottom: '1px solid ' + c.primary }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', borderBottom: '1px solid ' + c.primary }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', borderBottom: '1px solid ' + c.primary }">Amount</th>
</tr>
</thead>
<tbody>
@@ -659,7 +660,7 @@ void clientAddress
<!-- Centered header -->
<div :style="{ textAlign: 'center', marginBottom: '8px' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', margin: '0 auto 4px', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', margin: '0 auto 4px', display: 'block' }" />
<div :style="{ fontSize: '20px', fontWeight: '700', color: c.headerText, letterSpacing: '0.06em' }">INVOICE</div>
<div :style="{ fontSize: '10px', fontWeight: '600', color: c.headerText, marginTop: '2px' }">{{ biz?.name || 'Your Business' }}</div>
<div :style="{ fontSize: '7.5px', marginTop: '2px' }">
@@ -697,20 +698,20 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr>
<th :colspan="4" :style="{ padding: 0, height: '0' }">
<th scope="col" :colspan="4" :style="{ padding: 0, height: '0' }">
<div :style="{ height: '1px', backgroundColor: c.primary }" />
<div :style="{ height: '1.5px' }" />
<div :style="{ height: '1px', backgroundColor: c.primary }" />
</th>
</tr>
<tr>
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
<tr>
<th :colspan="4" :style="{ padding: 0, height: '0' }">
<th scope="col" :colspan="4" :style="{ padding: 0, height: '0' }">
<div :style="{ height: '1px', backgroundColor: c.primary }" />
<div :style="{ height: '1.5px' }" />
<div :style="{ height: '1px', backgroundColor: c.primary }" />
@@ -776,7 +777,7 @@ void clientAddress
<div :style="{ paddingLeft: '7%', paddingRight: '5%', paddingTop: '5%', paddingBottom: '5%' }">
<!-- Logo + Title -->
<div :style="{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain' }" />
<div>
<div :style="{ fontSize: '20px', fontWeight: '700', color: c.primary }">INVOICE</div>
<div :style="{ fontSize: '7.5px' }">#{{ invoice.invoice_number }}</div>
@@ -909,10 +910,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '10px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '38px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '46px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '52px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '38px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '46px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '52px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -975,7 +976,7 @@ void clientAddress
<div :style="{ width: '40px', height: '1px', backgroundColor: c.primary, marginTop: '4px' }" />
</div>
<div :style="{ textAlign: 'right' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<div :style="{ fontSize: '10px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
</div>
</div>
@@ -1008,10 +1009,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -1077,7 +1078,7 @@ void clientAddress
</div>
</div>
<div :style="{ textAlign: 'right' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<div :style="{ fontSize: '11px', fontWeight: '600' }">{{ biz?.name || 'Your Business' }}</div>
<div v-if="biz?.email" :style="{ fontSize: '7px', opacity: 0.9 }">{{ biz.email }}</div>
</div>
@@ -1106,10 +1107,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -1167,7 +1168,7 @@ void clientAddress
<!-- Deep blue band -->
<div :style="{ backgroundColor: c.headerBg, color: c.headerText, padding: '4% 6% 3%', minHeight: '12%', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }">
<div>
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px' }" />
<div :style="{ fontSize: '12px', fontWeight: '600' }">{{ biz?.name || 'Your Business' }}</div>
</div>
<div :style="{ fontSize: '22px', fontWeight: '700' }">INVOICE</div>
@@ -1204,10 +1205,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px', border: '1px solid ' + c.tableBorder }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
</tr>
</thead>
<tbody>
@@ -1266,7 +1267,7 @@ void clientAddress
<!-- Header with logo left, watermark invoice number right -->
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px', position: 'relative' }">
<div>
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', marginBottom: '3px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', marginBottom: '3px' }" />
<div :style="{ fontSize: '11px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
<div v-for="(line, i) in bizAddressLines" :key="'frf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
@@ -1307,10 +1308,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -1369,7 +1370,7 @@ void clientAddress
<!-- Header -->
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }">
<div>
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
<div :style="{ fontSize: '20px', fontWeight: '700', color: c.primary }">INVOICE</div>
</div>
<div :style="{ textAlign: 'right' }">
@@ -1407,10 +1408,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -1482,7 +1483,7 @@ void clientAddress
<!-- Biz info -->
<div :style="{ marginBottom: '10px' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
<div :style="{ fontSize: '9px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
<div v-for="(line, i) in bizAddressLines" :key="'sf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
@@ -1509,10 +1510,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr>
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>

View File

@@ -93,16 +93,18 @@ function selectTemplate(id: string) {
style="height: 480px"
>
<!-- Left panel: Template list -->
<div class="w-[30%] border-r border-border-subtle overflow-y-auto bg-bg-surface">
<div class="w-[30%] border-r border-border-subtle overflow-y-auto bg-bg-surface" role="radiogroup" aria-label="Invoice templates">
<div v-for="cat in TEMPLATE_CATEGORIES" :key="cat.id">
<div
class="text-[0.5625rem] text-text-tertiary uppercase tracking-[0.1em] font-medium px-3 pt-3 pb-1"
class="text-[0.5625rem] text-text-tertiary uppercase tracking-[0.08em] font-medium px-3 pt-3 pb-1"
>
{{ cat.label }}
</div>
<button
v-for="tmpl in getTemplatesByCategory(cat.id)"
:key="tmpl.id"
role="radio"
:aria-checked="tmpl.id === modelValue"
class="w-full flex items-center gap-2 px-3 py-1.5 text-[0.75rem] transition-colors"
:class="
tmpl.id === modelValue
@@ -114,6 +116,7 @@ function selectTemplate(id: string) {
<span
class="w-2.5 h-2.5 rounded-full shrink-0 border border-black/10"
:style="{ backgroundColor: tmpl.colors.primary }"
aria-hidden="true"
/>
<span class="truncate">{{ tmpl.name }}</span>
</button>
@@ -121,7 +124,7 @@ function selectTemplate(id: string) {
</div>
<!-- Right panel: Live preview -->
<div class="w-[70%] bg-bg-inset p-4 flex items-start justify-center overflow-y-auto">
<div class="w-[70%] bg-bg-inset p-4 flex items-start justify-center overflow-y-auto" aria-label="Template preview" aria-live="polite">
<div class="w-full max-w-sm">
<InvoicePreview
:template="selectedTemplate"

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import { watch, ref, computed } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { readTextFile } from '@tauri-apps/plugin-fs'
import { useFocusTrap } from '../utils/focusTrap'
import { useToastStore } from '../stores/toast'
import { Upload, ChevronLeft, ChevronRight, Loader2 } from 'lucide-vue-next'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; imported: [] }>()
const toastStore = useToastStore()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
const step = ref(1)
const filePath = ref('')
const parsedData = ref<Record<string, any[]> | null>(null)
const entityCounts = ref<{ key: string; count: number; selected: boolean }[]>([])
const importing = ref(false)
const entityLabels: Record<string, string> = {
clients: 'Clients',
projects: 'Projects',
tasks: 'Tasks',
time_entries: 'Time Entries',
tags: 'Tags',
invoices: 'Invoices',
invoice_items: 'Invoice Items',
expenses: 'Expenses',
favorites: 'Favorites',
recurring_entries: 'Recurring Entries',
entry_templates: 'Entry Templates',
settings: 'Settings',
}
async function pickFile() {
const selected = await open({
multiple: false,
filters: [{ name: 'JSON', extensions: ['json'] }],
})
if (selected) {
filePath.value = selected as string
try {
const text = await readTextFile(selected as string)
parsedData.value = JSON.parse(text)
entityCounts.value = Object.entries(parsedData.value!)
.filter(([, arr]) => Array.isArray(arr) && arr.length > 0)
.map(([key, arr]) => ({ key, count: arr.length, selected: true }))
step.value = 2
} catch {
toastStore.error('Failed to parse JSON file')
}
}
}
const selectedCount = computed(() => entityCounts.value.filter(e => e.selected).length)
async function runImport() {
if (!parsedData.value) return
importing.value = true
try {
const data: Record<string, any[]> = {}
for (const entity of entityCounts.value) {
if (entity.selected) {
data[entity.key] = parsedData.value[entity.key]
}
}
await invoke('import_json_data', { data: JSON.stringify(data) })
const totalItems = entityCounts.value.filter(e => e.selected).reduce((sum, e) => sum + e.count, 0)
toastStore.success(`Imported ${totalItems} items`)
emit('imported')
emit('close')
} catch (e) {
toastStore.error('Import failed: ' + String(e))
} finally {
importing.value = false
}
}
watch(() => props.show, (val) => {
if (val) {
step.value = 1
filePath.value = ''
parsedData.value = null
entityCounts.value = []
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('close') })
}, 50)
} else {
deactivate()
}
})
</script>
<template>
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-labelledby="import-title"
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"
>
<h2 id="import-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
Restore from Backup
</h2>
<!-- Step 1: File selection -->
<div v-if="step === 1" class="text-center py-4">
<button
@click="pickFile"
class="inline-flex items-center gap-2 px-6 py-3 bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
<Upload class="w-4 h-4" :stroke-width="1.5" aria-hidden="true" />
Select JSON File
</button>
<p class="text-[0.6875rem] text-text-tertiary mt-3">Choose a ZeroClock backup .json file</p>
</div>
<!-- Step 2: Preview and select -->
<div v-else-if="step === 2">
<p class="text-[0.75rem] text-text-secondary mb-3">
Found {{ entityCounts.length }} data types. Select which to import:
</p>
<div class="space-y-1.5 max-h-60 overflow-y-auto mb-4">
<label
v-for="entity in entityCounts"
:key="entity.key"
class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-elevated transition-colors duration-100 cursor-pointer"
>
<input
type="checkbox"
v-model="entity.selected"
class="w-4 h-4 rounded border-border-subtle text-accent focus:ring-accent"
/>
<span class="flex-1 text-[0.8125rem] text-text-primary">{{ entityLabels[entity.key] || entity.key }}</span>
<span class="text-[0.6875rem] text-text-tertiary">{{ entity.count }}</span>
</label>
</div>
</div>
<!-- Step 3: Importing -->
<div v-else-if="step === 3" class="flex items-center justify-center gap-3 py-8">
<Loader2 class="w-5 h-5 text-accent animate-spin" :stroke-width="1.5" aria-hidden="true" />
<span class="text-[0.8125rem] text-text-secondary">Importing data...</span>
</div>
<div v-if="step === 2" class="flex justify-between mt-4">
<button
@click="step = 1"
class="inline-flex items-center gap-1 px-3 py-2 text-[0.8125rem] text-text-secondary hover:text-text-primary transition-colors duration-150"
>
<ChevronLeft class="w-3.5 h-3.5" :stroke-width="2" aria-hidden="true" />
Back
</button>
<div class="flex gap-3">
<button
@click="$emit('close')"
class="px-4 py-2 text-[0.8125rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
@click="step = 3; runImport()"
:disabled="selectedCount === 0"
class="inline-flex items-center gap-1 px-4 py-2 text-[0.8125rem] bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150 disabled:opacity-50"
>
Import
<ChevronRight class="w-3.5 h-3.5" :stroke-width="2" aria-hidden="true" />
</button>
</div>
</div>
<div v-if="step === 1" class="flex justify-end mt-4">
<button
@click="$emit('close')"
class="px-4 py-2 text-[0.8125rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
</div>
</div>
</div>
</Transition>
</template>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { watch, ref } from 'vue'
import { useFocusTrap } from '../utils/focusTrap'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: [] }>()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
watch(() => props.show, (val) => {
if (val) {
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('close') })
}, 50)
} else {
deactivate()
}
})
const groups = [
{
label: 'Global',
shortcuts: [
{ keys: '?', description: 'Show keyboard shortcuts' },
{ keys: 'Ctrl+Shift+T', description: 'Toggle timer' },
{ keys: 'Ctrl+Shift+Z', description: 'Show/focus app' },
{ keys: 'Ctrl+Shift+N', description: 'Quick entry' },
]
},
{
label: 'Timer',
shortcuts: [
{ keys: 'Space', description: 'Start/stop timer (when focused)' },
]
},
{
label: 'Navigation',
shortcuts: [
{ keys: 'Arrow keys', description: 'Navigate tabs, calendar, lists' },
{ keys: 'Enter', description: 'Open/select focused item' },
{ keys: 'Escape', description: 'Close dialog/popover' },
]
},
]
</script>
<template>
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="emit('close')"
>
<div
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-title"
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"
>
<h2 id="shortcuts-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
Keyboard Shortcuts
</h2>
<div class="space-y-4">
<div v-for="group in groups" :key="group.label">
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium mb-2">
{{ group.label }}
</h3>
<div class="space-y-1.5">
<div
v-for="shortcut in group.shortcuts"
:key="shortcut.keys"
class="flex items-center justify-between text-[0.8125rem]"
>
<span class="text-text-secondary">{{ shortcut.description }}</span>
<kbd class="px-2 py-0.5 bg-bg-elevated border border-border-subtle rounded text-[0.6875rem] font-mono text-text-primary">
{{ shortcut.keys }}
</kbd>
</div>
</div>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
@click="emit('close')"
class="px-4 py-2 border border-border-subtle text-text-secondary text-[0.8125rem] rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Close
</button>
</div>
</div>
</div>
</Transition>
</template>

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTimerStore } from '../stores/timer'
import { useSettingsStore } from '../stores/settings'
import { useInvoicesStore } from '../stores/invoices'
import {
LayoutDashboard,
Clock,
@@ -12,78 +14,250 @@ import {
Grid3X3,
BarChart3,
FileText,
Settings
Settings,
PanelLeftOpen,
PanelLeftClose
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const timerStore = useTimerStore()
const settingsStore = useSettingsStore()
const invoicesStore = useInvoicesStore()
const navItems = [
{ name: 'Dashboard', path: '/', icon: LayoutDashboard },
{ name: 'Timer', path: '/timer', icon: Clock },
{ name: 'Clients', path: '/clients', icon: Users },
{ name: 'Projects', path: '/projects', icon: FolderKanban },
{ name: 'Entries', path: '/entries', icon: List },
{ name: 'Calendar', path: '/calendar', icon: CalendarDays },
{ name: 'Timesheet', path: '/timesheet', icon: Grid3X3 },
{ name: 'Invoices', path: '/invoices', icon: FileText },
{ name: 'Reports', path: '/reports', icon: BarChart3 },
{ name: 'Settings', path: '/settings', icon: Settings }
const expanded = ref(false)
interface NavItem {
name: string
path: string
icon: any
}
interface NavGroup {
label: string
items: NavItem[]
}
const groups: NavGroup[] = [
{
label: 'Track',
items: [
{ name: 'Dashboard', path: '/', icon: LayoutDashboard },
{ name: 'Timer', path: '/timer', icon: Clock },
],
},
{
label: 'Manage',
items: [
{ name: 'Clients', path: '/clients', icon: Users },
{ name: 'Projects', path: '/projects', icon: FolderKanban },
{ name: 'Entries', path: '/entries', icon: List },
],
},
{
label: 'Views',
items: [
{ name: 'Calendar', path: '/calendar', icon: CalendarDays },
{ name: 'Timesheet', path: '/timesheet', icon: Grid3X3 },
],
},
{
label: 'Business',
items: [
{ name: 'Invoices', path: '/invoices', icon: FileText },
{ name: 'Reports', path: '/reports', icon: BarChart3 },
],
},
]
const settingsItem: NavItem = { name: 'Settings', path: '/settings', icon: Settings }
const currentPath = computed(() => route.path)
const activeIndex = computed(() => {
return navItems.findIndex(item => item.path === currentPath.value)
})
// Only show tooltips when nav is collapsed (labels are hidden)
function tip(text: string) {
return expanded.value ? '' : text
}
function navigate(path: string) {
router.push(path)
}
async function toggleExpanded() {
expanded.value = !expanded.value
await settingsStore.updateSetting('nav_expanded', expanded.value ? 'true' : 'false')
}
// Watch for settings to load (NavRail mounts before App.vue's fetchSettings resolves)
watch(() => settingsStore.settings.nav_expanded, (val) => {
if (val !== undefined) {
expanded.value = val === 'true'
}
}, { immediate: true })
</script>
<template>
<nav class="w-12 flex flex-col items-center bg-bg-surface border-r border-border-subtle shrink-0">
<div class="relative flex-1 flex flex-col items-center pt-2 gap-1">
<!-- Sliding active indicator -->
<nav
aria-label="Main navigation"
class="flex flex-col bg-bg-surface border-r border-border-subtle shrink-0 transition-[width] duration-200 ease-out overflow-hidden"
:class="expanded ? 'w-[180px]' : 'w-12'"
>
<!-- Scrollable nav items -->
<div class="relative flex-1 flex flex-col pt-1 overflow-y-auto overflow-x-hidden">
<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-[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"
v-for="(group, groupIndex) in groups"
:key="group.label"
>
<component :is="item.icon" class="w-[18px] h-[18px]" :stroke-width="1.5" />
<!-- Tooltip -->
<div class="absolute left-full ml-2 px-2 py-1 bg-bg-elevated border border-border-subtle rounded-lg text-[0.6875rem] text-text-primary whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity duration-150 z-50">
<div class="absolute -left-1 top-1/2 -translate-y-1/2 w-0 h-0 border-y-4 border-y-transparent border-r-4" style="border-right-color: var(--color-bg-elevated)"></div>
{{ item.name }}
<!-- Section header (expanded only) -->
<div
class="overflow-hidden transition-[max-height,opacity] duration-200 ease-out"
:class="expanded ? 'max-h-8 opacity-100' : 'max-h-0 opacity-0'"
>
<p
class="px-3 pt-3 pb-1 text-[0.5625rem] text-text-tertiary uppercase tracking-[0.08em] font-medium truncate"
aria-hidden="true"
>
{{ group.label }}
</p>
</div>
</button>
<!-- Divider (collapsed only, between groups) -->
<div
class="mx-2.5 border-t border-border-subtle overflow-hidden transition-[max-height,opacity,margin] duration-200 ease-out"
:class="!expanded && groupIndex > 0 ? 'max-h-2 opacity-100 my-1' : 'max-h-0 opacity-0 my-0'"
aria-hidden="true"
/>
<!-- Items -->
<button
v-for="item in group.items"
:key="item.path"
v-tooltip.right="tip(item.name)"
@click="navigate(item.path)"
class="nav-item relative w-full flex items-center h-11 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
:class="currentPath === item.path
? 'text-text-primary'
: 'text-text-tertiary hover:text-text-secondary'"
:aria-label="item.name"
:aria-current="currentPath === item.path ? 'page' : undefined"
>
<!-- Active indicator -->
<div
class="absolute left-0 top-1 bottom-1 w-[2px] rounded-full transition-opacity duration-200"
:class="currentPath === item.path ? 'bg-accent opacity-100' : 'opacity-0'"
aria-hidden="true"
/>
<!-- Fixed-width icon container - always centered in 48px -->
<div class="relative w-12 flex items-center justify-center shrink-0">
<component :is="item.icon" aria-hidden="true" class="w-[18px] h-[18px]" :stroke-width="1.5" />
<span
v-if="item.path === '/invoices' && invoicesStore.overdueCount > 0"
class="absolute top-[-2px] right-1.5 min-w-[1rem] h-4 flex items-center justify-center text-[0.5625rem] font-bold text-white bg-status-error rounded-full px-1"
:aria-label="`${invoicesStore.overdueCount} overdue invoices`"
>
{{ invoicesStore.overdueCount }}
</span>
</div>
<!-- Label (fades in with expand) -->
<span
class="text-[0.75rem] truncate whitespace-nowrap transition-opacity duration-200"
:class="expanded ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
{{ item.name }}
</span>
</button>
</div>
</div>
<!-- Timer status indicator (bottom) -->
<div class="pb-4">
<!-- Bottom section: Settings + toggle + timer -->
<div class="flex flex-col pb-1.5 border-t border-border-subtle">
<!-- Settings -->
<button
v-tooltip.right="tip(settingsItem.name)"
@click="navigate(settingsItem.path)"
class="nav-item relative w-full flex items-center h-11 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
:class="currentPath === settingsItem.path
? 'text-text-primary'
: 'text-text-tertiary hover:text-text-secondary'"
:aria-label="settingsItem.name"
:aria-current="currentPath === settingsItem.path ? 'page' : undefined"
>
<!-- Active indicator -->
<div
class="absolute left-0 top-1 bottom-1 w-[2px] rounded-full transition-opacity duration-200"
:class="currentPath === settingsItem.path ? 'bg-accent opacity-100' : 'opacity-0'"
aria-hidden="true"
/>
<div class="w-12 flex items-center justify-center shrink-0">
<component :is="settingsItem.icon" aria-hidden="true" class="w-[18px] h-[18px]" :stroke-width="1.5" />
</div>
<span
class="text-[0.75rem] truncate whitespace-nowrap transition-opacity duration-200"
:class="expanded ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
{{ settingsItem.name }}
</span>
</button>
<!-- Expand/collapse toggle -->
<button
v-tooltip.right="tip(expanded ? 'Collapse' : 'Expand')"
@click="toggleExpanded"
class="relative w-full flex items-center h-11 transition-colors duration-150 text-text-tertiary hover:text-text-secondary focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
:aria-label="expanded ? 'Collapse navigation' : 'Expand navigation'"
:aria-expanded="expanded"
>
<div class="w-12 flex items-center justify-center shrink-0">
<component
:is="expanded ? PanelLeftClose : PanelLeftOpen"
aria-hidden="true"
class="w-[16px] h-[16px]"
:stroke-width="1.5"
/>
</div>
<span
class="text-[0.6875rem] truncate whitespace-nowrap transition-opacity duration-200"
:class="expanded ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
Collapse
</span>
</button>
<!-- Timer status indicator (only when running/paused) -->
<div
v-if="timerStore.isRunning"
class="w-2 h-2 rounded-full bg-status-running animate-pulse-dot"
/>
<div
v-else-if="timerStore.isPaused"
class="w-2 h-2 rounded-full bg-status-warning animate-pulse-dot"
/>
v-if="timerStore.isRunning || timerStore.isPaused"
class="flex items-center h-7"
role="status"
>
<span class="sr-only">
{{ timerStore.isRunning ? 'Timer running' : 'Timer paused' }}
</span>
<div class="w-12 flex items-center justify-center shrink-0">
<div
v-if="timerStore.isRunning"
class="w-2 h-2 rounded-full bg-status-running animate-pulse-dot"
aria-hidden="true"
/>
<div
v-else
class="w-2 h-2 rounded-full bg-status-warning animate-pulse-dot"
aria-hidden="true"
/>
</div>
<span
class="text-[0.6875rem] truncate whitespace-nowrap transition-opacity duration-200"
:class="[
expanded ? 'opacity-100' : 'opacity-0',
timerStore.isRunning ? 'text-status-running' : 'text-status-warning'
]"
>
{{ timerStore.isRunning ? 'Running' : 'Paused' }}
</span>
</div>
</div>
</nav>
</template>

View File

@@ -4,6 +4,7 @@ import { useFocusTrap } from '../utils/focusTrap'
import { useAnnouncer } from '../composables/useAnnouncer'
import AppSelect from './AppSelect.vue'
import AppDatePicker from './AppDatePicker.vue'
import AppTimePicker from './AppTimePicker.vue'
import { useProjectsStore, type Task } from '../stores/projects'
import { useEntriesStore } from '../stores/entries'
import { useSettingsStore } from '../stores/settings'
@@ -31,14 +32,15 @@ const selectedProjectId = ref<number | null>(null)
const selectedTaskId = ref<number | null>(null)
const description = ref('')
const entryDate = ref(new Date().toISOString().split('T')[0])
const durationInput = ref('')
const durationHour = ref(1)
const durationMinute = ref(0)
const billable = ref(1)
const tasks = ref<Task[]>([])
const saving = ref(false)
const availableProjects = computed(() => projectsStore.projects.filter(p => !p.archived))
const availableProjects = computed(() => projectsStore.activeProjects)
const canSave = computed(() => !!selectedProjectId.value && !!durationInput.value.trim() && !saving.value)
const canSave = computed(() => !!selectedProjectId.value && (durationHour.value > 0 || durationMinute.value > 0) && !saving.value)
watch(selectedProjectId, async (projectId) => {
selectedTaskId.value = null
@@ -62,7 +64,8 @@ watch(() => props.show, async (val) => {
selectedTaskId.value = null
description.value = ''
entryDate.value = new Date().toISOString().split('T')[0]
durationInput.value = ''
durationHour.value = 1
durationMinute.value = 0
billable.value = 1
saving.value = false
@@ -78,27 +81,9 @@ watch(() => props.show, async (val) => {
onUnmounted(() => deactivateTrap())
function parseDuration(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) return null
// H:MM format
if (trimmed.includes(':')) {
const [h, m] = trimmed.split(':').map(Number)
if (isNaN(h) || isNaN(m) || h < 0 || m < 0 || m > 59) return null
return h * 3600 + m * 60
}
// Decimal hours
const num = parseFloat(trimmed)
if (isNaN(num) || num < 0 || num > 24) return null
return Math.round(num * 3600)
}
async function handleSave() {
if (!selectedProjectId.value || !durationInput.value) return
const duration = parseDuration(durationInput.value)
if (duration === null || duration <= 0) return
const duration = durationHour.value * 3600 + durationMinute.value * 60
if (!selectedProjectId.value || duration <= 0) return
saving.value = true
@@ -132,7 +117,7 @@ async function handleSave() {
</script>
<template>
<Teleport to="body">
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
@@ -154,6 +139,7 @@ async function handleSave() {
@click="$emit('close')"
class="p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Close"
v-tooltip="'Close'"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
@@ -201,13 +187,11 @@ async function handleSave() {
<AppDatePicker v-model="entryDate" />
</div>
<div>
<label for="quick-entry-duration" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Duration *</label>
<input
id="quick-entry-duration"
v-model="durationInput"
type="text"
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary font-mono focus:outline-none focus:border-border-visible"
placeholder="1:30 or 1.5"
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Duration *</label>
<AppTimePicker
v-model:hour="durationHour"
v-model:minute="durationMinute"
placeholder="Duration"
/>
</div>
</div>

View File

@@ -53,7 +53,7 @@ function onKeydown(e: KeyboardEvent) {
</script>
<template>
<Teleport to="body">
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
@@ -74,6 +74,7 @@ function onKeydown(e: KeyboardEvent) {
@click="zoomOut"
class="p-2 bg-bg-surface/80 rounded-lg text-text-primary hover:bg-bg-surface transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Zoom out"
v-tooltip="'Zoom out'"
>
<ZoomOut class="w-4 h-4" aria-hidden="true" />
</button>
@@ -84,6 +85,7 @@ function onKeydown(e: KeyboardEvent) {
@click="zoomIn"
class="p-2 bg-bg-surface/80 rounded-lg text-text-primary hover:bg-bg-surface transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Zoom in"
v-tooltip="'Zoom in'"
>
<ZoomIn class="w-4 h-4" aria-hidden="true" />
</button>
@@ -91,6 +93,7 @@ function onKeydown(e: KeyboardEvent) {
@click="$emit('close')"
class="p-2 bg-bg-surface/80 rounded-lg text-text-primary hover:bg-bg-surface transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Close lightbox"
v-tooltip="'Close'"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { watch, ref, computed } from 'vue'
import { Clock, Calendar } from 'lucide-vue-next'
import { useFocusTrap } from '../utils/focusTrap'
interface Props {
show: boolean
projectName: string
projectColor?: string
taskName?: string
description?: string
duration: number
timeOfDay: string
}
const props = withDefaults(defineProps<Props>(), {
projectColor: '#6B7280',
taskName: '',
description: '',
})
const emit = defineEmits<{
confirm: []
snooze: []
skip: []
}>()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
const announcement = ref('')
const formattedDuration = computed(() => {
const totalSeconds = props.duration
const h = Math.floor(totalSeconds / 3600)
const m = Math.floor((totalSeconds % 3600) / 60)
if (h === 0) return `${m}m`
return `${h}h ${m}m`
})
watch(() => props.show, (val) => {
if (val) {
announcement.value = `Recurring entry ready: ${props.projectName} - ${props.description || 'Scheduled task'}`
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('skip') })
}, 50)
} else {
deactivate()
announcement.value = ''
}
})
</script>
<template>
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-[60]"
@click.self="$emit('skip')"
>
<div
ref="dialogRef"
role="alertdialog"
aria-modal="true"
aria-labelledby="recurring-prompt-title"
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
id="recurring-prompt-title"
class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4"
>
Recurring Entry Ready
</h2>
<div class="space-y-3 mb-6">
<!-- Project -->
<div class="flex items-center gap-2">
<span
class="w-2.5 h-2.5 rounded-full shrink-0"
:style="{ backgroundColor: projectColor }"
aria-hidden="true"
/>
<span class="text-[0.8125rem] font-medium text-text-primary">{{ projectName }}</span>
</div>
<!-- Task name -->
<div v-if="taskName" class="text-[0.75rem] text-text-secondary pl-[1.125rem]">
{{ taskName }}
</div>
<!-- Description -->
<div v-if="description" class="text-[0.75rem] text-text-secondary pl-[1.125rem]">
{{ description }}
</div>
<!-- Duration -->
<div class="flex items-center gap-2 text-[0.75rem] text-text-secondary">
<Clock class="w-3.5 h-3.5 shrink-0" :stroke-width="1.5" aria-hidden="true" />
<span>{{ formattedDuration }}</span>
</div>
<!-- Time of day -->
<div v-if="timeOfDay" class="flex items-center gap-2 text-[0.75rem] text-text-secondary">
<Calendar class="w-3.5 h-3.5 shrink-0" :stroke-width="1.5" aria-hidden="true" />
<span>{{ timeOfDay }}</span>
</div>
</div>
<div class="flex justify-end gap-2">
<button
@click="$emit('skip')"
class="px-3 py-1.5 text-[0.75rem] text-text-tertiary hover:text-text-secondary transition-colors"
>
Skip
</button>
<button
@click="$emit('snooze')"
class="px-3 py-1.5 text-[0.75rem] font-medium border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors"
>
Snooze 30min
</button>
<button
@click="$emit('confirm')"
class="px-3 py-1.5 text-[0.75rem] font-medium bg-accent text-bg-base rounded-lg hover:bg-accent-hover transition-colors"
>
Create Entry
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- aria-live announcement -->
<div class="sr-only" aria-live="assertive" aria-atomic="true">{{ announcement }}</div>
</template>

View File

@@ -61,7 +61,7 @@ async function fetchProcesses() {
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 280, estimatedHeight: 290 })
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 280, estimatedHeight: 290, panelEl: panelRef.value })
}
function toggle() {
@@ -77,6 +77,7 @@ async function open() {
updatePosition()
highlightedIndex.value = 0
searchQuery.value = ''
nextTick(() => updatePosition())
await fetchProcesses()
nextTick(() => searchInputRef.value?.focus())
document.addEventListener('click', onClickOutside, true)
@@ -144,13 +145,15 @@ onBeforeUnmount(() => {
ref="triggerRef"
type="button"
@click="toggle"
:aria-expanded="isOpen"
aria-haspopup="listbox"
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" />
<Monitor class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
From Running Apps
</button>
<Teleport to="body">
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
@@ -165,20 +168,22 @@ onBeforeUnmount(() => {
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..."
aria-label="Search running applications"
@keydown="onSearchKeydown"
/>
<button
type="button"
@click="fetchProcesses"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors"
title="Refresh"
aria-label="Refresh application list"
v-tooltip="'Refresh'"
>
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" :stroke-width="1.5" />
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" :stroke-width="1.5" aria-hidden="true" />
</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">
<div class="max-h-[240px] overflow-y-auto py-1" role="listbox" aria-label="Running applications">
<div v-if="loading && filteredProcesses.length === 0" class="px-3 py-4 text-center text-[0.75rem] text-text-tertiary" aria-live="polite">
Loading...
</div>
<div v-else-if="filteredProcesses.length === 0" class="px-3 py-4 text-center text-[0.75rem] text-text-tertiary">
@@ -187,6 +192,9 @@ onBeforeUnmount(() => {
<div
v-for="(app, index) in filteredProcesses"
:key="app.exe_path"
role="option"
:id="'app-option-' + index"
:aria-selected="highlightedIndex === index"
@click="selectApp(app)"
@mouseenter="highlightedIndex = index"
class="flex items-center gap-2.5 px-3 py-2 cursor-pointer transition-colors"

View File

@@ -63,7 +63,7 @@ function handleSave() {
</script>
<template>
<Teleport to="body">
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
@@ -86,6 +86,7 @@ function handleSave() {
@click="$emit('cancel')"
class="p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Close"
v-tooltip="'Close'"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>

View File

@@ -3,11 +3,13 @@ import { getCurrentWindow } from '@tauri-apps/api/window'
import { ref, onMounted } from 'vue'
import { useTimerStore } from '../stores/timer'
import { useProjectsStore } from '../stores/projects'
import { useSettingsStore } from '../stores/settings'
const appWindow = getCurrentWindow()
const isMaximized = ref(false)
const timerStore = useTimerStore()
const projectsStore = useProjectsStore()
const settingsStore = useSettingsStore()
onMounted(async () => {
isMaximized.value = await appWindow.isMaximized()
@@ -20,7 +22,11 @@ function getProjectName(projectId: number | null): string {
}
async function minimize() {
await appWindow.minimize()
if (settingsStore.settings.minimize_to_tray === 'true') {
await appWindow.hide()
} else {
await appWindow.minimize()
}
}
async function toggleMaximize() {
@@ -43,7 +49,7 @@ async function handleDoubleClick() {
<template>
<header
class="h-10 flex items-center justify-between px-4 bg-bg-surface border-b border-border-subtle select-none shrink-0"
class="h-11 flex items-center justify-between px-4 bg-bg-surface border-b border-border-subtle select-none shrink-0"
@mousedown.left="startDrag"
@dblclick="handleDoubleClick"
>
@@ -58,11 +64,18 @@ async function handleDoubleClick() {
<!-- Center: Running timer status -->
<div
role="status"
aria-live="off"
class="flex items-center gap-3 transition-opacity duration-150"
:class="timerStore.isRunning ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:class="timerStore.isRunning || timerStore.isPaused ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
<!-- Pulsing green dot -->
<div class="w-2 h-2 rounded-full bg-status-running animate-pulse-dot" />
<!-- Status dot -->
<div
aria-hidden="true"
class="w-2 h-2 rounded-full"
:class="timerStore.isRunning ? 'bg-status-running animate-pulse-dot' : 'bg-status-warning'"
/>
<span class="sr-only">{{ timerStore.isRunning ? 'Timer running' : timerStore.isPaused ? 'Timer paused' : 'Timer stopped' }}</span>
<!-- Project name -->
<span class="text-[0.6875rem] text-text-secondary">
@@ -70,18 +83,46 @@ async function handleDoubleClick() {
</span>
<!-- Elapsed time -->
<span class="text-[0.75rem] font-mono text-text-primary tracking-wider">
<span class="text-[0.75rem] font-[family-name:var(--font-timer)] text-text-primary tracking-wider">
{{ timerStore.formattedTime }}
</span>
<!-- Pause / Resume button -->
<button
v-if="timerStore.isRunning"
v-tooltip.bottom="'Pause timer'"
@click="timerStore.pauseManual()"
@mousedown.stop
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-status-warning transition-colors duration-150"
aria-label="Pause timer"
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
<rect x="3" y="2" width="3.5" height="12" rx="1" />
<rect x="9.5" y="2" width="3.5" height="12" rx="1" />
</svg>
</button>
<button
v-else-if="timerStore.timerState === 'PAUSED_MANUAL'"
v-tooltip.bottom="'Resume timer'"
@click="timerStore.resumeFromPause()"
@mousedown.stop
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-accent-text transition-colors duration-150"
aria-label="Resume timer"
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
<path d="M4 2.5a.5.5 0 01.77-.42l9 5.5a.5.5 0 010 .84l-9 5.5A.5.5 0 014 13.5V2.5z" />
</svg>
</button>
<!-- Stop button -->
<button
v-tooltip.bottom="'Stop timer'"
@click="timerStore.stop()"
@mousedown.stop
class="w-5 h-5 flex items-center justify-center text-text-tertiary hover:text-status-error transition-colors duration-150"
title="Stop timer"
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-status-error transition-colors duration-150"
aria-label="Stop timer"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
<rect x="3" y="3" width="10" height="10" rx="1" />
</svg>
</button>
@@ -90,35 +131,38 @@ async function handleDoubleClick() {
<!-- Right: Window controls -->
<div class="flex items-center" @mousedown.stop>
<button
v-tooltip.bottom="'Minimize'"
@click="minimize"
class="w-10 h-10 flex items-center justify-center text-text-tertiary hover:text-text-secondary transition-colors duration-150"
title="Minimize"
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-text-secondary transition-colors duration-150"
aria-label="Minimize"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" class="w-3.5 h-3.5">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" class="w-3.5 h-3.5">
<path d="M5 12h14" stroke="currentColor" stroke-width="2" />
</svg>
</button>
<button
v-tooltip.bottom="isMaximized ? 'Restore' : 'Maximize'"
@click="toggleMaximize"
class="w-10 h-10 flex items-center justify-center text-text-tertiary hover:text-text-secondary transition-colors duration-150"
:title="isMaximized ? 'Restore' : 'Maximize'"
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-text-secondary transition-colors duration-150"
:aria-label="isMaximized ? 'Restore' : 'Maximize'"
>
<svg v-if="!isMaximized" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3 h-3">
<svg v-if="!isMaximized" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3 h-3">
<rect x="4" y="4" width="16" height="16" rx="1" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3 h-3">
<svg v-else aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3 h-3">
<rect x="3" y="7" width="14" height="14" rx="1" />
<path d="M7 7V5a1 1 0 011-1h12a1 1 0 011 1v12a1 1 0 01-1 1h-2" />
</svg>
</button>
<button
v-tooltip.bottom="'Close'"
@click="close"
class="w-10 h-10 flex items-center justify-center text-text-tertiary hover:text-status-error transition-colors duration-150"
title="Close"
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-status-error transition-colors duration-150"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 011.06 0L12 10.94l5.47-5.47a.75.75 0 111.06 1.06L13.06 12l5.47 5.47a.75.75 0 11-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 01-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 010-1.06z" clip-rule="evenodd" />
</svg>
</button>

View File

@@ -11,8 +11,7 @@ const toastStore = useToastStore()
aria-label="Notifications"
aria-live="polite"
aria-atomic="false"
class="fixed top-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col gap-2 pointer-events-none"
style="margin-left: 24px;"
class="fixed top-4 left-1/2 -translate-x-1/2 ml-6 z-[100] flex flex-col gap-2 pointer-events-none"
>
<div
v-for="toast in toastStore.toasts"
@@ -24,7 +23,7 @@ const toastStore = useToastStore()
@mouseleave="toastStore.resumeToast(toast.id)"
@focusin="toastStore.pauseToast(toast.id)"
@focusout="toastStore.resumeToast(toast.id)"
class="w-80 flex items-center gap-3 px-4 py-3 bg-bg-surface border border-border-subtle rounded-lg shadow-lg pointer-events-auto border-l-[3px]"
class="relative w-80 flex items-center gap-3 px-4 py-3 bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] pointer-events-auto border-l-[3px] overflow-hidden"
:class="[
toast.exiting ? 'animate-toast-exit' : 'animate-toast-enter',
toast.type === 'success' ? 'border-l-status-running' : '',
@@ -36,22 +35,38 @@ const toastStore = useToastStore()
<AlertCircle v-if="toast.type === 'error'" class="w-4 h-4 text-status-error shrink-0" aria-hidden="true" :stroke-width="2" />
<Info v-if="toast.type === 'info'" class="w-4 h-4 text-accent shrink-0" aria-hidden="true" :stroke-width="2" />
<span class="sr-only">{{ toast.type === 'success' ? 'Success:' : toast.type === 'error' ? 'Error:' : 'Info:' }}</span>
<span class="text-sm text-text-primary flex-1">{{ toast.message }}</span>
<span class="text-[0.875rem] text-text-primary flex-1">{{ toast.message }}</span>
<button
v-if="toast.onUndo"
aria-label="Undo"
v-tooltip="'Undo'"
@click.stop="toastStore.undoToast(toast.id)"
class="shrink-0 p-1 text-accent hover:text-accent/80 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent rounded"
class="shrink-0 p-1 text-accent hover:text-accent/80 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent rounded-lg"
>
<Undo2 class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="2" />
</button>
<button
aria-label="Dismiss"
v-tooltip="'Dismiss'"
@click.stop="toastStore.removeToast(toast.id)"
class="shrink-0 p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent rounded"
class="shrink-0 p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent rounded-lg"
>
<X class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="2" />
</button>
<div
v-if="!toast.exiting"
aria-hidden="true"
class="absolute bottom-0 left-0 h-0.5 rounded-b-lg"
:class="[
toast.type === 'success' ? 'bg-status-running' : '',
toast.type === 'error' ? 'bg-status-error' : '',
toast.type === 'info' ? 'bg-accent' : ''
]"
:style="{
animation: `toast-progress ${toast.duration}ms linear forwards`,
animationPlayState: toast.paused ? 'paused' : 'running',
}"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useTourStore } from '../stores/tour'
import { getZoomFactor } from '../utils/dropdown'
const tourStore = useTourStore()
const spotlightStyle = ref<Record<string, string>>({})
const tooltipStyle = ref<Record<string, string>>({})
const tooltipRef = ref<HTMLElement | null>(null)
function positionElements() {
const step = tourStore.currentStep
if (!step) return
const el = document.querySelector(step.target) as HTMLElement | null
if (!el) {
if (!tourStore.isLastStep) {
tourStore.next()
} else {
tourStore.stop()
}
return
}
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
setTimeout(() => {
const rect = el.getBoundingClientRect()
const padding = 6
const zoom = getZoomFactor()
spotlightStyle.value = {
top: `${(rect.top - padding) / zoom}px`,
left: `${(rect.left - padding) / zoom}px`,
width: `${(rect.width + padding * 2) / zoom}px`,
height: `${(rect.height + padding * 2) / zoom}px`,
}
const placement = step.placement || 'bottom'
const tooltipWidth = 280
const tooltipGap = 12
let top = 0
let left = 0
if (placement === 'bottom') {
top = rect.bottom + tooltipGap
left = rect.left + rect.width / 2 - tooltipWidth * zoom / 2
} else if (placement === 'top') {
top = rect.top - tooltipGap
left = rect.left + rect.width / 2 - tooltipWidth * zoom / 2
} else if (placement === 'right') {
top = rect.top + rect.height / 2
left = rect.right + tooltipGap
} else if (placement === 'left') {
top = rect.top + rect.height / 2
left = rect.left - tooltipGap - tooltipWidth * zoom
}
left = Math.max(12, Math.min(left, window.innerWidth - tooltipWidth * zoom - 12))
tooltipStyle.value = {
top: placement === 'top' ? 'auto' : `${top / zoom}px`,
bottom: placement === 'top' ? `${(window.innerHeight - top) / zoom}px` : 'auto',
left: `${left / zoom}px`,
width: `${tooltipWidth}px`,
}
// Focus the tooltip for screen readers
nextTick(() => {
if (tooltipRef.value) {
tooltipRef.value.focus()
}
})
}, 350)
}
watch(() => tourStore.currentStepIndex, () => {
if (tourStore.isActive) {
nextTick(() => positionElements())
}
})
watch(() => tourStore.isActive, (active) => {
if (active) {
nextTick(() => positionElements())
}
})
function onKeydown(e: KeyboardEvent) {
if (!tourStore.isActive) return
if (e.key === 'Escape') {
e.preventDefault()
tourStore.stop()
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault()
tourStore.next()
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault()
if (!tourStore.isFirstStep) tourStore.back()
} else if (e.key === 'Tab') {
// Trap focus within the tooltip
if (!tooltipRef.value) return
const focusable = tooltipRef.value.querySelectorAll('button')
if (focusable.length === 0) return
const first = focusable[0] as HTMLElement
const last = focusable[focusable.length - 1] as HTMLElement
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
function onResize() {
if (tourStore.isActive) {
positionElements()
}
}
onMounted(() => {
document.addEventListener('keydown', onKeydown)
window.addEventListener('resize', onResize)
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeydown)
window.removeEventListener('resize', onResize)
})
</script>
<template>
<Teleport to="#app">
<Transition name="tour-fade">
<div
v-if="tourStore.isActive"
class="fixed inset-0 z-[9998]"
role="dialog"
aria-modal="true"
:aria-label="'Feature tour: step ' + (tourStore.currentStepIndex + 1) + ' of ' + tourStore.totalSteps"
>
<!-- Backdrop - click to dismiss -->
<div
class="absolute inset-0"
@click="tourStore.stop()"
aria-hidden="true"
/>
<!-- Spotlight cutout -->
<div
class="absolute rounded-md pointer-events-none transition-all duration-300 ease-out"
:style="spotlightStyle"
style="box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6);"
aria-hidden="true"
/>
<!-- Tooltip card -->
<div
ref="tooltipRef"
tabindex="-1"
class="absolute z-[9999] bg-bg-surface border border-border-subtle rounded-lg shadow-lg p-4 pointer-events-auto transition-all duration-300 ease-out focus:outline-none"
:style="tooltipStyle"
>
<p class="text-[0.8125rem] font-medium text-text-primary mb-1" aria-live="polite">
{{ tourStore.currentStep?.title }}
</p>
<p class="text-[0.75rem] text-text-secondary leading-relaxed mb-3">
{{ tourStore.currentStep?.content }}
</p>
<div class="flex items-center justify-between">
<span class="text-[0.6875rem] text-text-tertiary" aria-hidden="true">
{{ tourStore.currentStepIndex + 1 }} / {{ tourStore.totalSteps }}
</span>
<div class="flex items-center gap-2" role="group" aria-label="Tour navigation">
<button
@click="tourStore.stop()"
class="px-2.5 py-1 text-[0.6875rem] text-text-tertiary hover:text-text-secondary transition-colors duration-150 rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Skip
</button>
<button
v-if="!tourStore.isFirstStep"
@click="tourStore.back()"
class="px-2.5 py-1 text-[0.6875rem] border border-border-subtle text-text-secondary rounded-md hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Back
</button>
<button
@click="tourStore.next()"
class="px-3 py-1 text-[0.6875rem] bg-accent text-bg-base font-medium rounded-md hover:bg-accent-hover transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
>
{{ tourStore.isLastStep ? 'Done' : 'Next' }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>