feat: WizardOverlay and DocUploadZone — accessible multi-step wizard and upload UI
This commit is contained in:
parent
5f25d9a350
commit
e0bb4f0d8d
2 changed files with 287 additions and 4 deletions
|
|
@ -1,7 +1,79 @@
|
||||||
<template>
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,218 @@
|
||||||
<template>
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue