Files
zeroclock/src/components/EntryTemplatePicker.vue
Your Name ee82abe63e 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
2026-02-21 01:15:57 +02:00

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>