feat: WizardOverlay and DocUploadZone — accessible multi-step wizard and upload UI

This commit is contained in:
pyr0ball 2026-05-13 17:04:15 -07:00
parent 6f9cfb8018
commit 5068fabb54
2 changed files with 287 additions and 4 deletions

View file

@ -1,7 +1,79 @@
<template>
<div></div>
<div class="rounded border-2 border-dashed border-surface-border p-6 text-center"
:class="{ 'border-accent bg-accent/5': isDragging }"
@dragover.prevent="isDragging = true"
@dragleave="isDragging = false"
@drop.prevent="onDrop">
<p class="text-sm font-medium text-text-primary mb-1">Upload documents</p>
<p class="text-xs text-text-dim mb-3">
Accepted: .md, .txt, .yaml, .yml, .json, .conf max 5 MB
</p>
<!-- File picker is always the primary path -->
<label for="doc-file-input"
class="inline-block px-4 py-2 bg-accent text-surface text-sm rounded font-medium hover:opacity-90 cursor-pointer">
Choose files
</label>
<input
id="doc-file-input"
type="file"
multiple
accept=".md,.txt,.yaml,.yml,.json,.conf,.config,.toml"
class="sr-only"
@change="onFileInput"
/>
<!-- Per-file status -->
<div v-if="fileStatuses.length" class="mt-4 space-y-2 text-left" aria-live="polite">
<div v-for="fs in fileStatuses" :key="fs.name" class="flex items-center gap-3 text-sm">
<span class="font-mono text-text-primary flex-1 truncate">{{ fs.name }}</span>
<span v-if="fs.status === 'uploading'" class="text-text-dim">Uploading</span>
<span v-else-if="fs.status === 'done'" class="text-green-400">Uploaded</span>
<span v-else-if="fs.status === 'error'" class="text-sev-error">{{ fs.error }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineEmits<{ uploaded: [] }>()
import { ref } from 'vue'
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
const emit = defineEmits<{ (e: 'uploaded'): void }>()
const isDragging = ref(false)
interface FileStatus { name: string; status: 'uploading' | 'done' | 'error'; error?: string }
const fileStatuses = ref<FileStatus[]>([])
async function uploadFile(file: File) {
fileStatuses.value.push({ name: file.name, status: 'uploading' })
const idx = fileStatuses.value.length - 1
const form = new FormData()
form.append('file', file)
try {
const r = await fetch(`${BASE}/api/context/docs`, { method: 'POST', body: form })
if (r.ok) {
fileStatuses.value[idx] = { name: file.name, status: 'done' }
emit('uploaded')
} else {
const msg = await r.text()
fileStatuses.value[idx] = { name: file.name, status: 'error', error: msg || `Error ${r.status}` }
}
} catch {
fileStatuses.value[idx] = { name: file.name, status: 'error', error: 'Upload failed' }
}
}
function onFileInput(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) return
Array.from(input.files).forEach(uploadFile)
input.value = ''
}
function onDrop(e: DragEvent) {
isDragging.value = false
if (!e.dataTransfer?.files) return
Array.from(e.dataTransfer.files).forEach(uploadFile)
}
</script>

View file

@ -1,7 +1,218 @@
<template>
<div></div>
<div
role="dialog"
aria-modal="true"
:aria-labelledby="`wiz-heading-${currentStep?.step ?? 1}`"
class="fixed inset-0 z-50 bg-surface overflow-y-auto"
>
<div class="max-w-lg mx-auto py-12 px-6">
<!-- Progress heading -->
<h2
:id="`wiz-heading-${currentStep?.step ?? 1}`"
ref="headingRef"
tabindex="-1"
class="text-lg font-semibold text-text-primary mb-1 focus:outline-none"
>
Step {{ currentStep?.step ?? 1 }} of {{ totalSteps }}: {{ currentStep?.title ?? '…' }}
</h2>
<p v-if="currentStep?.help" class="text-sm text-text-dim mb-6">{{ currentStep.help }}</p>
<!-- Step input -->
<div class="mb-8">
<!-- Select -->
<div v-if="currentStep?.type === 'select'" class="space-y-2">
<label :for="`wiz-input-${currentStep.id}`" class="sr-only">{{ currentStep.title }}</label>
<select
:id="`wiz-input-${currentStep.id}`"
v-model="answer"
class="w-full bg-surface border border-surface-border rounded px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:border-accent"
>
<option value="" disabled>Choose one</option>
<option v-for="opt in currentStep.options" :key="opt" :value="opt">{{ opt }}</option>
</select>
</div>
<!-- Text -->
<div v-else-if="currentStep?.type === 'text'">
<label :for="`wiz-input-${currentStep.id}`" class="sr-only">{{ currentStep.title }}</label>
<input
:id="`wiz-input-${currentStep.id}`"
v-model="answer"
type="text"
:placeholder="currentStep.placeholder || ''"
class="w-full bg-surface border border-surface-border rounded px-4 py-2.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent"
/>
</div>
</div>
<!-- Button row: Back | Next/Skip -->
<div class="flex items-center gap-3">
<button
v-if="session.current_step > 1"
@click="goBack"
class="px-4 py-2 border border-surface-border rounded text-sm text-text-dim hover:text-text-primary transition-colors"
>Back</button>
<button
@click="goNext()"
:disabled="!canAdvance"
class="px-4 py-2 bg-accent text-surface text-sm rounded font-medium hover:opacity-90 disabled:opacity-40 transition-opacity"
>
{{ isLastStep ? 'Review' : 'Next' }}
</button>
<button
v-if="currentStep?.optional"
@click="goNext(true)"
class="px-4 py-2 text-sm text-text-dim hover:text-text-primary transition-colors"
>Skip this step (add later)</button>
</div>
<!-- Escape hatch -->
<p class="mt-8 pt-4 border-t border-surface-border">
<RouterLink to="/settings" @click="$emit('close')" class="text-xs text-text-dim hover:text-text-primary underline underline-offset-2">
Skip wizard edit sources directly in config
</RouterLink>
</p>
</div>
<!-- Summary / Apply screen -->
<div v-if="showSummary" class="max-w-lg mx-auto pb-12 px-6">
<h2 ref="summaryRef" tabindex="-1" class="text-lg font-semibold text-text-primary mb-4 focus:outline-none">
Review and apply
</h2>
<div class="rounded border border-surface-border bg-surface-raised p-4 mb-4 space-y-1">
<p class="text-xs text-text-dim font-medium uppercase tracking-wide mb-2">Facts that will be saved</p>
<div v-for="(val, key) in session.answers" :key="String(key)" class="flex gap-2 text-sm">
<span class="font-mono text-text-dim w-28 shrink-0">{{ key }}</span>
<span class="text-text-primary">{{ val || '(skipped)' }}</span>
</div>
</div>
<div class="flex gap-3">
<button
@click="applyWizard"
:disabled="applying"
class="px-4 py-2 bg-accent text-surface text-sm rounded font-medium hover:opacity-90 disabled:opacity-40"
>
{{ applying ? 'Saving…' : `Save ${factCount} facts and apply source config` }}
</button>
<button @click="showSummary = false" class="text-sm text-text-dim hover:text-text-primary">
Go back and edit
</button>
</div>
<div v-if="applyError" role="alert" class="mt-3 text-sm text-sev-error">{{ applyError }}</div>
</div>
</div>
</template>
<script setup lang="ts">
defineEmits<{ close: []; complete: [] }>()
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { RouterLink } from 'vue-router'
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
const emit = defineEmits<{ (e: 'close'): void; (e: 'complete'): void }>()
interface WizardStep {
step: number
id: string
title: string
type: string
options?: string[]
placeholder?: string
optional?: boolean
help?: string
condition?: { step_id: string; value: string }
}
const schema = ref<WizardStep[]>([])
const session = ref<{ current_step: number; answers: Record<string, string> }>({
current_step: 1,
answers: {},
})
const answer = ref('')
const showSummary = ref(false)
const applying = ref(false)
const applyError = ref<string | null>(null)
const headingRef = ref<HTMLElement | null>(null)
const summaryRef = ref<HTMLElement | null>(null)
const totalSteps = computed(() => schema.value.length)
const currentStep = computed(() =>
schema.value.find(s => s.step === session.value.current_step) ?? schema.value[0]
)
const isLastStep = computed(() => session.value.current_step === totalSteps.value)
const canAdvance = computed(() => currentStep.value?.optional || !!answer.value.trim())
const factCount = computed(() => Object.values(session.value.answers).filter(v => v).length)
async function loadSchema() {
try {
const r = await fetch(`${BASE}/api/context/wizard/schema`)
if (r.ok) schema.value = await r.json()
} catch { /* non-critical — wizard shows loading state */ }
const saved = sessionStorage.getItem('turnstone-wizard')
if (saved) {
try { session.value = JSON.parse(saved) } catch {}
}
}
async function goNext(skip = false) {
applyError.value = null
const stepId = currentStep.value?.id
if (!stepId) return
try {
const r = await fetch(`${BASE}/api/context/wizard/step`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: session.value, step_id: stepId, answer: skip ? '' : answer.value }),
})
if (!r.ok) { applyError.value = `Step failed (${r.status})`; return }
const data = await r.json()
session.value = data.session
sessionStorage.setItem('turnstone-wizard', JSON.stringify(session.value))
answer.value = session.value.answers[currentStep.value?.id ?? ''] ?? ''
if (data.complete) {
showSummary.value = true
nextTick(() => summaryRef.value?.focus())
} else {
nextTick(() => headingRef.value?.focus())
}
} catch {
applyError.value = 'Could not reach server'
}
}
function goBack() {
session.value = { ...session.value, current_step: session.value.current_step - 1 }
answer.value = session.value.answers[currentStep.value?.id ?? ''] ?? ''
nextTick(() => headingRef.value?.focus())
}
async function applyWizard() {
applying.value = true
applyError.value = null
try {
const r = await fetch(`${BASE}/api/context/wizard/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: session.value }),
})
if (!r.ok) throw new Error(await r.text())
sessionStorage.removeItem('turnstone-wizard')
emit('complete')
} catch (e) {
applyError.value = e instanceof Error ? e.message : 'Apply failed'
} finally {
applying.value = false
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
onMounted(() => {
loadSchema()
document.addEventListener('keydown', onKeydown)
nextTick(() => headingRef.value?.focus())
})
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
</script>