This commit is contained in:
2025-04-06 02:58:55 +00:00
parent 54becc8fb3
commit 702faf2fae
12 changed files with 1231 additions and 872 deletions

View File

@@ -1,23 +1,326 @@
import { StrictMode } from "react";
import { StrictMode, useLayoutEffect, useRef, useState, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { useParams, Navigate, BrowserRouter, Link, Outlet, Route, Routes } from "react-router";
import "./index.css";
import App from "./App.jsx";
import { useShallow } from "zustand/shallow";
import { useStore } from "./store.js";
import {
ArrowBackIcon,
ArrowForwardIcon,
MenuBookIcon,
MyLocationIcon,
TitleIcon,
WidthIcon,
} from "./icons/Icons";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<TopicListView />} />
<Route path=":topicId" element={<FileReader />} />
</Route>
</Routes>
</BrowserRouter>
</StrictMode>,
);
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/service-worker.js")
.then((registration) => {
console.log("SW registered:", registration);
})
.catch((error) => {
console.log("SW registration failed:", error);
});
});
function Layout() {
const params = useParams();
const topic = useStore((state) => params.topicId && state.topics.byId[params.topicId]);
const config = useStore((state) => state.config);
return (
<div className="max-w-7xl mx-auto h-full relative flex flex-col">
{config.displayTitle && (
<div className="w-full px-4 py-2 font-medium text-large text-white bg-blue-600">
<span>{topic ? `${topic.index + 1}: ${topic.title}` : "Конспект за Държавен Изпит"}</span>
</div>
)}
<Outlet />
</div>
);
}
export function TopicListView() {
const itemRefs = useRef({});
const topics = useStore(
useShallow((state) => state.topics.allIds.map((id) => state.topics.byId[id])),
);
const selectedTopic = useStore((state) => state.selectedTopic);
const selectTopic = useStore((state) => state.selectTopic);
useLayoutEffect(() => {
if (selectedTopic && selectedTopic.id !== null) {
itemRefs.current?.[Math.max(selectedTopic.index - 3, 0)].scrollIntoView();
}
}, [selectedTopic]);
return (
<>
<div className="flex-1 overflow-y-scroll">
{topics.map((topic, i) => (
<Link
key={topic.id}
ref={(node) => {
itemRefs.current[i] = node;
}}
to={`/${topic.id}`}
onClick={() => selectTopic(topic.id)}
className={`flex px-2 py-1 rounded-md cursor-pointer border-l-4 ${topic.id === selectedTopic?.id ? "bg-blue-100 border-blue-500" : "border-transparent hover:bg-gray-100"}`}
>
<div
className={`w-6 flex-shrink-0 flex font-medium justify-end ${topic.id === selectedTopic?.id ? "text-blue-600" : "text-blue-800"}`}
>
{i + 1}
</div>
<span className="ml-2">
<span
className={`leading-5 ${topic.id === selectedTopic?.id ? "font-medium" : "font-normal"}`}
>
{topic.title}
</span>
</span>
</Link>
))}
{selectedTopic !== null && (
<div className="sticky bottom-0 p-4 w-full flex flex-col">
<div className="w-full flex justify-between items-center gap-2">
<Link
to={`/${selectedTopic.id}`}
className="w-full p-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.title}</span>
</Link>
<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>
</div>
)}
</div>
</>
);
}
export function FileReader() {
const params = useParams();
const topicsById = useStore((state) => state.topics.byId);
const selectTopic = useStore((state) => state.selectTopic);
const topic = topicsById[params.topicId];
if (!topic) {
return <Navigate to="/" replace />;
}
selectTopic(topic.id);
return <Reader topic={topic} />;
}
export function Reader({ topic }) {
console.log("render!");
const config = useStore((state) => state.config);
const changeConfig = useStore((state) => state.changeConfig);
const selectedVersion = useStore((state) => state.topicVersions[topic.id] ?? 0);
const selectVersion = useStore((state) => state.selectTopicVersion);
const getTopicAtIndex = useStore((state) => state.getTopicAtIndex);
const prevTopic = getTopicAtIndex(topic.index - 1);
const nextTopic = getTopicAtIndex(topic.index + 1);
return (
<>
<div className="flex-1">
<PDFViewer file={topic.files[selectedVersion]} compact={config.narrowMode} />
<div className="absolute bottom-10 flex justify-between px-4 py-2 w-full z-999">
<div className="flex space-x-2">
<Link to="/" className="cursor-pointer p-2 rounded-full bg-blue-600 text-white">
<MenuBookIcon className="fill-gray-100" />
</Link>
<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>
{window.innerWidth > 576 && (
<button
className={`cursor-pointer p-2 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>
)}
</div>
{topic.files.length > 1 && (
<div className="flex space-x-1">
{topic.files.map((file, vIndex) => (
<button
key={file}
className={`flex-1 cursor-pointer px-2 py-1 rounded-md text-xs whitespace-nowrap ${
selectedVersion === vIndex
? "bg-blue-100 text-blue-800 font-medium"
: "bg-gray-100 hover:bg-gray-200"
}`}
onClick={() => selectVersion(topic.id, vIndex)}
>
Версия {vIndex + 1}
</button>
))}
</div>
)}
</div>
</div>
<div className="w-full flex flex-col space-y-2">
<div className="flex bg-gray-100 border-t border-blue-200 text-center">
{topic.isFirst ? (
<div className="flex-1 border-r border-blue-200" />
) : (
<Link
to={`/${prevTopic.id}`}
className="border-r border-blue-200 w-1/2 flex-1 px-4 py-2 hover:bg-blue-200 cursor-pointer flex align-center justify-start"
>
<ArrowBackIcon />
<span className="ml-2 truncate w-full ">
{prevTopic.index + 1}: {prevTopic.title}
</span>
</Link>
)}
{topic.isLast ? (
<div className="flex-1" />
) : (
<Link
to={`/${nextTopic.id}`}
className="flex-1 px-4 py-2 hover:bg-blue-200 w-1/2 cursor-pointer flex align-center justify-end"
>
<span className="mr-2 w-full truncate">
{nextTopic.index + 1}: {nextTopic.title}
</span>
<ArrowForwardIcon />
</Link>
)}
</div>
</div>
</>
);
}
export function PDFViewer({ file, compact }) {
const [content, setContent] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchFile = async () => {
try {
setIsLoading(true);
const response = await fetch(`/files_html/${file}`);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status}`);
}
let fileContent = await response.text();
// If no head tag, add a basic HTML structure with fonts
fileContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<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;
padding-bottom: 40px;
}
pre, code {
font-family: 'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
</style>
</head>
<body>
${fileContent}
</body>
</html>
`;
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 ${compact ? "max-w-xl mx-auto" : ""}`}>
<iframe
srcDoc={content}
title={`File: ${file}`}
className="w-full h-full border-0"
key={file}
allow="fullscreen"
/>
</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;
};