683 lines
17 KiB
Vue
683 lines
17 KiB
Vue
<script setup lang="ts">
|
|
type UploadType = "slides" | "past_paper" | "lab_worksheet";
|
|
|
|
interface UploadedFile {
|
|
file: File
|
|
uploadId: string | null
|
|
detectedType: UploadType | null
|
|
overrideType: UploadType | null
|
|
uploading: boolean
|
|
error: string | null
|
|
}
|
|
|
|
const router = useRouter();
|
|
|
|
const uploadedFiles = ref<UploadedFile[]>([]);
|
|
const editingIndex = ref<number | null>(null);
|
|
|
|
const dragging = ref(false);
|
|
const submitting = ref(false);
|
|
const submitted = ref(false);
|
|
const formError = ref<string | null>(null);
|
|
|
|
const showLicenseModal = ref(false);
|
|
const licenseKey = ref("");
|
|
const licenseError = ref<string | null>(null);
|
|
|
|
const typeLabels: Record<UploadType, string> = {
|
|
slides: "Lecture Slides",
|
|
past_paper: "Past Paper",
|
|
lab_worksheet: "Lab Worksheet",
|
|
};
|
|
|
|
function effectiveType(f: UploadedFile): UploadType | null {
|
|
return f.overrideType ?? f.detectedType;
|
|
}
|
|
|
|
function onDrop(e: DragEvent) {
|
|
dragging.value = false;
|
|
addFiles(Array.from(e.dataTransfer?.files ?? []));
|
|
}
|
|
|
|
function onFileInput(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
addFiles(Array.from(input.files ?? []));
|
|
input.value = "";
|
|
}
|
|
|
|
function addFiles(newFiles: File[]) {
|
|
for (const f of newFiles) {
|
|
if (f.type !== "application/pdf") continue;
|
|
uploadedFiles.value.push({
|
|
file: f,
|
|
uploadId: null,
|
|
detectedType: null,
|
|
overrideType: null,
|
|
uploading: false,
|
|
error: null,
|
|
});
|
|
}
|
|
}
|
|
|
|
function removeFile(i: number) {
|
|
uploadedFiles.value.splice(i, 1);
|
|
if (editingIndex.value === i) editingIndex.value = null;
|
|
}
|
|
|
|
async function uploadFile(entry: UploadedFile, courseId: string) {
|
|
entry.uploading = true;
|
|
entry.error = null;
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append("file", entry.file);
|
|
const res = await $fetch<{ uploadId: string; type: UploadType }>(
|
|
`/api/courses/${courseId}/upload`,
|
|
{ method: "POST", body: fd }
|
|
);
|
|
entry.uploadId = res.uploadId;
|
|
entry.detectedType = res.type;
|
|
} catch (err: any) {
|
|
entry.error = err?.data?.message ?? "Upload failed";
|
|
} finally {
|
|
entry.uploading = false;
|
|
}
|
|
}
|
|
|
|
async function applyOverride(entry: UploadedFile, newType: UploadType) {
|
|
if (!entry.uploadId) return;
|
|
entry.overrideType = newType;
|
|
editingIndex.value = null;
|
|
try {
|
|
await $fetch(`/api/uploads/${entry.uploadId}`, {
|
|
method: "PATCH",
|
|
body: { type: newType },
|
|
});
|
|
} catch {
|
|
entry.overrideType = null;
|
|
}
|
|
}
|
|
|
|
function requestSubmit() {
|
|
if (uploadedFiles.value.length === 0) {
|
|
formError.value = "Add at least one PDF to continue.";
|
|
return;
|
|
}
|
|
formError.value = null;
|
|
licenseError.value = null;
|
|
licenseKey.value = "";
|
|
showLicenseModal.value = true;
|
|
}
|
|
|
|
async function submit() {
|
|
if (!licenseKey.value.trim()) {
|
|
licenseError.value = "Enter a license key.";
|
|
return;
|
|
}
|
|
showLicenseModal.value = false;
|
|
submitting.value = true;
|
|
try {
|
|
const { courseId } = await $fetch<{ courseId: string }>("/api/courses", {
|
|
method: "POST",
|
|
headers: { "x-license-key": licenseKey.value.trim() },
|
|
});
|
|
for (const entry of uploadedFiles.value) {
|
|
await uploadFile(entry, courseId);
|
|
}
|
|
if (uploadedFiles.value.some((f) => f.error)) {
|
|
submitting.value = false;
|
|
return;
|
|
}
|
|
await $fetch(`/api/courses/${courseId}/generate`, { method: "POST" });
|
|
submitted.value = true;
|
|
setTimeout(() => router.push("/"), 3000);
|
|
} catch (err: any) {
|
|
const msg = err?.data?.message ?? "Something went wrong. Please try again.";
|
|
if (err?.status === 401) {
|
|
licenseError.value = msg;
|
|
showLicenseModal.value = true;
|
|
submitting.value = false;
|
|
} else {
|
|
formError.value = msg;
|
|
submitting.value = false;
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="upload-page">
|
|
<div class="upload-inner">
|
|
|
|
<!-- back -->
|
|
<NuxtLink to="/" class="back-link">
|
|
<svg width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M12.5 5l-5 5 5 5"/>
|
|
</svg>
|
|
Back
|
|
</NuxtLink>
|
|
|
|
<!-- heading -->
|
|
<div class="upload-head">
|
|
<h1 class="upload-title">Create a new course</h1>
|
|
<p class="upload-sub">Upload your materials and we'll build a complete learning path.</p>
|
|
</div>
|
|
|
|
<!-- drop zone -->
|
|
<div
|
|
class="drop-zone"
|
|
:class="{ 'drop-zone--active': dragging }"
|
|
@dragover.prevent="dragging = true"
|
|
@dragleave.prevent="dragging = false"
|
|
@drop.prevent="onDrop"
|
|
@click="($refs.fileInput as HTMLInputElement).click()"
|
|
>
|
|
<input ref="fileInput" type="file" accept=".pdf" multiple class="hidden" @change="onFileInput" />
|
|
<div class="drop-inner">
|
|
<div class="drop-icon-wrap">
|
|
<svg width="28" height="28" fill="none" viewBox="0 0 24 24" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
|
|
</svg>
|
|
</div>
|
|
<p class="drop-text">Drop PDFs here, or <span class="drop-browse">browse files</span></p>
|
|
<p class="drop-hint">slides · past papers · lab worksheets</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- file list -->
|
|
<TransitionGroup name="file-list" tag="div" class="file-list">
|
|
<div
|
|
v-for="(f, i) in uploadedFiles"
|
|
:key="f.file.name + i"
|
|
class="file-row"
|
|
>
|
|
<div class="file-row-left">
|
|
<div class="file-pdf-label">PDF</div>
|
|
<div class="file-info">
|
|
<p class="file-name">{{ f.file.name }}</p>
|
|
|
|
<div v-if="f.uploading" class="detecting-row">
|
|
<div class="detecting-dots"><span /><span /><span /></div>
|
|
<span class="detecting-text">detecting type…</span>
|
|
</div>
|
|
|
|
<p v-else-if="f.error" class="file-error">{{ f.error }}</p>
|
|
|
|
<div v-else-if="effectiveType(f)" class="type-row">
|
|
<span class="type-badge" :class="`type-badge--${effectiveType(f)}`">
|
|
{{ typeLabels[effectiveType(f)!] }}
|
|
<span v-if="f.overrideType" class="opacity-50"> · edited</span>
|
|
</span>
|
|
<button v-if="f.uploadId" class="change-btn" @click.stop="editingIndex = editingIndex === i ? null : i">
|
|
{{ editingIndex === i ? 'cancel' : 'change' }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="editingIndex === i && f.uploadId" class="type-select-wrap">
|
|
<select
|
|
class="type-select"
|
|
:value="effectiveType(f) ?? ''"
|
|
@change="applyOverride(f, ($event.target as HTMLSelectElement).value as UploadType)"
|
|
@click.stop
|
|
>
|
|
<option value="slides">Lecture Slides</option>
|
|
<option value="past_paper">Past Paper</option>
|
|
<option value="lab_worksheet">Lab Worksheet</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="remove-btn" @click.stop="removeFile(i)">
|
|
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</TransitionGroup>
|
|
|
|
<!-- submitted -->
|
|
<div v-if="submitted" class="submitted-msg">
|
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--green)" stroke-width="2.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
|
</svg>
|
|
<span>Your course is being generated — check back in a few minutes</span>
|
|
</div>
|
|
|
|
<p v-else-if="formError" class="form-error">{{ formError }}</p>
|
|
|
|
<button
|
|
v-if="!submitted"
|
|
class="submit-btn"
|
|
:class="{ 'submit-btn--loading': submitting }"
|
|
:disabled="submitting || uploadedFiles.length === 0"
|
|
@click="requestSubmit"
|
|
>
|
|
<span v-if="!submitting">Generate my course →</span>
|
|
<span v-else class="btn-loading">
|
|
<span class="btn-dots"><span /><span /><span /></span>
|
|
Uploading your materials
|
|
</span>
|
|
</button>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- license key modal -->
|
|
<Teleport to="body">
|
|
<Transition name="modal">
|
|
<div v-if="showLicenseModal" class="modal-backdrop" @click.self="showLicenseModal = false">
|
|
<div class="modal-box">
|
|
<p class="modal-title">License key required</p>
|
|
<p class="modal-sub">Run <code>npm run gen:key</code> locally to get a key.</p>
|
|
|
|
<input
|
|
v-model="licenseKey"
|
|
class="modal-input"
|
|
type="text"
|
|
placeholder="paste key here…"
|
|
autofocus
|
|
@keydown.enter="submit"
|
|
@keydown.esc="showLicenseModal = false"
|
|
/>
|
|
|
|
<p v-if="licenseError" class="modal-error">{{ licenseError }}</p>
|
|
|
|
<div class="modal-actions">
|
|
<button class="modal-cancel" @click="showLicenseModal = false">Cancel</button>
|
|
<button class="modal-confirm" @click="submit">Continue →</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.upload-page {
|
|
padding: 40px 48px;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.upload-inner {
|
|
max-width: 560px;
|
|
}
|
|
|
|
.back-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 13px;
|
|
color: var(--text-3);
|
|
text-decoration: none;
|
|
margin-bottom: 32px;
|
|
transition: color 0.15s;
|
|
}
|
|
.back-link:hover { color: var(--text); }
|
|
|
|
.upload-head {
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.upload-title {
|
|
font-family: "Lora", serif;
|
|
font-size: 26px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
letter-spacing: -0.4px;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.upload-sub {
|
|
font-size: 14px;
|
|
color: var(--text-2);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.drop-zone {
|
|
background: var(--surface);
|
|
border: 2px dashed var(--border-2);
|
|
border-radius: var(--r-surface);
|
|
padding: 48px 24px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: border-color 0.2s, background 0.2s;
|
|
margin-bottom: 16px;
|
|
}
|
|
.drop-zone:hover { border-color: var(--accent); }
|
|
.drop-zone--active {
|
|
border-style: solid;
|
|
border-color: var(--accent);
|
|
background: var(--accent-light);
|
|
}
|
|
|
|
.drop-inner {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 8px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.drop-icon-wrap {
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.drop-text {
|
|
font-size: 14px;
|
|
color: var(--text-2);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.drop-browse {
|
|
color: var(--accent);
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
}
|
|
|
|
.drop-hint {
|
|
font-size: 12px;
|
|
color: var(--text-3);
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.file-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.file-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
padding: 12px 14px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--r-btn);
|
|
}
|
|
|
|
.file-row-left {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.file-pdf-label {
|
|
font-size: 10px;
|
|
letter-spacing: 0.08em;
|
|
color: var(--text-3);
|
|
margin-top: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.file-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.file-name {
|
|
font-size: 14px;
|
|
color: var(--text);
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.detecting-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.detecting-text {
|
|
font-size: 12px;
|
|
color: var(--text-3);
|
|
}
|
|
|
|
.file-error {
|
|
font-size: 12px;
|
|
color: oklch(42% 0.12 15);
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.type-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 6px;
|
|
}
|
|
|
|
.type-badge {
|
|
font-size: 11px;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
padding: 2px 8px;
|
|
border-radius: 20px;
|
|
display: inline-block;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.type-badge--slides {
|
|
background: var(--accent-light);
|
|
color: var(--accent);
|
|
border: 1px solid var(--accent-dim);
|
|
}
|
|
|
|
.type-badge--past_paper {
|
|
background: oklch(95% 0.03 80);
|
|
color: oklch(40% 0.12 65);
|
|
border: 1px solid oklch(85% 0.05 75);
|
|
}
|
|
|
|
.type-badge--lab_worksheet {
|
|
background: var(--green-light);
|
|
color: var(--green);
|
|
border: 1px solid oklch(85% 0.05 155);
|
|
}
|
|
|
|
.change-btn {
|
|
font-size: 11px;
|
|
color: var(--text-3);
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
transition: color 0.15s;
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
}
|
|
.change-btn:hover { color: var(--accent); }
|
|
|
|
.type-select-wrap {
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.type-select {
|
|
background: var(--surface-2);
|
|
border: 1px solid var(--border-2);
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
padding: 6px 10px;
|
|
border-radius: var(--r-sm);
|
|
outline: none;
|
|
}
|
|
|
|
.remove-btn {
|
|
flex-shrink: 0;
|
|
color: var(--text-3);
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 2px;
|
|
margin-top: 2px;
|
|
transition: color 0.15s;
|
|
}
|
|
.remove-btn:hover { color: var(--text); }
|
|
|
|
.submitted-msg {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 14px;
|
|
color: var(--green);
|
|
background: var(--green-light);
|
|
border: 1px solid oklch(85% 0.05 155);
|
|
border-radius: var(--r-btn);
|
|
padding: 12px 16px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.form-error {
|
|
font-size: 12px;
|
|
color: oklch(42% 0.12 15);
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.submit-btn {
|
|
width: 100%;
|
|
background: var(--accent);
|
|
color: white;
|
|
font-size: 15px;
|
|
font-weight: 500;
|
|
height: 52px;
|
|
border-radius: var(--r-btn);
|
|
border: none;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s, transform 0.1s;
|
|
}
|
|
.submit-btn:hover:not(:disabled) { opacity: 0.88; }
|
|
.submit-btn:active:not(:disabled) { transform: scale(0.99); }
|
|
.submit-btn--loading,
|
|
.submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
.btn-loading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.detecting-dots, .btn-dots { display: flex; gap: 3px; align-items: center; }
|
|
.detecting-dots span, .btn-dots span {
|
|
width: 4px; height: 4px; border-radius: 50%;
|
|
animation: dot-pulse 1.4s ease-in-out infinite;
|
|
}
|
|
|
|
.detecting-dots span { background: var(--text-3); }
|
|
.btn-dots span { background: rgba(255,255,255,0.8); }
|
|
|
|
.detecting-dots span:nth-child(2), .btn-dots span:nth-child(2) { animation-delay: 0.2s; }
|
|
.detecting-dots span:nth-child(3), .btn-dots span:nth-child(3) { animation-delay: 0.4s; }
|
|
|
|
@keyframes dot-pulse {
|
|
0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); }
|
|
40% { opacity: 1; transform: scale(1); }
|
|
}
|
|
|
|
.file-list-enter-active { transition: all 0.25s ease; }
|
|
.file-list-leave-active { transition: all 0.2s ease; }
|
|
.file-list-enter-from { opacity: 0; transform: translateY(-6px); }
|
|
.file-list-leave-to { opacity: 0; transform: translateX(10px); }
|
|
|
|
.modal-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.45);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 100;
|
|
}
|
|
|
|
.modal-box {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border-2);
|
|
border-radius: var(--r-surface);
|
|
padding: 28px 28px 24px;
|
|
width: 100%;
|
|
max-width: 420px;
|
|
}
|
|
|
|
.modal-title {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.modal-sub {
|
|
font-size: 13px;
|
|
color: var(--text-3);
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.modal-sub code {
|
|
font-family: monospace;
|
|
background: var(--surface);
|
|
padding: 1px 5px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.modal-input {
|
|
width: 100%;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border-2);
|
|
border-radius: var(--r-sm);
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
font-family: monospace;
|
|
padding: 10px 12px;
|
|
outline: none;
|
|
box-sizing: border-box;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.modal-input:focus { border-color: var(--accent); }
|
|
|
|
.modal-error {
|
|
font-size: 12px;
|
|
color: oklch(42% 0.12 15);
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 10px;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.modal-cancel {
|
|
font-size: 14px;
|
|
color: var(--text-3);
|
|
background: none;
|
|
border: 1px solid var(--border-2);
|
|
border-radius: var(--r-btn);
|
|
padding: 8px 16px;
|
|
cursor: pointer;
|
|
transition: color 0.15s;
|
|
}
|
|
.modal-cancel:hover { color: var(--text); }
|
|
|
|
.modal-confirm {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
background: var(--accent);
|
|
color: white;
|
|
border: none;
|
|
border-radius: var(--r-btn);
|
|
padding: 8px 20px;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.modal-confirm:hover { opacity: 0.88; }
|
|
|
|
.modal-enter-active, .modal-leave-active { transition: opacity 0.18s ease; }
|
|
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
|
</style>
|