feat: add file upload for materials (PDF/DOCX) with ingestion pipeline
This commit is contained in:
@@ -25,6 +25,9 @@ var els = {
|
||||
progressDivider: document.querySelector("#progress-divider"),
|
||||
refreshProgress: document.querySelector("#refresh-progress"),
|
||||
materialForm: document.querySelector("#material-form"),
|
||||
materialFile: document.querySelector("#material-file"),
|
||||
fileNameDisplay: document.querySelector("#file-name"),
|
||||
uploadFileButton: document.querySelector("#upload-file-button"),
|
||||
assetForm: document.querySelector("#asset-form"),
|
||||
ontology: document.querySelector("#ontology"),
|
||||
assetOutput: document.querySelector("#asset-output"),
|
||||
@@ -232,6 +235,55 @@ function renderBlock(el, title, items) {
|
||||
"</ul>";
|
||||
}
|
||||
|
||||
/* ---- File upload ---- */
|
||||
els.materialFile.addEventListener("change", function() {
|
||||
var file = els.materialFile.files[0];
|
||||
if (file) {
|
||||
els.fileNameDisplay.textContent = file.name;
|
||||
els.uploadFileButton.disabled = false;
|
||||
} else {
|
||||
els.fileNameDisplay.textContent = "";
|
||||
els.uploadFileButton.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
els.uploadFileButton.addEventListener("click", function() {
|
||||
var file = els.materialFile.files[0];
|
||||
if (!file) return;
|
||||
|
||||
clearError();
|
||||
setStatus(t("ingestingMaterial"), true);
|
||||
els.uploadFileButton.disabled = true;
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append("file", file);
|
||||
var title = document.querySelector("#material-title").value;
|
||||
if (title) formData.append("title", title);
|
||||
|
||||
var token = localStorage.getItem("tutor_token");
|
||||
var lang = localStorage.getItem("tutor_lang") || document.documentElement.lang || "ko";
|
||||
var headers = {};
|
||||
if (token) headers["Authorization"] = "Bearer " + token;
|
||||
|
||||
fetch("/api/v1/materials/upload", { method:"POST", headers:headers, body:formData })
|
||||
.then(function(response) {
|
||||
return response.json().then(function(body) {
|
||||
if (!response.ok) throw new Error(body.error || "Upload failed: " + response.status);
|
||||
state.ontology = body.snapshot;
|
||||
renderOntology();
|
||||
setStatus(t("materialIngested", body.material.id));
|
||||
els.materialFile.value = "";
|
||||
els.fileNameDisplay.textContent = "";
|
||||
});
|
||||
})
|
||||
["catch"](function(error) {
|
||||
showError(error.message); setStatus(t("contentReady"));
|
||||
})
|
||||
["finally"](function() {
|
||||
els.uploadFileButton.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
/* ---- Progress ---- */
|
||||
els.refreshProgress.addEventListener("click", function() { clearError(); refreshProgress(); });
|
||||
|
||||
|
||||
@@ -74,6 +74,9 @@ var i18n = {
|
||||
questionId: "질문 ID",
|
||||
starting: "시작 중…",
|
||||
grading: "채점 중…",
|
||||
uploadFile: "파일 업로드",
|
||||
uploadAndIngest: "업로드 및 수집",
|
||||
pasteTextToggle: "또는 텍스트 붙여넣기",
|
||||
ingesting: "수집 중…",
|
||||
generating: "생성 중…",
|
||||
questionsSuffix: "개 질문",
|
||||
@@ -159,6 +162,9 @@ var i18n = {
|
||||
questionId: "question id",
|
||||
starting: "Starting…",
|
||||
grading: "Grading…",
|
||||
uploadFile: "Upload file",
|
||||
uploadAndIngest: "Upload & ingest",
|
||||
pasteTextToggle: "Or paste text",
|
||||
ingesting: "Ingesting…",
|
||||
generating: "Generating…",
|
||||
questionsSuffix: "questions",
|
||||
|
||||
@@ -151,15 +151,27 @@
|
||||
<input id="material-source" value="markdown" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span data-i18n="sourceMaterial">Source material</span>
|
||||
<textarea id="material-body" rows="4">
|
||||
Idempotent API retries need transactions. Cache invalidation uses TTL tradeoffs and database indexes support query plans.</textarea>
|
||||
</label>
|
||||
<button id="material-button" type="submit">
|
||||
<span class="btn-text" data-i18n="ingestMaterial">Ingest material</span>
|
||||
<span class="btn-spinner" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="file-upload-row">
|
||||
<label class="file-label">
|
||||
<span data-i18n="uploadFile">Upload file</span>
|
||||
<input id="material-file" type="file" accept=".md,.markdown,.pdf,.docx" />
|
||||
</label>
|
||||
<span id="file-name" class="file-name"></span>
|
||||
<button id="upload-file-button" type="button" class="small-button" data-i18n="uploadAndIngest" disabled>Upload & ingest</button>
|
||||
</div>
|
||||
<details class="paste-toggle">
|
||||
<summary data-i18n="pasteTextToggle">Or paste text</summary>
|
||||
<label class="wide-field">
|
||||
<span data-i18n="sourceMaterial">Source material</span>
|
||||
<textarea id="material-body" rows="5">
|
||||
Idempotent API retries need transactions. Cache invalidation uses TTL tradeoffs and database indexes support query plans.</textarea
|
||||
>
|
||||
</label>
|
||||
<button id="material-button" type="submit">
|
||||
<span class="btn-text" data-i18n="ingestMaterial">Ingest material</span>
|
||||
<span class="btn-spinner" aria-hidden="true"></span>
|
||||
</button>
|
||||
</details>
|
||||
</form>
|
||||
|
||||
<div id="ontology" class="ontology-view empty-state">
|
||||
|
||||
@@ -362,6 +362,88 @@ button.is-loading .btn-spinner { display:inline-block; }
|
||||
margin:0; padding:12px; white-space:pre-wrap; font-size:12px; line-height:1.5; color:var(--text);
|
||||
}
|
||||
|
||||
/* ===== FILE UPLOAD ===== */
|
||||
.file-upload-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.file-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.file-label input[type="file"] {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fbfcfa;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.file-label input[type="file"]::file-selector-button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.file-label input[type="file"]::file-selector-button:hover {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.paste-toggle {
|
||||
grid-column: 1 / -1;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
background: #fbfcfa;
|
||||
}
|
||||
|
||||
.paste-toggle summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.paste-toggle[open] summary {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.paste-toggle .wide-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.small-button {
|
||||
min-height: 32px;
|
||||
padding: 0 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media (max-width:900px) {
|
||||
.main-grid { grid-template-columns:1fr; }
|
||||
|
||||
Reference in New Issue
Block a user