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