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:
115
src/components/AppCascadeDeleteDialog.vue
Normal file
115
src/components/AppCascadeDeleteDialog.vue
Normal 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>
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
169
src/components/AppDateRangePresets.vue
Normal file
169
src/components/AppDateRangePresets.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
161
src/components/AppShortcutRecorder.vue
Normal file
161
src/components/AppShortcutRecorder.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
376
src/components/AppTimePicker.vue
Normal file
376
src/components/AppTimePicker.vue
Normal 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>
|
||||
@@ -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 & Save
|
||||
</button>
|
||||
|
||||
123
src/components/EntrySplitDialog.vue
Normal file
123
src/components/EntrySplitDialog.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
164
src/components/GettingStartedChecklist.vue
Normal file
164
src/components/GettingStartedChecklist.vue
Normal 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>
|
||||
192
src/components/GlobalSearchDialog.vue
Normal file
192
src/components/GlobalSearchDialog.vue
Normal 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>
|
||||
@@ -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 & Save
|
||||
</button>
|
||||
|
||||
212
src/components/InvoicePipelineView.vue
Normal file
212
src/components/InvoicePipelineView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
192
src/components/JsonImportWizard.vue
Normal file
192
src/components/JsonImportWizard.vue
Normal 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>
|
||||
95
src/components/KeyboardShortcutsDialog.vue
Normal file
95
src/components/KeyboardShortcutsDialog.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
137
src/components/RecurringPromptDialog.vue
Normal file
137
src/components/RecurringPromptDialog.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
208
src/components/TourOverlay.vue
Normal file
208
src/components/TourOverlay.vue
Normal 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>
|
||||
Reference in New Issue
Block a user