- 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
241 lines
9.6 KiB
Vue
241 lines
9.6 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, nextTick, onUnmounted } from 'vue'
|
|
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, Pencil, Trash2 } from 'lucide-vue-next'
|
|
|
|
const props = defineProps<{
|
|
show: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
select: [template: EntryTemplate]
|
|
cancel: []
|
|
}>()
|
|
|
|
const templatesStore = useEntryTemplatesStore()
|
|
const projectsStore = useProjectsStore()
|
|
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
|
|
const { announce } = useAnnouncer()
|
|
const dialogRef = ref<HTMLElement | null>(null)
|
|
const activeIndex = ref(0)
|
|
|
|
function getProjectName(projectId: number): string {
|
|
return projectsStore.projects.find(p => p.id === projectId)?.name || 'Unknown'
|
|
}
|
|
|
|
function formatDuration(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600)
|
|
const m = Math.floor((seconds % 3600) / 60)
|
|
if (h > 0 && m > 0) return `${h}h ${m}m`
|
|
if (h > 0) return `${h}h`
|
|
return `${m}m`
|
|
}
|
|
|
|
watch(() => props.show, async (val) => {
|
|
if (val) {
|
|
activeIndex.value = 0
|
|
await templatesStore.fetchTemplates()
|
|
await nextTick()
|
|
if (dialogRef.value) {
|
|
activateTrap(dialogRef.value, { onDeactivate: () => emit('cancel') })
|
|
}
|
|
announce(`Template picker opened. ${templatesStore.templates.length} templates available.`)
|
|
} else {
|
|
deactivateTrap()
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => deactivateTrap())
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
const len = templatesStore.templates.length
|
|
if (!len) return
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
activeIndex.value = (activeIndex.value + 1) % len
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
activeIndex.value = (activeIndex.value - 1 + len) % len
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
emit('select', templatesStore.templates[activeIndex.value])
|
|
}
|
|
}
|
|
|
|
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="#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-50"
|
|
@click.self="$emit('cancel')"
|
|
>
|
|
<div
|
|
ref="dialogRef"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="template-picker-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"
|
|
@keydown="onKeydown"
|
|
>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 id="template-picker-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary">
|
|
From Template
|
|
</h2>
|
|
<button
|
|
@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">
|
|
<div
|
|
v-for="(tpl, i) in templatesStore.templates"
|
|
:key="tpl.id"
|
|
>
|
|
<!-- 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">
|
|
<FileText class="w-8 h-8 text-text-tertiary mx-auto mb-2" :stroke-width="1.5" aria-hidden="true" />
|
|
<p class="text-[0.8125rem] text-text-secondary">No templates saved yet</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-1">Use "Save as Template" when editing an entry</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|