harden database interactions and improve error handling
This commit is contained in:
+283
-62
@@ -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
@@ -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 }} / {{ course.topicCount }}</span>
|
||||
<span class="progress-label">{{ completedCount(course) }} / {{ course.topicCount }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user