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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user