updating whole project

This commit is contained in:
2026-05-05 14:44:27 +03:30
commit e5780abc4e
9 changed files with 2436 additions and 0 deletions

42
.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# Dependencies
node_modules/
# Build output
dist/
build/
.cache/
.npm/
.yarn/
.pnp.cjs
.pnp.loader.mjs
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment variables
.env
.env.local
.env.development
.env.production
.env.test
# Editor
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Testing
coverage/
# Generated static assets
*.out
*.log
# React-specific
*.map

69
README.md Normal file
View File

@@ -0,0 +1,69 @@
# RAG Data Uploader
This is a minimal React front‑end for your Retrieval‑Augmented Generation (RAG) school project. It allows you to:
- Fetch and list available subjects from the back‑end.
- Select a subject to view its details and list of lessons.
- Select a lesson and upload custom text (with an optional title) associated with that subject/lesson.
The client uses the API endpoints documented in your OpenAPI file:
- `GET /api/subjects/` — list available subjects
- `GET /api/lessons/?subject=<subject_id>` — list lessons for a subject
- `POST /api/upload-text` — upload a text payload for a subject/lesson
## Prerequisites
1. **Node.js** (≥14) installed on your machine.
2. The back‑end API running locally or remotely and accepting requests at a known base URL.
## Configuration
The front‑end expects an environment variable called `VITE_API_BASE_URL` to prefix API calls. If your API is served from `http://localhost:8000`, you would set:
```bash
export VITE_API_BASE_URL=http://localhost:8000
```
If you do not provide this environment variable the client assumes the API is served from the same origin as the front‑end (i.e. relative paths such as `/api/subjects/`).
To set local environment variables for Vite, you can create a `.env` file in the project root with the following content:
```
VITE_API_BASE_URL=http://localhost:8000
```
## Running the application
1. Install dependencies:
```bash
cd rag-data-frontend
npm install
```
2. Start the development server:
```bash
npm run dev
```
Vite will start a server (default port `3000`) and automatically open the app in your browser. Any changes you make to the source files will hot reload during development.
3. Build for production:
```bash
npm run build
```
The optimized, minified output will be placed in the `dist/` directory. You can serve these static files from any HTTP server.
## Customizing
This project was kept deliberately simple so you can adapt it to your needs:
* **Styling**: The default CSS is minimal. You can swap it out for Tailwind, Bootstrap or your own styles.
* **Fields**: The upload payload currently uses the lesson’s `chapter` property as the `lesson_number`. Adjust this logic in `src/App.jsx` if your API expects a different value.
* **Error handling**: Basic errors are surfaced under the form. You can extend this to surface more detailed error messages or integrate a notification library.
Happy coding!

17
index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RAG Data Uploader</title>
</head>
<body>
<div id="root"></div>
<!--
Vite will inject your compiled React bundle here. The script tag
points at our entry point in the src directory. When you run
`npm run dev` Vite serves this page and hot‑reloads changes.
-->
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1547
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "rag-data-frontend",
"version": "1.0.0",
"private": true,
"description": "A minimal React front‑end for selecting subjects and lessons and uploading custom text to the RAG back end.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
"vite": "^4.4.0"
}
}

633
src/App.jsx Normal file
View File

@@ -0,0 +1,633 @@
import React, { useEffect, useMemo, useState } from "react";
import axios from "axios";
const API_BASE =
(typeof import.meta !== "undefined" && import.meta.env?.VITE_API_BASE_URL) ||
"https://apirag.tadrisino.org";
const GRADES = [1, 2, 3, 4, 5, 6];
function normalizeAxiosError(err) {
const out = { title: "Request failed", status: null, lines: [] };
if (!err) {
out.lines.push("Unknown error.");
return out;
}
// Network/CORS/timeout
if (!err.response) {
out.title = "Network error";
out.lines.push(
err.message ||
"No response received. Check internet, CORS, and server availability."
);
return out;
}
const { status, statusText, data } = err.response;
out.status = status;
out.title = `HTTP ${status}${statusText ? ` ${statusText}` : ""}`;
if (data == null) {
out.lines.push("Empty error response from server.");
return out;
}
if (typeof data === "string") {
out.lines.push(data);
return out;
}
if (Array.isArray(data)) {
data.forEach((x) => out.lines.push(String(x)));
return out;
}
if (typeof data === "object") {
// Common keys
if (typeof data.detail === "string") out.lines.push(data.detail);
if (typeof data.message === "string") out.lines.push(data.message);
if (typeof data.error === "string") out.lines.push(data.error);
// Field errors (DRF-like): {field: ["msg"], ...}
for (const [k, v] of Object.entries(data)) {
if (["detail", "message", "error"].includes(k)) continue;
if (Array.isArray(v)) {
v.forEach((msg) => out.lines.push(`${k}: ${String(msg)}`));
} else if (v && typeof v === "object") {
out.lines.push(`${k}: ${JSON.stringify(v)}`);
} else if (v != null) {
out.lines.push(`${k}: ${String(v)}`);
}
}
if (out.lines.length === 0) out.lines.push(JSON.stringify(data));
return out;
}
out.lines.push(String(data));
return out;
}
function StepChip({ active, done, label }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
borderRadius: 999,
border: "1px solid #e6e8f0",
background: active ? "#111827" : "#fff",
color: active ? "#fff" : "#344054",
fontSize: 12,
userSelect: "none",
}}
>
<span
style={{
width: 18,
height: 18,
borderRadius: 99,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid",
borderColor: active ? "#111827" : "#d0d5dd",
background: done ? "#22c55e" : active ? "#fff" : "#fff",
color: done ? "#fff" : active ? "#111827" : "#667085",
fontWeight: 800,
fontSize: 12,
}}
>
{done ? "✓" : "•"}
</span>
<span>{label}</span>
</div>
);
}
export default function App() {
// Step state
const [step, setStep] = useState(1);
// Selections
const [gradeId, setGradeId] = useState("");
const [subjectId, setSubjectId] = useState("");
const [lessonId, setLessonId] = useState("");
// Loaded data
const [subjects, setSubjects] = useState([]);
const [lessons, setLessons] = useState([]);
// Upload fields
const [lessonNumber, setLessonNumber] = useState("");
const [title, setTitle] = useState("");
const [text, setText] = useState("");
// UI states
const [loadingSubjects, setLoadingSubjects] = useState(false);
const [loadingLessons, setLoadingLessons] = useState(false);
const [uploading, setUploading] = useState(false);
// Messaging
const [success, setSuccess] = useState("");
const [errorBox, setErrorBox] = useState(null);
function clearMessages() {
setSuccess("");
setErrorBox(null);
}
const selectedSubject = useMemo(
() => subjects.find((s) => String(s.id) === String(subjectId)) || null,
[subjects, subjectId]
);
const selectedLesson = useMemo(
() => lessons.find((l) => String(l.id) === String(lessonId)) || null,
[lessons, lessonId]
);
// Keep step consistent
useEffect(() => {
if (!gradeId) setStep(1);
else if (!subjectId) setStep(2);
else if (!lessonId) setStep(3);
else setStep(4);
}, [gradeId, subjectId, lessonId]);
function resetAfterGradeChange() {
setSubjects([]);
setLessons([]);
setSubjectId("");
setLessonId("");
setLessonNumber("");
setTitle("");
setText("");
}
function resetAfterSubjectChange() {
setLessons([]);
setLessonId("");
setLessonNumber("");
setTitle("");
setText("");
}
// Step 2: Load subjects (GET /subjects/?grade=)
async function loadSubjects() {
clearMessages();
if (!gradeId) {
setErrorBox({
title: "Missing grade",
status: null,
lines: ["Select a grade first."],
});
return;
}
setLoadingSubjects(true);
try {
const res = await axios.get(`${API_BASE}/api/subjects/`, {
params: { grade: Number(gradeId) },
});
if (!Array.isArray(res.data)) {
setSubjects([]);
setErrorBox({
title: "Unexpected response",
status: 200,
lines: ["Subjects response is not a list (array)."],
});
return;
}
setSubjects(res.data);
setSubjectId("");
resetAfterSubjectChange();
setStep(2);
} catch (err) {
setSubjects([]);
setErrorBox(normalizeAxiosError(err));
} finally {
setLoadingSubjects(false);
}
}
// Step 3: Load lessons (GET /lessons/?grade=&subject=)
async function loadLessons() {
clearMessages();
if (!gradeId || !subjectId) {
setErrorBox({
title: "Missing selection",
status: null,
lines: ["Select grade and subject first."],
});
return;
}
setLoadingLessons(true);
try {
const res = await axios.get(`${API_BASE}/api/lessons/`, {
params: {
grade: Number(gradeId),
subject: Number(subjectId),
},
});
if (!Array.isArray(res.data)) {
setLessons([]);
setErrorBox({
title: "Unexpected response",
status: 200,
lines: ["Lessons response is not a list (array)."],
});
return;
}
setLessons(res.data);
setLessonId("");
setLessonNumber("");
setTitle("");
setText("");
setStep(3);
} catch (err) {
setLessons([]);
setErrorBox(normalizeAxiosError(err));
} finally {
setLoadingLessons(false);
}
}
// Auto-fill lesson_number if API provides something usable; user can override
useEffect(() => {
if (!selectedLesson) return;
const maybe =
selectedLesson.lesson_number ??
selectedLesson.lessonNumber ??
selectedLesson.chapter ??
selectedLesson.number ??
"";
if (lessonNumber === "" && maybe !== "" && !Number.isNaN(Number(maybe))) {
setLessonNumber(String(maybe));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedLesson]);
const payload = useMemo(
() => ({
lesson_number: Number(lessonNumber),
title: title ?? "",
text: text ?? "",
subject_id: Number(subjectId),
}),
[lessonNumber, title, text, subjectId]
);
const validationErrors = useMemo(() => {
const errs = [];
if (!gradeId) errs.push("Grade is required.");
if (!subjectId) errs.push("Subject is required.");
if (!lessonId) errs.push("Lesson is required.");
if (lessonNumber === "" || Number.isNaN(Number(lessonNumber)))
errs.push("Lesson number must be a valid number.");
if (!text.trim()) errs.push("Text is required.");
return errs;
}, [gradeId, subjectId, lessonId, lessonNumber, text]);
async function uploadText() {
clearMessages();
if (validationErrors.length) {
setErrorBox({
title: "Fix these before upload",
status: null,
lines: validationErrors,
});
return;
}
setUploading(true);
try {
const res = await axios.post(`${API_BASE}/api/upload-text`, payload, {
headers: { "Content-Type": "application/json" },
});
// You asked specifically: when upload was 201, clear textarea to avoid duplicates
if (res?.status === 201) {
setTitle("");
setText("");
} else {
// Still a success (2xx), but not 201; keep behavior safe and consistent
setTitle("");
setText("");
}
setSuccess("✅ Uploaded successfully.");
} catch (err) {
setErrorBox(normalizeAxiosError(err));
} finally {
setUploading(false);
}
}
const done1 = !!gradeId;
const done2 = !!subjectId;
const done3 =
!!lessonId && lessonNumber !== "" && !Number.isNaN(Number(lessonNumber));
const done4 = false;
return (
<div style={{ minHeight: "100vh", background: "#f6f7fb", padding: 22 }}>
<style>{`
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; }
.wrap { max-width: 980px; margin: 0 auto; }
.card { background: #fff; border: 1px solid #e6e8f0; border-radius: 16px; box-shadow: 0 10px 30px rgba(10, 20, 60, 0.06); overflow: hidden; }
.head { padding: 16px 16px 12px; border-bottom: 1px solid #eef0f6; display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; align-items: baseline; }
h1 { margin: 0; font-size: 18px; }
.sub { margin: 0; color: #667085; font-size: 13px; }
.pill { border: 1px solid #e6e8f0; border-radius: 999px; padding: 6px 10px; font-size: 12px; color: #344054; background: #fff; }
.steps { padding: 12px 16px; display: flex; gap: 8px; flex-wrap: wrap; border-bottom: 1px solid #eef0f6; background: #fcfdff; }
.body { padding: 16px; display: grid; grid-template-columns: 1fr; gap: 14px; }
@media (min-width: 900px) { .body { grid-template-columns: 1.05fr 0.95fr; } }
.panel { border: 1px solid #eef0f6; border-radius: 14px; background: #fff; padding: 14px; }
.panel h3 { margin: 0 0 10px; font-size: 14px; color: #111827; }
label { display: block; font-size: 12px; color: #344054; margin: 10px 0 6px; }
select, input, textarea {
width: 100%;
border: 1px solid #d9dce7;
border-radius: 12px;
padding: 10px 12px;
font-size: 14px;
background: #fff;
outline: none;
}
textarea { resize: vertical; min-height: 150px; }
select:disabled, input:disabled, textarea:disabled { opacity: 0.65; background: #f5f6fa; }
.hint { margin-top: 6px; font-size: 12px; color: #667085; }
.btnrow { display: flex; gap: 10px; justify-content: flex-end; flex-wrap: wrap; margin-top: 12px; }
.btn {
border-radius: 12px;
padding: 10px 14px;
font-size: 14px;
cursor: pointer;
border: 1px solid #d0d5dd;
background: #111827;
color: #fff;
}
.btn.secondary { background: #fff; color: #111827; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.msg { margin: 12px 16px 0; border-radius: 14px; padding: 12px; border: 1px solid; }
.msg.success { background: #ecfdf3; border-color: #abefc6; color: #067647; }
.msg.error { background: #fffbfa; border-color: #fecdca; color: #b42318; }
.msg h4 { margin: 0 0 6px; font-size: 13px; }
.msg ul { margin: 0; padding-left: 18px; }
.kv { display: grid; grid-template-columns: 120px 1fr; gap: 8px 12px; font-size: 13px; color: #111827; }
.muted { color: #667085; }
`}</style>
<div className="wrap">
<div className="card">
<div className="head">
<div>
<h1>RAG Text Uploader</h1>
<p className="sub">Step-by-step upload flow</p>
</div>
<div className="pill">Ready</div>
</div>
<div className="steps">
<StepChip active={step === 1} done={done1} label="1) Grade" />
<StepChip active={step === 2} done={done2} label="2) Subject" />
<StepChip active={step === 3} done={done3} label="3) Lesson + number" />
<StepChip active={step === 4} done={done4} label="4) Upload" />
</div>
{success ? (
<div className="msg success">
<h4>Success</h4>
<div>{success}</div>
</div>
) : null}
{errorBox ? (
<div className="msg error">
<h4>
{errorBox.title}
{typeof errorBox.status === "number" ? ` (status ${errorBox.status})` : ""}
</h4>
<ul>
{errorBox.lines.map((l, i) => (
<li key={i}>{l}</li>
))}
</ul>
</div>
) : null}
<div className="body">
{/* LEFT: Steps */}
<div className="panel">
<h3>Steps</h3>
<label>Step 1: Choose Grade</label>
<select
value={gradeId}
onChange={(e) => {
clearMessages();
setGradeId(e.target.value);
setStep(1);
resetAfterGradeChange();
}}
disabled={uploading}
>
<option value="">Select grade</option>
{GRADES.map((g) => (
<option key={g} value={g}>
Grade {g}
</option>
))}
</select>
<div className="btnrow">
<button
className="btn"
onClick={loadSubjects}
disabled={!gradeId || loadingSubjects || uploading}
>
{loadingSubjects ? "Loading..." : "Step 2: Load Subjects"}
</button>
</div>
<label style={{ marginTop: 14 }}>Step 2: Choose Subject</label>
<select
value={subjectId}
onChange={(e) => {
clearMessages();
setSubjectId(e.target.value);
resetAfterSubjectChange();
setStep(2);
}}
disabled={!subjects.length || loadingSubjects || uploading}
>
<option value="">
{!gradeId
? "Select grade first"
: loadingSubjects
? "Loading..."
: subjects.length
? "Select subject"
: "No subjects (load first)"}
</option>
{subjects.map((s) => (
<option key={s.id} value={s.id}>
{s.name ?? `Subject #${s.id}`}
</option>
))}
</select>
<div className="btnrow">
<button
className="btn"
onClick={loadLessons}
disabled={!gradeId || !subjectId || loadingLessons || uploading}
>
{loadingLessons ? "Loading..." : "Step 3: Load Lessons"}
</button>
</div>
<label style={{ marginTop: 14 }}>Step 3: Choose Lesson</label>
<select
value={lessonId}
onChange={(e) => {
clearMessages();
setLessonId(e.target.value);
setStep(3);
}}
disabled={!lessons.length || loadingLessons || uploading}
>
<option value="">
{!subjectId
? "Select subject first"
: loadingLessons
? "Loading..."
: lessons.length
? "Select lesson"
: "No lessons (load first)"}
</option>
{lessons.map((l) => (
<option key={l.id} value={l.id}>
{l.title ?? `Lesson #${l.id}`}
</option>
))}
</select>
<label>Lesson Number</label>
<input
type="number"
inputMode="numeric"
value={lessonNumber}
onChange={(e) => {
clearMessages();
setLessonNumber(e.target.value);
}}
disabled={!lessonId || uploading}
placeholder="Example: 1"
/>
<div className="btnrow" style={{ marginTop: 14 }}>
<button
className="btn secondary"
onClick={() => {
clearMessages();
setTitle("");
setText("");
}}
disabled={uploading}
>
Clear text
</button>
<button
className="btn"
onClick={uploadText}
disabled={uploading || validationErrors.length > 0}
title={validationErrors.length ? validationErrors.join(" | ") : "Upload"}
>
{uploading ? "Uploading..." : "Step 4: Upload"}
</button>
</div>
{validationErrors.length > 0 ? (
<div className="hint">
<strong className="muted">Blocked:</strong>{" "}
{validationErrors.join(" • ")}
</div>
) : null}
</div>
{/* RIGHT: Form */}
<div className="panel">
<h3>Upload Form</h3>
<div className="kv">
<div className="muted">Grade</div>
<div>{gradeId || "-"}</div>
<div className="muted">Subject</div>
<div>
{selectedSubject
? `${selectedSubject.name} (ID: ${selectedSubject.id})`
: "-"}
</div>
<div className="muted">Lesson</div>
<div>
{selectedLesson
? `${selectedLesson.title ?? "Lesson"} (ID: ${selectedLesson.id})`
: "-"}
</div>
</div>
<label style={{ marginTop: 14 }}>Title (optional)</label>
<input
value={title}
onChange={(e) => {
clearMessages();
setTitle(e.target.value);
}}
disabled={!lessonId || uploading}
placeholder="Optional title"
/>
<label>Text</label>
<textarea
value={text}
onChange={(e) => {
clearMessages();
setText(e.target.value);
}}
disabled={!lessonId || uploading}
placeholder="Paste your content here..."
/>
<div className="hint">
After a successful upload, the text is cleared automatically to prevent double-submitting.
</div>
</div>
</div>
<div style={{ padding: "0 16px 16px" }} className="hint">
Tip: If you see an error, it will show the server details here clearly.
</div>
</div>
</div>
</div>
);
}

83
src/index.css Normal file
View File

@@ -0,0 +1,83 @@
/* Basic styles for the RAG data uploader. */
body {
margin: 0;
padding: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: #f9f9f9;
color: #333;
}
#root {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.container {
width: 100%;
max-width: 600px;
padding: 1rem;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h1 {
font-size: 1.5rem;
margin-bottom: 1rem;
text-align: center;
}
label {
display: block;
margin-top: 1rem;
margin-bottom: 0.25rem;
font-weight: 500;
}
select,
input[type='text'],
textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
box-sizing: border-box;
}
button {
margin-top: 1rem;
padding: 0.5rem 1rem;
font-size: 1rem;
background-color: #007bff;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s ease;
}
button:hover {
background-color: #0056b3;
}
.status {
margin-top: 1rem;
padding: 0.75rem;
border-radius: 4px;
}
.status.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}

13
src/main.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
// Entrypoint for our React app.
// This mounts the App component into the DOM element with id "root".
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

12
vite.config.js Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Vite configuration for the RAG data uploader.
// See https://vitejs.dev/config/ for more details.
export default defineConfig({
plugins: [react()],
server: {
port: 3000
}
});