update
This commit is contained in:
@@ -1,9 +1,7 @@
|
|||||||
import { StrictMode, useLayoutEffect, useRef, useState, useEffect, useMemo } from "react";
|
import { StrictMode, useLayoutEffect, useRef, useState, useEffect, useMemo } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { Navigate, BrowserRouter, Link, Outlet, Route, Routes } from "react-router";
|
||||||
import { useParams, Navigate, BrowserRouter, Link, Outlet, Route, Routes } from "react-router";
|
|
||||||
import { useShallow } from "zustand/shallow";
|
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { apiInstance, resourcesInstance } from "./api.js";
|
import { resourcesInstance } from "./api.js";
|
||||||
import { useStore } from "./store.js";
|
import { useStore } from "./store.js";
|
||||||
import { useSubject, useTopic, useTopicSyncParams, useTopicsAround } from "./hooks.js";
|
import { useSubject, useTopic, useTopicSyncParams, useTopicsAround } from "./hooks.js";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +18,7 @@ import {
|
|||||||
TextDecreaseIcon,
|
TextDecreaseIcon,
|
||||||
VRuleIcon,
|
VRuleIcon,
|
||||||
} from "./icons/Icons";
|
} from "./icons/Icons";
|
||||||
|
import ResourcePage from "./ResourcePage.jsx";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
@@ -36,6 +35,7 @@ export function App() {
|
|||||||
<Route index element={<TopicListView />} />
|
<Route index element={<TopicListView />} />
|
||||||
<Route path=":topicId" element={<FileReader />} />
|
<Route path=":topicId" element={<FileReader />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/edit" element={<ResourcePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
@@ -60,12 +60,12 @@ function Layout() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto h-full relative flex flex-col">
|
<div className="max-w-7xl mx-auto h-full relative flex flex-col">
|
||||||
{config.displayTitle && (
|
{config.displayTitle && (
|
||||||
<div className="w-full px-4 py-2 font-medium text-large text-white bg-blue-600">
|
<div
|
||||||
<span>
|
className="w-full px-4 py-2 font-medium text-large text-white bg-blue-600"
|
||||||
{topic
|
style={{ lineHeight: 1.2 }}
|
||||||
? `${topic.sequence}: ${topic.title}`
|
>
|
||||||
: `${subject.name} - Конспект за Държавен Изпит`}
|
<div className="text-sm font-normal">{subject.name}</div>
|
||||||
</span>
|
<span>{topic ? `${topic.sequence}: ${topic.title}` : `Конспект за Държавен Изпит`}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
@@ -207,7 +207,7 @@ export function TopicListView() {
|
|||||||
function FileReader() {
|
function FileReader() {
|
||||||
const topic = useTopicSyncParams();
|
const topic = useTopicSyncParams();
|
||||||
|
|
||||||
if (topic.isLoading) {
|
if (topic && topic.isLoading) {
|
||||||
return null;
|
return null;
|
||||||
//return "Loading...";
|
//return "Loading...";
|
||||||
}
|
}
|
||||||
|
|||||||
338
reader/src/ResourcePage.jsx
Normal file
338
reader/src/ResourcePage.jsx
Normal 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;
|
||||||
@@ -21,9 +21,20 @@ server {
|
|||||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
||||||
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept, Authorization" 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
|
# Set proper content type for markdown files
|
||||||
location ~* \.md$ {
|
location ~* \.md$ {
|
||||||
add_header Content-Type "text/markdown; charset=utf-8";
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user