update - with versions
This commit is contained in:
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
generated
Normal file
2782
reader/pnpm-lock.yaml
generated
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"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user