change footer linsk

This commit is contained in:
itsrubberduck
2025-10-19 16:48:33 +02:00
parent c6c67e00a0
commit 4a5ba4033c

View File

@@ -43,7 +43,8 @@
v-if="option.id === activeExperience.id" v-if="option.id === activeExperience.id"
size="16" size="16"
class="experience-option-check" class="experience-option-check"
>mdi-check</v-icon> >mdi-check
</v-icon>
</button> </button>
</div> </div>
</v-menu> </v-menu>
@@ -81,7 +82,9 @@
aria-label="Lesson search results" aria-label="Lesson search results"
> >
<div class="lesson-search-header"> <div class="lesson-search-header">
<span>{{ lessonSearchResults.length }} {{ lessonSearchResults.length === 1 ? 'match' : 'matches' }}</span> <span>{{ lessonSearchResults.length }} {{
lessonSearchResults.length === 1 ? 'match' : 'matches'
}}</span>
<button class="link small" type="button" @click="clearLessonSearch">Clear</button> <button class="link small" type="button" @click="clearLessonSearch">Clear</button>
</div> </div>
<div v-if="lessonSearchResults.length" class="lesson-search-groups" role="presentation"> <div v-if="lessonSearchResults.length" class="lesson-search-groups" role="presentation">
@@ -94,7 +97,10 @@
> >
<div class="lesson-search-group-header"> <div class="lesson-search-group-header">
<div class="lesson-search-group-title">{{ group.module.title }}</div> <div class="lesson-search-group-title">{{ group.module.title }}</div>
<div v-if="group.module.subtitle" class="lesson-search-group-sub">{{ group.module.subtitle }}</div> <div v-if="group.module.subtitle" class="lesson-search-group-sub">{{
group.module.subtitle
}}
</div>
</div> </div>
<button <button
v-for="hit in group.hits" v-for="hit in group.hits"
@@ -106,9 +112,13 @@
> >
<div class="lesson-search-item-text"> <div class="lesson-search-item-text">
<div class="lesson-search-item-title">{{ hit.lesson.title }}</div> <div class="lesson-search-item-title">{{ hit.lesson.title }}</div>
<div class="lesson-search-item-desc">{{ hit.lesson.desc || 'Practice radio calls with ATC' }}</div> <div class="lesson-search-item-desc">{{
hit.lesson.desc || 'Practice radio calls with ATC'
}}
</div>
</div> </div>
<span class="lesson-score lesson-search-item-score" :class="lessonScoreClass(hit.module.id, hit.lesson.id)"> <span class="lesson-score lesson-search-item-score"
:class="lessonScoreClass(hit.module.id, hit.lesson.id)">
<v-icon size="14">{{ lessonScoreIcon(hit.module.id, hit.lesson.id) }}</v-icon> <v-icon size="14">{{ lessonScoreIcon(hit.module.id, hit.lesson.id) }}</v-icon>
{{ lessonScoreLabel(hit.module.id, hit.lesson.id) }} {{ lessonScoreLabel(hit.module.id, hit.lesson.id) }}
</span> </span>
@@ -142,7 +152,6 @@
</header> </header>
<!-- HUB --> <!-- HUB -->
<main v-if="panel==='hub'" class="container" role="main"> <main v-if="panel==='hub'" class="container" role="main">
<div class="hub-head"> <div class="hub-head">
@@ -204,7 +213,8 @@
type="button" type="button"
class="tile-overlay-link" class="tile-overlay-link"
@click.stop.prevent="attemptUnlockModule(m.id)" @click.stop.prevent="attemptUnlockModule(m.id)"
>unlock</button> >unlock
</button>
this briefing. this briefing.
</div> </div>
</div> </div>
@@ -235,11 +245,13 @@
<div class="play-tools"> <div class="play-tools">
<div v-if="requiresFlightPlan" class="plan-status" :class="{ 'is-ready': !!currentPlan }"> <div v-if="requiresFlightPlan" class="plan-status" :class="{ 'is-ready': !!currentPlan }">
<div class="plan-status-icon"> <div class="plan-status-icon">
<v-icon :icon="currentPlan ? 'mdi-check-circle-outline' : 'mdi-alert-circle-outline'" size="22" /> <v-icon :icon="currentPlan ? 'mdi-check-circle-outline' : 'mdi-alert-circle-outline'" size="22"/>
</div> </div>
<div class="plan-status-body"> <div class="plan-status-body">
<span class="plan-status-title"> <span class="plan-status-title">
{{ currentPlan ? (currentPlan.scenario.callsign || currentPlan.scenario.radioCall) : 'Flight plan pending' }} {{
currentPlan ? (currentPlan.scenario.callsign || currentPlan.scenario.radioCall) : 'Flight plan pending'
}}
</span> </span>
<span class="plan-status-sub" v-if="currentPlan">{{ currentPlanRoute }}</span> <span class="plan-status-sub" v-if="currentPlan">{{ currentPlanRoute }}</span>
<span class="plan-status-sub muted" v-else>Select or import a flight to launch.</span> <span class="plan-status-sub muted" v-else>Select or import a flight to launch.</span>
@@ -256,7 +268,9 @@
</div> </div>
</div> </div>
<div v-if="moduleStage==='lessons'" class="stats"> <div v-if="moduleStage==='lessons'" class="stats">
<span class="stat"><v-icon size="18">mdi-check-circle-outline</v-icon> {{ doneCount(current.id) }}/{{ current.lessons.length }}</span> <span class="stat"><v-icon size="18">mdi-check-circle-outline</v-icon> {{
doneCount(current.id)
}}/{{ current.lessons.length }}</span>
<span class="stat"><v-icon size="18">mdi-star</v-icon> Ø {{ avgScore(current.id) }}%</span> <span class="stat"><v-icon size="18">mdi-star</v-icon> Ø {{ avgScore(current.id) }}%</span>
</div> </div>
</div> </div>
@@ -289,7 +303,7 @@
<div v-if="flightPlanMode==='random'" class="plan-panel"> <div v-if="flightPlanMode==='random'" class="plan-panel">
<div class="plan-summary"> <div class="plan-summary">
<NuxtImg :src="currentBriefingArt" alt="Mission hero" class="plan-hero" /> <NuxtImg :src="currentBriefingArt" alt="Mission hero" class="plan-hero"/>
<div class="plan-summary-body"> <div class="plan-summary-body">
<span class="plan-tag">Auto flight</span> <span class="plan-tag">Auto flight</span>
<div class="plan-callout">{{ displayCallsign(draftPlanScenario?.radioCall, draftPlanScenario) }}</div> <div class="plan-callout">{{ displayCallsign(draftPlanScenario?.radioCall, draftPlanScenario) }}</div>
@@ -309,7 +323,8 @@
</div> </div>
</div> </div>
<form v-else-if="flightPlanMode==='manual'" class="plan-panel manual-panel" @submit.prevent="handleManualSubmit"> <form v-else-if="flightPlanMode==='manual'" class="plan-panel manual-panel"
@submit.prevent="handleManualSubmit">
<div class="manual-grid"> <div class="manual-grid">
<div class="manual-form"> <div class="manual-form">
<div class="manual-card manual-card--intro"> <div class="manual-card manual-card--intro">
@@ -319,7 +334,8 @@
</div> </div>
<div> <div>
<div class="manual-card-title">Design your own mission</div> <div class="manual-card-title">Design your own mission</div>
<p class="muted small">Fill in the essentials below. Expand the optional sections when you want to brief gates, taxi routes or procedures.</p> <p class="muted small">Fill in the essentials below. Expand the optional sections when you want to
brief gates, taxi routes or procedures.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -331,29 +347,30 @@
</div> </div>
<div> <div>
<div class="manual-card-title">Core flight data</div> <div class="manual-card-title">Core flight data</div>
<p class="muted small">We only need a callsign plus departure and destination to spin up a training scenario.</p> <p class="muted small">We only need a callsign plus departure and destination to spin up a training
scenario.</p>
</div> </div>
</div> </div>
<div class="field-grid required-grid"> <div class="field-grid required-grid">
<label class="field"> <label class="field">
<span>Airline ICAO<span class="required-dot" aria-hidden="true"></span></span> <span>Airline ICAO<span class="required-dot" aria-hidden="true"></span></span>
<input v-model="manualForm.airlineCode" maxlength="4" placeholder="DLH" /> <input v-model="manualForm.airlineCode" maxlength="4" placeholder="DLH"/>
</label> </label>
<label class="field"> <label class="field">
<span>Flight number<span class="required-dot" aria-hidden="true"></span></span> <span>Flight number<span class="required-dot" aria-hidden="true"></span></span>
<input v-model="manualForm.flightNumber" placeholder="400" /> <input v-model="manualForm.flightNumber" placeholder="400"/>
</label> </label>
<label class="field"> <label class="field">
<span>Spoken callsign</span> <span>Spoken callsign</span>
<input v-model="manualForm.airlineCall" placeholder="Lufthansa" /> <input v-model="manualForm.airlineCall" placeholder="Lufthansa"/>
</label> </label>
<label class="field"> <label class="field">
<span>Departure ICAO<span class="required-dot" aria-hidden="true"></span></span> <span>Departure ICAO<span class="required-dot" aria-hidden="true"></span></span>
<input v-model="manualForm.departureIcao" placeholder="EDDF" maxlength="4" /> <input v-model="manualForm.departureIcao" placeholder="EDDF" maxlength="4"/>
</label> </label>
<label class="field"> <label class="field">
<span>Destination ICAO<span class="required-dot" aria-hidden="true"></span></span> <span>Destination ICAO<span class="required-dot" aria-hidden="true"></span></span>
<input v-model="manualForm.destinationIcao" placeholder="KJFK" maxlength="4" /> <input v-model="manualForm.destinationIcao" placeholder="KJFK" maxlength="4"/>
</label> </label>
</div> </div>
</div> </div>
@@ -379,31 +396,31 @@
<div class="field-grid"> <div class="field-grid">
<label class="field"> <label class="field">
<span>City</span> <span>City</span>
<input v-model="manualForm.departureCity" placeholder="Frankfurt" /> <input v-model="manualForm.departureCity" placeholder="Frankfurt"/>
</label> </label>
<label class="field"> <label class="field">
<span>Airport name</span> <span>Airport name</span>
<input v-model="manualForm.departureName" placeholder="Frankfurt/Main" /> <input v-model="manualForm.departureName" placeholder="Frankfurt/Main"/>
</label> </label>
<label class="field"> <label class="field">
<span>Stand</span> <span>Stand</span>
<input v-model="manualForm.stand" placeholder="A12" /> <input v-model="manualForm.stand" placeholder="A12"/>
</label> </label>
<label class="field"> <label class="field">
<span>Taxi route</span> <span>Taxi route</span>
<input v-model="manualForm.taxiRoute" placeholder="N3 U4" /> <input v-model="manualForm.taxiRoute" placeholder="N3 U4"/>
</label> </label>
<label class="field"> <label class="field">
<span>Runway</span> <span>Runway</span>
<input v-model="manualForm.departureRunway" placeholder="25C" /> <input v-model="manualForm.departureRunway" placeholder="25C"/>
</label> </label>
<label class="field"> <label class="field">
<span>SID</span> <span>SID</span>
<input v-model="manualForm.sid" placeholder="ANEKI 7S" /> <input v-model="manualForm.sid" placeholder="ANEKI 7S"/>
</label> </label>
<label class="field"> <label class="field">
<span>Transition</span> <span>Transition</span>
<input v-model="manualForm.transition" placeholder="ANEKI" /> <input v-model="manualForm.transition" placeholder="ANEKI"/>
</label> </label>
</div> </div>
</div> </div>
@@ -431,35 +448,35 @@
<div class="field-grid"> <div class="field-grid">
<label class="field"> <label class="field">
<span>City</span> <span>City</span>
<input v-model="manualForm.destinationCity" placeholder="New York" /> <input v-model="manualForm.destinationCity" placeholder="New York"/>
</label> </label>
<label class="field"> <label class="field">
<span>Airport name</span> <span>Airport name</span>
<input v-model="manualForm.destinationName" placeholder="John F. Kennedy" /> <input v-model="manualForm.destinationName" placeholder="John F. Kennedy"/>
</label> </label>
<label class="field"> <label class="field">
<span>Runway</span> <span>Runway</span>
<input v-model="manualForm.arrivalRunway" placeholder="22R" /> <input v-model="manualForm.arrivalRunway" placeholder="22R"/>
</label> </label>
<label class="field"> <label class="field">
<span>STAR</span> <span>STAR</span>
<input v-model="manualForm.arrivalStar" placeholder="ROBER 3" /> <input v-model="manualForm.arrivalStar" placeholder="ROBER 3"/>
</label> </label>
<label class="field"> <label class="field">
<span>Transition</span> <span>Transition</span>
<input v-model="manualForm.arrivalTransition" placeholder="ROBER" /> <input v-model="manualForm.arrivalTransition" placeholder="ROBER"/>
</label> </label>
<label class="field"> <label class="field">
<span>Approach</span> <span>Approach</span>
<input v-model="manualForm.approach" placeholder="ILS 22R" /> <input v-model="manualForm.approach" placeholder="ILS 22R"/>
</label> </label>
<label class="field"> <label class="field">
<span>Arrival stand</span> <span>Arrival stand</span>
<input v-model="manualForm.arrivalStand" placeholder="Gate 5" /> <input v-model="manualForm.arrivalStand" placeholder="Gate 5"/>
</label> </label>
<label class="field"> <label class="field">
<span>Taxi-in</span> <span>Taxi-in</span>
<input v-model="manualForm.arrivalTaxiRoute" placeholder="B K5" /> <input v-model="manualForm.arrivalTaxiRoute" placeholder="B K5"/>
</label> </label>
</div> </div>
</div> </div>
@@ -477,7 +494,8 @@
<v-icon size="20">mdi-altimeter</v-icon> <v-icon size="20">mdi-altimeter</v-icon>
</div> </div>
<div class="optional-body"> <div class="optional-body">
<div class="manual-card-title">Altitude &amp; codes <span class="optional-chip">optional</span></div> <div class="manual-card-title">Altitude &amp; codes <span class="optional-chip">optional</span>
</div>
<p class="muted small">Initial altitudes, squawk, push timing and remarks.</p> <p class="muted small">Initial altitudes, squawk, push timing and remarks.</p>
</div> </div>
<v-icon size="18" class="chevron">mdi-chevron-down</v-icon> <v-icon size="18" class="chevron">mdi-chevron-down</v-icon>
@@ -487,23 +505,23 @@
<div class="field-grid"> <div class="field-grid">
<label class="field"> <label class="field">
<span>Initial altitude (ft)</span> <span>Initial altitude (ft)</span>
<input v-model="manualForm.initialAltitude" inputmode="numeric" placeholder="5000" /> <input v-model="manualForm.initialAltitude" inputmode="numeric" placeholder="5000"/>
</label> </label>
<label class="field"> <label class="field">
<span>Climb altitude (ft)</span> <span>Climb altitude (ft)</span>
<input v-model="manualForm.climbAltitude" inputmode="numeric" placeholder="7000" /> <input v-model="manualForm.climbAltitude" inputmode="numeric" placeholder="7000"/>
</label> </label>
<label class="field"> <label class="field">
<span>Squawk</span> <span>Squawk</span>
<input v-model="manualForm.squawk" placeholder="4213" /> <input v-model="manualForm.squawk" placeholder="4213"/>
</label> </label>
<label class="field"> <label class="field">
<span>Push delay (min)</span> <span>Push delay (min)</span>
<input v-model="manualForm.pushDelay" inputmode="numeric" placeholder="5" /> <input v-model="manualForm.pushDelay" inputmode="numeric" placeholder="5"/>
</label> </label>
<label class="field wide"> <label class="field wide">
<span>Briefing notes</span> <span>Briefing notes</span>
<input v-model="manualForm.remarks" placeholder="Optional remarks" /> <input v-model="manualForm.remarks" placeholder="Optional remarks"/>
</label> </label>
</div> </div>
</div> </div>
@@ -571,12 +589,14 @@
<div v-else class="plan-panel simbrief-panel"> <div v-else class="plan-panel simbrief-panel">
<div class="simbrief-hero-card"> <div class="simbrief-hero-card">
<NuxtImg src="/img/learn/missions/full-flight/briefing-hero.png" alt="SimBrief import preview" class="simbrief-hero-art" format="webp" /> <NuxtImg src="/img/learn/missions/full-flight/briefing-hero.png" alt="SimBrief import preview"
class="simbrief-hero-art" format="webp"/>
<div class="simbrief-hero-overlay"> <div class="simbrief-hero-overlay">
<span class="simbrief-tag">SimBrief import</span> <span class="simbrief-tag">SimBrief import</span>
<h3 class="simbrief-hero-title">Load your airline dispatch</h3> <h3 class="simbrief-hero-title">Load your airline dispatch</h3>
<p class="simbrief-hero-text"> <p class="simbrief-hero-text">
Sync the exact OFP you're flying with one click. We'll transform it into a mission-ready briefing and readback drill. Sync the exact OFP you're flying with one click. We'll transform it into a mission-ready briefing and
readback drill.
</p> </p>
<div class="simbrief-hero-highlights"> <div class="simbrief-hero-highlights">
<span><v-icon size="16">mdi-airplane-cog</v-icon> Real routes</span> <span><v-icon size="16">mdi-airplane-cog</v-icon> Real routes</span>
@@ -607,7 +627,8 @@
<span class="step-number">2</span> <span class="step-number">2</span>
<span class="simbrief-step-title">Copy your Pilot ID</span> <span class="simbrief-step-title">Copy your Pilot ID</span>
</div> </div>
<p>Find the ID on the share link of the finished OFP it's the number we use to fetch your dispatch.</p> <p>Find the ID on the share link of the finished OFP it's the number we use to fetch your
dispatch.</p>
</div> </div>
<div class="simbrief-step-card"> <div class="simbrief-step-card">
<div class="simbrief-step-head"> <div class="simbrief-step-head">
@@ -644,7 +665,7 @@
</p> </p>
<div v-if="simbriefForm.loading" class="simbrief-status"> <div v-if="simbriefForm.loading" class="simbrief-status">
<v-progress-circular indeterminate color="cyan" size="20" /> <v-progress-circular indeterminate color="cyan" size="20"/>
<span class="muted small">Contacting SimBrief…</span> <span class="muted small">Contacting SimBrief…</span>
</div> </div>
<div v-if="flightPlanError" class="error-banner"> <div v-if="flightPlanError" class="error-banner">
@@ -667,7 +688,7 @@
<div v-else-if="moduleStage==='briefing' && briefingSnapshot" class="module-stage-panel mission-briefing"> <div v-else-if="moduleStage==='briefing' && briefingSnapshot" class="module-stage-panel mission-briefing">
<section class="briefing-hero-banner"> <section class="briefing-hero-banner">
<NuxtImg :src="currentBriefingArt" alt="Mission hero" class="briefing-hero-bg" /> <NuxtImg :src="currentBriefingArt" alt="Mission hero" class="briefing-hero-bg"/>
<div class="briefing-hero-content"> <div class="briefing-hero-content">
<div class="briefing-tag-row"> <div class="briefing-tag-row">
<span class="plan-tag">Mission briefing</span> <span class="plan-tag">Mission briefing</span>
@@ -714,7 +735,8 @@
<div class="briefing-layout"> <div class="briefing-layout">
<div class="briefing-main-grid"> <div class="briefing-main-grid">
<div class="briefing-card"> <div class="briefing-card">
<NuxtImg src="/img/learn/missions/full-flight/briefing-route.png" alt="Route preview" class="briefing-card-art" format="webp" /> <NuxtImg src="/img/learn/missions/full-flight/briefing-route.png" alt="Route preview"
class="briefing-card-art" format="webp"/>
<div class="card-title"> <div class="card-title">
<v-icon size="16">mdi-map-marker-path</v-icon> <v-icon size="16">mdi-map-marker-path</v-icon>
Flight deck setup Flight deck setup
@@ -722,11 +744,14 @@
<ul class="briefing-list"> <ul class="briefing-list">
<li><strong>Push</strong>: {{ briefingSnapshot.codes.push }}</li> <li><strong>Push</strong>: {{ briefingSnapshot.codes.push }}</li>
<li><strong>ATIS</strong>: Information {{ briefingSnapshot.departure.atis }}</li> <li><strong>ATIS</strong>: Information {{ briefingSnapshot.departure.atis }}</li>
<li><strong>Delivery</strong>: {{ briefingSnapshot.departure.freq }} · {{ briefingSnapshot.departure.freqWords }}</li> <li><strong>Delivery</strong>: {{ briefingSnapshot.departure.freq }} ·
{{ briefingSnapshot.departure.freqWords }}
</li>
</ul> </ul>
</div> </div>
<div class="briefing-card"> <div class="briefing-card">
<NuxtImg src="/img/learn/missions/full-flight/briefing-departure.png" alt="Departure" class="briefing-card-art" format="webp" /> <NuxtImg src="/img/learn/missions/full-flight/briefing-departure.png" alt="Departure"
class="briefing-card-art" format="webp"/>
<div class="card-title"> <div class="card-title">
<v-icon size="16">mdi-airplane-takeoff</v-icon> <v-icon size="16">mdi-airplane-takeoff</v-icon>
Departure flow Departure flow
@@ -734,25 +759,32 @@
<ul class="briefing-list"> <ul class="briefing-list">
<li><strong>Stand</strong>: {{ briefingSnapshot.departure.stand || 'As assigned' }}</li> <li><strong>Stand</strong>: {{ briefingSnapshot.departure.stand || 'As assigned' }}</li>
<li><strong>Taxi</strong>: {{ briefingSnapshot.departure.taxiRoute || 'As assigned' }}</li> <li><strong>Taxi</strong>: {{ briefingSnapshot.departure.taxiRoute || 'As assigned' }}</li>
<li><strong>SID</strong>: {{ briefingSnapshot.departure.sid }} · {{ briefingSnapshot.departure.transition }}</li> <li><strong>SID</strong>: {{ briefingSnapshot.departure.sid }} ·
{{ briefingSnapshot.departure.transition }}
</li>
<li><strong>Initial altitude</strong>: {{ briefingSnapshot.altitudes.initial }}</li> <li><strong>Initial altitude</strong>: {{ briefingSnapshot.altitudes.initial }}</li>
</ul> </ul>
</div> </div>
<div class="briefing-card"> <div class="briefing-card">
<NuxtImg src="/img/learn/missions/full-flight/briefing-arrival.png" alt="Arrival" class="briefing-card-art" format="webp" /> <NuxtImg src="/img/learn/missions/full-flight/briefing-arrival.png" alt="Arrival"
class="briefing-card-art" format="webp"/>
<div class="card-title"> <div class="card-title">
<v-icon size="16">mdi-airplane-landing</v-icon> <v-icon size="16">mdi-airplane-landing</v-icon>
Arrival setup Arrival setup
</div> </div>
<ul class="briefing-list"> <ul class="briefing-list">
<li><strong>STAR</strong>: {{ briefingSnapshot.arrival.star }} · {{ briefingSnapshot.arrival.transition }}</li> <li><strong>STAR</strong>: {{ briefingSnapshot.arrival.star }} · {{
briefingSnapshot.arrival.transition
}}
</li>
<li><strong>Approach</strong>: {{ briefingSnapshot.arrival.approach }}</li> <li><strong>Approach</strong>: {{ briefingSnapshot.arrival.approach }}</li>
<li><strong>Taxi-in</strong>: {{ briefingSnapshot.arrival.taxiRoute || 'As assigned' }}</li> <li><strong>Taxi-in</strong>: {{ briefingSnapshot.arrival.taxiRoute || 'As assigned' }}</li>
<li><strong>Arrival stand</strong>: {{ briefingSnapshot.arrival.stand || 'As assigned' }}</li> <li><strong>Arrival stand</strong>: {{ briefingSnapshot.arrival.stand || 'As assigned' }}</li>
</ul> </ul>
</div> </div>
<div class="briefing-card"> <div class="briefing-card">
<NuxtImg src="/img/learn/missions/full-flight/briefing-weather.png" alt="Weather" class="briefing-card-art" format="webp" /> <NuxtImg src="/img/learn/missions/full-flight/briefing-weather.png" alt="Weather"
class="briefing-card-art" format="webp"/>
<div class="card-title"> <div class="card-title">
<v-icon size="16">mdi-weather-cloudy</v-icon> <v-icon size="16">mdi-weather-cloudy</v-icon>
Weather snapshot Weather snapshot
@@ -776,21 +808,25 @@
<span class="check-number">1</span> <span class="check-number">1</span>
<div> <div>
<div class="check-title">Clearance &amp; push</div> <div class="check-title">Clearance &amp; push</div>
<p class="muted small">Tune delivery, confirm ATIS {{ briefingSnapshot.departure.atis }} and expect push {{ briefingSnapshot.codes.push }}.</p> <p class="muted small">Tune delivery, confirm ATIS {{ briefingSnapshot.departure.atis }} and expect
push {{ briefingSnapshot.codes.push }}.</p>
</div> </div>
</li> </li>
<li> <li>
<span class="check-number">2</span> <span class="check-number">2</span>
<div> <div>
<div class="check-title">Taxi &amp; departure</div> <div class="check-title">Taxi &amp; departure</div>
<p class="muted small">Follow taxi {{ briefingSnapshot.departure.taxiRoute || 'as assigned' }} to RWY {{ briefingSnapshot.departure.runway }} and fly the {{ briefingSnapshot.departure.sid }}.</p> <p class="muted small">Follow taxi {{ briefingSnapshot.departure.taxiRoute || 'as assigned' }} to
RWY {{ briefingSnapshot.departure.runway }} and fly the {{ briefingSnapshot.departure.sid }}.</p>
</div> </div>
</li> </li>
<li> <li>
<span class="check-number">3</span> <span class="check-number">3</span>
<div> <div>
<div class="check-title">Arrival briefing</div> <div class="check-title">Arrival briefing</div>
<p class="muted small">Plan for {{ briefingSnapshot.arrival.star }} leading to {{ briefingSnapshot.arrival.approach }} and taxi to {{ briefingSnapshot.arrival.stand || 'assigned stand' }}.</p> <p class="muted small">Plan for {{ briefingSnapshot.arrival.star }} leading to
{{ briefingSnapshot.arrival.approach }} and taxi to
{{ briefingSnapshot.arrival.stand || 'assigned stand' }}.</p>
</div> </div>
</li> </li>
</ol> </ol>
@@ -809,8 +845,8 @@
Adjust plan Adjust plan
</button> </button>
<button class="btn primary" type="button" @click="handleBriefingConfirm()"> <button class="btn primary" type="button" @click="handleBriefingConfirm()">
<v-icon size="18">{{ briefingReturnStage==='setup' ? 'mdi-airplane' : 'mdi-play-circle' }}</v-icon> <v-icon size="18">{{ briefingReturnStage === 'setup' ? 'mdi-airplane' : 'mdi-play-circle' }}</v-icon>
{{ briefingReturnStage==='setup' ? 'Start mission' : 'Return to mission' }} {{ briefingReturnStage === 'setup' ? 'Start mission' : 'Return to mission' }}
</button> </button>
</div> </div>
</div> </div>
@@ -1008,7 +1044,9 @@
<template v-for="(segment, idx) in activeLesson.readback" <template v-for="(segment, idx) in activeLesson.readback"
:key="segment.type === 'field' ? `f-${segment.key}` : `t-${idx}`"> :key="segment.type === 'field' ? `f-${segment.key}` : `t-${idx}`">
<span v-if="segment.type === 'text'" class="cloze-chunk cloze-text"> <span v-if="segment.type === 'text'" class="cloze-chunk cloze-text">
{{ displayCallsign(typeof segment.text === 'function' && scenario ? segment.text(scenario) : segment.text) }} {{
displayCallsign(typeof segment.text === 'function' && scenario ? segment.text(scenario) : segment.text)
}}
</span> </span>
<label <label
v-else v-else
@@ -1065,7 +1103,8 @@
<div class="lesson-tip-body"> <div class="lesson-tip-body">
<div class="lesson-tip-title">Did you know?</div> <div class="lesson-tip-title">Did you know?</div>
<p class="muted small"> <p class="muted small">
Use the dice icon “New scenario” to rehearse the same call with fresh data instantly. Just click it and you'll Use the dice icon “New scenario” to rehearse the same call with fresh data instantly. Just click it and
you'll
get different values repeat that 510 times per lesson and the radio call will really stick. get different values repeat that 510 times per lesson and the radio call will really stick.
</p> </p>
</div> </div>
@@ -1075,7 +1114,6 @@
</section> </section>
<!-- NEXT OBJECTIVE --> <!-- NEXT OBJECTIVE -->
<div class="container" v-if="panel==='hub'"> <div class="container" v-if="panel==='hub'">
<h2 class="h2">Your progress</h2> <h2 class="h2">Your progress</h2>
@@ -1167,10 +1205,13 @@
</div> </div>
</div> </div>
<div v-else class="container footer-container"> <div v-else class="container footer-container">
<div class="footer-meta"> <div class="footer-meta gap-2 flex items-center justify-center">
<span class="muted small">&copy; 2025 OpenSquawk. All rights reserved.</span> <span class="muted small">
<NuxtLink to="/feedback" target="_blank" class="link ml-2">Give feedback </NuxtLink> <a href="/" class="link">
<a href="/" class="link ml-4">Back to opensquawk.de </a> Visit home</a>
</span>
&middot;
<NuxtLink to="/feedback" target="_blank" class="link">Give feedback </NuxtLink>
</div> </div>
</div> </div>
</footer> </footer>
@@ -1278,7 +1319,14 @@ import {useAuthStore} from '~/stores/auth'
import {createDefaultLearnConfig} from '~~/shared/learn/config' import {createDefaultLearnConfig} from '~~/shared/learn/config'
import type {LearnConfig, LearnProgress, LearnState} from '~~/shared/learn/config' import type {LearnConfig, LearnProgress, LearnState} from '~~/shared/learn/config'
import {learnModules, seedFullFlightScenario} from '~~/shared/data/learnModules' import {learnModules, seedFullFlightScenario} from '~~/shared/data/learnModules'
import {createBaseScenario, digitsToWords, lettersToNato, runwayToWords, altitudeToWords, minutesToWords} from '~~/shared/learn/scenario' import {
createBaseScenario,
digitsToWords,
lettersToNato,
runwayToWords,
altitudeToWords,
minutesToWords
} from '~~/shared/learn/scenario'
import type {BlankWidth, Frequency, Lesson, LessonField, ModuleDef, Scenario} from '~~/shared/learn/types' import type {BlankWidth, Frequency, Lesson, LessonField, ModuleDef, Scenario} from '~~/shared/learn/types'
import {loadPizzicatoLite} from '~~/shared/utils/pizzicatoLite' import {loadPizzicatoLite} from '~~/shared/utils/pizzicatoLite'
import type {PizzicatoLite} from '~~/shared/utils/pizzicatoLite' import type {PizzicatoLite} from '~~/shared/utils/pizzicatoLite'
@@ -1396,8 +1444,8 @@ const lessonSearchResults = computed<LessonSearchHit[]>(() => {
modules.value.forEach(module => { modules.value.forEach(module => {
module.lessons.forEach(lesson => { module.lessons.forEach(lesson => {
const searchable = [lesson.title, lesson.desc, module.title, module.subtitle, (lesson.keywords || []).join(' ')] const searchable = [lesson.title, lesson.desc, module.title, module.subtitle, (lesson.keywords || []).join(' ')]
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
const haystack = norm(searchable) const haystack = norm(searchable)
const matchesTerms = terms.every(term => haystack.includes(term)) const matchesTerms = terms.every(term => haystack.includes(term))
const scores = [ const scores = [
@@ -1421,15 +1469,15 @@ const lessonSearchResults = computed<LessonSearchHit[]>(() => {
}) })
return results return results
.sort((a, b) => { .sort((a, b) => {
if (b.score === a.score) { if (b.score === a.score) {
const moduleCompare = a.module.title.localeCompare(b.module.title) const moduleCompare = a.module.title.localeCompare(b.module.title)
if (moduleCompare !== 0) return moduleCompare if (moduleCompare !== 0) return moduleCompare
return a.lesson.title.localeCompare(b.lesson.title) return a.lesson.title.localeCompare(b.lesson.title)
} }
return b.score - a.score return b.score - a.score
}) })
.slice(0, 20) .slice(0, 20)
}) })
type LessonSearchGroup = { type LessonSearchGroup = {
@@ -1455,16 +1503,16 @@ const lessonSearchGroups = computed<LessonSearchGroup[]>(() => {
} }
return Array.from(groups.values()) return Array.from(groups.values())
.map(group => ({ .map(group => ({
...group, ...group,
hits: group.hits.sort((a, b) => b.score - a.score) hits: group.hits.sort((a, b) => b.score - a.score)
})) }))
.sort((a, b) => { .sort((a, b) => {
if (b.score === a.score) { if (b.score === a.score) {
return a.module.title.localeCompare(b.module.title) return a.module.title.localeCompare(b.module.title)
} }
return b.score - a.score return b.score - a.score
}) })
}) })
function computeLessonSearchOverlayStyle(): Record<string, string> { function computeLessonSearchOverlayStyle(): Record<string, string> {
@@ -1582,7 +1630,7 @@ function displayCallsign(value?: string | null, source?: CallsignContext | null)
if (!value) return '' if (!value) return ''
const context = source ?? scenario.value const context = source ?? scenario.value
if (!context) return value if (!context) return value
const { radioCall, callsign } = context const {radioCall, callsign} = context
if (radioCall && callsign && value.includes(radioCall)) { if (radioCall && callsign && value.includes(radioCall)) {
return value.split(radioCall).join(callsign) return value.split(radioCall).join(callsign)
} }
@@ -1629,7 +1677,7 @@ const manualForm = reactive<ManualForm>({
}) })
const manualErrors = ref<string[]>([]) const manualErrors = ref<string[]>([])
const flightPlanError = ref<string | null>(null) const flightPlanError = ref<string | null>(null)
const simbriefForm = reactive({ userId: '', loading: false }) const simbriefForm = reactive({userId: '', loading: false})
const simbriefPlanMeta = ref<{ callsign: string; route: string } | null>(null) const simbriefPlanMeta = ref<{ callsign: string; route: string } | null>(null)
const lessonTrack = ref<HTMLElement | null>(null) const lessonTrack = ref<HTMLElement | null>(null)
const moduleOverviewExpanded = ref(false) const moduleOverviewExpanded = ref(false)
@@ -1689,7 +1737,7 @@ const activeExperience = computed<ExperienceOption>(() => {
async function handleExperienceSelect(option: ExperienceOption) { async function handleExperienceSelect(option: ExperienceOption) {
experienceMenu.value = false experienceMenu.value = false
if (option.matches(route.path)) return if (option.matches(route.path)) return
if( option.target === '_blank') { if (option.target === '_blank') {
window.open(option.to, '_blank') window.open(option.to, '_blank')
return return
} }
@@ -1735,10 +1783,10 @@ function computeQuerySignature(query: Record<string, unknown>): string {
} }
const isValidStage = (value: string | null): value is 'lessons' | 'setup' | 'briefing' => const isValidStage = (value: string | null): value is 'lessons' | 'setup' | 'briefing' =>
value === 'lessons' || value === 'setup' || value === 'briefing' value === 'lessons' || value === 'setup' || value === 'briefing'
const isValidPlanMode = (value: string | null): value is FlightPlanMode => const isValidPlanMode = (value: string | null): value is FlightPlanMode =>
value === 'random' || value === 'manual' || value === 'simbrief' value === 'random' || value === 'manual' || value === 'simbrief'
function buildStateRouteQuery(): Record<string, string> { function buildStateRouteQuery(): Record<string, string> {
const state: Record<string, string> = {} const state: Record<string, string> = {}
@@ -1896,25 +1944,25 @@ function applyRouteStateFromQuery(query: RouteQueryLike) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
watch( watch(
() => [panel.value, current.value?.id ?? null, moduleStage.value, activeLesson.value?.id ?? null, flightPlanMode.value], () => [panel.value, current.value?.id ?? null, moduleStage.value, activeLesson.value?.id ?? null, flightPlanMode.value],
() => { () => {
void syncRouteFromState() void syncRouteFromState()
} }
) )
watch( watch(
() => route.query, () => route.query,
newQuery => { newQuery => {
if (isSyncingRoute) { if (isSyncingRoute) {
lastSyncedQuerySignature = computeQuerySignature(newQuery as Record<string, unknown>) lastSyncedQuerySignature = computeQuerySignature(newQuery as Record<string, unknown>)
return return
}
const signature = computeQuerySignature(newQuery as Record<string, unknown>)
if (signature === lastSyncedQuerySignature) {
return
}
applyRouteStateFromQuery(newQuery as RouteQueryLike)
} }
const signature = computeQuerySignature(newQuery as Record<string, unknown>)
if (signature === lastSyncedQuerySignature) {
return
}
applyRouteStateFromQuery(newQuery as RouteQueryLike)
}
) )
} }
@@ -2002,16 +2050,16 @@ const flightPlanModes: Array<{ id: FlightPlanMode; title: string; icon: string;
] ]
watch( watch(
() => simbriefForm.userId, () => simbriefForm.userId,
value => { value => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
const trimmed = value.trim() const trimmed = value.trim()
if (trimmed) { if (trimmed) {
window.localStorage.setItem(SIMBRIEF_STORAGE_KEY, trimmed) window.localStorage.setItem(SIMBRIEF_STORAGE_KEY, trimmed)
} else { } else {
window.localStorage.removeItem(SIMBRIEF_STORAGE_KEY) window.localStorage.removeItem(SIMBRIEF_STORAGE_KEY)
}
} }
}
) )
function toggleManualSection(section: ManualSection) { function toggleManualSection(section: ManualSection) {
@@ -2410,7 +2458,7 @@ async function loadSimbriefPlan() {
manualErrors.value = [] manualErrors.value = []
simbriefForm.loading = true simbriefForm.loading = true
try { try {
const response = await api.get<{ data: any }>('/api/learn/simbrief', { query: { userId }, auth: true }) const response = await api.get<{ data: any }>('/api/learn/simbrief', {query: {userId}, auth: true})
const payload = response?.data const payload = response?.data
if (!payload) { if (!payload) {
flightPlanError.value = 'No SimBrief dispatch found.' flightPlanError.value = 'No SimBrief dispatch found.'
@@ -2614,6 +2662,7 @@ function normalizeSimbriefPlan(raw: any): MissionPlanInput {
return plan return plan
} }
const hasSpokenTarget = ref(false) const hasSpokenTarget = ref(false)
const pendingAutoSay = ref(false) const pendingAutoSay = ref(false)
const activeFrequency = ref<Frequency | null>(null) const activeFrequency = ref<Frequency | null>(null)
@@ -2848,9 +2897,9 @@ type LearnStateResponse = LearnState
function sanitizeModuleList(value: unknown): string[] { function sanitizeModuleList(value: unknown): string[] {
if (!Array.isArray(value)) return [] if (!Array.isArray(value)) return []
const sanitized = value const sanitized = value
.filter(item => typeof item === 'string') .filter(item => typeof item === 'string')
.map(item => item.trim()) .map(item => item.trim())
.filter(item => item.length > 0) .filter(item => item.length > 0)
return Array.from(new Set(sanitized)) return Array.from(new Set(sanitized))
} }
@@ -3313,8 +3362,8 @@ const missionFooterPrimary = computed(() => {
const lessonAnswerSignature = computed(() => { const lessonAnswerSignature = computed(() => {
if (!activeLesson.value) return '' if (!activeLesson.value) return ''
return activeLesson.value.fields return activeLesson.value.fields
.map(field => (userAnswers[field.key] ?? '').trim()) .map(field => (userAnswers[field.key] ?? '').trim())
.join('|') .join('|')
}) })
const previousActionLabel = computed(() => { const previousActionLabel = computed(() => {
@@ -5569,9 +5618,8 @@ onMounted(() => {
position: relative; position: relative;
overflow: visible; overflow: visible;
border-color: color-mix(in srgb, var(--accent) 38%, transparent); border-color: color-mix(in srgb, var(--accent) 38%, transparent);
background: background: radial-gradient(420px 260px at -10% -20%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%),
radial-gradient(420px 260px at -10% -20%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%), linear-gradient(150deg, color-mix(in srgb, var(--bg2) 82%, transparent), color-mix(in srgb, var(--text) 6%, transparent));
linear-gradient(150deg, color-mix(in srgb, var(--bg2) 82%, transparent), color-mix(in srgb, var(--text) 6%, transparent));
--module-overview-gap: 28px; --module-overview-gap: 28px;
--lesson-track-max-height: 400px; --lesson-track-max-height: 400px;
--lesson-track-opacity: 1; --lesson-track-opacity: 1;
@@ -7645,33 +7693,42 @@ onMounted(() => {
.plan-mode-card { .plan-mode-card {
min-width: 180px; min-width: 180px;
} }
.plan-summary { .plan-summary {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
.plan-hero { .plan-hero {
width: 100%; width: 100%;
height: 140px; height: 140px;
} }
.plan-actions .btn, .plan-actions .btn,
.briefing-actions .btn { .briefing-actions .btn {
flex: 1 1 100%; flex: 1 1 100%;
} }
.manual-grid { .manual-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.manual-preview { .manual-preview {
position: static; position: static;
} }
.briefing-hero-content { .briefing-hero-content {
padding: 24px; padding: 24px;
} }
.briefing-hero-title { .briefing-hero-title {
font-size: 28px; font-size: 28px;
} }
.briefing-layout { .briefing-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.briefing-sidebar { .briefing-sidebar {
position: static; position: static;
} }
@@ -7697,10 +7754,12 @@ onMounted(() => {
flex-wrap: nowrap; flex-wrap: nowrap;
justify-content: space-between; justify-content: space-between;
} }
.hud-right[data-v-06cbe329] { .hud-right[data-v-06cbe329] {
/* justify-content: flex-start; */ /* justify-content: flex-start; */
justify-content: flex-end; justify-content: flex-end;
} }
.hud-inner { .hud-inner {
.sep, .hud-divider, .brand { .sep, .hud-divider, .brand {
@apply hidden @apply hidden