harden database interactions and improve error handling

This commit is contained in:
ImBenji
2026-04-28 17:05:48 +01:00
parent e1f168a302
commit b9f7d1ff25
16 changed files with 980 additions and 159 deletions
+283 -62
View File
@@ -80,8 +80,16 @@ onMounted(() => {
onUnmounted(() => { if (tipTimer) clearInterval(tipTimer); });
function getTopicProgress(topicId: string): any {
if (!import.meta.client) return null;
try {
const p = localStorage.getItem(`progress:${topicId}`);
return p ? JSON.parse(p) : null;
} catch { return null; }
}
function completedCount(): number {
return course.value?.topics?.filter((t: any) => t.progress?.lessonComplete).length ?? 0;
return course.value?.topics?.filter((t: any) => getTopicProgress(t.id)?.lessonComplete).length ?? 0;
}
const auditReport = computed(() => {
@@ -110,6 +118,55 @@ function scoreColor(score: number): string {
const editingTitle = ref(false);
const titleDraft = ref("");
// ── dev dropdown ──────────────────────────────────────────────────────────
const devOpenId = ref<string | null>(null);
const devKey = ref("");
const devPromptTopicId = ref<string | null>(null);
const devPendingAction = ref<"regenerate-lesson" | "regenerate-audio" | null>(null);
const devLoading = ref(false);
const devMsg = ref<{ text: string; ok: boolean } | null>(null);
function openDevPrompt(topicId: string, action: "regenerate-lesson" | "regenerate-audio") {
devOpenId.value = null;
devPromptTopicId.value = topicId;
devPendingAction.value = action;
devMsg.value = null;
}
async function runDevAction() {
if (!devPromptTopicId.value || !devPendingAction.value || devLoading.value) return;
devLoading.value = true;
devMsg.value = null;
try {
const result = await $fetch<{ status: string }>(
`/api/topics/${devPromptTopicId.value}/dev/${devPendingAction.value}`,
{ method: "POST", headers: { "x-license-key": devKey.value } }
);
if (result.status === "ready") {
devMsg.value = { text: "Done.", ok: true };
await refresh();
setTimeout(() => {
devPromptTopicId.value = null;
devKey.value = "";
devPendingAction.value = null;
devMsg.value = null;
}, 900);
} else {
devMsg.value = { text: "Returned an error — check server logs.", ok: false };
}
} catch (err: any) {
devMsg.value = {
text: err?.statusCode === 401 ? "Invalid license key." : "Request failed — check server logs.",
ok: false,
};
} finally {
devLoading.value = false;
}
}
function startEditTitle() {
titleDraft.value = course.value?.title ?? "";
editingTitle.value = true;
@@ -332,75 +389,93 @@ async function saveTitle() {
<!-- topic list -->
<div class="topic-list">
<TransitionGroup name="topic-appear" tag="div" class="topic-list-inner">
<NuxtLink
<div class="topic-list-inner">
<div
v-for="(topic, idx) in course.topics"
:key="topic.id"
:to="`/learn/${topic.id}`"
class="topic-row topic-row--unlocked"
:class="{
'topic-row--complete': topic.progress?.lessonComplete,
'topic-row--available': !topic.progress?.lessonComplete && topic.status === 'ready',
'topic-row--pending': topic.status === 'pending' || topic.status === 'generating',
}"
class="topic-item"
>
<span class="topic-index" :style="topic.status === 'pending' ? 'opacity:0.4' : ''">
{{ String(idx + 1).padStart(2, "0") }}
</span>
<NuxtLink
:to="`/learn/${topic.id}`"
class="topic-row topic-row--unlocked"
:class="{
'topic-row--complete': getTopicProgress(topic.id)?.lessonComplete,
'topic-row--available': !getTopicProgress(topic.id)?.lessonComplete && topic.status === 'ready',
'topic-row--pending': topic.status === 'pending' || topic.status === 'generating',
}"
>
<span class="topic-index" :style="topic.status === 'pending' ? 'opacity:0.4' : ''">
{{ String(idx + 1).padStart(2, "0") }}
</span>
<div class="topic-info">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<span class="topic-name" :style="topic.status === 'pending' ? 'opacity:0.55' : ''">{{ topic.title }}</span>
<span v-if="topic.progress?.lessonComplete" class="done-badge">done</span>
<span
v-if="topic.progress?.lessonComplete && topic.progress?.tookBranches"
class="branch-dot"
title="You needed a little extra help here — that's completely normal."
/>
<span v-if="topic.status === 'pending'" class="topic-status-label topic-status-label--pending">Not yet generated</span>
<span v-else-if="topic.status === 'generating'" class="topic-status-label topic-status-label--generating">
<span class="generating-dot" />Generating...
</span>
<span v-else-if="topic.status === 'error'" class="topic-status-label topic-status-label--error">Error</span>
<div class="topic-info">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<span class="topic-name" :style="topic.status === 'pending' ? 'opacity:0.55' : ''">{{ topic.title }}</span>
<span v-if="getTopicProgress(topic.id)?.lessonComplete" class="done-badge">done</span>
<span
v-if="getTopicProgress(topic.id)?.lessonComplete && getTopicProgress(topic.id)?.tookBranches"
class="branch-dot"
title="You needed a little extra help here — that's completely normal."
/>
<span v-if="topic.status === 'pending'" class="topic-status-label topic-status-label--pending">Not yet generated</span>
<span v-else-if="topic.status === 'generating'" class="topic-status-label topic-status-label--generating">
<span class="generating-dot" />Generating...
</span>
<span v-else-if="topic.status === 'error'" class="topic-status-label topic-status-label--error">Error</span>
</div>
<div class="topic-meta">
<div class="diff-dots">
<span
v-for="d in 5"
:key="d"
class="diff-dot"
:class="d <= topic.difficulty ? 'diff-dot--filled' : 'diff-dot--empty'"
/>
</div>
<span class="diff-label">
{{ ['', 'intro', 'basic', 'intermediate', 'advanced', 'expert'][topic.difficulty] }}
</span>
<span v-if="getTopicProgress(topic.id)?.quizScore != null" class="diff-label">
· quiz {{ getTopicProgress(topic.id).quizScore }}/4
</span>
<span
v-if="topic.lessonCost != null"
class="lesson-cost-badge"
title="AI generation cost for this lesson"
>
~${{ topic.lessonCost.toFixed(2) }}
</span>
</div>
</div>
<div class="topic-meta">
<div class="diff-dots">
<span
v-for="d in 5"
:key="d"
class="diff-dot"
:class="d <= topic.difficulty ? 'diff-dot--filled' : 'diff-dot--empty'"
/>
</div>
<span class="diff-label">
{{ ['', 'intro', 'basic', 'intermediate', 'advanced', 'expert'][topic.difficulty] }}
</span>
<span v-if="topic.progress?.quizScore != null" class="diff-label">
· quiz {{ topic.progress.quizScore }}/4
</span>
<span
v-if="topic.progress?.lessonComplete && topic.lessonCost != null"
class="lesson-cost-badge"
title="AI generation cost for this lesson"
>
~${{ topic.lessonCost.toFixed(2) }}
</span>
<svg v-if="topic.status !== 'error'" class="topic-arrow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5l7 7-7 7"/>
</svg>
<button
v-else
class="topic-retry-btn"
@click.prevent="$fetch(`/api/topics/${topic.id}/generate`, { method: 'POST' }).then(() => refresh())"
>
Retry
</button>
</NuxtLink>
<!-- dev dropdown sibling of NuxtLink so clicks don't trigger navigation -->
<div class="dev-wrap">
<button
class="dev-cog"
:class="{ 'dev-cog--open': devOpenId === topic.id }"
@click="devOpenId = devOpenId === topic.id ? null : topic.id"
title="Dev tools"
>⚙</button>
<div v-if="devOpenId === topic.id" class="dev-drop">
<button class="dev-item" @click="openDevPrompt(topic.id, 'regenerate-lesson')">Regenerate lesson</button>
<button class="dev-item" @click="openDevPrompt(topic.id, 'regenerate-audio')">Regenerate audio</button>
</div>
</div>
<svg v-if="topic.status !== 'error'" class="topic-arrow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5l7 7-7 7"/>
</svg>
<button
v-else
class="topic-retry-btn"
@click.prevent="$fetch(`/api/topics/${topic.id}/generate`, { method: 'POST' }).then(() => refresh())"
>
Retry
</button>
</NuxtLink>
</TransitionGroup>
</div>
</div>
</div>
</div>
@@ -408,6 +483,32 @@ async function saveTitle() {
</template>
</div>
<!-- dev key prompt modal -->
<Transition name="dev-fade">
<div v-if="devPromptTopicId" class="dev-overlay" @click.self="devPromptTopicId = null">
<div class="dev-modal">
<p class="dev-modal-title">{{ devPendingAction === 'regenerate-lesson' ? 'Regenerate Lesson' : 'Regenerate Audio' }}</p>
<p class="dev-modal-sub">Enter your license key to continue.</p>
<input
v-model="devKey"
class="dev-modal-input"
type="password"
placeholder="License key"
@keydown.enter="runDevAction"
@keydown.esc="devPromptTopicId = null"
/>
<p v-if="devMsg" class="dev-modal-msg" :class="devMsg.ok ? 'dev-modal-msg--ok' : 'dev-modal-msg--err'">{{ devMsg.text }}</p>
<div class="dev-modal-actions">
<button class="dev-modal-cancel" :disabled="devLoading" @click="devPromptTopicId = null">Cancel</button>
<button class="dev-modal-run" :disabled="devLoading || !devKey" @click="runDevAction">
{{ devLoading ? 'Running' : 'Run' }}
</button>
</div>
</div>
</div>
</Transition>
</main>
</template>
@@ -640,6 +741,10 @@ async function saveTitle() {
/* topic list */
.topic-list {}
.topic-item {
position: relative;
}
.topic-row {
display: flex;
align-items: center;
@@ -869,6 +974,122 @@ async function saveTitle() {
font-size: 10px; color: var(--text-3); letter-spacing: 0.04em;
}
/* dev dropdown */
.dev-wrap {
position: absolute;
top: 50%;
right: 2.5rem;
transform: translateY(-50%);
z-index: 10;
}
.dev-cog {
background: none;
border: 1px solid var(--border);
border-radius: 5px;
width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center;
font-size: 13px;
cursor: pointer;
color: var(--text-3);
transition: border-color 0.15s, color 0.15s;
opacity: 0;
}
.topic-item:hover .dev-cog,
.dev-cog--open { opacity: 1; }
.dev-cog:hover,
.dev-cog--open { border-color: var(--border-2); color: var(--text); }
.dev-drop {
position: absolute;
top: calc(100% + 5px);
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 16px oklch(0% 0 0 / 0.1);
min-width: 170px;
z-index: 50;
overflow: hidden;
}
.dev-item {
display: block; width: 100%; text-align: left;
background: none; border: none;
padding: 9px 13px;
font-size: 12px; color: var(--text-2);
cursor: pointer;
transition: background 0.1s;
}
.dev-item:hover { background: var(--surface-2); }
.dev-item + .dev-item { border-top: 1px solid var(--border); }
/* dev modal */
.dev-overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.35);
display: flex; align-items: center; justify-content: center;
padding: 2rem;
}
.dev-modal {
background: var(--surface);
border-radius: 12px;
padding: 1.5rem;
width: 100%; max-width: 340px;
display: flex; flex-direction: column; gap: 0.875rem;
box-shadow: 0 8px 32px oklch(0% 0 0 / 0.18);
}
.dev-modal-title {
font-size: 14px; font-weight: 600; color: var(--text); margin: 0;
}
.dev-modal-sub {
font-size: 13px; color: var(--text-3); margin: 0;
}
.dev-modal-input {
width: 100%; padding: 9px 11px;
border: 1.5px solid var(--border);
border-radius: 7px;
font-size: 13px; color: var(--text);
background: var(--bg);
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.dev-modal-input:focus { border-color: var(--accent); }
.dev-modal-msg { font-size: 12px; margin: 0; }
.dev-modal-msg--ok { color: var(--green); }
.dev-modal-msg--err { color: oklch(50% 0.18 25); }
.dev-modal-actions { display: flex; gap: 0.625rem; justify-content: flex-end; }
.dev-modal-cancel {
background: none; border: 1px solid var(--border);
border-radius: 7px; padding: 7px 14px;
font-size: 12px; color: var(--text-3); cursor: pointer;
transition: border-color 0.15s;
}
.dev-modal-cancel:hover:not(:disabled) { border-color: var(--border-2); }
.dev-modal-cancel:disabled { opacity: 0.5; cursor: not-allowed; }
.dev-modal-run {
background: var(--accent); color: #fff;
border: none; border-radius: 7px;
padding: 7px 18px;
font-size: 12px; font-weight: 500;
cursor: pointer; transition: opacity 0.15s;
}
.dev-modal-run:hover:not(:disabled) { opacity: 0.85; }
.dev-modal-run:disabled { opacity: 0.45; cursor: not-allowed; }
.dev-fade-enter-active { transition: opacity 0.15s ease; }
.dev-fade-leave-active { transition: opacity 0.1s ease; }
.dev-fade-enter-from, .dev-fade-leave-to { opacity: 0; }
/* retry button on error topics */
.topic-retry-btn {
font-size: 11px; letter-spacing: 0.06em;
+13 -3
View File
@@ -9,7 +9,7 @@ interface CourseSummary {
stage: string | null
createdAt: string
topicCount: number
completedCount: number
topicIds: string[]
costAI: number | null
costAudio: number | null
organisation: string | null
@@ -111,9 +111,19 @@ async function confirmDelete() {
}
}
function completedCount(course: CourseSummary): number {
if (!import.meta.client) return 0;
return course.topicIds.filter(id => {
try {
const p = localStorage.getItem(`progress:${id}`);
return p ? JSON.parse(p).lessonComplete === true : false;
} catch { return false; }
}).length;
}
function progressPct(course: CourseSummary): number {
if (!course.topicCount) return 0;
return Math.round((course.completedCount / course.topicCount) * 100);
return Math.round((completedCount(course) / course.topicCount) * 100);
}
// deterministic warm color from subject string
@@ -205,7 +215,7 @@ function subjectColor(subject: string): string {
<div class="progress-track">
<div class="progress-fill" :style="{ width: `${progressPct(course)}%`, background: subjectColor(course.subject) }" />
</div>
<span class="progress-label">{{ course.completedCount }}&thinsp;/&thinsp;{{ course.topicCount }}</span>
<span class="progress-label">{{ completedCount(course) }}&thinsp;/&thinsp;{{ course.topicCount }}</span>
</div>
<div class="card-footer">
+31 -13
View File
@@ -541,26 +541,22 @@ async function completeLesson() {
completing.value = true;
completeError.value = false;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 10000);
try {
await $fetch(`/api/topics/${topicId}/progress`, {
method: "POST",
signal: ctrl.signal,
body: {
lessonComplete: true,
tookBranches: branchesEntered.value > 0,
branchCount: branchesEntered.value,
},
});
const existing = localStorage.getItem(`progress:${topicId}`);
const prev = existing ? JSON.parse(existing) : {};
localStorage.setItem(`progress:${topicId}`, JSON.stringify({
...prev,
lessonComplete: true,
tookBranches: branchesEntered.value > 0,
branchCount: branchesEntered.value,
}));
lessonDone.value = true;
} catch {
completing.value = false;
completeError.value = true;
return;
} finally {
clearTimeout(timer);
completing.value = false;
}
}
@@ -571,6 +567,7 @@ function goBack() {
history.back();
}
function stepBack() {
stopAllAudio();
if (lessonState.mode === "branch" || lessonState.mode === "confirm" || lessonState.mode === "hardStop") {
@@ -1377,6 +1374,26 @@ onBeforeUnmount(() => {
}
.karaoke-skip:hover { opacity: 0.85; }
.karaoke-start {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: oklch(54% 0.140 44);
color: #fff;
border: none;
border-radius: 9999px;
padding: 0.85rem 2rem;
font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: 0.04em;
cursor: pointer;
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
transition: opacity 0.15s, transform 0.1s;
}
.karaoke-start:hover { opacity: 0.88; }
.karaoke-start:active { transform: scale(0.97); }
.karaoke-fade-enter-active { transition: opacity 0.2s ease; }
.karaoke-fade-leave-active { transition: opacity 0.15s ease; }
.karaoke-fade-enter-from,
@@ -2099,4 +2116,5 @@ onBeforeUnmount(() => {
transition: opacity 0.15s;
}
.branch-loading-skip:hover { opacity: 0.85; }
</style>