feat: WizardOverlay and DocUploadZone — accessible multi-step wizard and upload UI
This commit is contained in:
parent
6f9cfb8018
commit
5068fabb54
2 changed files with 287 additions and 4 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue