This commit is contained in:
Tomas Mirchev 2025-06-26 23:10:47 +00:00
parent c1d50b4c7a
commit 16523150f5
3 changed files with 360 additions and 11 deletions

View File

@ -1,9 +1,7 @@
import { StrictMode, useLayoutEffect, useRef, useState, useEffect, useMemo } from "react";
import { createRoot } from "react-dom/client";
import { useParams, Navigate, BrowserRouter, Link, Outlet, Route, Routes } from "react-router";
import { useShallow } from "zustand/shallow";
import { Navigate, BrowserRouter, Link, Outlet, Route, Routes } from "react-router";
import { marked } from "marked";
import { apiInstance, resourcesInstance } from "./api.js";
import { resourcesInstance } from "./api.js";
import { useStore } from "./store.js";
import { useSubject, useTopic, useTopicSyncParams, useTopicsAround } from "./hooks.js";
import {
@ -20,6 +18,7 @@ import {
TextDecreaseIcon,
VRuleIcon,
} from "./icons/Icons";
import ResourcePage from "./ResourcePage.jsx";
export function App() {
return (
@ -36,6 +35,7 @@ export function App() {
<Route index element={<TopicListView />} />
<Route path=":topicId" element={<FileReader />} />
</Route>
<Route path="/edit" element={<ResourcePage />} />
</Routes>
</BrowserRouter>
);
@ -60,12 +60,12 @@ function Layout() {
return (
<div className="max-w-7xl mx-auto h-full relative flex flex-col">
{config.displayTitle && (
<div className="w-full px-4 py-2 font-medium text-large text-white bg-blue-600">
<span>
{topic
? `${topic.sequence}: ${topic.title}`
: `${subject.name} - Конспект за Държавен Изпит`}
</span>
<div
className="w-full px-4 py-2 font-medium text-large text-white bg-blue-600"
style={{ lineHeight: 1.2 }}
>
<div className="text-sm font-normal">{subject.name}</div>
<span>{topic ? `${topic.sequence}: ${topic.title}` : `Конспект за Държавен Изпит`}</span>
</div>
)}
<Outlet />
@ -207,7 +207,7 @@ export function TopicListView() {
function FileReader() {
const topic = useTopicSyncParams();
if (topic.isLoading) {
if (topic && topic.isLoading) {
return null;
//return "Loading...";
}

338
reader/src/ResourcePage.jsx Normal file
View File

@ -0,0 +1,338 @@
import React, { useState, useEffect, useMemo, useRef, useLayoutEffect } from "react";
import { useStore } from "./store.js";
import { resourcesInstance, apiInstance } from "./api.js";
function LoadingWrapper() {
const isLoading = useStore((state) => state.isLoading);
if (isLoading) {
return "Loading...";
}
return <ResourcePage />;
}
function SelectResource({ onChange }) {
const subjects = useStore((state) => state.subjects);
const [selectedSubject, setSelectedSubject] = useState(-1);
const [selectedTopic, setSelectedTopic] = useState(-1);
function handleChange(newSubject, newTopic) {
setSelectedSubject(newSubject);
setSelectedTopic(newTopic);
if (newSubject === -1 || newTopic === -1) {
onChange(null);
return;
}
const subject = subjects[newSubject];
const topic = subject.topics[newTopic];
const resource = topic.resources.at(-1);
onChange({
subjectName: subject.name,
topic,
resource,
});
}
return (
<div>
{/* File Selector */}
<div className="bg-white border-b border-gray-200 p-4 flex space-x-6">
<div>
<label htmlFor="subjectSelect" className="block text-sm font-medium text-gray-700 mb-2">
Избери раздел:
</label>
<select
id="subjectSelect"
value={selectedSubject}
onChange={(event) => {
handleChange(parseInt(event.target.value), -1);
}}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white min-w-64"
>
<option value="-1" disabled>
Избери раздел...
</option>
{subjects.map((subject, sIndex) => (
<option key={subject.id} value={sIndex}>
{subject.sequence}. {subject.name}
</option>
))}
</select>
</div>
<div>
<label htmlFor="topicSelect" className="block text-sm font-medium text-gray-700 mb-2">
Избери тема:
</label>
<select
id="topicSelect"
value={selectedTopic}
onChange={(event) => {
handleChange(selectedSubject, parseInt(event.target.value));
}}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
>
<option value="-1" disabled>
{selectedSubject === -1 ? "Първо избери раздел" : "Избери тема..."}
</option>
{selectedSubject !== -1 &&
subjects[selectedSubject].topics.map((topic, tIndex) => (
<option key={topic.id} value={tIndex}>
{topic.sequence}.{" "}
{topic.title.length > 60 ? topic.title.substring(0, 60) + "..." : topic.title}
</option>
))}
</select>
</div>
</div>
</div>
);
}
function ResourcePage() {
return (
<Content token="aaaaaa" initialContent="# hola" subjectName="asdasd" topicTitle="tiilte" />
);
}
function useFileContent(file) {
const [content, setContent] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!file) {
return;
}
let ignore = false;
setIsLoading(true);
setContent(null);
resourcesInstance.get(`/${file.filename}`).then((res) => {
if (!ignore) {
setContent(res.data);
setIsLoading(false);
}
});
return () => {
ignore = true;
};
}, [file]);
return { content, isLoading };
}
const Content = () => {
const authToken = useStore((state) => state.config.token);
const changeConfig = useStore((state) => state.changeConfig);
const setAuthToken = (token) => changeConfig({ token });
const [selected, setSelected] = useState(null);
const { isLoading, content: initialContent } = useFileContent(selected?.resource);
const [content, setContent] = useState(initialContent);
const [tokenInput, setTokenInput] = useState("");
const [isSavingLoading, setIsSavingLoading] = useState(false);
const [message, setMessage] = useState(null);
const [prevContent, setPrevContent] = useState(initialContent);
if (prevContent !== initialContent) {
setPrevContent(initialContent);
setContent(initialContent);
}
const handleTokenSubmit = () => {
if (tokenInput.trim()) {
setAuthToken(tokenInput);
}
};
const handleSave = async () => {
setIsSavingLoading(true);
try {
const res = await apiInstance.post(
`/resources`,
{
topicId: selected.topic.id,
content: content.trim(),
},
{
headers: {
token: authToken,
},
},
);
setMessage(`Success! New filename: ${res.data.filename}`);
} catch (error) {
console.log(error);
setMessage("Something failed :(");
if (error.response.status === 401) {
setAuthToken(null);
}
} finally {
setTimeout(() => {
setMessage(null);
}, 8000);
setIsSavingLoading(false);
setContent("");
setSelected(null);
}
};
const handleCancel = () => {
if (window.confirm("Сигурни ли сте, че искате да затворите страницата?")) {
window.close();
}
};
const handleCopyContent = async () => {
try {
await navigator.clipboard.writeText(content);
alert("Съдържанието е копирано в клипборда!");
} catch (err) {
console.error("Грешка при копиране:", err);
alert("Грешка при копиране на съдържанието");
}
};
function handleUndoInitial() {
setContent(initialContent);
}
const handleDeleteAll = () => {
if (window.confirm("Сигурни ли сте, че искате да изтриете цялото съдържание?")) {
setContent("");
}
};
// Token authentication screen
if (!authToken) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-8">
<div className="bg-white rounded-lg shadow-md p-8 w-full max-w-md">
<h2 className="text-2xl font-bold text-gray-800 mb-6 text-center">
Въведете токен за достъп
</h2>
<div className="space-y-4">
<div>
<label htmlFor="token" className="block text-sm font-medium text-gray-700 mb-2">
Токен:
</label>
<input
type="text"
id="token"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Въведете вашия токен..."
/>
</div>
<button
onClick={handleTokenSubmit}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Влизане
</button>
</div>
</div>
</div>
);
}
// Main content screen
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<SelectResource onChange={(values) => setSelected(values)} />
{message && <div className="text-lg p-12 text-green-600 bg-gray-200">{message}</div>}
{selected && !isLoading && (
<>
{/* Header */}
<div className="bg-white border-b border-gray-200 p-4">
<div className="text-2xl font-bold text-gray-800">{selected.subjectName}</div>
<div className="text-2xl/7 mt-2 text-gray-800 line-">
Тема: {selected.topic.sequence}. {selected.topic.title}
</div>
<div className="text-sm text-gray-400 mt-6">
Topic Id: <span className="font-bold">{selected.topic.id}</span>
{" - "}
Original Filename: <span className="font-bold">{selected.resource.filename}</span>
{" - "}
Current Version: <span className="font-bold">{selected.resource.version}</span>
{" - "}
New Version: <span className="font-bold">{selected.resource.version + 1}</span>
</div>
</div>
{/* Quick Actions */}
<div className="bg-white border-b border-gray-200 p-4">
<div className="flex flex-wrap gap-3">
<button
onClick={handleCopyContent}
className="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-colors"
>
Копирай съдържанието
</button>
<button
onClick={handleDeleteAll}
className="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition-colors"
>
Изтрий всичко
</button>
<button
onClick={handleUndoInitial}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Възстанови оригинал
</button>
</div>
</div>
{/* Content Editor */}
<div className="flex-1 bg-white p-4 flex flex-col">
<label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-2">
Markdown съдържание:
</label>
<div className="flex-1">
<textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full h-full resize-none px-3 py-2 bg-gray-100 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
placeholder="Въведете вашето Markdown съдържание тук..."
/>
</div>
</div>
{/* Bottom Actions */}
<div className="bg-white border-t border-gray-200 p-4">
{isSavingLoading && <span className="flex justify-end mr-8">Saving...</span>}
<div className={`flex justify-end gap-3 ${isSavingLoading ? "invisible" : ""}`}>
<button
onClick={handleCancel}
className="bg-gray-600 text-white px-6 py-2 rounded-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors"
>
Отказ
</button>
<button
onClick={handleSave}
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Запази
</button>
</div>
</div>
</>
)}
</div>
);
};
export default LoadingWrapper;

View File

@ -21,9 +21,20 @@ server {
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept, Authorization" always;
# Aggressive caching - 1 year
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header Expires "Thu, 31 Dec 2037 23:55:55 GMT" always;
# Set proper content type for markdown files
location ~* \.md$ {
add_header Content-Type "text/markdown; charset=utf-8";
# Aggressive caching for markdown too
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header Expires "Thu, 31 Dec 2037 23:55:55 GMT" always;
}
# Enable gzip compression for better performance
gzip on;
gzip_types text/plain text/css text/markdown application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}
}