update - with versions

This commit is contained in:
Tomas Mirchev 2025-06-30 14:41:37 +00:00
parent 841cb61acf
commit 732cac4393
30 changed files with 5867 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.env
data
data_bak
static-files
static-bak
node_modules
dist
.pnpm-store
ppt_data
data_structure

20
README.md Normal file
View File

@ -0,0 +1,20 @@
# Notes
filename: `file-{counter}.docx` -> `file-{counter}.pdf`
- group by title
structure:
```
{
topics: [
{
id: "t111",
title: "some title",
files: [
"file-11.pdf",
"file-42.pdf"
]
}
]
}
```

32
docker-compose.yml Normal file
View File

@ -0,0 +1,32 @@
services:
reader:
build:
context: ./reader
dockerfile: Dockerfile
container_name: reader
ports:
- "3001:80"
networks:
- app-network
restart: unless-stopped
resource-provider:
build:
context: ./resource-provider
dockerfile: Dockerfile
container_name: resource-provider
ports:
- "3000:3000" # Node.js API
environment:
- NODE_ENV=production
- PORT=3000
- API_TOKEN=${API_TOKEN}
volumes:
- ./static-files:/static-files
networks:
- app-network
restart: unless-stopped
networks:
app-network:
driver: bridge

24
reader/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
#dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
reader/.prettierrc Normal file
View File

@ -0,0 +1,3 @@
{
"printWidth": 100
}

35
reader/Dockerfile Normal file
View File

@ -0,0 +1,35 @@
# React App Dockerfile
FROM node:20-alpine AS build
# Set working directory
WORKDIR /app
# Install pnpm globally
RUN npm install -g pnpm
# Copy package.json and pnpm-lock.yaml
COPY package.json pnpm-lock.yaml* ./
# Install dependencies with pnpm
RUN pnpm install --frozen-lockfile
# Copy the rest of the application
COPY . .
# Build the application
RUN pnpm run build
# Production stage
FROM nginx:alpine AS production
# Copy built assets from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

12
reader/README.md Normal file
View File

@ -0,0 +1,12 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript and enable type-aware lint rules. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

33
reader/eslint.config.js Normal file
View File

@ -0,0 +1,33 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

16
reader/index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="bg">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Конспект</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

40
reader/nginx.conf Normal file
View File

@ -0,0 +1,40 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Never cache HTML files - ADD THIS
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
}
# Special handling for root index.html - ADD THIS
location = / {
try_files /index.html =404;
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
}
# Handle React Router (SPA) - MODIFY THIS
location / {
try_files $uri $uri/ @fallback;
}
# Fallback for SPA routing - ADD THIS
location @fallback {
rewrite ^.*$ /index.html last;
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
}
# Cache static assets with hashes - EXPAND THIS
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable" always;
}
}

38
reader/package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "reader",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.2",
"axios": "^1.10.0",
"marked": "^15.0.12",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-pdf": "^9.2.1",
"react-router": "^7.5.0",
"react-toastify": "^11.0.5",
"socket.io-client": "^4.8.1",
"tailwindcss": "^4.1.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"prettier": "^3.5.3",
"vite": "^6.2.0"
},
"packageManager": "pnpm@10.5.2+sha512.da9dc28cd3ff40d0592188235ab25d3202add8a207afbedc682220e4a0029ffbff4562102b9e6e46b4e3f9e8bd53e6d05de48544b0c57d4b0179e22c76d1199b"
}

2782
reader/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

1
reader/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 31.357 31.357" xml:space="preserve"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <g> <g> <path d="M15.679,0C7.033,0,0.001,7.033,0.001,15.678c0,8.646,7.032,15.68,15.678,15.68c8.644,0,15.677-7.033,15.677-15.68 C31.356,7.033,24.323,0,15.679,0z M15.679,28.861c-7.27,0-13.183-5.913-13.183-13.184c0-7.268,5.913-13.183,13.183-13.183 c7.269,0,13.182,5.915,13.182,13.183C28.861,22.948,22.948,28.861,15.679,28.861z"></path> <path d="M19.243,12.368V7.33c0-0.868-0.703-1.57-1.57-1.57h-3.396c-0.867,0-1.569,0.703-1.569,1.57v5.038h-5.04 c-0.867,0-1.569,0.703-1.569,1.57v3.468c0,0.867,0.702,1.57,1.569,1.57h5.039v5.037c0,0.867,0.702,1.57,1.569,1.57h3.397 c0.866,0,1.569-0.703,1.569-1.57v-5.037h5.038c0.867,0,1.57-0.703,1.57-1.57v-3.468c0-0.868-0.703-1.57-1.57-1.57H19.243z"></path> </g> </g> </g></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

582
reader/src/App.jsx Normal file
View File

@ -0,0 +1,582 @@
import { StrictMode, Fragment, useLayoutEffect, useRef, useState, useEffect, useMemo } from "react";
import { Navigate, BrowserRouter, Link, Outlet, Route, Routes } from "react-router";
import { marked } from "marked";
import { resourcesInstance } from "./api.js";
import { useStore } from "./store.js";
import { useTopic, useTopicSyncParams, useTopicsAround } from "./hooks.js";
import { ToastContainer, toast } from "react-toastify";
import {
ArrowBackIcon,
ArrowForwardIcon,
MenuBookIcon,
MyLocationIcon,
TitleIcon,
WidthIcon,
EllipsisIcon,
CloseIcon,
JustifyTextIcon,
TextIncreaseIcon,
TextDecreaseIcon,
VRuleIcon,
} from "./icons/Icons";
import ResourcePage from "./ResourcePage.jsx";
import io from "socket.io-client";
//const socket = io("http://localhost:3000");
const DIVIDER_AT = 16;
//function SocketTest() {
// const [booleanArray, setBooleanArray] = useState(new Array(10).fill(false));
// const [isConnected, setIsConnected] = useState(false);
//
// useEffect(() => {
// // Connection status
// socket.on("connect", () => {
// setIsConnected(true);
// console.log("Connected to server");
// });
//
// socket.on("disconnect", () => {
// setIsConnected(false);
// console.log("Disconnected from server");
// });
//
// // Listen for array updates
// socket.on("arrayChanged", (newArray) => {
// setBooleanArray(newArray);
// });
//
// // Cleanup on unmount
// return () => {
// socket.off("connect");
// socket.off("disconnect");
// socket.off("arrayChanged");
// };
// }, []);
//
// const toggleValue = (index) => {
// const newValue = !booleanArray[index];
//
// // Emit update to server
// socket.emit("setArrayValue", { index, value: newValue });
// };
//
// const refreshArray = () => {
// socket.emit("getArray");
// };
//
// return (
// <div style={{ padding: "20px" }}>
// <h1>Socket.IO Boolean Array</h1>
//
// <div style={{ marginBottom: "20px" }}>
// Status:{" "}
// <span style={{ color: isConnected ? "green" : "red" }}>
// {isConnected ? "Connected" : "Disconnected"}
// </span>
// </div>
//
// <button onClick={refreshArray} style={{ marginBottom: "20px" }}>
// Refresh Array
// </button>
//
// <div style={{ display: "grid", gridTemplateColumns: "repeat(5, 100px)", gap: "10px" }}>
// {booleanArray.map((value, index) => (
// <button
// key={index}
// onClick={() => toggleValue(index)}
// style={{
// padding: "20px",
// backgroundColor: value ? "#4CAF50" : "#f44336",
// color: "white",
// border: "none",
// borderRadius: "4px",
// cursor: "pointer",
// }}
// >
// {index}: {value.toString()}
// </button>
// ))}
// </div>
//
// <div style={{ marginTop: "20px" }}>
// <h3>Current Array:</h3>
// <pre>{JSON.stringify(booleanArray)}</pre>
// </div>
// </div>
// );
//}
const contextClass = {
success: "bg-blue-600",
error: "bg-red-600",
info: "bg-gray-600",
warning: "bg-orange-400",
default: "bg-indigo-600",
dark: "bg-white-600 font-gray-300",
};
export function App() {
return (
<>
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<LoadingWrapper>
<Layout />
</LoadingWrapper>
}
>
<Route index element={<TopicListView />} />
<Route path=":topicId" element={<FileReader />} />
</Route>
<Route path="/edit" element={<ResourcePage />} />
</Routes>
</BrowserRouter>
<ToastContainer />
</>
);
}
function myToast(message) {
return toast.info(message, {
className: "border border-green-700",
style: { backgroundColor: "oklch(98.7% 0.022 95.277)", color: "black" },
});
}
function LoadingWrapper({ children }) {
const isLoading = useStore((state) => state.isLoading);
if (isLoading) {
//return "Loading...";
return null;
}
return children;
}
function Layout() {
const config = useStore((state) => state.config);
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"
style={{ lineHeight: 1.2 }}
>
<span>{topic ? `${topic.seq}: ${topic.title}` : `Конспект за Държавен Изпит`}</span>
</div>
)}
<Outlet />
</div>
);
}
export function TopicListView() {
const itemRefs = useRef({});
const config = useStore((state) => state.config);
const changeConfig = useStore((state) => state.changeConfig);
const topics = useStore((state) => state.topics);
const selectedTopic = useTopic();
const selectTopic = useStore((state) => state.selectTopic);
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]"}`}
>
{topics.map((topic, topicIdx) => (
<Fragment key={topic.id}>
{topicIdx === DIVIDER_AT && (
<hr className="my-4 border-t border-dotted border-gray-400" />
)}
<Link
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.seq}
</div>
<span
className={`ml-2 leading-5 ${topic.id === selectedTopic?.id ? "font-medium" : "font-normal"} ${config.wrapTopicTitles ? "truncate" : ""}`}
>
{topic.title}
</span>
</Link>
</Fragment>
))}
</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>
{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.seq}. {selectedTopic.title}
</span>
</Link>
)}
</div>
</>
);
}
function FileReader() {
const topic = useTopicSyncParams();
if (topic && topic.isLoading) {
return null;
//return "Loading...";
}
if (!topic) {
return <Navigate to="/" replace />;
}
return <Reader key={topic.id} topic={topic} />;
}
export function Reader({ topic }) {
const config = useStore((state) => state.config);
const changeConfig = useStore((state) => state.changeConfig);
const { prevTopic, nextTopic } = useTopicsAround();
const [isSelectingResource, setIsSelectingResource] = useState(false);
const resourceIdx = useStore((state) => state.resourceIdx);
const selectResource = useStore((state) => state.selectResource);
const selectedResource = topic.resources[resourceIdx];
return (
<>
<div className="flex-1">
{topic.resources[resourceIdx].filename === null ? (
<div className="text-sm font-medium p-4">No data</div>
) : (
<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 h-full flex ml-2 w-[42px]">
<button
className={`${isSelectingResource ? "invisible" : ""} flex-1 shadow-xl cursor-pointer p-2 rounded-full 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.seq === 1 ? "Общ" : `v${selectedResource.seq - 1}`}
</button>
{isSelectingResource && (
<button
className={`absolute w-full h-full flex-1 flex justify-center items-center cursor-pointer rounded-full 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 flex-col space-y-1">
{topic.resources.toReversed().map((resource) => (
<button
key={resource.id}
className={`flex-1 shadow-xl w-[42px] min-h-[42px] cursor-pointer p-2 rounded-full 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(resource.seq - 1);
setIsSelectingResource(false);
}}
>
{resource.seq === 1 ? "Общ" : `v${resource.seq - 1}`}
</button>
))}
</div>
)}
</div>
</div>
<div className="w-full flex flex-col space-y-2">
<div className="grid grid-cols-[1fr_32px_1fr] grid-rows-1 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="overflow-hidden min-w-0 border-r border-blue-200 px-4 py-2 hover:bg-blue-200 cursor-pointer flex align-center justify-start"
>
<ArrowBackIcon className="min-w-8" />
<span className="ml-2 truncate">
{prevTopic.seq}: {prevTopic.title}
</span>
</Link>
)}
<div className="w-[32px] px-2 flex items-center border-r border-blue-200 bg-amber-200 font-bold justify-center">
{topic.seq}
</div>
{nextTopic === null ? (
<div className="flex-1" />
) : (
<Link
to={`/${nextTopic.id}`}
className="overflow-hidden min-w-0 px-4 py-2 hover:bg-blue-200 cursor-pointer flex align-center justify-end"
>
<span className="mr-auto truncate">
{nextTopic.seq}: {nextTopic.title}
</span>
<ArrowForwardIcon className="min-w-8 ml-2" />
</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">
<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}`);
let fileContent = response.data;
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;
};

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

@ -0,0 +1,346 @@
import React, { useState, useEffect, useMemo } from "react";
import { marked } from "marked";
import { toast } from "react-toastify";
import { useStore } from "./store.js";
import { resourcesInstance, apiInstance } from "./api.js";
function LoadingWrapper() {
const isLoading = useStore((state) => state.isLoading);
const topics = useStore((state) => state.topics);
if (isLoading) {
return "Loading...";
}
return <ResourcePage topics={topics} />;
}
function SelectResource({ topics, selectedTopic, selectedResource, onChange, disabled }) {
function handleChange(newTopicIdx, newResourceIdx) {
if (disabled) {
return;
}
if (newResourceIdx === -1 || newTopicIdx === -1) {
onChange(null);
return;
}
onChange({ newTopicIdx, newResourceIdx });
}
return (
<div>
{/* File Selector */}
<div className="bg-white border-b border-gray-200 p-2 flex space-x-6">
<div className="flex items-center">
<label htmlFor="topicSelect" className="block mr-2 text-sm font-medium text-gray-700 ">
Избери тема:
</label>
<select
disabled={disabled}
id="topicSelect"
value={selectedTopic.seq - 1}
onChange={(event) => {
handleChange(parseInt(event.target.value), 0);
}}
className="disabled:bg-gray-50 disabled:text-gray-500 px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
>
{topics.map((topic, tIndex) => (
<option key={topic.id} value={tIndex}>
{topic.seq}.{" "}
{topic.title.length > 30 ? topic.title.substring(0, 30) + "..." : topic.title}
</option>
))}
</select>
</div>
<div className="px-4 flex space-x-1 items-end">
{!disabled &&
selectedTopic.resources.map((resource, rIndex) => (
<button
key={resource.id}
className={`${resource.seq === 1 ? "rounded-md h-[27px] px-2 mr-4" : "w-[27px] h-[27px] rounded-full"} flex-1 shadow-xl cursor-pointer 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={() => {
handleChange(selectedTopic.seq - 1, rIndex);
}}
>
{resource.seq === 1 ? "Общ" : `v${resource.seq - 1}`}
</button>
))}
</div>
</div>
</div>
);
}
function useFileContent(resource) {
const [content, setContent] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!resource) {
return;
}
if (resource.filename === null) {
setContent("");
setIsLoading(false);
return;
}
let ignore = false;
setIsLoading(true);
setContent(null);
resourcesInstance.get(`/${resource.filename}`).then((res) => {
if (!ignore) {
setContent(res.data);
setIsLoading(false);
}
});
return () => {
ignore = true;
};
}, [resource]);
return { content, isLoading };
}
const ResourcePage = ({ topics }) => {
// Config + Token
const authToken = useStore((state) => state.config.token);
const changeConfig = useStore((state) => state.changeConfig);
const setAuthToken = (token) => changeConfig({ token });
const [tokenInput, setTokenInput] = useState("");
// Topic + resource
const [topicIdx, setTopicIdx] = useState(0);
const [resourceIdx, setResourceIdx] = useState(0);
const setStructure = useStore((state) => state.setStructure);
const selectedTopic = topics[topicIdx];
const selectedResource = selectedTopic.resources[resourceIdx];
// MD content
const { isLoading, content: initialContent } = useFileContent(selectedResource);
const [content, setContent] = useState(initialContent);
const [prevContent, setPrevContent] = useState(initialContent);
if (prevContent !== initialContent) {
setPrevContent(initialContent);
setContent(initialContent);
}
// Saving Status
const [isSavingLoading, setIsSavingLoading] = useState(false);
const [message, setMessage] = useState({ status: "empty", text: "" });
const htmlContent = useMemo(() => {
let fileContent = content || "**No Data!**";
fileContent = marked.parse(fileContent);
fileContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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;
}
</style>
</head>
<body>
${fileContent}
</body>
</html>
`;
return fileContent;
}, [content]);
const handleTokenSubmit = () => {
if (tokenInput.trim()) {
setAuthToken(tokenInput);
}
};
const handleSave = async () => {
setIsSavingLoading(true);
try {
const res = await apiInstance.post(
`/resources`,
{
topicSeq: selectedTopic.seq,
resourceSeq: selectedResource.seq,
content: content.trim(),
},
{
headers: {
token: authToken,
},
},
);
//setMessage({ status: "success", text: `Success! New filename: ${res.data.filename}` });
toast.success("Success!");
setStructure(res.data.structure);
} catch (error) {
console.log(error);
setMessage({ status: "error", text: JSON.stringify(error.message, null, 2) });
toast.error("Ups, error! Check message");
if (error.response.status === 401) {
setAuthToken(null);
}
} finally {
setIsSavingLoading(false);
}
};
const handleCancel = () => {
if (window.confirm("Сигурни ли сте, че искате да затворите страницата?")) {
window.close();
}
};
const handleCopyContent = async () => {
try {
await navigator.clipboard.writeText(content);
} catch (err) {
console.error("Грешка при копиране:", err);
alert("Грешка при копиране на съдържанието");
}
};
function handleUndoInitial() {
setContent(initialContent);
}
const handleDeleteAll = () => {
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
topics={topics}
selectedTopic={selectedTopic}
selectedResource={selectedResource}
onChange={({ newTopicIdx, newResourceIdx }) => {
setTopicIdx(newTopicIdx);
setResourceIdx(newResourceIdx);
}}
disabled={isLoading}
/>
{message.status === "error" && (
<div className="text-lg p-12 text-red-600 bg-gray-200">
<pre>{message.text}</pre>
</div>
)}
{/* Content Editor */}
<div className="flex-1 bg-white p-2 flex flex-col">
<div className="flex-1 h-full flex space-x-2">
<div className="flex-1 flex flex-col">
<textarea
id="content"
disabled={isLoading || isSavingLoading}
value={content || ""}
onChange={(e) => setContent(e.target.value)}
className="w-full h-full min-h-full flex-1 items-stretch 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 className="flex-1 w-full min-h-full overflow-hidden px-3 py-2 border border-gray-300 rounded-md">
<iframe
srcDoc={htmlContent}
title="MD Content"
className="w-full h-full border-0"
allow="fullscreen"
/>
</div>
</div>
</div>
{/* Bottom Actions */}
<div className="bg-white border-t border-gray-200 p-2 flex justify-between">
<div className="flex flex-wrap gap-3">
<button
onClick={handleCopyContent}
className="bg-green-600 text-sm text-white px-2 py-1 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-sm text-white px-2 py-1 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-sm text-white px-2 py-1 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Възстанови оригинал
</button>
</div>
{isSavingLoading && <span className="flex justify-end mr-8">Saving...</span>}
<div className={`flex justify-end gap-3 ${isSavingLoading ? "invisible" : ""}`}>
<button
onClick={handleSave}
className="text-sm bg-blue-600 text-white px-2 py-1 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;

14
reader/src/api.js Normal file
View File

@ -0,0 +1,14 @@
import axios from "axios";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const RESOURCES_BASE_URL = import.meta.env.VITE_RESOURCES_BASE_URL;
export const apiInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 5000,
});
export const resourcesInstance = axios.create({
baseURL: RESOURCES_BASE_URL,
timeout: 5000,
});

67
reader/src/hooks.js Normal file
View File

@ -0,0 +1,67 @@
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 useTopic() {
return useStore(
useShallow((state) => {
if (state.topicIdx === null) {
return null;
}
const { topicIdx, topics } = state;
return {
index: topicIdx,
...topics[topicIdx],
};
}),
);
}
export function useTopicSyncParams() {
const { topicId } = useParams();
const topics = useStore((state) => state.topics);
const currentTopicIdx = useStore((state) => state.topicIdx);
const selectTopic = useStore((state) => state.selectTopic);
const topic = useMemo(() => {
if (!topicId) return null;
const indices = getIndexFromTopicId(topicId);
if (!indices) return null;
const [topicIdx] = indices;
const foundTopic = topics[topicIdx];
if (!foundTopic) return null;
return { ...foundTopic, index: topicIdx };
}, [topicId, topics]);
useEffect(() => {
if (topic) {
selectTopic(topic.index);
}
}, [selectTopic, topic]);
if (topic && currentTopicIdx === null) {
return { isLoading: true };
}
return topic;
}
export function useTopicsAround() {
return useStore(
useShallow((state) => {
const { topics, topicIdx } = state;
const prevTopic = topicIdx === 0 ? null : topics[topicIdx - 1];
const nextTopic = topicIdx === topics.length - 1 ? null : topics[topicIdx + 1];
return { prevTopic, nextTopic };
}),
);
}

232
reader/src/icons/Icons.jsx Normal file
View File

@ -0,0 +1,232 @@
export function MyLocationIcon({ className }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="inherit"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm8.94 3c-.46-4.17-3.77-7.48-7.94-7.94V1h-2v2.06C6.83 3.52 3.52 6.83 3.06 11H1v2h2.06c.46 4.17 3.77 7.48 7.94 7.94V23h2v-2.06c4.17-.46 7.48-3.77 7.94-7.94H23v-2h-2.06zM12 19c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z" />
</svg>
);
}
export function TitleIcon({ className }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="inherit"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M5 4v3h5.5v12h3V7H19V4H5z" />
</svg>
);
}
export function JustifyTextIcon({ className }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="inherit"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M14 17H4v2h10v-2zm6-8H4v2h16V9zM4 15h16v-2H4v2zM4 5v2h16V5H4z" />
</svg>
);
}
export function TextIncreaseIcon({ className }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
enableBackground="new 0 0 24 24"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="inherit"
>
<rect fill="none" height="24" width="24" />
<path d="M0.99,19h2.42l1.27-3.58h5.65L11.59,19h2.42L8.75,5h-2.5L0.99,19z M5.41,13.39L7.44,7.6h0.12l2.03,5.79H5.41z M20,11h3v2h-3 v3h-2v-3h-3v-2h3V8h2V11z" />
</svg>
);
}
export function TextDecreaseIcon({ className }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
enableBackground="new 0 0 24 24"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="inherit"
>
<rect fill="none" height="24" width="24" />
<path d="M0.99,19h2.42l1.27-3.58h5.65L11.59,19h2.42L8.75,5h-2.5L0.99,19z M5.41,13.39L7.44,7.6h0.12l2.03,5.79H5.41z M23,11v2h-8 v-2H23z" />
</svg>
);
}
export function HRuleIcon({ className }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
enable-background="new 0 0 24 24"
height="24px"
viewBox="0 0 24 24"
width="8px"
fill="inherit"
>
<g>
<rect fill="none" fill-rule="evenodd" height="24" width="24" />
<g>
<rect fill-rule="evenodd" height="2" width="16" x="4" y="11" />
</g>
</g>
</svg>
);
}
export function VRuleIcon({ className = "" }) {
return (
<svg
className={`rotate-90 ${className}`}
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 0 8 24" // Match the 8px width
width="8px"
fill="inherit"
>
<rect fillRule="evenodd" height="2" width="8" x="0" y="11" />
</svg>
);
}
export function EllipsisIcon({ className = "" }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="inherit"
viewBox="0 0 24 24"
strokeWidth={2}
className={"size-6 " + className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
/>
</svg>
);
}
export function CloseIcon({ className = "" }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="inherit"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={"size-6 " + className}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
);
}
export function WidthIcon({ className }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
enableBackground="new 0 0 24 24"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="inherit"
>
<g>
<rect fill="none" height="24" width="24" />
</g>
<g>
<path d="M20,4H4C2.9,4,2,4.9,2,6v12c0,1.1,0.9,2,2,2h16c1.1,0,2-0.9,2-2V6C22,4.9,21.1,4,20,4z M4,18V6h2v12H4z M8,18V6h8v12H8z M20,18h-2V6h2V18z" />
</g>
</svg>
);
}
export function MenuBookIcon({ className }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
enableBackground="new 0 0 24 24"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="inherit"
>
<g>
<rect fill="none" height="24" width="24" />
</g>
<g>
<g />
<g>
<path d="M21,5c-1.11-0.35-2.33-0.5-3.5-0.5c-1.95,0-4.05,0.4-5.5,1.5c-1.45-1.1-3.55-1.5-5.5-1.5S2.45,4.9,1,6v14.65 c0,0.25,0.25,0.5,0.5,0.5c0.1,0,0.15-0.05,0.25-0.05C3.1,20.45,5.05,20,6.5,20c1.95,0,4.05,0.4,5.5,1.5c1.35-0.85,3.8-1.5,5.5-1.5 c1.65,0,3.35,0.3,4.75,1.05c0.1,0.05,0.15,0.05,0.25,0.05c0.25,0,0.5-0.25,0.5-0.5V6C22.4,5.55,21.75,5.25,21,5z M21,18.5 c-1.1-0.35-2.3-0.5-3.5-0.5c-1.7,0-4.15,0.65-5.5,1.5V8c1.35-0.85,3.8-1.5,5.5-1.5c1.2,0,2.4,0.15,3.5,0.5V18.5z" />
<g>
<path d="M17.5,10.5c0.88,0,1.73,0.09,2.5,0.26V9.24C19.21,9.09,18.36,9,17.5,9c-1.7,0-3.24,0.29-4.5,0.83v1.66 C14.13,10.85,15.7,10.5,17.5,10.5z" />
<path d="M13,12.49v1.66c1.13-0.64,2.7-0.99,4.5-0.99c0.88,0,1.73,0.09,2.5,0.26V11.9c-0.79-0.15-1.64-0.24-2.5-0.24 C15.8,11.66,14.26,11.96,13,12.49z" />
<path d="M17.5,14.33c-1.7,0-3.24,0.29-4.5,0.83v1.66c1.13-0.64,2.7-0.99,4.5-0.99c0.88,0,1.73,0.09,2.5,0.26v-1.52 C19.21,14.41,18.36,14.33,17.5,14.33z" />
</g>
</g>
</g>
</svg>
);
}
export function ArrowBackIcon({ className }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="inherit"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
);
}
export function ArrowForwardIcon({ className }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="inherit"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8-8-8z" />
</svg>
);
}

9
reader/src/index.css Normal file
View File

@ -0,0 +1,9 @@
@import "tailwindcss";
body {
overflow: hidden;
}
#root {
height: 100dvh;
}

10
reader/src/main.jsx Normal file
View File

@ -0,0 +1,10 @@
import "./index.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>,
);

67
reader/src/store.js Normal file
View File

@ -0,0 +1,67 @@
import { create } from "zustand";
import { apiInstance } from "./api.js";
function getLocalSubjectIdx() {
let subject = localStorage.getItem("topics");
if (typeof subject === "undefined" || subject === null) {
subject = 0;
}
subject = parseInt(subject);
return Math.max(0, subject);
}
export const useStore = create((set, get) => ({
isLoading: true,
topics: [],
topicIdx: null,
resourceIdx: null,
selectTopic: (topicIdx) => {
if (topicIdx === null) {
set({ topicIdx, resourceIdx: null });
} else {
set({ topicIdx, resourceIdx: 0 });
}
},
selectResource: (resourceIdx) => {
set({ resourceIdx });
},
getStructure: async () => {
const { data: topics } = await apiInstance("/structure");
set({
isLoading: false,
topics,
});
},
setStructure: (topics) => {
set({ topics });
},
config: getLocalConfig(),
changeConfig: (config) => {
const newConfig = { ...get().config, ...config };
if ("contentZoomLevel" in config) {
newConfig.contentZoomFactor = config.contentZoomLevel / 100;
}
set({ config: newConfig });
localStorage.setItem("config", JSON.stringify(newConfig));
},
}));
useStore.getState().getStructure().catch(console.error);
function getLocalConfig() {
const defaultConfig = {
displayTitle: true,
wrapTopicTitles: false,
narrowMode: true,
justifyText: false,
contentZoomLevel: 100,
};
const config_str = localStorage.getItem("config");
const config = config_str ? JSON.parse(config_str) : {};
return { ...defaultConfig, ...config };
}

10
reader/src/utils.js Normal file
View File

@ -0,0 +1,10 @@
export function getIndexFromTopicId(topicId) {
//const match = topicId?.match(/^F_T(\d+)_R(\d+)_(\d+)\.md$/);
const match = topicId?.match(/^T(\d+)$/);
if (!match) {
console.warn(`Invalid topic id: ${topicId}`);
return null;
}
return [Number(match[1]) - 1];
}

11
reader/vite.config.js Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [tailwindcss(), react()],
server: {
allowedHosts: ["personal.orb.local"],
},
});

View File

@ -0,0 +1,23 @@
# Resource Provider (Node.js) Dockerfile
FROM node:20-alpine
# Set working directory
WORKDIR /app
# Install pnpm globally
RUN npm install -g pnpm
# Copy package.json and pnpm-lock.yaml
COPY package.json pnpm-lock.yaml* ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy the rest of the application
COPY . .
# Expose port
EXPOSE 3000
# Start the Node.js application
CMD ["node", "server.js"]

View File

@ -0,0 +1,55 @@
export const STATIC_DIR =
process.env.NODE_ENV === "production" ? "/static-files" : "../static-files";
const _TOPICS = [
"Социалната медицина като наука определение, предмет, задачи, методи. Обществено здраве дефиниране, цикъл на социално-здравните явления. Системен подход при анализ на общественото здраве.",
"Здраве дефиниция, концепции, същност и измерения. Детерминанти на здравето. Нова философия за здраве. Континуум на здравето. Традиционно и съвременно разбиране за здраве.",
"Социално-медицински подход към личността в здраве и болест. Социални фактори на здравето / болестта. Социална профилактика, социална терапия, социална рехабилитация. Социална история на заболяването / болния.",
"Демографски измерители на общественото здраве. Статика на населението –значение за здравеопазването. Демографски показатели за естествено движение на населението.",
"Риск и рискови фактори. Дефиниция, видове, класификация. Рискови групи. Подходи за определяне на рискови групи в здравеопазването. Фази на естественото развитие на патологичния процес.",
"Профилактика на заболяванията. Нива на профилактика - цели, прицелни контингенти, обхват. Стратегии за първична профилактика.",
"Здравна система определение, цели, функции, типове. Континуум на здравните дейности. Здравни заведения определение и класификация.",
"Здравна помощ дефиниция, характеристики, нива на специализация. Здравни потребности и здравни нужди определение. Функции на здравните служби, организационни принципи.",
"Първична здравна помощ. Извънболнична специализирана медицинска помощ. Лечебни заведения за извънболнична медицинска помощ определение и класификация.",
"Болнична медицинска помощ. Видове болнични заведения. Съвременни тенденции за развитие на болничната помощ.",
"Съвременни проблеми и промени в световното здраве. Здравна политика. Приоритети в здравеопазването.",
"Здравна култура дефиниране, видове. Насоки на проява на субективна здравна култура. Планиране на здравно-образователни програми. Формиране на мотиви за здравно поведение.",
"Здравно възпитание - цели, принципи, фази, форми - класически и модерни.",
"Хронични неинфекциозни заболявания - характеристика, обществена значимост, рискови фактори, действия за ограничаването им.",
"Промоция на здравето - определение, цели и компоненти. Съвременни приоритети пред общественото здравеопазване. Актуални области и подходи, залегнали в Отавската харта.",
"Здравословен начин на живот. Аспекти и компоненти на начина на живот. Нагласи и мотивация за водене на здравословен начин на живот.",
"Правен режим на социалното осигуряване. КСО, ЗСП, ЗИХУ.",
"Закон за здравето. Органи в системата на здравеопазването.",
"Закон за здравното осигуряване. Принципи на здравното осигуряване.",
"Закон за лечебните заведения. Видове лечебни заведения.",
"Закон за съсловната организация на медицинските сестри, акушерките и асоциираните медицински специалисти. Функции, устройство, права и задължения.",
"Кодекс на професионалната етика. Здравна информация и документация. Лични данни на пациентите",
"Права на пациента. Правни способи за защита.",
"Кодекс на труда. Характеристика на трудовото правоотношение.",
"Юридическа отговорност. Гражданска отговорност на медицинските специалисти. Дисциплинарна отговорност.",
"Административно-наказателна отговорност на медицинските специалисти. Лишаване от права.",
"Наказателна отговорност на медицинските специалисти.",
];
const N_RESOURCES = 9;
export const TOPICS = _TOPICS.map((topicTitle, topicIdx) => {
const topicSeq = topicIdx + 1;
const topicId = `T${topicSeq.toString().padStart(2, "0")}`;
const resources = Array.from({ length: N_RESOURCES }, (_, i) => {
const resourceSeq = i + 1;
const resourceId = `${topicId}_R${resourceSeq.toString().padStart(2, "0")}`;
return {
id: resourceId,
seq: resourceSeq,
filename: null,
};
});
return {
id: topicId,
seq: topicSeq,
title: topicTitle,
resources,
};
});
export const FILENAME_REGEX = /^F_T\d+_R\d+_\d+.md$/;

View File

@ -0,0 +1,68 @@
import { promises as fs } from "fs";
import path from "path";
import { FILENAME_REGEX, STATIC_DIR, TOPICS } from "./constants.js";
export const cache = new Map();
function getObjectWithMaxTimestamp(array) {
if (!array || array.length === 0) {
return null;
}
return array.reduce((max, current) => {
return current.timestamp > max.timestamp ? current : max;
});
}
async function populateResources() {
const filenamesRaw = await fs.readdir(STATIC_DIR);
const filenames = filenamesRaw
.filter((filename) => FILENAME_REGEX.test(filename))
.map((filename) => {
const parts = filename.replace(".md", "").split("_");
if (parts.length !== 4 || parts[0] !== "F") {
console.warn(`File not following format: ${filename}`);
return;
}
const topicSeq = Number(parts[1].substring(1));
const resourceSeq = Number(parts[2].substring(1));
const timestamp = Number(parts[3]);
return { topicSeq, resourceSeq, timestamp, filename };
});
const topicsBySeq = Object.groupBy(filenames, ({ topicSeq }) => topicSeq);
TOPICS.forEach((topic) => {
if (topic.seq in topicsBySeq) {
const resourcesBySeq = Object.groupBy(
topicsBySeq[topic.seq],
({ resourceSeq }) => resourceSeq,
);
topic.resources.forEach((resource) => {
if (resource.seq in resourcesBySeq) {
const latestResource = getObjectWithMaxTimestamp(
resourcesBySeq[resource.seq],
);
resource.filename = latestResource.filename;
}
});
}
});
}
export async function getStructure({ refresh = false } = {}) {
const structureKey = "structure";
if (refresh) {
cache.delete(structureKey);
}
if (!cache.has(structureKey)) {
await populateResources();
cache.set(structureKey, TOPICS);
}
return cache.get(structureKey);
}

View File

@ -0,0 +1,24 @@
{
"name": "resource-provider",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"dev": "nodemon server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.5.2",
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"helmet": "^8.1.0",
"morgan": "^1.10.0",
"mutter": "^1.0.1",
"nodemon": "^3.1.10",
"prettier": "^3.6.1",
"socket.io": "^4.8.1"
}
}

File diff suppressed because it is too large Load Diff

208
resource-provider/server.js Normal file
View File

@ -0,0 +1,208 @@
import { readdir } from "fs/promises";
import express from "express";
import cors from "cors";
import helmet from "helmet";
import path from "path";
import fs from "fs/promises";
import { createServer } from "http";
import { Server } from "socket.io";
import morgan from "morgan";
import { STATIC_DIR, TOPICS } from "./constants.js";
import { getStructure, cache } from "./helper.js";
if (typeof process.env.API_TOKEN === "undefined") {
throw new Error("Service cannot be started without API_TOKEN");
}
// Load cache on start
getStructure({ refresh: true }).catch(console.error);
const app = express();
const server = createServer(app);
// Global boolean array
let booleanArray = new Array(10).fill(false);
// Socket.IO setup with CORS
const io = new Server(server, {
cors: {
origin: "http://localhost:5173", // React app URL
methods: ["GET", "POST"],
},
});
const corsOptions = {
methods: ["GET", "POST", "PUT", "DELETE"],
credentials: true,
origin: (origin, callback) => {
if (!origin) {
if (process.env.NODE_ENV === "production") {
return callback(new Error("Origin required in production"));
}
return callback(null, true);
}
if (isOriginAllowed(origin)) {
return callback(null, true);
} else {
return callback(new Error("Not allowed by CORS"));
}
},
};
app.use(cors(corsOptions));
app.use(helmet());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(morgan("tiny"));
// Socket connection handling
//io.on("connection", (socket) => {
// console.log("Client connected:", socket.id);
//
// // Send current array state to newly connected client
// socket.emit("arrayChanged", booleanArray);
//
// // Handle array updates from client
// socket.on("setArrayValue", (data) => {
// const { index, value } = data;
//
// if (index >= 0 && index < booleanArray.length) {
// booleanArray[index] = value;
// console.log(`Updated index ${index} to ${value}`);
//
// // Broadcast updated array to all clients
// io.emit("arrayChanged", booleanArray);
// }
// });
//
// // Handle getting current array state
// socket.on("getArray", () => {
// socket.emit("arrayChanged", booleanArray);
// });
//
// socket.on("disconnect", () => {
// console.log("Client disconnected:", socket.id);
// });
//});
app.get("/_health", (req, res) => {
res.json({ healthy: true });
});
app.get(
"/resources-status",
asyncHandler((req, res) => {
res.json({ array: booleanArray });
}),
);
// Serve static files with automatic ETag handling
app.use(
"/content",
express.static(STATIC_DIR, {
etag: true, // Enable automatic ETag generation
lastModified: true, // Include Last-Modified header
maxAge: 3600000, // Cache for 1 hour, but always revalidate
immutable: false, // Files can change
}),
);
app.get(
"/structure",
asyncHandler(async (req, res) => {
let { refresh } = req.query;
refresh = Boolean(refresh);
const structure = await getStructure({ refresh });
res.json(structure);
}),
);
app.post(
"/resources",
verifyToken,
asyncHandler(async (req, res) => {
try {
const { topicSeq, resourceSeq, content } = req.body;
if (!topicSeq || !resourceSeq || !content) {
res.status(400).json({ message: "Missing body" });
return;
}
if (
typeof topicSeq !== "number" ||
typeof resourceSeq !== "number" ||
typeof content !== "string"
) {
res.status(400).json({ message: "Invalid body" });
}
const topicIdx = topicSeq - 1;
const resourceIdx = resourceSeq - 1;
const topics = await getStructure();
const resource = topics[topicIdx].resources[resourceIdx];
if (!resource) {
res.status(404).json({ message: "Resource not found to update" });
}
const filename = `F_${resource.id}_${Date.now()}.md`;
const filePath = path.join(STATIC_DIR, filename);
await fs.writeFile(filePath, content, "utf8");
resource.filename = filename;
res.json({ success: true, filename, structure: topics });
} catch (error) {
console.log(error);
res.status(500).json({ error: "Failed to update topic resource" });
}
}),
);
app.use(errorRequestHandler);
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => console.log("Resource Provider started"));
/**
* HELPERS
*/
function asyncHandler(fn) {
return (req, res, next) => {
return Promise.resolve(fn(req, res, next)).catch(next);
};
}
function errorRequestHandler(error, _req, res, next) {
if (error) {
console.log(error);
res
.status(error.status || 500)
.json({ message: error.message || "Server failed" });
} else {
next();
}
}
function isOriginAllowed(origin) {
const url = new URL(origin);
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
return true;
}
return url.hostname.endsWith("tomastm.com");
}
function verifyToken(req, res, next) {
const token = req.headers["token"];
if (!token || token !== process.env.API_TOKEN) {
res.status(401).json({ message: "Token not provided" });
return;
}
next();
}

View File

@ -0,0 +1,40 @@
server {
listen 80;
server_name localhost;
root /static-files;
location / {
# Handle preflight requests first
if ($request_method = 'OPTIONS') {
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;
add_header Access-Control-Max-Age 3600 always;
return 204;
}
# Serve static files
try_files $uri $uri/ =404;
# Add CORS headers for actual 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;
# 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;
}
}