From da1860a269604340c900517768c2bfd2b1d92712 Mon Sep 17 00:00:00 2001 From: hzhang Date: Thu, 5 Dec 2024 18:28:15 +0000 Subject: [PATCH] manage markdowns by path --- BuildConfig.sh | 2 +- Dockerfile | 1 - src/components/MarkdownContent.js | 7 +- src/components/Markdowns/MarkdownEditor.js | 38 +-- src/components/Navigations/PathNode.css | 37 +++ src/components/Navigations/PathNode.js | 221 ++++++++++++++++++ src/components/Navigations/SideNavigation.css | 97 ++++---- src/components/Navigations/SideNavigation.js | 94 +++----- src/index.js | 4 - src/utils/fetchWithCache.js | 44 ---- src/utils/requestUtils.js | 88 +++++++ webpack.config.js | 6 +- 12 files changed, 441 insertions(+), 198 deletions(-) create mode 100644 src/components/Navigations/PathNode.css create mode 100644 src/components/Navigations/PathNode.js delete mode 100644 src/utils/fetchWithCache.js create mode 100644 src/utils/requestUtils.js 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 && ( + + )} + + {/* Edit Popup */} + {isEditing && ( +
    +
    +
    +

    Edit Path

    +
    + +
    + + setEditData({ + ...editData, + name: e.target.value, + }) + } + required + /> +
    +
    +
    + +
    + + setEditData({ + ...editData, + parent_id: Number( + e.target.value + ), + }) + } + required + /> +
    +
    +
    + + +
    +
    +
    +
    + )} +
  • + ); +}; + +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 ( - - ); - } - 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'),