diff --git a/app/pages/classroom.vue b/app/pages/classroom.vue index a4f7cb3..c939b72 100644 --- a/app/pages/classroom.vue +++ b/app/pages/classroom.vue @@ -16,7 +16,7 @@
@@ -63,37 +110,6 @@
Start with the ICAO alphabet & numbers, then basics, ground, and more.
-
-
- {{ lessonSearchResults.length }} {{ lessonSearchResults.length === 1 ? 'match' : 'matches' }} - -
-
- -
-

No lessons found for “{{ lessonSearch }}”.

-
-
@@ -1308,6 +1325,8 @@ type LessonSearchHit = { } const lessonSearch = ref('') +const lessonSearchAnchor = ref(null) +const lessonSearchOverlayStyle = ref>({}) const normalizedLessonSearch = computed(() => norm(lessonSearch.value)) const hasLessonSearch = computed(() => normalizedLessonSearch.value.length > 0) const lessonSearchResults = computed(() => { @@ -1355,6 +1374,79 @@ const lessonSearchResults = computed(() => { .slice(0, 20) }) +type LessonSearchGroup = { + module: ModuleDef + hits: LessonSearchHit[] + score: number +} + +const lessonSearchGroups = computed(() => { + const groups = new Map() + for (const hit of lessonSearchResults.value) { + const existing = groups.get(hit.module.id) + if (existing) { + existing.hits.push(hit) + existing.score = Math.max(existing.score, hit.score) + } else { + groups.set(hit.module.id, { + module: hit.module, + hits: [hit], + score: hit.score + }) + } + } + + return Array.from(groups.values()) + .map(group => ({ + ...group, + hits: group.hits.sort((a, b) => b.score - a.score) + })) + .sort((a, b) => { + if (b.score === a.score) { + return a.module.title.localeCompare(b.module.title) + } + return b.score - a.score + }) +}) + +function computeLessonSearchOverlayStyle(): Record { + if (!isClient) return {} + const anchor = lessonSearchAnchor.value + if (!anchor) return {} + + const rect = anchor.getBoundingClientRect() + const padding = 16 + const available = Math.max(window.innerWidth - padding * 2, rect.width) + const width = Math.min(Math.max(rect.width, 320), available) + const maxLeft = Math.max(padding, window.innerWidth - width - padding) + const left = Math.min(Math.max(rect.left, padding), maxLeft) + const top = rect.bottom + 12 + + return { + left: `${left}px`, + top: `${top}px`, + width: `${width}px` + } +} + +function updateLessonSearchOverlay() { + lessonSearchOverlayStyle.value = computeLessonSearchOverlayStyle() +} + +let lessonSearchOverlayCleanup: (() => void) | null = null + +function bindLessonSearchOverlay() { + if (!isClient || lessonSearchOverlayCleanup) return + const handler = () => updateLessonSearchOverlay() + window.addEventListener('resize', handler, {passive: true}) + window.addEventListener('scroll', handler, true) + lessonSearchOverlayCleanup = () => { + window.removeEventListener('resize', handler) + window.removeEventListener('scroll', handler, true) + lessonSearchOverlayCleanup = null + } +} + type FlightPlanMode = 'random' | 'manual' | 'simbrief' type MissionPlanState = { scenario: Scenario; mode: FlightPlanMode; updatedAt: number } type MissionPlanInput = { @@ -2835,6 +2927,9 @@ onBeforeUnmount(() => { void ctx.close().catch(() => { }) } + if (lessonSearchOverlayCleanup) { + lessonSearchOverlayCleanup() + } }) const fieldMap = computed>(() => { @@ -2998,6 +3093,57 @@ const nextActionLabel = computed(() => { return 'Next lesson' }) +const lessonHasInput = computed(() => { + if (!activeLesson.value) return false + return activeLesson.value.fields.some(field => { + const value = userAnswers[field.key] + return typeof value === 'string' && value.trim().length > 0 + }) +}) + +const canAdvanceLesson = computed(() => Boolean(nextLessonMeta.value || nextMissionMeta.value)) + +const missionFooterNoop = () => { +} + +const missionFooterPrimary = computed(() => { + if (lessonHasInput.value && !result.value) { + return { + label: evaluating.value ? 'Checking…' : 'Check lesson', + icon: 'mdi-check', + disabled: evaluating.value, + action: evaluating.value ? missionFooterNoop : evaluate, + mode: 'is-check' + } + } + + if (!lessonHasInput.value) { + return { + label: 'Skip lesson', + icon: 'mdi-skip-next', + disabled: !canAdvanceLesson.value, + action: canAdvanceLesson.value ? goToNextLesson : missionFooterNoop, + mode: 'is-skip' + } + } + + const icon = nextLessonMeta.value ? 'mdi-arrow-right' : nextMissionMeta.value ? 'mdi-flag-checkered' : 'mdi-arrow-right' + return { + label: nextActionLabel.value, + icon, + disabled: !canAdvanceLesson.value, + action: canAdvanceLesson.value ? goToNextLesson : missionFooterNoop, + mode: 'is-next' + } +}) + +const lessonAnswerSignature = computed(() => { + if (!activeLesson.value) return '' + return activeLesson.value.fields + .map(field => (userAnswers[field.key] ?? '').trim()) + .join('|') +}) + const previousActionLabel = computed(() => { if (prevLessonMeta.value) return 'Previous lesson' if (prevMissionMeta.value) return 'Previous module' @@ -3023,6 +3169,34 @@ watch(activeLesson, lesson => { } }) +watch(lessonAnswerSignature, (signature, previous) => { + if (previous === undefined) return + if (!result.value) return + if (signature !== previous) { + result.value = null + } +}) + +watch(hasLessonSearch, active => { + if (active) { + updateLessonSearchOverlay() + bindLessonSearchOverlay() + nextTick(() => { + updateLessonSearchOverlay() + }) + } else if (lessonSearchOverlayCleanup) { + lessonSearchOverlayCleanup() + } +}) + +watch(lessonSearchAnchor, anchor => { + if (anchor && hasLessonSearch.value) { + nextTick(() => { + updateLessonSearchOverlay() + }) + } +}) + watch(scenario, newScenario => { hasSpokenTarget.value = false if (!newScenario) { @@ -4619,11 +4793,135 @@ onMounted(() => { color: var(--accent); } -.lesson-search-results { +.lesson-search-overlay { + position: fixed; + inset: 0; + z-index: 5000; + pointer-events: none; +} + +.lesson-search-popover { + position: absolute; + background: color-mix(in srgb, var(--bg) 96%, rgba(20, 24, 31, .92)); + border: 1px solid color-mix(in srgb, var(--text) 18%, transparent); + border-radius: 18px; + box-shadow: 0 32px 64px rgba(0, 0, 0, .35); display: flex; flex-direction: column; - gap: 10px; - margin: 10px 0 20px; + gap: 8px; + max-height: min(70vh, 540px); + overflow: hidden; + pointer-events: auto; + backdrop-filter: blur(18px); +} + +.lesson-search-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px 10px; + font-size: 12px; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--t3); +} + +.link.small { + font-size: 12px; + letter-spacing: .12em; + text-transform: uppercase; +} + +.lesson-search-groups { + overflow-y: auto; + padding: 4px 8px 12px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.lesson-search-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.lesson-search-group-header { + padding: 0 10px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.lesson-search-group-title { + font-size: 13px; + font-weight: 600; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--t2); +} + +.lesson-search-group-sub { + font-size: 12px; + color: var(--t3); + line-height: 1.4; +} + +.lesson-search-item { + border: none; + background: transparent; + border-radius: 14px; + padding: 12px 14px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + text-align: left; + color: inherit; + transition: background .18s ease, transform .18s ease; +} + +.lesson-search-item:focus-visible, +.lesson-search-item:hover { + background: color-mix(in srgb, var(--accent) 14%, transparent); + transform: translateY(-1px); +} + +.lesson-search-item-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.lesson-search-item-title { + font-weight: 600; + font-size: 15px; + line-height: 1.3; + color: var(--t2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.lesson-search-item-desc { + font-size: 12px; + color: var(--t3); + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.lesson-search-item-score { + font-size: 12px; + padding: 2px 10px; +} + +.lesson-search-empty { + padding: 0 18px 18px; + font-size: 13px; + color: var(--t3); } @media (max-width: 900px) { @@ -4642,67 +4940,16 @@ onMounted(() => { width: 100%; } + .lesson-search-popover { + max-width: calc(100vw - 24px); + } + .hud-right { width: 100%; justify-content: flex-start; } } -.search-results-meta { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 12px; - letter-spacing: .14em; - text-transform: uppercase; - color: var(--t3); -} - -.link.small { - font-size: 12px; - letter-spacing: .12em; - text-transform: uppercase; -} - -.search-results-list { - display: grid; - gap: 10px; -} - -.search-hit { - text-align: left; - border: 1px solid var(--border); - background: color-mix(in srgb, var(--text) 5%, transparent); - border-radius: 18px; - padding: 14px 16px; - display: flex; - flex-direction: column; - gap: 10px; - box-shadow: 0 12px 28px rgba(0, 0, 0, .22); - transition: transform .2s ease, border-color .2s ease, box-shadow .2s ease; -} - -.search-hit:hover { - transform: translateY(-2px); - border-color: color-mix(in srgb, var(--accent) 28%, transparent); - box-shadow: 0 18px 36px rgba(0, 0, 0, .28); -} - -.search-hit-top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.search-hit-title { - font-weight: 600; - font-size: 16px; - line-height: 1.3; - flex: 1; - min-width: 0; -} - /* HUB tiles */ .hub-head { margin: 6px 0 10px @@ -5680,6 +5927,24 @@ onMounted(() => { justify-content: center; } +.mission-footer-primary { + min-width: 170px; + justify-content: center; +} + +.mission-footer-primary.is-skip { + background: color-mix(in srgb, var(--text) 8%, transparent); + color: var(--t2); +} + +.mission-footer-primary.is-skip:hover { + background: color-mix(in srgb, var(--accent) 14%, transparent); +} + +.mission-footer-primary.is-skip .v-icon { + color: currentColor; +} + .mission-footer-prev { min-width: 150px; justify-content: center;