feat(survey): implement SurveyView with navigation wiring
This commit is contained in:
parent
7b634cb46a
commit
ff45f4f6a8
4 changed files with 725 additions and 11 deletions
|
|
@ -12,6 +12,7 @@ const props = defineProps<{
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
move: [jobId: number, preSelectedStage?: PipelineStage]
|
move: [jobId: number, preSelectedStage?: PipelineStage]
|
||||||
prep: [jobId: number]
|
prep: [jobId: number]
|
||||||
|
survey: [jobId: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Signal state
|
// Signal state
|
||||||
|
|
@ -180,6 +181,11 @@ const columnColor = computed(() => {
|
||||||
<footer class="card-footer">
|
<footer class="card-footer">
|
||||||
<button class="card-action" @click.stop="emit('move', job.id)">Move to… ›</button>
|
<button class="card-action" @click.stop="emit('move', job.id)">Move to… ›</button>
|
||||||
<button v-if="['phone_screen', 'interviewing', 'offer'].includes(job.status)" class="card-action" @click.stop="emit('prep', job.id)">Prep →</button>
|
<button v-if="['phone_screen', 'interviewing', 'offer'].includes(job.status)" class="card-action" @click.stop="emit('prep', job.id)">Prep →</button>
|
||||||
|
<button
|
||||||
|
v-if="['survey', 'phone_screen', 'interviewing', 'offer'].includes(job.status)"
|
||||||
|
class="card-action"
|
||||||
|
@click.stop="emit('survey', job.id)"
|
||||||
|
>Survey →</button>
|
||||||
</footer>
|
</footer>
|
||||||
<!-- Signal banners -->
|
<!-- Signal banners -->
|
||||||
<template v-if="job.stage_signals?.length">
|
<template v-if="job.stage_signals?.length">
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export const router = createRouter({
|
||||||
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
|
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
|
||||||
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
|
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
|
||||||
{ path: '/survey', component: () => import('../views/SurveyView.vue') },
|
{ path: '/survey', component: () => import('../views/SurveyView.vue') },
|
||||||
|
{ path: '/survey/:id', component: () => import('../views/SurveyView.vue') },
|
||||||
{ path: '/settings', component: () => import('../views/SettingsView.vue') },
|
{ path: '/settings', component: () => import('../views/SettingsView.vue') },
|
||||||
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
|
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
|
|
|
||||||
|
|
@ -379,6 +379,11 @@ function daysSince(dateStr: string | null) {
|
||||||
<div class="pre-row-meta">
|
<div class="pre-row-meta">
|
||||||
<span v-if="daysSince(job.applied_at) !== null" class="pre-row-days">{{ daysSince(job.applied_at) }}d ago</span>
|
<span v-if="daysSince(job.applied_at) !== null" class="pre-row-days">{{ daysSince(job.applied_at) }}d ago</span>
|
||||||
<button class="btn-move-pre" @click="openMove(job.id)" :aria-label="`Move ${job.title}`">Move to… ›</button>
|
<button class="btn-move-pre" @click="openMove(job.id)" :aria-label="`Move ${job.title}`">Move to… ›</button>
|
||||||
|
<button
|
||||||
|
v-if="job.status === 'survey'"
|
||||||
|
class="btn-move-pre"
|
||||||
|
@click="router.push('/survey/' + job.id)"
|
||||||
|
>Survey →</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Signal banners for pre-list rows -->
|
<!-- Signal banners for pre-list rows -->
|
||||||
|
|
@ -461,7 +466,7 @@ function daysSince(dateStr: string | null) {
|
||||||
</div>
|
</div>
|
||||||
<InterviewCard v-for="(job, i) in store.phoneScreen" :key="job.id" :job="job"
|
<InterviewCard v-for="(job, i) in store.phoneScreen" :key="job.id" :job="job"
|
||||||
:focused="focusedCol === 0 && focusedCard === i"
|
:focused="focusedCol === 0 && focusedCard === i"
|
||||||
@move="openMove" @prep="router.push(`/prep/${$event}`)" />
|
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 1 }" aria-label="Interviewing">
|
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 1 }" aria-label="Interviewing">
|
||||||
|
|
@ -474,7 +479,7 @@ function daysSince(dateStr: string | null) {
|
||||||
</div>
|
</div>
|
||||||
<InterviewCard v-for="(job, i) in store.interviewing" :key="job.id" :job="job"
|
<InterviewCard v-for="(job, i) in store.interviewing" :key="job.id" :job="job"
|
||||||
:focused="focusedCol === 1 && focusedCard === i"
|
:focused="focusedCol === 1 && focusedCard === i"
|
||||||
@move="openMove" @prep="router.push(`/prep/${$event}`)" />
|
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 2 }" aria-label="Offer and Hired">
|
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 2 }" aria-label="Offer and Hired">
|
||||||
|
|
@ -487,7 +492,7 @@ function daysSince(dateStr: string | null) {
|
||||||
</div>
|
</div>
|
||||||
<InterviewCard v-for="(job, i) in store.offerHired" :key="job.id" :job="job"
|
<InterviewCard v-for="(job, i) in store.offerHired" :key="job.id" :job="job"
|
||||||
:focused="focusedCol === 2 && focusedCard === i"
|
:focused="focusedCol === 2 && focusedCard === i"
|
||||||
@move="openMove" @prep="router.push(`/prep/${$event}`)" />
|
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,720 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useInterviewsStore } from '../stores/interviews'
|
||||||
|
import { useSurveyStore } from '../stores/survey'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const interviewsStore = useInterviewsStore()
|
||||||
|
const surveyStore = useSurveyStore()
|
||||||
|
|
||||||
|
const VALID_STAGES = ['survey', 'phone_screen', 'interviewing', 'offer']
|
||||||
|
|
||||||
|
const rawId = route.params.id
|
||||||
|
const jobId = rawId ? parseInt(String(rawId), 10) : NaN
|
||||||
|
|
||||||
|
// Redirect if no valid id
|
||||||
|
if (!jobId || isNaN(jobId)) {
|
||||||
|
router.replace('/interviews')
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const activeTab = ref<'text' | 'screenshot'>('text')
|
||||||
|
const textInput = ref('')
|
||||||
|
const imageB64 = ref<string | null>(null)
|
||||||
|
const imagePreviewUrl = ref<string | null>(null)
|
||||||
|
const selectedMode = ref<'quick' | 'detailed'>('quick')
|
||||||
|
const surveyName = ref('')
|
||||||
|
const reportedScore = ref('')
|
||||||
|
const saveSuccess = ref(false)
|
||||||
|
|
||||||
|
// Computed job from store
|
||||||
|
const job = computed(() =>
|
||||||
|
interviewsStore.jobs.find(j => j.id === jobId) ?? null
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!jobId || isNaN(jobId)) return
|
||||||
|
if (interviewsStore.jobs.length === 0) {
|
||||||
|
await interviewsStore.fetchAll()
|
||||||
|
}
|
||||||
|
if (!job.value || !VALID_STAGES.includes(job.value.status)) {
|
||||||
|
router.replace('/interviews')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await surveyStore.fetchFor(jobId)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
surveyStore.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Screenshot handling
|
||||||
|
function handlePaste(e: ClipboardEvent) {
|
||||||
|
if (!surveyStore.visionAvailable) return
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) loadImageFile(file)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!surveyStore.visionAvailable) return
|
||||||
|
const file = e.dataTransfer?.files[0]
|
||||||
|
if (file && file.type.startsWith('image/')) loadImageFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileUpload(e: Event) {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (file) loadImageFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImageFile(file: File) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const result = ev.target?.result as string
|
||||||
|
imagePreviewUrl.value = result
|
||||||
|
imageB64.value = result.split(',')[1] // strip "data:image/...;base64,"
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearImage() {
|
||||||
|
imageB64.value = null
|
||||||
|
imagePreviewUrl.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analysis
|
||||||
|
const canAnalyze = computed(() =>
|
||||||
|
activeTab.value === 'text' ? textInput.value.trim().length > 0 : imageB64.value !== null
|
||||||
|
)
|
||||||
|
|
||||||
|
async function runAnalyze() {
|
||||||
|
const payload: { text?: string; image_b64?: string; mode: 'quick' | 'detailed' } = {
|
||||||
|
mode: selectedMode.value,
|
||||||
|
}
|
||||||
|
if (activeTab.value === 'screenshot' && imageB64.value) {
|
||||||
|
payload.image_b64 = imageB64.value
|
||||||
|
} else {
|
||||||
|
payload.text = textInput.value
|
||||||
|
}
|
||||||
|
await surveyStore.analyze(jobId, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save
|
||||||
|
async function saveToJob() {
|
||||||
|
await surveyStore.saveResponse(jobId, {
|
||||||
|
surveyName: surveyName.value,
|
||||||
|
reportedScore: reportedScore.value,
|
||||||
|
image_b64: activeTab.value === 'screenshot' ? imageB64.value ?? undefined : undefined,
|
||||||
|
})
|
||||||
|
if (!surveyStore.error) {
|
||||||
|
saveSuccess.value = true
|
||||||
|
surveyName.value = ''
|
||||||
|
reportedScore.value = ''
|
||||||
|
setTimeout(() => { saveSuccess.value = false }, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage label helper
|
||||||
|
const stageLabel: Record<string, string> = {
|
||||||
|
survey: 'Survey', phone_screen: 'Phone Screen',
|
||||||
|
interviewing: 'Interviewing', offer: 'Offer',
|
||||||
|
}
|
||||||
|
|
||||||
|
// History accordion
|
||||||
|
const historyOpen = ref(false)
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
}
|
||||||
|
const expandedHistory = ref<Set<number>>(new Set())
|
||||||
|
function toggleHistoryEntry(id: number) {
|
||||||
|
if (expandedHistory.value.has(id)) expandedHistory.value.delete(id)
|
||||||
|
else expandedHistory.value.add(id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="view-placeholder">
|
<div class="survey-layout">
|
||||||
<h1>SurveyView</h1>
|
<!-- Sticky context bar -->
|
||||||
<p class="placeholder-note">Vue port in progress — Streamlit equivalent at app/pages/</p>
|
<div class="context-bar" v-if="job">
|
||||||
|
<span class="context-company">{{ job.company }}</span>
|
||||||
|
<span class="context-sep">·</span>
|
||||||
|
<span class="context-title">{{ job.title }}</span>
|
||||||
|
<span class="stage-badge">{{ stageLabel[job.status] ?? job.status }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load/history error banner -->
|
||||||
|
<div class="error-banner" v-if="surveyStore.error && !surveyStore.analysis">
|
||||||
|
{{ surveyStore.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="survey-content">
|
||||||
|
<!-- Input card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ active: activeTab === 'text' }"
|
||||||
|
@click="activeTab = 'text'"
|
||||||
|
>📝 Paste Text</button>
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ active: activeTab === 'screenshot', disabled: !surveyStore.visionAvailable }"
|
||||||
|
:aria-disabled="!surveyStore.visionAvailable"
|
||||||
|
:title="!surveyStore.visionAvailable ? 'Vision service not running — start it with: bash scripts/manage-vision.sh start' : undefined"
|
||||||
|
@click="surveyStore.visionAvailable && (activeTab = 'screenshot')"
|
||||||
|
>📷 Screenshot</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Text tab -->
|
||||||
|
<div v-if="activeTab === 'text'" class="tab-panel">
|
||||||
|
<textarea
|
||||||
|
v-model="textInput"
|
||||||
|
class="survey-textarea"
|
||||||
|
placeholder="Paste your survey questions here, e.g.: Q1: Which best describes your work style? A. I prefer working alone B. I thrive in teams C. Depends on the project"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screenshot tab -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="screenshot-zone"
|
||||||
|
@paste="handlePaste"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop="handleDrop"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div v-if="imagePreviewUrl" class="image-preview">
|
||||||
|
<img :src="imagePreviewUrl" alt="Survey screenshot preview" />
|
||||||
|
<button class="remove-btn" @click="clearImage">✕ Remove</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="drop-hint">
|
||||||
|
<p>Paste (Ctrl+V), drag & drop, or upload a screenshot</p>
|
||||||
|
<label class="upload-label">
|
||||||
|
Choose file
|
||||||
|
<input type="file" accept="image/*" class="file-input" @change="handleFileUpload" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode selection -->
|
||||||
|
<div class="mode-cards">
|
||||||
|
<button
|
||||||
|
class="mode-card"
|
||||||
|
:class="{ selected: selectedMode === 'quick' }"
|
||||||
|
@click="selectedMode = 'quick'"
|
||||||
|
>
|
||||||
|
<span class="mode-icon">⚡</span>
|
||||||
|
<span class="mode-name">Quick</span>
|
||||||
|
<span class="mode-desc">Best answer + one-liner per question</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mode-card"
|
||||||
|
:class="{ selected: selectedMode === 'detailed' }"
|
||||||
|
@click="selectedMode = 'detailed'"
|
||||||
|
>
|
||||||
|
<span class="mode-icon">📋</span>
|
||||||
|
<span class="mode-name">Detailed</span>
|
||||||
|
<span class="mode-desc">Option-by-option breakdown with reasoning</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Analyze button -->
|
||||||
|
<button
|
||||||
|
class="analyze-btn"
|
||||||
|
:disabled="!canAnalyze || surveyStore.loading"
|
||||||
|
@click="runAnalyze"
|
||||||
|
>
|
||||||
|
<span v-if="surveyStore.loading" class="spinner" aria-hidden="true"></span>
|
||||||
|
{{ surveyStore.loading ? 'Analyzing…' : '🔍 Analyze' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Analyze error -->
|
||||||
|
<div class="error-inline" v-if="surveyStore.error && !surveyStore.analysis">
|
||||||
|
{{ surveyStore.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results card -->
|
||||||
|
<div class="card results-card" v-if="surveyStore.analysis">
|
||||||
|
<div class="results-output">{{ surveyStore.analysis.output }}</div>
|
||||||
|
<div class="save-form">
|
||||||
|
<input
|
||||||
|
v-model="surveyName"
|
||||||
|
class="save-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Survey name (e.g. Culture Fit Round 1)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="reportedScore"
|
||||||
|
class="save-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Reported score (e.g. 82% or 4.2/5)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="save-btn"
|
||||||
|
:disabled="surveyStore.saving"
|
||||||
|
@click="saveToJob"
|
||||||
|
>
|
||||||
|
<span v-if="surveyStore.saving" class="spinner" aria-hidden="true"></span>
|
||||||
|
💾 Save to job
|
||||||
|
</button>
|
||||||
|
<div v-if="saveSuccess" class="save-success">Saved!</div>
|
||||||
|
<div v-if="surveyStore.error" class="error-inline">{{ surveyStore.error }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History accordion -->
|
||||||
|
<details class="history-accordion" :open="historyOpen" @toggle="historyOpen = ($event.target as HTMLDetailsElement).open">
|
||||||
|
<summary class="history-summary">
|
||||||
|
Survey history ({{ surveyStore.history.length }} response{{ surveyStore.history.length === 1 ? '' : 's' }})
|
||||||
|
</summary>
|
||||||
|
<div v-if="surveyStore.history.length === 0" class="history-empty">No responses saved yet.</div>
|
||||||
|
<div v-else class="history-list">
|
||||||
|
<div v-for="resp in surveyStore.history" :key="resp.id" class="history-entry">
|
||||||
|
<button class="history-toggle" @click="toggleHistoryEntry(resp.id)">
|
||||||
|
<span class="history-name">{{ resp.survey_name ?? 'Survey response' }}</span>
|
||||||
|
<span class="history-meta">{{ formatDate(resp.received_at) }}{{ resp.reported_score ? ` · ${resp.reported_score}` : '' }}</span>
|
||||||
|
<span class="history-chevron">{{ expandedHistory.has(resp.id) ? '▲' : '▼' }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="expandedHistory.has(resp.id)" class="history-detail">
|
||||||
|
<div class="history-tags">
|
||||||
|
<span class="tag">{{ resp.mode }}</span>
|
||||||
|
<span class="tag">{{ resp.source }}</span>
|
||||||
|
<span v-if="resp.received_at" class="tag">{{ resp.received_at }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="history-output">{{ resp.llm_output }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.view-placeholder {
|
.survey-layout {
|
||||||
padding: var(--space-8);
|
display: flex;
|
||||||
max-width: 60ch;
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
.placeholder-note {
|
|
||||||
color: var(--color-text-muted);
|
.context-bar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 0 var(--space-6);
|
||||||
|
height: 40px;
|
||||||
|
background: var(--color-surface-raised, #f8f9fa);
|
||||||
|
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-company {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-sep {
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-title {
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-accent-subtle, #ebf4ff);
|
||||||
|
color: var(--color-accent, #3182ce);
|
||||||
|
}
|
||||||
|
|
||||||
|
.survey-content {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-6);
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--color-accent, #3182ce);
|
||||||
|
background: var(--color-accent-subtle, #ebf4ff);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.survey-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
padding: var(--space-3);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
resize: vertical;
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-zone {
|
||||||
|
min-height: 160px;
|
||||||
|
padding: var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px dashed var(--color-border, #e2e8f0);
|
||||||
|
margin: var(--space-4);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-zone:focus {
|
||||||
|
border-color: var(--color-accent, #3182ce);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-hint {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-label {
|
||||||
|
display: inline-block;
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 300px;
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2rem 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0 var(--space-2);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
border: 2px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card.selected {
|
||||||
|
border-color: var(--color-accent, #3182ce);
|
||||||
|
background: var(--color-accent-subtle, #ebf4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-icon {
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analyze-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--color-accent, #3182ce);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analyze-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-card {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-output {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-input {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: var(--color-surface-raised, #f8f9fa);
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-success {
|
||||||
|
color: var(--color-success, #38a169);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-accordion {
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-summary {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
font-weight: 500;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-summary::-webkit-details-marker { display: none; }
|
||||||
|
|
||||||
|
.history-empty {
|
||||||
|
padding: var(--space-4);
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--color-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-entry {
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-toggle {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-meta {
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-chevron {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-detail {
|
||||||
|
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: var(--color-accent-subtle, #ebf4ff);
|
||||||
|
color: var(--color-accent, #3182ce);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-output {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: var(--color-error-subtle, #fff5f5);
|
||||||
|
border-bottom: 1px solid var(--color-error, #fc8181);
|
||||||
|
padding: var(--space-2) var(--space-6);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-error-text, #c53030);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-inline {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-error-text, #c53030);
|
||||||
|
padding: var(--space-1) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
border: 2px solid rgba(255,255,255,0.4);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analyze-btn .spinner {
|
||||||
|
border-color: rgba(255,255,255,0.4);
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn .spinner {
|
||||||
|
border-color: rgba(0,0,0,0.15);
|
||||||
|
border-top-color: var(--color-accent, #3182ce);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue