Revisione/app/pages/new.vue

534 lines
13 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 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;
}
}
async function submit() {
if (uploadedFiles.value.length === 0) {
formError.value = "Add at least one PDF to continue.";
return;
}
formError.value = null;
submitting.value = true;
try {
const { courseId } = await $fetch<{ courseId: string }>("/api/courses", {
method: "POST",
});
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) {
formError.value = err?.data?.message ?? "Something went wrong. Please try again.";
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="submit"
>
<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>
</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); }
</style>