diff --git a/BuildConfig.sh b/BuildConfig.sh
index 89eea36..5565e21 100644
--- a/BuildConfig.sh
+++ b/BuildConfig.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/bin/bash
rm -f /app/config.js;
if [ -z "$BACKEND_HOST" ]; then
BACKEND_HOST="http://localhost:5000"
diff --git a/Dockerfile b/Dockerfile
index e96d24a..99e6846 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,7 +8,6 @@ RUN npm install
COPY . .
COPY BuildConfig.sh /app/BuildConfig.sh
RUN chmod +x /app/BuildConfig.sh
-RUN /app/BuildConfig.sh >&1
RUN npm run build
EXPOSE 3000
diff --git a/src/components/MarkdownContent.js b/src/components/MarkdownContent.js
index c9ad2f6..ace5ff4 100644
--- a/src/components/MarkdownContent.js
+++ b/src/components/MarkdownContent.js
@@ -3,7 +3,7 @@
import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
-import {fetchWithCache} from "../utils/fetchWithCache";
+import {fetch_} from "../utils/requestUtils";
const MarkdownContent = () => {
const { id } = useParams();
@@ -11,7 +11,10 @@ const MarkdownContent = () => {
const [error, setError] = useState(null);
useEffect(() => {
- fetchWithCache(`/api/markdown/${id}`)
+ fetch_(`/api/markdown/${id}`, {}, {
+ use_cache: true,
+ use_token: false
+ })
.then((data) => setContent(data))
.catch((error) => setError(error));
}, [id]);
diff --git a/src/components/Markdowns/MarkdownEditor.js b/src/components/Markdowns/MarkdownEditor.js
index 08eed96..90eee44 100644
--- a/src/components/Markdowns/MarkdownEditor.js
+++ b/src/components/Markdowns/MarkdownEditor.js
@@ -1,7 +1,6 @@
import React, { useContext, useEffect, useState } from "react";
import { AuthContext } from "../../AuthProvider";
import { useNavigate, useParams } from "react-router-dom";
-import { fetchWithCache } from "../../utils/fetchWithCache";
import ReactMarkdown from "react-markdown";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
@@ -11,6 +10,7 @@ import { okaidia } from "react-syntax-highlighter/dist/esm/styles/prism";
import "katex/dist/katex.min.css";
import "./MarkdownEditor.css";
import config from "../../config";
+import {fetch_} from "../../utils/requestUtils";
const MarkdownEditor = () => {
const { roles } = useContext(AuthContext);
@@ -22,7 +22,10 @@ const MarkdownEditor = () => {
useEffect(() => {
if (id) {
- fetchWithCache(`/api/markdown/${id}`)
+ fetch_(`/api/markdown/${id}`, {}, {
+ use_cache: true,
+ use_token: false
+ })
.then((data) => {
setTitle(data.title);
setContent(data.content);
@@ -37,26 +40,23 @@ const MarkdownEditor = () => {
const handleSave = () => {
const url = id ? `${config.BACKEND_HOST}/api/markdown/${id}` : `${config.BACKEND_HOST}/api/markdown`;
const method = id ? "PUT" : "POST";
- fetch(url, {
+ fetch_(url, {
method,
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
- },
body: JSON.stringify({ title, content, path }),
+ }, {
+ use_cache: false,
+ use_token: true,
+ }).then((res) => {
+ if(res.ok)
+ navigate("/");
+ else
+ return res.json().then((data) => {
+ throw new Error(data.error || "Failed to load markdown");
+ });
+ }).catch((err) => {
+ console.error("failed to load markdown", err);
})
- .then((res) => {
- if (res.ok) {
- navigate("/");
- } else {
- return res.json().then((data) => {
- throw new Error(data.error || "Failed to save markdown");
- });
- }
- })
- .catch((err) => {
- console.error("failed to load markdown", err);
- });
+
};
const hasPermission = roles.includes("admin") || roles.includes("creator");
diff --git a/src/components/Navigations/PathNode.css b/src/components/Navigations/PathNode.css
new file mode 100644
index 0000000..db8f2b3
--- /dev/null
+++ b/src/components/Navigations/PathNode.css
@@ -0,0 +1,37 @@
+
+.menu {
+ background-color: white;
+ border: 1px solid #eee;
+}
+
+.path-node {
+ margin-left: 1rem;
+}
+
+.path-node > .has-text-weight-bold {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.25rem 0;
+}
+
+.path-node .is-expanded {
+ font-size: 1rem;
+ font-weight: bold;
+ color: #363636;
+}
+
+.path-node .menu-list-item {
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+}
+
+.path-node .menu-list-item:hover {
+ background-color: #f0f0f0;
+ color: #00d1b2;
+}
+
+.loading-indicator {
+ color: #00d1b2;
+ font-size: 0.9rem;
+ margin-left: 1rem;
+}
diff --git a/src/components/Navigations/PathNode.js b/src/components/Navigations/PathNode.js
new file mode 100644
index 0000000..d0651ac
--- /dev/null
+++ b/src/components/Navigations/PathNode.js
@@ -0,0 +1,221 @@
+import React, { useState } from "react";
+import { Link } from "react-router-dom";
+import PermissionGuard from "../PermissionGuard";
+import config from "../../config";
+import "./PathNode.css";
+import {fetch_} from "../../utils/requestUtils";
+
+const PathNode = ({ path, isRoot = false }) => {
+ const [children, setChildren] = useState([]);
+ const [markdowns, setMarkdowns] = useState([]);
+ const [isExpanded, setIsExpanded] = useState(isRoot); // Root is always expanded
+ const [loading, setLoading] = useState(false);
+ const [isEditing, setIsEditing] = useState(false); // Track if editing
+ const [editData, setEditData] = useState({
+ name: path.name,
+ parent_id: path.parent_id,
+ });
+
+ const toggleExpand = () => {
+ if (isRoot || isExpanded) {
+ setIsExpanded(false);
+ return;
+ }
+
+ setIsExpanded(true);
+
+ if (children.length === 0 && markdowns.length === 0) {
+ setLoading(true);
+ fetch_(`${config.BACKEND_HOST}/api/path/parent/${path.id}`, {}, {
+ use_cache: true,
+ use_token: false
+ })
+ .then((childPaths) => {
+ setChildren(childPaths);
+ return fetch_(
+ `${config.BACKEND_HOST}/api/markdown/by_path/${path.id}`
+ );
+ })
+ .then((markdownData) => setMarkdowns(markdownData))
+ .catch((error) => console.error(error))
+ .finally(() => setLoading(false));
+ }
+ };
+
+ const handleDelete = () => {
+ if (window.confirm(`Are you sure you want to delete ${path.name}?`)) {
+ fetch_(`${config.BACKEND_HOST}/api/path/${path.id}`, {
+ method: "DELETE"
+ }, {
+ use_cache: false,
+ use_token: true
+ })
+ .then((response) => {
+ if (response.ok) {
+ alert("Path deleted successfully!");
+ setChildren([]);
+ setMarkdowns([]);
+ } else {
+ response.json().then((data) => {
+ alert(data.error || "Failed to delete path.");
+ });
+ }
+ })
+ .catch((error) => console.error(error));
+ }
+ };
+
+ const handleEdit = () => {
+ setIsEditing(true);
+ };
+
+ const handleEditSubmit = (e) => {
+ e.preventDefault();
+ fetch_(`${config.BACKEND_HOST}/api/path/${path.id}`, {
+ method: "PUT",
+ body: JSON.stringify(editData),
+ },{
+ use_cache: false,
+ use_token: true
+ })
+ .then((response) => {
+ if (response.ok) {
+ alert("Path updated successfully!");
+ path.name = editData.name;
+ path.parent_id = editData.parent_id;
+ setIsEditing(false);
+ } else {
+ response.json().then((data) => {
+ alert(data.error || "Failed to update path.");
+ });
+ }
+ })
+ .catch((error) => console.error(error));
+ };
+
+ const handleEditCancel = () => {
+ setEditData({ name: path.name, parent_id: path.parent_id });
+ setIsEditing(false);
+ };
+
+ return (
+
+
+
+ {isExpanded && !isRoot ? "▼ " : isRoot ? "" : "▶ "}
+ {path.name}
+
+
+ {/* Admin controls */}
+
+
+
+
+
+
+
+
+ {isExpanded && (
+
+ {/* Render child paths */}
+ {loading && Loading...
}
+ {children.map((child) => (
+
+ ))}
+
+ {/* Render markdowns */}
+ {markdowns.map((markdown) => (
+ -
+
+ {markdown.title}
+
+
+ ))}
+
+ )}
+
+ {/* Edit Popup */}
+ {isEditing && (
+
+ )}
+
+ );
+};
+
+export default PathNode;
diff --git a/src/components/Navigations/SideNavigation.css b/src/components/Navigations/SideNavigation.css
index d1db9e8..39ddcb1 100644
--- a/src/components/Navigations/SideNavigation.css
+++ b/src/components/Navigations/SideNavigation.css
@@ -1,60 +1,47 @@
/* src/components/SideNavigation.css */
-.side-navigation {
- width: 250px;
- background-color: #ffffff;
+.menu {
+ border: 1px solid #ddd;
+ border-radius: 8px;
padding: 1rem;
- border-right: 1px solid #dbdbdb;
- height: 100%;
- overflow-y: auto;
- box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
+ background-color: #f9f9f9;
}
-.side-navigation h3 {
- font-size: 1.2rem;
- font-weight: 600;
- color: #4a4a4a;
- margin-bottom: 0.75rem;
- border-bottom: 1px solid #dbdbdb;
- padding-bottom: 0.5rem;
-}
-
-.side-navigation ul {
- list-style: none;
- padding: 0;
- margin: 0;
-}
-
-.side-navigation ul li {
- margin-bottom: 0.5rem;
- transition: background-color 0.3s ease, padding-left 0.3s ease;
-}
-
-.side-navigation ul li:hover {
- background-color: #f5f5f5;
- padding-left: 10px;
-}
-
-.side-navigation ul li a {
- text-decoration: none;
- color: #3273dc;
- font-weight: 500;
- display: inline-block;
- width: 100%;
- padding: 0.5rem 0.75rem;
- border-radius: 4px;
- transition: color 0.3s ease, background-color 0.3s ease;
-}
-
-.side-navigation ul li a:hover {
- text-decoration: none;
- color: #ffffff;
- background-color: #3273dc;
-}
-
-.side-navigation ul li span {
- font-weight: 700;
+.menu-label {
+ font-size: 1.25rem;
color: #363636;
- margin-bottom: 0.5rem;
- display: inline-block;
- padding: 0.5rem 0.75rem;
-}
\ No newline at end of file
+ margin-bottom: 1rem;
+}
+
+.menu-list {
+ margin-left: 0;
+}
+
+.menu-list-item {
+ padding: 0.5rem;
+ font-size: 1rem;
+ color: #4a4a4a;
+ border-bottom: 1px solid #ddd;
+}
+
+.menu-list-item:last-child {
+ border-bottom: none;
+}
+
+.menu-list-item:hover {
+ background-color: #f5f5f5;
+ color: #00d1b2;
+}
+
+.has-text-weight-bold {
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.is-clickable {
+ color: #3273dc;
+ text-decoration: underline;
+}
+
+.is-clickable:hover {
+ color: #2759a7;
+}
diff --git a/src/components/Navigations/SideNavigation.js b/src/components/Navigations/SideNavigation.js
index 67338b0..5fac53d 100644
--- a/src/components/Navigations/SideNavigation.js
+++ b/src/components/Navigations/SideNavigation.js
@@ -1,86 +1,46 @@
import React, { useEffect, useState } from "react";
-import { Link } from "react-router-dom";
-import { fetchWithCache } from "../../utils/fetchWithCache";
import PermissionGuard from "../PermissionGuard";
+import PathNode from "./PathNode";
import config from "../../config";
-
+import "./SideNavigation.css";
+import {fetch_} from "../../utils/requestUtils";
const SideNavigation = () => {
- const [markdowns, setMarkdowns] = useState([]);
- const [tree, setTree] = useState(null);
+ const [paths, setPaths] = useState([]);
+ const [loading, setLoading] = useState(false);
useEffect(() => {
- fetchWithCache(`${config.BACKEND_HOST}/api/markdown/`)
+ setLoading(true);
+ fetch_(`${config.BACKEND_HOST}/api/path/`, {},{ use_cache: true, use_token:false })
.then((data) => {
- setMarkdowns(data);
- setTree(buildTree(data));
+ setPaths(data);
})
- .catch((error) => console.log(error));
+ .catch((error) => console.log(error))
+ .finally(() => setLoading(false));
}, []);
- function buildTree(markdowns) {
- const root = {};
-
- markdowns.forEach((markdown) => {
- const segments = markdown.path.split("/").filter(Boolean);
- let current = root;
-
- segments.forEach((segment, index) => {
- if (!current[segment]) {
- current[segment] =
- index === segments.length - 1 ? { markdown } : {};
- }
- current = current[segment];
- });
- });
-
- return root;
- }
-
- function renderTree(node, basePath = "") {
- return (
-
-
- -
-
- Create New Markdown
-
-
-
- {Object.entries(node).map(([key, value]) => {
- if (value.markdown) {
- return (
- -
-
- {value.markdown.title}
-
-
- );
- }
- return (
- -
- {key}
- {renderTree(value, `${basePath}/${key}`)}
-
- );
- })}
-
- );
- }
-
return (
);
};
-export default SideNavigation;
\ No newline at end of file
+export default SideNavigation;
diff --git a/src/index.js b/src/index.js
index 4bf5994..d204881 100644
--- a/src/index.js
+++ b/src/index.js
@@ -5,10 +5,6 @@ import AuthProvider from "./AuthProvider";
import "bulma/css/bulma.min.css";
-
-//ReactDOM.render(, document.getElementById("root"));
-
-
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
diff --git a/src/utils/fetchWithCache.js b/src/utils/fetchWithCache.js
deleted file mode 100644
index ca1bb0d..0000000
--- a/src/utils/fetchWithCache.js
+++ /dev/null
@@ -1,44 +0,0 @@
-const ongoingRequests = new Map();
-
-export async function fetchWithCache(url, cacheKey = url, cacheExpiry = 60) {
- if (ongoingRequests.has(url)) {
- return ongoingRequests.get(url);
- }
-
- const cachedData = localStorage.getItem(cacheKey);
- const now = Date.now();
-
- if (cachedData) {
- const { data, timestamp } = JSON.parse(cachedData);
- if (now - timestamp < cacheExpiry * 1000) {
- console.log("Cache hit for:", url);
- return data;
- } else {
- console.log("Cache expired for:", url);
- }
- }
-
- try {
- const token = localStorage.getItem("accessToken");
- const headers = {
- ...(token? {Authorization: `Bearer ${token}`} : {}),
- }
- const fetchPromise = fetch(url, {headers})
- .then((response) => {
- if (!response.ok) {
- throw new Error(`HTTP error! Status: ${response.status}`);
- }
- return response.json();
- })
- .then((data) => {
- localStorage.setItem(cacheKey, JSON.stringify({ data, timestamp: now }));
- ongoingRequests.delete(url);
- return data;
- });
- ongoingRequests.set(url, fetchPromise);
- return await fetchPromise;
- } catch (error) {
- ongoingRequests.delete(url);
- throw error;
- }
-}
\ No newline at end of file
diff --git a/src/utils/requestUtils.js b/src/utils/requestUtils.js
new file mode 100644
index 0000000..fbf055f
--- /dev/null
+++ b/src/utils/requestUtils.js
@@ -0,0 +1,88 @@
+const ongoingRequests = new Map();
+
+
+function _default_hash_function(url, method, body){
+ const url_obj = new URL(url, window.location.origin);
+
+ const query_params = [...url_obj.searchParams.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([key, value]) => `${key}=${value}`)
+ .join("&");
+ const normalized_url = `${url_obj.origin}${url_obj.pathname}${query_params ? "?" + query_params : ""}`;
+ const normalized_body = body
+ ? JSON.stringify(
+ Object.keys(body)
+ .sort()
+ .reduce((acc, key) => {
+ acc[key] = body[key];
+ return acc;
+ }, {})
+ )
+ : "";
+ return `${method.toUpperCase()}:${normalized_url}:${normalized_body}`;
+}
+
+
+export async function fetch_(url, init = {}, init_options = {}){
+ const default_options = {
+ use_cache: true,
+ cache_key: _default_hash_function(url, init.method || "GET", init.body || null),
+ cache_expires: 60,
+ use_token: true,
+ };
+ const options = { ...default_options, ...init_options };
+
+
+ const token = options.use_token ? localStorage.getItem("accessToken") : null;
+
+ const request_options = {
+ ...init,
+ headers: {
+ ...(init.headers || {}),
+ ...(token ? {Authorization: `Bearer ${token}`} : {}),
+ }
+ };
+
+ const now = Date.now();
+ const cached_data = localStorage.getItem(options.cache_key);
+ if(options.use_cache && cached_data){
+ const {data, timestamp} = JSON.parse(cached_data);
+ if(now - timestamp < options.cache_expires * 1000)
+ return data;
+ }
+
+ try {
+ const fetchPromise = fetch(url, request_options)
+ .then((response) => {
+ if(!response.ok)
+ throw new Error(`RESPONSE_ERROR: ${response.status}`);
+ return response.json();
+ })
+ .then((data) => {
+ if (options.use_cache)
+ {
+ localStorage.setItem(
+ options.cache_key,
+ JSON.stringify({ data, timestamp: now })
+ );
+ ongoingRequests.delete(options.cache_key);
+ }
+ return data;
+ });
+ if(options.use_cache){
+ ongoingRequests.set(options.cache_key, fetchPromise);
+ }
+ return await fetchPromise;
+ }catch(error){
+ if(options.use_cache){
+ ongoingRequests.delete(options.cache_key);
+ }
+ throw error;
+ }
+
+
+
+}
+
+
+
diff --git a/webpack.config.js b/webpack.config.js
index 14c56a7..a24f913 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,13 +1,13 @@
//webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
-const webpack = require('webpack')
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'bundle.js',
+ publicPath: '/',
clean: true,
},
module: {
@@ -29,10 +29,6 @@ module.exports = {
new HtmlWebpackPlugin({
template: "./public/index.html",
}),
- new webpack.DefinePlugin({
- FRONT_END_CLIENT_ID: process.env.FRONT_END_CLIENT_ID,
- SERVER_HOST: process.env.SERVER_HOST,
- }),
],
devServer: {
static: path.join(__dirname, 'public'),