update
This commit is contained in:
parent
78212cc2a9
commit
7908feebf3
@ -5,7 +5,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link href="/src/index.css" rel="stylesheet">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Конспект</title>
|
||||
</head>
|
||||
|
||||
514
reader/src/App.jsx
Normal file
514
reader/src/App.jsx
Normal file
@ -0,0 +1,514 @@
|
||||
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 { marked } from "marked";
|
||||
import { apiInstance, resourcesInstance } from "./api.js";
|
||||
import { useStore } from "./store.js";
|
||||
import { useSubject, useTopic, useTopicSyncParams, useTopicsAround } from "./hooks.js";
|
||||
import {
|
||||
ArrowBackIcon,
|
||||
ArrowForwardIcon,
|
||||
MenuBookIcon,
|
||||
MyLocationIcon,
|
||||
TitleIcon,
|
||||
WidthIcon,
|
||||
EllipsisIcon,
|
||||
CloseIcon,
|
||||
JustifyTextIcon,
|
||||
TextIncreaseIcon,
|
||||
TextDecreaseIcon,
|
||||
VRuleIcon,
|
||||
} from "./icons/Icons";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<LoadingWrapper>
|
||||
<Layout />
|
||||
</LoadingWrapper>
|
||||
}
|
||||
>
|
||||
<Route index element={<TopicListView />} />
|
||||
<Route path=":topicId" element={<FileReader />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingWrapper({ children }) {
|
||||
const isLoading = useStore((state) => state.isLoading);
|
||||
|
||||
if (isLoading) {
|
||||
return "Loading...";
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function Layout() {
|
||||
const config = useStore((state) => state.config);
|
||||
const subject = useSubject();
|
||||
const topic = useTopic();
|
||||
|
||||
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>
|
||||
)}
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopicListView() {
|
||||
const itemRefs = useRef({});
|
||||
|
||||
const subjects = useStore((state) => state.subjects);
|
||||
|
||||
const selectedSubject = useSubject();
|
||||
const selectSubject = useStore((state) => state.selectSubject);
|
||||
const [isSelectingSubject, setIsSelectingSubject] = useState(false);
|
||||
|
||||
const selectedTopic = useTopic();
|
||||
const selectTopic = useStore((state) => state.selectTopic);
|
||||
|
||||
const config = useStore((state) => state.config);
|
||||
const changeConfig = useStore((state) => state.changeConfig);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (selectedTopic) {
|
||||
itemRefs.current?.[Math.max(selectedTopic.index - 3, 0)].scrollIntoView();
|
||||
}
|
||||
}, [selectedTopic]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`flex-1 overflow-y-scroll ${selectedTopic === null ? "pb-[92px]" : "pb-[156px]"}`}
|
||||
>
|
||||
{selectedSubject.topics.map((topic, topicIdx) => (
|
||||
<Link
|
||||
key={topic.id}
|
||||
ref={(node) => {
|
||||
itemRefs.current[topicIdx] = node;
|
||||
}}
|
||||
to={`/${topic.id}`}
|
||||
onClick={() => selectTopic(topicIdx)}
|
||||
className={`flex px-2 py-1 rounded-md cursor-pointer border-l-4 ${topic.id === selectedTopic?.id ? "bg-blue-100 border-blue-500" : "border-transparent hover:bg-gray-100"}`}
|
||||
>
|
||||
<div
|
||||
className={`w-6 flex-shrink-0 flex font-medium justify-end ${topic.id === selectedTopic?.id ? "text-blue-600" : "text-blue-800"}`}
|
||||
>
|
||||
{topic.sequence}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-2 leading-5 ${topic.id === selectedTopic?.id ? "font-medium" : "font-normal"} ${config.wrapTopicTitles ? "truncate" : ""}`}
|
||||
>
|
||||
{topic.title}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute bottom-0 sm:p-4 px-2 py-0 w-full flex flex-col">
|
||||
<div className="ml-auto p-2 flex space-x-1 h-[60px]">
|
||||
<button
|
||||
className={`cursor-pointer p-2 rounded-full text-white border ${config.displayTitle ? "bg-blue-100 border-blue-400" : "bg-gray-100 border-gray-400"}`}
|
||||
onClick={() => changeConfig({ displayTitle: !config.displayTitle })}
|
||||
>
|
||||
<TitleIcon className="fill-gray-600" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`cursor-pointer p-2 rounded-full text-white border ${config.wrapTopicTitles ? "bg-blue-100 border-blue-400" : "bg-gray-100 border-gray-400"}`}
|
||||
onClick={() => changeConfig({ wrapTopicTitles: !config.wrapTopicTitles })}
|
||||
>
|
||||
<EllipsisIcon className="fill-gray-600" />
|
||||
</button>
|
||||
|
||||
{selectedTopic && (
|
||||
<button
|
||||
className="px-3 py-3 bg-teal-500 hover:bg-teal-300 cursor-pointer rounded-full flex items-center justify-center shadow-md transition-colors"
|
||||
onClick={() => {
|
||||
itemRefs.current?.[Math.max(selectedTopic.index - 3, 0)].scrollIntoView({
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MyLocationIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="relative flex ml-2">
|
||||
<button
|
||||
className={`${isSelectingSubject ? "invisible" : ""} flex-1 shadow-xl cursor-pointer px-2 py-1 rounded-md text-xs text-blue-800 font-medium whitespace-nowrap bg-blue-100/50 backdrop-blur hover:bg-blue-200/50 border border-blue-100`}
|
||||
onClick={() => setIsSelectingSubject(true)}
|
||||
>
|
||||
{selectedSubject.name}
|
||||
</button>
|
||||
{isSelectingSubject && (
|
||||
<>
|
||||
<button
|
||||
className={`absolute w-full h-full flex-1 flex justify-center items-center cursor-pointer rounded-md backdrop-blur bg-blue-100/40 hover:bg-blue-100/80`}
|
||||
onClick={() => setIsSelectingSubject(false)}
|
||||
>
|
||||
<CloseIcon className="fill-gray-600" />
|
||||
</button>
|
||||
<div className="absolute bottom-full right-0 p-2 flex space-x-1">
|
||||
{subjects.map((subject, subjectIdx) => (
|
||||
<button
|
||||
key={subject.id}
|
||||
className={`flex-1 shadow-xl cursor-pointer px-2 py-1 rounded-md text-xs whitespace-nowrap h-[44px] ${
|
||||
selectedSubject.id === subject.id
|
||||
? "bg-blue-100 text-blue-800 font-medium border border-blue-400"
|
||||
: "bg-gray-100 hover:bg-gray-200 border border-gray-400"
|
||||
}`}
|
||||
onClick={() => {
|
||||
selectSubject(subjectIdx);
|
||||
setIsSelectingSubject(false);
|
||||
}}
|
||||
>
|
||||
{subject.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedTopic && (
|
||||
<Link
|
||||
to={`/${selectedTopic.id}`}
|
||||
className="w-full p-2 mb-2 bg-blue-600 hover:bg-blue-700 cursor-pointer truncate rounded-md text-sm text-white text-center shadow-md transition-colors"
|
||||
>
|
||||
<span>Продължи четенето:</span>
|
||||
<br />
|
||||
<span className="font-medium">
|
||||
{selectedTopic.sequence}. {selectedTopic.title}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FileReader() {
|
||||
const topic = useTopicSyncParams();
|
||||
const topicIdx = useStore((state) => state.topicIdx);
|
||||
|
||||
if (!topic) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
console.log({ topic, topicIdx });
|
||||
|
||||
return <Reader topic={topic} />;
|
||||
}
|
||||
|
||||
export function Reader({ topic }) {
|
||||
const config = useStore((state) => state.config);
|
||||
const changeConfig = useStore((state) => state.changeConfig);
|
||||
|
||||
const { prevTopic, nextTopic } = useTopicsAround();
|
||||
|
||||
const resourceIdx = useStore((state) => state.resourceIdx);
|
||||
const selectResource = useStore((state) => state.selectResource);
|
||||
const [isSelectingResource, setIsSelectingResource] = useState(false);
|
||||
const selectedResource = topic.resources[resourceIdx];
|
||||
|
||||
console.log("reader!");
|
||||
if (!prevTopic || !nextTopic) {
|
||||
return "balb";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
<PDFViewer
|
||||
file={topic.resources[resourceIdx]}
|
||||
compact={config.narrowMode}
|
||||
justifyText={config.justifyText}
|
||||
zoomFactor={config.contentZoomFactor}
|
||||
/>
|
||||
<div className="absolute bottom-10 flex justify-between px-4 py-2 w-full z-999">
|
||||
<div className="flex w-full space-x-2">
|
||||
<Link to="/" className="cursor-pointer p-2 rounded-full bg-blue-600 text-white mr-auto">
|
||||
<MenuBookIcon className="fill-gray-100" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="text-sm text-gray-600 rounded bg-gray-300/30 backdrop-blur px-2">
|
||||
{config.contentZoomLevel}%
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`cursor-pointer p-2 mx-1 rounded-full text-white bg-gray-100/30 backdrop-blur`}
|
||||
onClick={() =>
|
||||
changeConfig({ contentZoomLevel: Math.max(50, config.contentZoomLevel - 10) })
|
||||
}
|
||||
>
|
||||
<TextDecreaseIcon className="fill-gray-600" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`cursor-pointer p-2 rounded-full text-white bg-gray-100/30 backdrop-blur`}
|
||||
onClick={() => {
|
||||
changeConfig({ contentZoomLevel: Math.min(150, config.contentZoomLevel + 10) });
|
||||
}}
|
||||
>
|
||||
<TextIncreaseIcon className="fill-gray-600" />
|
||||
</button>
|
||||
|
||||
<VRuleIcon className="fill-gray-300" />
|
||||
|
||||
<button
|
||||
className={`cursor-pointer p-2 mr-1 rounded-full text-white border ${config.displayTitle ? "bg-blue-100 border-blue-400" : "bg-gray-100 border-gray-400"}`}
|
||||
onClick={() => changeConfig({ displayTitle: !config.displayTitle })}
|
||||
>
|
||||
<TitleIcon className="fill-gray-600" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`cursor-pointer p-2 mr-1 rounded-full text-white border ${config.justifyText ? "bg-blue-100 border-blue-400" : "bg-gray-100 border-gray-400"}`}
|
||||
onClick={() => changeConfig({ justifyText: !config.justifyText })}
|
||||
>
|
||||
<JustifyTextIcon className="fill-gray-600" />
|
||||
</button>
|
||||
|
||||
{window.innerWidth > 576 && (
|
||||
<button
|
||||
className={`cursor-pointer p-2 mr-1 rounded-full text-white border ${config.narrowMode ? "bg-blue-100 border-blue-400" : "bg-gray-100 border-gray-400"}`}
|
||||
onClick={() => changeConfig({ narrowMode: !config.narrowMode })}
|
||||
>
|
||||
<WidthIcon className="fill-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
{topic.resources.length > 1 && (
|
||||
<div className="relative flex ml-2">
|
||||
<button
|
||||
key={selectedResource.id}
|
||||
className={`${isSelectingResource ? "invisible" : ""} flex-1 shadow-xl cursor-pointer px-2 py-1 rounded-md text-xs text-blue-800 font-medium whitespace-nowrap bg-blue-100/50 backdrop-blur hover:bg-blue-200/50 border border-blue-100`}
|
||||
onClick={() => setIsSelectingResource(true)}
|
||||
>
|
||||
Версия {selectedResource.version}
|
||||
</button>
|
||||
{isSelectingResource && (
|
||||
<button
|
||||
key={selectedResource.id}
|
||||
className={`absolute w-full h-full flex-1 flex justify-center items-center cursor-pointer rounded-md hover:backdrop-blur hover:bg-blue-100/30`}
|
||||
onClick={() => setIsSelectingResource(false)}
|
||||
>
|
||||
<CloseIcon className="fill-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSelectingResource && (
|
||||
<div className="absolute bottom-full right-0 px-4 flex space-x-1 h-10">
|
||||
{topic.resources.map((resource, rIndex) => (
|
||||
<button
|
||||
key={resource.id}
|
||||
className={`flex-1 shadow-xl cursor-pointer px-2 py-1 rounded-md text-xs whitespace-nowrap ${
|
||||
selectedResource.id === resource.id
|
||||
? "bg-blue-100 text-blue-800 font-medium border border-blue-400"
|
||||
: "bg-gray-100 hover:bg-gray-200 border border-gray-400"
|
||||
}`}
|
||||
onClick={() => {
|
||||
selectResource(rIndex);
|
||||
setIsSelectingResource(false);
|
||||
}}
|
||||
>
|
||||
Версия {resource.version}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col space-y-2">
|
||||
<div className="flex bg-gray-100 border-t border-blue-200 text-center">
|
||||
{prevTopic === null ? (
|
||||
<div className="flex-1 border-r border-blue-200" />
|
||||
) : (
|
||||
<Link
|
||||
to={`/${prevTopic.id}`}
|
||||
className="border-r border-blue-200 w-1/2 flex-1 px-4 py-2 hover:bg-blue-200 cursor-pointer flex align-center justify-start"
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
<span className="ml-2 truncate w-full ">
|
||||
{prevTopic.sequence - 1 - 1}: {prevTopic.title}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
{nextTopic === null ? (
|
||||
<div className="flex-1" />
|
||||
) : (
|
||||
<Link
|
||||
to={`/${nextTopic.id}`}
|
||||
className="flex-1 px-4 py-2 hover:bg-blue-200 w-1/2 cursor-pointer flex align-center justify-end"
|
||||
>
|
||||
<span className="mr-2 w-full truncate">
|
||||
{nextTopic.sequence - 1 + 1}: {nextTopic.title}
|
||||
</span>
|
||||
<ArrowForwardIcon />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PDFViewer({ file, compact, zoomFactor, justifyText }) {
|
||||
const iframeRef = useRef(null);
|
||||
const [content, setContent] = useState(null);
|
||||
const htmlContent = useMemo(() => {
|
||||
const fileContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.4.0/github-markdown-light.min.css">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
margin: 0;
|
||||
padding: 0 12px 40px;
|
||||
${compact ? `max-width: calc(36rem / ${zoomFactor}); margin: 0 auto;` : ""}
|
||||
}
|
||||
${
|
||||
justifyText
|
||||
? `
|
||||
p {
|
||||
text-align: justify;
|
||||
text-justify: inter-word;
|
||||
}
|
||||
pre, code {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${content}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
return fileContent;
|
||||
}, [content, compact, justifyText, zoomFactor]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const contentZoomLevel = useStore((state) => state.config.contentZoomLevel);
|
||||
|
||||
useEffect(() => {
|
||||
if (iframeRef.current && iframeRef.current.contentDocument) {
|
||||
iframeRef.current.contentDocument.body.style.zoom = `${contentZoomLevel}%`;
|
||||
}
|
||||
}, [contentZoomLevel]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFile = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await resourcesInstance.get(`/${file.filename}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load file: ${response.status}`);
|
||||
}
|
||||
|
||||
let fileContent = await response.text();
|
||||
if (fileContent === "") {
|
||||
fileContent = "**No Data!**";
|
||||
}
|
||||
fileContent = marked.parse(fileContent);
|
||||
|
||||
setContent(fileContent);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Error loading file:", err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFile();
|
||||
}, [file]);
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500 p-4 border border-red-300 rounded">Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <DelayedLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full overflow-hidden`}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={htmlContent}
|
||||
title={`File: ${file.id}`}
|
||||
className="w-full h-full border-0"
|
||||
key={file.id}
|
||||
allow="fullscreen"
|
||||
onLoad={() => {
|
||||
if (iframeRef.current?.contentDocument?.body) {
|
||||
iframeRef.current.contentDocument.body.style.zoom = `${contentZoomLevel}%`;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DelayedLoader = ({
|
||||
delayMs = 2000,
|
||||
className = "p-4 flex justify-center items-center h-40",
|
||||
text = "Loading...",
|
||||
}) => {
|
||||
const [showLoader, setShowLoader] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Set a timeout to show the loader after the specified delay
|
||||
const timer = setTimeout(() => {
|
||||
setShowLoader(true);
|
||||
}, delayMs);
|
||||
|
||||
// Clear the timeout on unmount
|
||||
return () => clearTimeout(timer);
|
||||
}, []); // Empty dependency array - only run on mount
|
||||
|
||||
// Only render if the delay has passed
|
||||
if (showLoader) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="animate-pulse">{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Return null before delay threshold
|
||||
return null;
|
||||
};
|
||||
81
reader/src/hooks.js
Normal file
81
reader/src/hooks.js
Normal file
@ -0,0 +1,81 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useStore } from "./store.js";
|
||||
import { getIndexFromTopicId } from "./utils.js";
|
||||
|
||||
export function useSubject() {
|
||||
return useStore(
|
||||
useShallow((state) => ({
|
||||
index: state.subjectIdx,
|
||||
...state.subjects[state.subjectIdx],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function useTopic() {
|
||||
return useStore(
|
||||
useShallow((state) => {
|
||||
if (state.topicIdx === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { topicIdx, subjectIdx } = state;
|
||||
return {
|
||||
index: topicIdx,
|
||||
subjectIdx,
|
||||
...state.subjects[subjectIdx].topics[topicIdx],
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function useTopicSyncParams() {
|
||||
const { topicId } = useParams();
|
||||
const subjects = useStore((state) => state.subjects);
|
||||
|
||||
const selectSubject = useStore((state) => state.selectSubject);
|
||||
const selectTopic = useStore((state) => state.selectTopic);
|
||||
|
||||
const currentTopicIdx = useStore((state) => state.topicIdx);
|
||||
const currentSubjectIdx = useStore((state) => state.subjectIdx);
|
||||
|
||||
const topic = useMemo(() => {
|
||||
if (!topicId) return null;
|
||||
|
||||
const indices = getIndexFromTopicId(topicId);
|
||||
if (!indices) return null;
|
||||
|
||||
const [subjectIdx, topicIdx] = indices;
|
||||
const foundTopic = subjects[subjectIdx]?.topics[topicIdx];
|
||||
if (!foundTopic) return null;
|
||||
|
||||
return { ...foundTopic, index: topicIdx, subjectIdx };
|
||||
}, [topicId, subjects]);
|
||||
|
||||
useEffect(() => {
|
||||
if (topic) {
|
||||
selectSubject(topic.subjectIdx);
|
||||
selectTopic(topic.index);
|
||||
}
|
||||
}, [selectSubject, selectTopic, topic]);
|
||||
|
||||
if (currentTopicIdx === null || currentSubjectIdx === null) {
|
||||
return null;
|
||||
}
|
||||
return topic;
|
||||
}
|
||||
|
||||
export function useTopicsAround() {
|
||||
return useStore(
|
||||
useShallow((state) => {
|
||||
const { subjects, subjectIdx, topicIdx } = state;
|
||||
const { topics } = subjects[subjectIdx];
|
||||
console.log({ topics, topicIdx });
|
||||
const prevTopic = topicIdx === 0 ? null : topics[topicIdx - 1];
|
||||
const nextTopic = topicIdx === topics.length - 1 ? null : topics[topicIdx + 1];
|
||||
|
||||
return { prevTopic, nextTopic };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -1,496 +1,10 @@
|
||||
import "./index.css";
|
||||
|
||||
import { StrictMode, useLayoutEffect, useRef, useState, useEffect, useMemo } from "react";
|
||||
import { StrictMode } 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 { marked } from "marked";
|
||||
import { apiInstance, resourcesInstance } from "./api.js";
|
||||
import { useStore } from "./store.js";
|
||||
import {
|
||||
ArrowBackIcon,
|
||||
ArrowForwardIcon,
|
||||
MenuBookIcon,
|
||||
MyLocationIcon,
|
||||
TitleIcon,
|
||||
WidthIcon,
|
||||
EllipsisIcon,
|
||||
CloseIcon,
|
||||
JustifyTextIcon,
|
||||
TextIncreaseIcon,
|
||||
TextDecreaseIcon,
|
||||
VRuleIcon,
|
||||
} from "./icons/Icons";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<TopicListView />} />
|
||||
<Route path=":topicId" element={<FileReader />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
function Layout() {
|
||||
const params = useParams();
|
||||
const topic = useStore((state) => params.topicId && state.topics.byId[params.topicId]);
|
||||
const config = useStore((state) => state.config);
|
||||
|
||||
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.index + 1}: ${topic.title}` : "Конспект за Държавен Изпит"}</span>
|
||||
</div>
|
||||
)}
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SUBJECTS = [
|
||||
{ id: 0, name: "ВЪТРЕШНИ БОЛЕСТИ" },
|
||||
{ id: 1, name: "ФАРМАКОЛОГИЯ" },
|
||||
];
|
||||
export function TopicListView() {
|
||||
const itemRefs = useRef({});
|
||||
const topics = useStore(
|
||||
useShallow((state) => state.topics.allIds.map((id) => state.topics.byId[id])),
|
||||
);
|
||||
const selectedTopic = useStore((state) => state.selectedTopic);
|
||||
const selectTopic = useStore((state) => state.selectTopic);
|
||||
const selectedSubjectIndex = useStore((state) => state.subject);
|
||||
const setSelectedSubjectIndex = useStore((state) => state.selectSubject);
|
||||
const config = useStore((state) => state.config);
|
||||
const changeConfig = useStore((state) => state.changeConfig);
|
||||
|
||||
const [isSelectingSubject, setIsSelectingSubject] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (selectedTopic && selectedTopic.id !== null) {
|
||||
itemRefs.current?.[Math.max(selectedTopic.index - 3, 0)].scrollIntoView();
|
||||
}
|
||||
}, [selectedTopic]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`flex-1 overflow-y-scroll ${selectedTopic === null ? "pb-[92px]" : "pb-[156px]"}`}
|
||||
>
|
||||
{topics.map((topic, i) => (
|
||||
<Link
|
||||
key={topic.id}
|
||||
ref={(node) => {
|
||||
itemRefs.current[i] = node;
|
||||
}}
|
||||
to={`/${topic.id}`}
|
||||
onClick={() => selectTopic(topic.id)}
|
||||
className={`flex px-2 py-1 rounded-md cursor-pointer border-l-4 ${topic.id === selectedTopic?.id ? "bg-blue-100 border-blue-500" : "border-transparent hover:bg-gray-100"}`}
|
||||
>
|
||||
<div
|
||||
className={`w-6 flex-shrink-0 flex font-medium justify-end ${topic.id === selectedTopic?.id ? "text-blue-600" : "text-blue-800"}`}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-2 leading-5 ${topic.id === selectedTopic?.id ? "font-medium" : "font-normal"} ${config.wrapTopicTitles ? "truncate" : ""}`}
|
||||
>
|
||||
{topic.title}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute bottom-0 sm:p-4 px-2 py-0 w-full flex flex-col">
|
||||
<div className="ml-auto p-2 flex space-x-1 h-[60px]">
|
||||
<button
|
||||
className={`cursor-pointer p-2 rounded-full text-white border ${config.displayTitle ? "bg-blue-100 border-blue-400" : "bg-gray-100 border-gray-400"}`}
|
||||
onClick={() => changeConfig({ displayTitle: !config.displayTitle })}
|
||||
>
|
||||
<TitleIcon className="fill-gray-600" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`cursor-pointer p-2 rounded-full text-white border ${config.wrapTopicTitles ? "bg-blue-100 border-blue-400" : "bg-gray-100 border-gray-400"}`}
|
||||
onClick={() => changeConfig({ wrapTopicTitles: !config.wrapTopicTitles })}
|
||||
>
|
||||
<EllipsisIcon className="fill-gray-600" />
|
||||
</button>
|
||||
|
||||
{selectedTopic !== null && (
|
||||
<button
|
||||
className="px-3 py-3 bg-teal-500 hover:bg-teal-300 cursor-pointer rounded-full flex items-center justify-center shadow-md transition-colors"
|
||||
onClick={() => {
|
||||
itemRefs.current?.[Math.max(selectedTopic.index - 3, 0)].scrollIntoView({
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MyLocationIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="relative flex ml-2">
|
||||
<button
|
||||
className={`${isSelectingSubject ? "invisible" : ""} flex-1 shadow-xl cursor-pointer px-2 py-1 rounded-md text-xs text-blue-800 font-medium whitespace-nowrap bg-blue-100/50 backdrop-blur hover:bg-blue-200/50 border border-blue-100`}
|
||||
onClick={() => setIsSelectingSubject(true)}
|
||||
>
|
||||
{SUBJECTS[selectedSubjectIndex].name}
|
||||
</button>
|
||||
{isSelectingSubject && (
|
||||
<>
|
||||
<button
|
||||
className={`absolute w-full h-full flex-1 flex justify-center items-center cursor-pointer rounded-md backdrop-blur bg-blue-100/40 hover:bg-blue-100/80`}
|
||||
onClick={() => setIsSelectingSubject(false)}
|
||||
>
|
||||
<CloseIcon className="fill-gray-600" />
|
||||
</button>
|
||||
<div className="absolute bottom-full right-0 p-2 flex space-x-1">
|
||||
{SUBJECTS.map((subject) => (
|
||||
<button
|
||||
key={subject.id}
|
||||
className={`flex-1 shadow-xl cursor-pointer px-2 py-1 rounded-md text-xs whitespace-nowrap h-[44px] ${
|
||||
selectedSubjectIndex === subject.id
|
||||
? "bg-blue-100 text-blue-800 font-medium border border-blue-400"
|
||||
: "bg-gray-100 hover:bg-gray-200 border border-gray-400"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedSubjectIndex(subject.id);
|
||||
setIsSelectingSubject(false);
|
||||
}}
|
||||
>
|
||||
{subject.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedTopic !== null && (
|
||||
<Link
|
||||
to={`/${selectedTopic.id}`}
|
||||
className="w-full p-2 mb-2 bg-blue-600 hover:bg-blue-700 cursor-pointer truncate rounded-md text-sm text-white text-center shadow-md transition-colors"
|
||||
>
|
||||
<span>Продължи четенето:</span>
|
||||
<br />
|
||||
<span className="font-medium">
|
||||
{selectedTopic.index + 1}. {selectedTopic.title}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileReader() {
|
||||
const params = useParams();
|
||||
const topicsById = useStore((state) => state.topics.byId);
|
||||
const selectTopic = useStore((state) => state.selectTopic);
|
||||
|
||||
const topic = topicsById[params.topicId];
|
||||
if (!topic) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
selectTopic(topic.id);
|
||||
|
||||
return <Reader topic={topic} />;
|
||||
}
|
||||
|
||||
export function Reader({ topic }) {
|
||||
const config = useStore((state) => state.config);
|
||||
const changeConfig = useStore((state) => state.changeConfig);
|
||||
const selectedVersion = useStore((state) => state.topicVersions[topic.id] ?? 0);
|
||||
const selectVersion = useStore((state) => state.selectTopicVersion);
|
||||
const getTopicAtIndex = useStore((state) => state.getTopicAtIndex);
|
||||
|
||||
const prevTopic = getTopicAtIndex(topic.index - 1);
|
||||
const nextTopic = getTopicAtIndex(topic.index + 1);
|
||||
|
||||
const [isSelectingVersion, setIsSelectingVersion] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
<PDFViewer
|
||||
file={topic.files[selectedVersion]}
|
||||
compact={config.narrowMode}
|
||||
justifyText={config.justifyText}
|
||||
zoomFactor={config.contentZoomFactor}
|
||||
/>
|
||||
<div className="absolute bottom-10 flex justify-between px-4 py-2 w-full z-999">
|
||||
<div className="flex w-full space-x-2">
|
||||
<Link to="/" className="cursor-pointer p-2 rounded-full bg-blue-600 text-white mr-auto">
|
||||
<MenuBookIcon className="fill-gray-100" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="text-sm text-gray-600 rounded bg-gray-300/30 backdrop-blur px-2">
|
||||
{config.contentZoomLevel}%
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`cursor-pointer p-2 mx-1 rounded-full text-white bg-gray-100/30 backdrop-blur`}
|
||||
onClick={() =>
|
||||
changeConfig({ contentZoomLevel: Math.max(50, config.contentZoomLevel - 10) })
|
||||
}
|
||||
>
|
||||
<TextDecreaseIcon className="fill-gray-600" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`cursor-pointer p-2 rounded-full text-white bg-gray-100/30 backdrop-blur`}
|
||||
onClick={() => {
|
||||
changeConfig({ contentZoomLevel: Math.min(150, config.contentZoomLevel + 10) });
|
||||
}}
|
||||
>
|
||||
<TextIncreaseIcon className="fill-gray-600" />
|
||||
</button>
|
||||
|
||||
<VRuleIcon className="fill-gray-300" />
|
||||
|
||||
<button
|
||||
className={`cursor-pointer p-2 mr-1 rounded-full text-white border ${config.displayTitle ? "bg-blue-100 border-blue-400" : "bg-gray-100 border-gray-400"}`}
|
||||
onClick={() => changeConfig({ displayTitle: !config.displayTitle })}
|
||||
>
|
||||
<TitleIcon className="fill-gray-600" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`cursor-pointer p-2 mr-1 rounded-full text-white border ${config.justifyText ? "bg-blue-100 border-blue-400" : "bg-gray-100 border-gray-400"}`}
|
||||
onClick={() => changeConfig({ justifyText: !config.justifyText })}
|
||||
>
|
||||
<JustifyTextIcon className="fill-gray-600" />
|
||||
</button>
|
||||
|
||||
{window.innerWidth > 576 && (
|
||||
<button
|
||||
className={`cursor-pointer p-2 mr-1 rounded-full text-white border ${config.narrowMode ? "bg-blue-100 border-blue-400" : "bg-gray-100 border-gray-400"}`}
|
||||
onClick={() => changeConfig({ narrowMode: !config.narrowMode })}
|
||||
>
|
||||
<WidthIcon className="fill-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
{topic.files.length > 1 && (
|
||||
<div className="relative flex ml-2">
|
||||
<button
|
||||
key={topic.files[selectedVersion].file}
|
||||
className={`${isSelectingVersion ? "invisible" : ""} flex-1 shadow-xl cursor-pointer px-2 py-1 rounded-md text-xs text-blue-800 font-medium whitespace-nowrap bg-blue-100/50 backdrop-blur hover:bg-blue-200/50 border border-blue-100`}
|
||||
onClick={() => setIsSelectingVersion(true)}
|
||||
>
|
||||
Версия {selectedVersion + 1}
|
||||
</button>
|
||||
{isSelectingVersion && (
|
||||
<button
|
||||
key={topic.files[selectedVersion].file}
|
||||
className={`absolute w-full h-full flex-1 flex justify-center items-center cursor-pointer rounded-md hover:backdrop-blur hover:bg-blue-100/30`}
|
||||
onClick={() => setIsSelectingVersion(false)}
|
||||
>
|
||||
<CloseIcon className="fill-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSelectingVersion && (
|
||||
<div className="absolute bottom-full right-0 px-4 flex space-x-1 h-10">
|
||||
{topic.files.map((file, vIndex) => (
|
||||
<button
|
||||
key={file}
|
||||
className={`flex-1 shadow-xl cursor-pointer px-2 py-1 rounded-md text-xs whitespace-nowrap ${
|
||||
selectedVersion === vIndex
|
||||
? "bg-blue-100 text-blue-800 font-medium border border-blue-400"
|
||||
: "bg-gray-100 hover:bg-gray-200 border border-gray-400"
|
||||
}`}
|
||||
onClick={() => {
|
||||
selectVersion(topic.id, vIndex);
|
||||
setIsSelectingVersion(false);
|
||||
}}
|
||||
>
|
||||
Версия {vIndex + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col space-y-2">
|
||||
<div className="flex bg-gray-100 border-t border-blue-200 text-center">
|
||||
{topic.isFirst ? (
|
||||
<div className="flex-1 border-r border-blue-200" />
|
||||
) : (
|
||||
<Link
|
||||
to={`/${prevTopic.id}`}
|
||||
className="border-r border-blue-200 w-1/2 flex-1 px-4 py-2 hover:bg-blue-200 cursor-pointer flex align-center justify-start"
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
<span className="ml-2 truncate w-full ">
|
||||
{prevTopic.index + 1}: {prevTopic.title}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
{topic.isLast ? (
|
||||
<div className="flex-1" />
|
||||
) : (
|
||||
<Link
|
||||
to={`/${nextTopic.id}`}
|
||||
className="flex-1 px-4 py-2 hover:bg-blue-200 w-1/2 cursor-pointer flex align-center justify-end"
|
||||
>
|
||||
<span className="mr-2 w-full truncate">
|
||||
{nextTopic.index + 1}: {nextTopic.title}
|
||||
</span>
|
||||
<ArrowForwardIcon />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PDFViewer({ file, compact, zoomFactor, justifyText }) {
|
||||
const iframeRef = useRef(null);
|
||||
const [content, setContent] = useState(null);
|
||||
const htmlContent = useMemo(() => {
|
||||
const fileContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.4.0/github-markdown-light.min.css">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
margin: 0;
|
||||
padding: 0 12px 40px;
|
||||
${compact ? `max-width: calc(36rem / ${zoomFactor}); margin: 0 auto;` : ""}
|
||||
}
|
||||
${
|
||||
justifyText
|
||||
? `
|
||||
p {
|
||||
text-align: justify;
|
||||
text-justify: inter-word;
|
||||
}
|
||||
pre, code {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${content}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
return fileContent;
|
||||
}, [content, compact, justifyText, zoomFactor]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const contentZoomLevel = useStore((state) => state.config.contentZoomLevel);
|
||||
|
||||
useEffect(() => {
|
||||
if (iframeRef.current && iframeRef.current.contentDocument) {
|
||||
iframeRef.current.contentDocument.body.style.zoom = `${contentZoomLevel}%`;
|
||||
}
|
||||
}, [contentZoomLevel]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFile = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const mdPath = `/files_md/${file.filename}`;
|
||||
const response = await fetch(mdPath);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load file: ${response.status}`);
|
||||
}
|
||||
|
||||
let fileContent = await response.text();
|
||||
if (fileContent === "") {
|
||||
fileContent = "**No Data!**";
|
||||
}
|
||||
fileContent = marked.parse(fileContent);
|
||||
|
||||
setContent(fileContent);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Error loading file:", err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFile();
|
||||
}, [file]);
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500 p-4 border border-red-300 rounded">Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <DelayedLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full overflow-hidden`}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={htmlContent}
|
||||
title={`File: ${file}`}
|
||||
className="w-full h-full border-0"
|
||||
key={file}
|
||||
allow="fullscreen"
|
||||
onLoad={() => {
|
||||
if (iframeRef.current?.contentDocument?.body) {
|
||||
iframeRef.current.contentDocument.body.style.zoom = `${contentZoomLevel}%`;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DelayedLoader = ({
|
||||
delayMs = 2000,
|
||||
className = "p-4 flex justify-center items-center h-40",
|
||||
text = "Loading...",
|
||||
}) => {
|
||||
const [showLoader, setShowLoader] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Set a timeout to show the loader after the specified delay
|
||||
const timer = setTimeout(() => {
|
||||
setShowLoader(true);
|
||||
}, delayMs);
|
||||
|
||||
// Clear the timeout on unmount
|
||||
return () => clearTimeout(timer);
|
||||
}, []); // Empty dependency array - only run on mount
|
||||
|
||||
// Only render if the delay has passed
|
||||
if (showLoader) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="animate-pulse">{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Return null before delay threshold
|
||||
return null;
|
||||
};
|
||||
|
||||
@ -1,36 +1,47 @@
|
||||
import { create } from "zustand";
|
||||
import { apiInstance } from "./api.js";
|
||||
import structure from "./topics.json";
|
||||
|
||||
const localSubject = (() => {
|
||||
function getLocalSubjectIdx() {
|
||||
let subject = localStorage.getItem("subject");
|
||||
subject = typeof subject !== "undefined" ? Number(subject) : 0;
|
||||
return subject;
|
||||
})();
|
||||
if (typeof subject === "undefined") {
|
||||
subject = 0;
|
||||
}
|
||||
subject = parseInt(subject);
|
||||
|
||||
return Math.max(0, subject);
|
||||
}
|
||||
|
||||
export const useStore = create((set, get) => ({
|
||||
subject: localSubject,
|
||||
selectSubject: (id) => {
|
||||
set({ subject: id, topics: structure[id] });
|
||||
localStorage.setItem("subject", id);
|
||||
isLoading: true,
|
||||
subjects: [],
|
||||
subjectIdx: null,
|
||||
topicIdx: null,
|
||||
resourceIdx: null,
|
||||
selectSubject: (subjectIdx) => {
|
||||
set({ subjectIdx, topicIdx: null, resourceIdx: null });
|
||||
localStorage.setItem("subject", subjectIdx);
|
||||
},
|
||||
topics: structure[localSubject],
|
||||
topicVersions: {},
|
||||
getTopicAtIndex: (index) => {
|
||||
const topicId = get().topics.allIds[index];
|
||||
if (!topicId) {
|
||||
return null;
|
||||
selectTopic: (topicIdx) => {
|
||||
if (topicIdx === null) {
|
||||
set({ topicIdx, resourceIdx: null });
|
||||
}
|
||||
return get().topics.byId[topicId];
|
||||
|
||||
const { subjects, subjectIdx } = get();
|
||||
const resources = subjects[subjectIdx].topics[topicIdx].resources;
|
||||
const resourceIdx = resources.length - 1;
|
||||
set({ topicIdx, resourceIdx });
|
||||
},
|
||||
selectTopicVersion: (id, version) =>
|
||||
set({ topicVersions: { ...get().topicVersions, [id]: version } }),
|
||||
selectedTopic: null,
|
||||
selectTopic: (id) => {
|
||||
const topic = get().topics.byId[id];
|
||||
if (topic) {
|
||||
set({ selectedTopic: topic });
|
||||
}
|
||||
selectResource: (resourceIdx) => {
|
||||
set({ resourceIdx });
|
||||
},
|
||||
getStructure: async () => {
|
||||
const { data: subjects } = await apiInstance("/structure");
|
||||
const subjectIdx = get().subjectIdx ?? getLocalSubjectIdx();
|
||||
set({
|
||||
isLoading: false,
|
||||
subjects,
|
||||
subjectIdx,
|
||||
});
|
||||
},
|
||||
config: getLocalConfig(),
|
||||
changeConfig: (config) => {
|
||||
@ -46,6 +57,8 @@ export const useStore = create((set, get) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
useStore.getState().getStructure().catch(console.error);
|
||||
|
||||
function getLocalConfig() {
|
||||
const defaultConfig = {
|
||||
displayTitle: true,
|
||||
@ -59,9 +72,3 @@ function getLocalConfig() {
|
||||
|
||||
return { ...defaultConfig, ...config };
|
||||
}
|
||||
|
||||
async function getStructure() {
|
||||
const res = await apiInstance.get("/structure");
|
||||
console.log(res);
|
||||
}
|
||||
getStructure().catch(console.error);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
9
reader/src/utils.js
Normal file
9
reader/src/utils.js
Normal file
@ -0,0 +1,9 @@
|
||||
export function getIndexFromTopicId(topicId) {
|
||||
const match = topicId?.match(/^S(\d+)_T(\d+)$/);
|
||||
if (!match) {
|
||||
console.warn(`Invalid topic id: ${topicId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return [parseInt(match[1]) - 1, parseInt(match[2]) - 1];
|
||||
}
|
||||
@ -121,7 +121,6 @@ function errorRequestHandler(error, _req, res, next) {
|
||||
}
|
||||
|
||||
function isOriginAllowed(origin) {
|
||||
console.log({ origin });
|
||||
const url = new URL(origin);
|
||||
|
||||
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
|
||||
|
||||
@ -7,9 +7,20 @@ server {
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
|
||||
# Add CORS headers for all requests
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept, Authorization" always;
|
||||
|
||||
# Handle preflight requests
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
# Set proper content type for markdown files
|
||||
location ~* \.md$ {
|
||||
add_header Content-Type "text/markdown; charset=utf-8";
|
||||
# CORS headers are inherited from parent location
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user