update - with versions
This commit is contained in:
parent
841cb61acf
commit
732cac4393
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal 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
20
README.md
Normal 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
32
docker-compose.yml
Normal 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
24
reader/.gitignore
vendored
Normal 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
3
reader/.prettierrc
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"printWidth": 100
|
||||
}
|
||||
35
reader/Dockerfile
Normal file
35
reader/Dockerfile
Normal 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
12
reader/README.md
Normal 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
33
reader/eslint.config.js
Normal 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
16
reader/index.html
Normal 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
40
reader/nginx.conf
Normal 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
38
reader/package.json
Normal 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
2782
reader/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
1
reader/public/vite.svg
Normal file
1
reader/public/vite.svg
Normal 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
582
reader/src/App.jsx
Normal 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
346
reader/src/ResourcePage.jsx
Normal 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
14
reader/src/api.js
Normal 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
67
reader/src/hooks.js
Normal 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
232
reader/src/icons/Icons.jsx
Normal 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
9
reader/src/index.css
Normal file
@ -0,0 +1,9 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100dvh;
|
||||
}
|
||||
10
reader/src/main.jsx
Normal file
10
reader/src/main.jsx
Normal 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
67
reader/src/store.js
Normal 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
10
reader/src/utils.js
Normal 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
11
reader/vite.config.js
Normal 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"],
|
||||
},
|
||||
});
|
||||
23
resource-provider/Dockerfile
Normal file
23
resource-provider/Dockerfile
Normal 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"]
|
||||
55
resource-provider/constants.js
Normal file
55
resource-provider/constants.js
Normal 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$/;
|
||||
68
resource-provider/helper.js
Normal file
68
resource-provider/helper.js
Normal 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);
|
||||
}
|
||||
24
resource-provider/package.json
Normal file
24
resource-provider/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1055
resource-provider/pnpm-lock.yaml
Normal file
1055
resource-provider/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
208
resource-provider/server.js
Normal file
208
resource-provider/server.js
Normal 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();
|
||||
}
|
||||
40
static-provider/nginx.conf
Normal file
40
static-provider/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user