This commit is contained in:
2025-06-30 13:08:35 +00:00
parent 1bf921a56e
commit 841cb61acf
579 changed files with 0 additions and 70142 deletions

24
reader/.gitignore vendored
View File

@@ -1,24 +0,0 @@
# 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?

View File

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

View File

@@ -1,35 +0,0 @@
# 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;"]

View File

@@ -1,12 +0,0 @@
# 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.

View File

@@ -1,33 +0,0 @@
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 },
],
},
},
]

View File

@@ -1,16 +0,0 @@
<!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>

View File

@@ -1,40 +0,0 @@
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;
}
}

View File

@@ -1,37 +0,0 @@
{
"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",
"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"
}

2767
reader/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,599 +0,0 @@
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 { useSubject, useTopic, useTopicSyncParams, useTopicsAround } from "./hooks.js";
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>
// );
//}
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>
);
}
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 subject = useSubject();
const topic = useTopic();
return (
<div className="max-w-7xl mx-auto h-full relative flex flex-col">
{config.displayTitle && (
<div
className="w-full px-4 py-2 font-medium text-large text-white bg-blue-600"
style={{ lineHeight: 1.2 }}
>
<div className="text-sm font-normal">{subject.name}</div>
<span>{topic ? `${topic.sequence}: ${topic.title}` : `Конспект за Държавен Изпит`}</span>
</div>
)}
<Outlet />
</div>
);
}
export function TopicListView() {
const itemRefs = useRef({});
const subjects = useStore((state) => state.subjects);
const selectedSubject = useSubject();
const selectSubject = useStore((state) => state.selectSubject);
const [isSelectingSubject, setIsSelectingSubject] = useState(false);
const selectedTopic = useTopic();
const selectTopic = useStore((state) => state.selectTopic);
const config = useStore((state) => state.config);
const changeConfig = useStore((state) => state.changeConfig);
useLayoutEffect(() => {
if (selectedTopic) {
itemRefs.current?.[Math.max(selectedTopic.index - 3, 0)].scrollIntoView();
}
}, [selectedTopic]);
return (
<>
<div
className={`flex-1 overflow-y-scroll ${selectedTopic === null ? "pb-[92px]" : "pb-[156px]"}`}
>
{selectedSubject.topics.map((topic, topicIdx) => (
<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.sequence}
</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 className="relative flex ml-2">
<button
className={`${isSelectingSubject ? "invisible" : ""} flex-1 shadow-xl cursor-pointer px-2 py-1 rounded-md text-xs text-blue-800 font-medium whitespace-nowrap bg-blue-100/50 backdrop-blur hover:bg-blue-200/50 border border-blue-100`}
onClick={() => setIsSelectingSubject(true)}
>
{selectedSubject.name}
</button>
{isSelectingSubject && (
<>
<button
className={`absolute w-full h-full flex-1 flex justify-center items-center cursor-pointer rounded-md backdrop-blur bg-blue-100/40 hover:bg-blue-100/80`}
onClick={() => setIsSelectingSubject(false)}
>
<CloseIcon className="fill-gray-600" />
</button>
<div className="absolute bottom-full right-0 p-2 flex space-x-1">
{subjects.map((subject, subjectIdx) => (
<button
key={subject.id}
className={`flex-1 shadow-xl cursor-pointer px-2 py-1 rounded-md text-xs whitespace-nowrap h-[44px] ${
selectedSubject.id === subject.id
? "bg-blue-100 text-blue-800 font-medium border border-blue-400"
: "bg-gray-100 hover:bg-gray-200 border border-gray-400"
}`}
onClick={() => {
selectSubject(subjectIdx);
setIsSelectingSubject(false);
}}
>
{subject.name}
</button>
))}
</div>
</>
)}
</div>
</div>
{selectedTopic && (
<Link
to={`/${selectedTopic.id}`}
className="w-full p-2 mb-2 bg-blue-600 hover:bg-blue-700 cursor-pointer truncate rounded-md text-sm text-white text-center shadow-md transition-colors"
>
<span>Продължи четенето:</span>
<br />
<span className="font-medium">
{selectedTopic.sequence}. {selectedTopic.title}
</span>
</Link>
)}
</div>
</>
);
}
function FileReader() {
const topic = useTopicSyncParams();
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, setResourceIdx] = useState(topic.resources.length - 1);
const selectedResource = topic.resources[resourceIdx];
return (
<>
<div className="flex-1">
{resourceIdx === -1 ? (
<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">
<button
className={`${isSelectingResource ? "invisible" : ""} flex-1 shadow-xl cursor-pointer px-2 py-1 rounded-md text-xs text-blue-800 font-medium whitespace-nowrap bg-blue-100/50 backdrop-blur hover:bg-blue-200/50 border border-blue-100`}
onClick={() => setIsSelectingResource(true)}
>
Версия {selectedResource.version}
</button>
{isSelectingResource && (
<button
className={`absolute w-full h-full flex-1 flex justify-center items-center cursor-pointer rounded-md hover:backdrop-blur hover:bg-blue-100/30`}
onClick={() => setIsSelectingResource(false)}
>
<CloseIcon className="fill-gray-600" />
</button>
)}
</div>
)}
</div>
{isSelectingResource && (
<div className="absolute bottom-full right-0 px-4 flex space-x-1 h-10">
{topic.resources.map((resource, rIndex) => (
<button
key={resource.id}
className={`flex-1 shadow-xl cursor-pointer px-2 py-1 rounded-md text-xs whitespace-nowrap ${
selectedResource.id === resource.id
? "bg-blue-100 text-blue-800 font-medium border border-blue-400"
: "bg-gray-100 hover:bg-gray-200 border border-gray-400"
}`}
onClick={() => {
setResourceIdx(rIndex);
setIsSelectingResource(false);
}}
>
Версия {resource.version}
</button>
))}
</div>
)}
</div>
</div>
<div className="w-full flex flex-col space-y-2">
<div className="flex bg-gray-100 border-t border-blue-200 text-center">
{prevTopic === null ? (
<div className="flex-1 border-r border-blue-200" />
) : (
<Link
to={`/${prevTopic.id}`}
className="border-r border-blue-200 w-1/2 flex-1 px-4 py-2 hover:bg-blue-200 cursor-pointer flex align-center justify-start"
>
<ArrowBackIcon />
<span className="ml-2 truncate w-full ">
{prevTopic.sequence}: {prevTopic.title}
</span>
</Link>
)}
{nextTopic === null ? (
<div className="flex-1" />
) : (
<Link
to={`/${nextTopic.id}`}
className="flex-1 px-4 py-2 hover:bg-blue-200 w-1/2 cursor-pointer flex align-center justify-end"
>
<span className="mr-2 w-full truncate">
{nextTopic.sequence}: {nextTopic.title}
</span>
<ArrowForwardIcon />
</Link>
)}
</div>
</div>
</>
);
}
export function PDFViewer({ file, compact, zoomFactor, justifyText }) {
const iframeRef = useRef(null);
const [content, setContent] = useState(null);
const htmlContent = useMemo(() => {
const fileContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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;
};

View File

@@ -1,390 +0,0 @@
import React, { useState, useEffect, useMemo } from "react";
import { marked } from "marked";
import { useStore } from "./store.js";
import { resourcesInstance, apiInstance } from "./api.js";
function LoadingWrapper() {
const isLoading = useStore((state) => state.isLoading);
if (isLoading) {
return "Loading...";
}
return <ResourcePage />;
}
function SelectResource({ onChange, refresh }) {
const subjects = useStore((state) => state.subjects);
const [selectedSubject, setSelectedSubject] = useState(-1);
const [selectedTopic, setSelectedTopic] = useState(-1);
function handleChange(newSubject, newTopic) {
setSelectedSubject(newSubject);
setSelectedTopic(newTopic);
if (newSubject === -1 || newTopic === -1) {
onChange(null);
return;
}
const subject = subjects[newSubject];
const topic = subject.topics[newTopic];
const resource = topic.resources.at(-1) ?? { id: "/", filename: "/", version: 0 };
onChange({
subjectName: subject.name,
topic,
resource,
});
}
if (refresh) {
return null;
}
return (
<div>
{/* File Selector */}
<div className="bg-white border-b border-gray-200 p-4 flex space-x-6">
<div>
<label htmlFor="subjectSelect" className="block text-sm font-medium text-gray-700 mb-2">
Избери раздел:
</label>
<select
id="subjectSelect"
value={selectedSubject}
onChange={(event) => {
handleChange(parseInt(event.target.value), -1);
}}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white min-w-64"
>
<option value="-1" disabled>
Избери раздел...
</option>
{subjects.map((subject, sIndex) => (
<option key={subject.id} value={sIndex}>
{subject.sequence}. {subject.name}
</option>
))}
</select>
</div>
<div>
<label htmlFor="topicSelect" className="block text-sm font-medium text-gray-700 mb-2">
Избери тема:
</label>
<select
id="topicSelect"
value={selectedTopic}
onChange={(event) => {
handleChange(selectedSubject, parseInt(event.target.value));
}}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
>
<option value="-1" disabled>
{selectedSubject === -1 ? "Първо избери раздел" : "Избери тема..."}
</option>
{selectedSubject !== -1 &&
subjects[selectedSubject].topics.map((topic, tIndex) => (
<option key={topic.id} value={tIndex}>
{topic.sequence}.{" "}
{topic.title.length > 60 ? topic.title.substring(0, 60) + "..." : topic.title}
</option>
))}
</select>
</div>
</div>
</div>
);
}
function ResourcePage() {
return <Content />;
}
function useFileContent(file) {
const [content, setContent] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!file) {
return;
}
if (file.version === 0) {
setIsLoading(false);
setContent("");
return;
}
let ignore = false;
setIsLoading(true);
setContent(null);
resourcesInstance.get(`/${file.filename}`).then((res) => {
if (!ignore) {
setContent(res.data);
setIsLoading(false);
}
});
return () => {
ignore = true;
};
}, [file]);
return { content, isLoading };
}
const Content = () => {
const [refresh, setRefresh] = useState(false);
const authToken = useStore((state) => state.config.token);
const changeConfig = useStore((state) => state.changeConfig);
const setAuthToken = (token) => changeConfig({ token });
const [selected, setSelected] = useState(null);
const { isLoading, content: initialContent } = useFileContent(selected?.resource);
const [content, setContent] = useState(initialContent);
const [tokenInput, setTokenInput] = useState("");
const [isSavingLoading, setIsSavingLoading] = useState(false);
const [message, setMessage] = useState(null);
const 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 [prevContent, setPrevContent] = useState(initialContent);
if (prevContent !== initialContent) {
setPrevContent(initialContent);
setContent(initialContent);
}
const handleTokenSubmit = () => {
if (tokenInput.trim()) {
setAuthToken(tokenInput);
}
};
const handleSave = async () => {
setIsSavingLoading(true);
setRefresh(true);
try {
const res = await apiInstance.post(
`/resources`,
{
topicId: selected.topic.id,
content: content.trim(),
},
{
headers: {
token: authToken,
},
},
);
setMessage(`Success! New filename: ${res.data.filename}`);
} catch (error) {
console.log(error);
setMessage("Something failed :(");
if (error.response.status === 401) {
setAuthToken(null);
}
} finally {
setTimeout(() => {
//setMessage(null);
if (window) {
window.location.reload();
} else {
console.warn("No window");
}
}, 3000);
setIsSavingLoading(false);
setContent("");
setSelected(null);
}
};
const handleCancel = () => {
if (window.confirm("Сигурни ли сте, че искате да затворите страницата?")) {
window.close();
}
};
const handleCopyContent = async () => {
try {
await navigator.clipboard.writeText(content);
alert("Съдържанието е копирано в клипборда!");
} catch (err) {
console.error("Грешка при копиране:", err);
alert("Грешка при копиране на съдържанието");
}
};
function handleUndoInitial() {
setContent(initialContent);
}
const handleDeleteAll = () => {
if (window.confirm("Сигурни ли сте, че искате да изтриете цялото съдържание?")) {
setContent("");
}
};
// Token authentication screen
if (!authToken) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-8">
<div className="bg-white rounded-lg shadow-md p-8 w-full max-w-md">
<h2 className="text-2xl font-bold text-gray-800 mb-6 text-center">
Въведете токен за достъп
</h2>
<div className="space-y-4">
<div>
<label htmlFor="token" className="block text-sm font-medium text-gray-700 mb-2">
Токен:
</label>
<input
type="text"
id="token"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Въведете вашия токен..."
/>
</div>
<button
onClick={handleTokenSubmit}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Влизане
</button>
</div>
</div>
</div>
);
}
// Main content screen
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<SelectResource onChange={(values) => setSelected(values)} refresh={refresh} />
{message && <div className="text-lg p-12 text-green-600 bg-gray-200">{message}</div>}
{selected && !isLoading && (
<>
{/* Header */}
<div className="hidden bg-white border-b border-gray-200 p-4">
<div className="text-2xl font-bold text-gray-800">{selected.subjectName}</div>
<div className="text-2xl/7 mt-2 text-gray-800 line-">
Тема: {selected.topic.sequence}. {selected.topic.title}
</div>
<div className="text-sm text-gray-400 mt-6">
Topic Id: <span className="font-bold">{selected.topic.id}</span>
{" - "}
Original Filename: <span className="font-bold">{selected.resource.filename}</span>
{" - "}
Current Version: <span className="font-bold">{selected.resource.version}</span>
{" - "}
New Version: <span className="font-bold">{selected.resource.version + 1}</span>
</div>
</div>
{/* Content Editor */}
<div className="flex-1 bg-white p-4 flex flex-col">
<label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-2">
Markdown съдържание:
</label>
<div className="flex-1 h-full flex space-x-2">
<div className="flex-1 flex flex-col">
<textarea
id="content"
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-4 flex justify-between">
<div className="flex flex-wrap gap-3">
<button
onClick={handleCopyContent}
className="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-colors"
>
Копирай съдържанието
</button>
<button
onClick={handleDeleteAll}
className="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition-colors"
>
Изтрий всичко
</button>
<button
onClick={handleUndoInitial}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Възстанови оригинал
</button>
</div>
{isSavingLoading && <span className="flex justify-end mr-8">Saving...</span>}
<div className={`flex justify-end gap-3 ${isSavingLoading ? "invisible" : ""}`}>
<button
onClick={handleCancel}
className="bg-gray-600 text-white px-6 py-2 rounded-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors"
>
Отказ
</button>
<button
onClick={handleSave}
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Запази
</button>
</div>
</div>
</>
)}
</div>
);
};
export default LoadingWrapper;

View File

@@ -1,15 +0,0 @@
import axios from "axios";
const API_BASE_URL = "https://api.med.tomastm.com";
//const RESOURCES_BASE_URL = "https://resources.med.tomastm.com";
const RESOURCES_BASE_URL = "https://api.med.tomastm.com/content";
export const apiInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 5000,
});
export const resourcesInstance = axios.create({
baseURL: RESOURCES_BASE_URL,
timeout: 5000,
});

View File

@@ -1,82 +0,0 @@
import { useEffect, useMemo } from "react";
import { useParams } from "react-router";
import { useShallow } from "zustand/react/shallow";
import { useStore } from "./store.js";
import { getIndexFromTopicId } from "./utils.js";
export function useSubject() {
return useStore(
useShallow((state) => ({
index: state.subjectIdx,
...state.subjects[state.subjectIdx],
})),
);
}
export function useTopic() {
return useStore(
useShallow((state) => {
if (state.topicIdx === null) {
return null;
}
const { topicIdx, subjectIdx } = state;
return {
index: topicIdx,
subjectIdx,
...state.subjects[subjectIdx].topics[topicIdx],
};
}),
);
}
export function useTopicSyncParams() {
const { topicId } = useParams();
const subjects = useStore((state) => state.subjects);
const selectSubject = useStore((state) => state.selectSubject);
const selectTopic = useStore((state) => state.selectTopic);
const currentTopicIdx = useStore((state) => state.topicIdx);
const currentSubjectIdx = useStore((state) => state.subjectIdx);
const topic = useMemo(() => {
if (!topicId) return null;
const indices = getIndexFromTopicId(topicId);
if (!indices) return null;
const [subjectIdx, topicIdx] = indices;
const foundTopic = subjects[subjectIdx]?.topics[topicIdx];
if (!foundTopic) return null;
return { ...foundTopic, index: topicIdx, subjectIdx };
}, [topicId, subjects]);
useEffect(() => {
if (topic) {
selectSubject(topic.subjectIdx);
selectTopic(topic.index);
}
}, [selectSubject, selectTopic, topic]);
if (topic && (currentTopicIdx === null || currentSubjectIdx === null)) {
return { isLoading: true };
}
return topic;
}
export function useTopicsAround() {
return useStore(
useShallow((state) => {
const { subjects, subjectIdx, topicIdx } = state;
const { topics } = subjects[subjectIdx];
const prevTopic = topicIdx === 0 ? null : topics[topicIdx - 1];
const nextTopic = topicIdx === topics.length - 1 ? null : topics[topicIdx + 1];
return { prevTopic, nextTopic };
}),
);
}

View File

@@ -1,232 +0,0 @@
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>
);
}

View File

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

View File

@@ -1,10 +0,0 @@
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>,
);

View File

@@ -1,74 +0,0 @@
import { create } from "zustand";
import { apiInstance } from "./api.js";
function getLocalSubjectIdx() {
let subject = localStorage.getItem("subject");
if (typeof subject === "undefined" || subject === null) {
subject = 0;
}
subject = parseInt(subject);
return Math.max(0, subject);
}
export const useStore = create((set, get) => ({
isLoading: true,
subjects: [],
subjectIdx: null,
topicIdx: null,
resourceIdx: null,
selectSubject: (subjectIdx) => {
set({ subjectIdx, topicIdx: null, resourceIdx: null });
localStorage.setItem("subject", subjectIdx);
},
selectTopic: (topicIdx) => {
if (topicIdx === null) {
set({ topicIdx, resourceIdx: null });
}
const { subjects, subjectIdx } = get();
const resources = subjects[subjectIdx].topics[topicIdx].resources;
const resourceIdx = resources.length - 1;
set({ topicIdx, resourceIdx });
},
selectResource: (resourceIdx) => {
set({ resourceIdx });
},
getStructure: async () => {
const { data: subjects } = await apiInstance("/structure");
const subjectIdx = get().subjectIdx ?? getLocalSubjectIdx();
set({
isLoading: false,
subjects,
subjectIdx,
});
},
config: getLocalConfig(),
changeConfig: (config) => {
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 };
}

View File

@@ -1,9 +0,0 @@
export function getIndexFromTopicId(topicId) {
const match = topicId?.match(/^S(\d+)_T(\d+)$/);
if (!match) {
console.warn(`Invalid topic id: ${topicId}`);
return null;
}
return [parseInt(match[1]) - 1, parseInt(match[2]) - 1];
}

View File

@@ -1,11 +0,0 @@
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"],
},
});