1
This commit is contained in:
+9
-1
@@ -8,7 +8,7 @@ from fastapi.responses import FileResponse, PlainTextResponse
|
|||||||
|
|
||||||
from backend.app.config import settings
|
from backend.app.config import settings
|
||||||
from backend.app.managers.knowledge_manager import DuplicateSourceError, KnowledgeManager
|
from backend.app.managers.knowledge_manager import DuplicateSourceError, KnowledgeManager
|
||||||
from backend.app.models.schemas import MarkdownUpdate, SubjectCreate, WeekCreate, WeekUpdate
|
from backend.app.models.schemas import MarkdownUpdate, SubjectCreate, SubjectUpdate, WeekCreate, WeekUpdate
|
||||||
from backend.app.services.ingestion_service import IngestionService
|
from backend.app.services.ingestion_service import IngestionService
|
||||||
|
|
||||||
app = FastAPI(title="Knowledge Station", version="0.1.0")
|
app = FastAPI(title="Knowledge Station", version="0.1.0")
|
||||||
@@ -60,6 +60,14 @@ def get_subject(subject_id: int) -> dict:
|
|||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/subjects/{subject_id}")
|
||||||
|
def update_subject(subject_id: int, payload: SubjectUpdate) -> dict:
|
||||||
|
try:
|
||||||
|
return manager.update_subject(subject_id, payload.name)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/subjects/{subject_id}")
|
@app.delete("/subjects/{subject_id}")
|
||||||
def delete_subject(subject_id: int) -> dict:
|
def delete_subject(subject_id: int) -> dict:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -154,6 +154,14 @@ class KnowledgeManager:
|
|||||||
raise ValueError("Materia no encontrada")
|
raise ValueError("Materia no encontrada")
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
def update_subject(self, subject_id: int, name: str) -> dict[str, Any]:
|
||||||
|
if not name.strip():
|
||||||
|
raise ValueError("El nombre de la materia no puede estar vacio")
|
||||||
|
self.get_subject(subject_id)
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute("update subjects set name = ? where id = ?", (name.strip(), subject_id))
|
||||||
|
return self.get_subject(subject_id)
|
||||||
|
|
||||||
def delete_subject(self, subject_id: int) -> None:
|
def delete_subject(self, subject_id: int) -> None:
|
||||||
subject = self.get_subject(subject_id)
|
subject = self.get_subject(subject_id)
|
||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
@@ -224,6 +232,8 @@ class KnowledgeManager:
|
|||||||
def update_week(self, subject_id: int, current_number: int, new_number: int, title: str | None = None) -> dict[str, Any]:
|
def update_week(self, subject_id: int, current_number: int, new_number: int, title: str | None = None) -> dict[str, Any]:
|
||||||
subject = self.get_subject(subject_id)
|
subject = self.get_subject(subject_id)
|
||||||
week = self.get_week(subject_id, current_number)
|
week = self.get_week(subject_id, current_number)
|
||||||
|
old_dir: Path | None = None
|
||||||
|
new_dir: Path | None = None
|
||||||
if new_number != current_number:
|
if new_number != current_number:
|
||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
@@ -248,6 +258,22 @@ class KnowledgeManager:
|
|||||||
"update weeks set number = ?, title = ? where id = ?",
|
"update weeks set number = ?, title = ? where id = ?",
|
||||||
(new_number, title, week["id"]),
|
(new_number, title, week["id"]),
|
||||||
)
|
)
|
||||||
|
if old_dir and new_dir:
|
||||||
|
old_prefix = str(old_dir)
|
||||||
|
new_prefix = str(new_dir)
|
||||||
|
conn.execute(
|
||||||
|
"update sources set stored_path = replace(stored_path, ?, ?) where week_id = ? and stored_path like ?",
|
||||||
|
(old_prefix, new_prefix, week["id"], f"{old_prefix}%"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
update documents
|
||||||
|
set markdown_path = replace(markdown_path, ?, ?)
|
||||||
|
where source_id in (select id from sources where week_id = ?)
|
||||||
|
and markdown_path like ?
|
||||||
|
""",
|
||||||
|
(old_prefix, new_prefix, week["id"], f"{old_prefix}%"),
|
||||||
|
)
|
||||||
return self.get_week(subject_id, new_number)
|
return self.get_week(subject_id, new_number)
|
||||||
|
|
||||||
def week_dir(self, subject_slug: str, week_number: int) -> Path:
|
def week_dir(self, subject_slug: str, week_number: int) -> Path:
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ class SubjectCreate(BaseModel):
|
|||||||
name: str = Field(min_length=1)
|
name: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectUpdate(BaseModel):
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class Subject(BaseModel):
|
class Subject(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
+67
-10
@@ -10,6 +10,8 @@ export default function DashboardPage() {
|
|||||||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||||
const [settings, setSettings] = useState<SettingsStatus | null>(null);
|
const [settings, setSettings] = useState<SettingsStatus | null>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
const [editingSubject, setEditingSubject] = useState<Subject | null>(null);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
@@ -31,9 +33,47 @@ export default function DashboardPage() {
|
|||||||
async function createSubject(event: FormEvent) {
|
async function createSubject(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
await api.createSubject(name.trim());
|
try {
|
||||||
setName("");
|
setError("");
|
||||||
await load();
|
await api.createSubject(name.trim());
|
||||||
|
setName("");
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "No se pudo crear la materia");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(subject: Subject) {
|
||||||
|
setEditingSubject(subject);
|
||||||
|
setEditName(subject.name);
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editingSubject || !editName.trim()) return;
|
||||||
|
try {
|
||||||
|
setError("");
|
||||||
|
await api.updateSubject(editingSubject.id, editName.trim());
|
||||||
|
setEditingSubject(null);
|
||||||
|
setEditName("");
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "No se pudo editar la materia");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSubject(subject: Subject) {
|
||||||
|
const confirmed = window.confirm(`Eliminar la materia "${subject.name}" tambien borrara todas sus semanas, archivos subidos, Markdown procesado y exports. Esta accion no se puede deshacer.`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
setError("");
|
||||||
|
await api.deleteSubject(subject.id);
|
||||||
|
if (editingSubject?.id === subject.id) setEditingSubject(null);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "No se pudo eliminar la materia");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,13 +90,15 @@ export default function DashboardPage() {
|
|||||||
{loading ? <Card>Cargando...</Card> : (
|
{loading ? <Card>Cargando...</Card> : (
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{subjects.map((subject) => (
|
{subjects.map((subject) => (
|
||||||
<Link key={subject.id} href={`/subjects/${subject.id}`}>
|
<Card key={subject.id} className="transition hover:border-blue-400/40 hover:bg-blue-500/10">
|
||||||
<Card className="transition hover:border-blue-400/40 hover:bg-blue-500/10">
|
<div className="text-xl font-semibold">{subject.name}</div>
|
||||||
<div className="text-xl font-semibold">{subject.name}</div>
|
<div className="mt-2 text-sm text-slate-400">{subject.slug}</div>
|
||||||
<div className="mt-2 text-sm text-slate-400">{subject.slug}</div>
|
<div className="mt-5 flex flex-wrap gap-2 text-sm">
|
||||||
<div className="mt-5 text-sm text-blue-300">Abrir materia</div>
|
<Link className="rounded-lg bg-blue-600 px-3 py-2 font-medium text-white hover:bg-blue-500" href={`/subjects/${subject.id}`}>Abrir materia</Link>
|
||||||
</Card>
|
<button className="rounded-lg border border-white/10 px-3 py-2 text-slate-200 hover:bg-white/10" type="button" onClick={() => startEdit(subject)}>Editar</button>
|
||||||
</Link>
|
<button className="rounded-lg border border-red-400/30 px-3 py-2 text-red-200 hover:bg-red-500/10" type="button" onClick={() => deleteSubject(subject)}>Eliminar</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
{subjects.length === 0 && <Card className="md:col-span-2">No hay materias todavia. Crea la primera para empezar.</Card>}
|
{subjects.length === 0 && <Card className="md:col-span-2">No hay materias todavia. Crea la primera para empezar.</Card>}
|
||||||
</div>
|
</div>
|
||||||
@@ -64,6 +106,21 @@ export default function DashboardPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside className="space-y-4">
|
<aside className="space-y-4">
|
||||||
|
{editingSubject && (
|
||||||
|
<Card>
|
||||||
|
<h2 className="text-lg font-semibold">Editar materia</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">Solo cambia el nombre visible. La carpeta interna queda igual para mantener rutas estables.</p>
|
||||||
|
<form className="mt-4 space-y-3" onSubmit={saveEdit}>
|
||||||
|
<Label>Nombre</Label>
|
||||||
|
<Input value={editName} onChange={(event) => setEditName(event.target.value)} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button className="flex-1" disabled={!editName.trim()}>Guardar</Button>
|
||||||
|
<button className="flex-1 rounded-xl border border-white/10 px-4 py-2 hover:bg-white/10" type="button" onClick={() => setEditingSubject(null)}>Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<h2 className="text-lg font-semibold">Nueva materia</h2>
|
<h2 className="text-lg font-semibold">Nueva materia</h2>
|
||||||
<form className="mt-4 space-y-3" onSubmit={createSubject}>
|
<form className="mt-4 space-y-3" onSubmit={createSubject}>
|
||||||
|
|||||||
@@ -62,6 +62,19 @@ export default function SubjectPage({ params }: { params: { subjectId: string }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteWeek(week: Week) {
|
||||||
|
const confirmed = window.confirm(`Eliminar la semana ${week.number} tambien borrara sus archivos subidos, Markdown procesado y exports. Esta accion no se puede deshacer.`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
setError("");
|
||||||
|
await api.deleteWeek(subjectId, week.number);
|
||||||
|
if (editingWeek?.id === week.id) setEditingWeek(null);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "No se pudo eliminar la semana");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell>
|
<Shell>
|
||||||
<Link href="/" className="text-sm text-blue-300">Volver a materias</Link>
|
<Link href="/" className="text-sm text-blue-300">Volver a materias</Link>
|
||||||
@@ -78,6 +91,7 @@ export default function SubjectPage({ params }: { params: { subjectId: string }
|
|||||||
<div className="mt-5 flex flex-wrap gap-2 text-sm">
|
<div className="mt-5 flex flex-wrap gap-2 text-sm">
|
||||||
<Link className="rounded-lg bg-blue-600 px-3 py-2 font-medium text-white hover:bg-blue-500" href={`/subjects/${subjectId}/weeks/${week.number}`}>Abrir semana</Link>
|
<Link className="rounded-lg bg-blue-600 px-3 py-2 font-medium text-white hover:bg-blue-500" href={`/subjects/${subjectId}/weeks/${week.number}`}>Abrir semana</Link>
|
||||||
<button className="rounded-lg border border-white/10 px-3 py-2 text-slate-200 hover:bg-white/10" type="button" onClick={() => startEdit(week)}>Editar</button>
|
<button className="rounded-lg border border-white/10 px-3 py-2 text-slate-200 hover:bg-white/10" type="button" onClick={() => startEdit(week)}>Editar</button>
|
||||||
|
<button className="rounded-lg border border-red-400/30 px-3 py-2 text-red-200 hover:bg-red-500/10" type="button" onClick={() => deleteWeek(week)}>Eliminar</button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -65,6 +65,16 @@ export const api = {
|
|||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
async updateSubject(id: number, name: string) {
|
||||||
|
return parseResponse<Subject>(await fetch(`${API_URL}/subjects/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async deleteSubject(id: number) {
|
||||||
|
return parseResponse<{ status: string }>(await fetch(`${API_URL}/subjects/${id}`, { method: "DELETE" }));
|
||||||
|
},
|
||||||
async weeks(subjectId: number) {
|
async weeks(subjectId: number) {
|
||||||
return parseResponse<Week[]>(await fetch(`${API_URL}/subjects/${subjectId}/weeks`, { cache: "no-store" }));
|
return parseResponse<Week[]>(await fetch(`${API_URL}/subjects/${subjectId}/weeks`, { cache: "no-store" }));
|
||||||
},
|
},
|
||||||
@@ -85,6 +95,9 @@ export const api = {
|
|||||||
body: JSON.stringify({ number, title: title || null }),
|
body: JSON.stringify({ number, title: title || null }),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
async deleteWeek(subjectId: number, weekNumber: number) {
|
||||||
|
return parseResponse<{ status: string }>(await fetch(`${API_URL}/subjects/${subjectId}/weeks/${weekNumber}`, { method: "DELETE" }));
|
||||||
|
},
|
||||||
async sources(subjectId: number, weekNumber: number) {
|
async sources(subjectId: number, weekNumber: number) {
|
||||||
return parseResponse<Source[]>(await fetch(`${API_URL}/subjects/${subjectId}/weeks/${weekNumber}/sources`, { cache: "no-store" }));
|
return parseResponse<Source[]>(await fetch(`${API_URL}/subjects/${subjectId}/weeks/${weekNumber}/sources`, { cache: "no-store" }));
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user