manage markdowns by path

This commit is contained in:
h z
2024-12-05 18:28:15 +00:00
parent 788fd2f37a
commit da1860a269
12 changed files with 441 additions and 198 deletions

View File

@@ -1,4 +1,4 @@
#/bin/bash
#!/bin/bash
rm -f /app/config.js;
if [ -z "$BACKEND_HOST" ]; then
BACKEND_HOST="http://localhost:5000"

View File

@@ -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

View File

@@ -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]);

View File

@@ -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 }),
})
.then((res) => {
if (res.ok) {
}, {
use_cache: false,
use_token: true,
}).then((res) => {
if(res.ok)
navigate("/");
} else {
else
return res.json().then((data) => {
throw new Error(data.error || "Failed to save markdown");
throw new Error(data.error || "Failed to load markdown");
});
}
})
.catch((err) => {
}).catch((err) => {
console.error("failed to load markdown", err);
});
})
};
const hasPermission = roles.includes("admin") || roles.includes("creator");

View File

@@ -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;
}

View File

@@ -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 (
<li>
<div className="path-node-header">
<span
className={`has-text-weight-bold ${
isRoot ? "" : "is-clickable"
}`}
onClick={isRoot ? undefined : toggleExpand}
style={{ cursor: isRoot ? "default" : "pointer" }}
>
{isExpanded && !isRoot ? "▼ " : isRoot ? "" : "▶ "}
{path.name}
</span>
{/* Admin controls */}
<PermissionGuard rolesRequired={["admin"]}>
<span className="path-node-actions">
<button
onClick={handleEdit}
className="button is-small is-info is-light"
>
Edit
</button>
<button
onClick={handleDelete}
className="button is-small is-danger is-light"
>
Delete
</button>
</span>
</PermissionGuard>
</div>
{isExpanded && (
<ul>
{/* Render child paths */}
{loading && <p>Loading...</p>}
{children.map((child) => (
<PathNode key={child.id} path={child} />
))}
{/* Render markdowns */}
{markdowns.map((markdown) => (
<li key={markdown.id} className="menu-list-item">
<Link
to={`/markdown/${markdown.id}`}
className="is-link"
>
{markdown.title}
</Link>
</li>
))}
</ul>
)}
{/* Edit Popup */}
{isEditing && (
<div className="popup">
<div className="popup-content">
<form onSubmit={handleEditSubmit}>
<h3>Edit Path</h3>
<div className="field">
<label className="label">Name</label>
<div className="control">
<input
className="input"
type="text"
value={editData.name}
onChange={(e) =>
setEditData({
...editData,
name: e.target.value,
})
}
required
/>
</div>
</div>
<div className="field">
<label className="label">Parent ID</label>
<div className="control">
<input
className="input"
type="number"
value={editData.parent_id}
onChange={(e) =>
setEditData({
...editData,
parent_id: Number(
e.target.value
),
})
}
required
/>
</div>
</div>
<div className="buttons">
<button
type="submit"
className="button is-primary"
>
Save
</button>
<button
type="button"
className="button is-light"
onClick={handleEditCancel}
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</li>
);
};
export default PathNode;

View File

@@ -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;
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;
}

View File

@@ -1,83 +1,43 @@
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 (
<ul>
<PermissionGuard rolesRequired={["admin", "creator"]}>
<li>
<Link
to="/markdown/create"
className="button is-primary is-small"
>
Create New Markdown
</Link>
</li>
</PermissionGuard>
{Object.entries(node).map(([key, value]) => {
if (value.markdown) {
return (
<li key={value.markdown.id} className="menu-list-item">
<Link
to={`${config.BACKEND_HOST}/markdown/${value.markdown.id}`}
className="is-link"
>
{value.markdown.title}
</Link>
</li>
);
}
return (
<li key={key}>
<span className="has-text-weight-bold">{key}</span>
{renderTree(value, `${basePath}/${key}`)}
</li>
);
})}
</ul>
);
}
return (
<aside className="menu">
<p className="menu-label">Markdown Directory</p>
<PermissionGuard rolesRequired={["admin", "creator"]}>
<a
href="/markdown/create"
className="button is-primary is-small"
>
Create New Markdown
</a>
</PermissionGuard>
<ul className="menu-list">
{tree ? renderTree(tree) : <p>Loading...</p>}
{loading && <p>Loading...</p>}
{paths.map((path) => (
<PathNode
key={path.id}
path={path}
isRoot={true}
/>
))}
</ul>
</aside>
);

View File

@@ -5,10 +5,6 @@ import AuthProvider from "./AuthProvider";
import "bulma/css/bulma.min.css";
//ReactDOM.render(<App />, document.getElementById("root"));
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<AuthProvider>

View File

@@ -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;
}
}

88
src/utils/requestUtils.js Normal file
View File

@@ -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;
}
}

View File

@@ -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'),