manage markdowns by path
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
rm -f /app/config.js;
|
rm -f /app/config.js;
|
||||||
if [ -z "$BACKEND_HOST" ]; then
|
if [ -z "$BACKEND_HOST" ]; then
|
||||||
BACKEND_HOST="http://localhost:5000"
|
BACKEND_HOST="http://localhost:5000"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ RUN npm install
|
|||||||
COPY . .
|
COPY . .
|
||||||
COPY BuildConfig.sh /app/BuildConfig.sh
|
COPY BuildConfig.sh /app/BuildConfig.sh
|
||||||
RUN chmod +x /app/BuildConfig.sh
|
RUN chmod +x /app/BuildConfig.sh
|
||||||
RUN /app/BuildConfig.sh >&1
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import {fetchWithCache} from "../utils/fetchWithCache";
|
import {fetch_} from "../utils/requestUtils";
|
||||||
|
|
||||||
const MarkdownContent = () => {
|
const MarkdownContent = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -11,7 +11,10 @@ const MarkdownContent = () => {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchWithCache(`/api/markdown/${id}`)
|
fetch_(`/api/markdown/${id}`, {}, {
|
||||||
|
use_cache: true,
|
||||||
|
use_token: false
|
||||||
|
})
|
||||||
.then((data) => setContent(data))
|
.then((data) => setContent(data))
|
||||||
.catch((error) => setError(error));
|
.catch((error) => setError(error));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useContext, useEffect, useState } from "react";
|
import React, { useContext, useEffect, useState } from "react";
|
||||||
import { AuthContext } from "../../AuthProvider";
|
import { AuthContext } from "../../AuthProvider";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { fetchWithCache } from "../../utils/fetchWithCache";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkMath from "remark-math";
|
import remarkMath from "remark-math";
|
||||||
import rehypeKatex from "rehype-katex";
|
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 "katex/dist/katex.min.css";
|
||||||
import "./MarkdownEditor.css";
|
import "./MarkdownEditor.css";
|
||||||
import config from "../../config";
|
import config from "../../config";
|
||||||
|
import {fetch_} from "../../utils/requestUtils";
|
||||||
|
|
||||||
const MarkdownEditor = () => {
|
const MarkdownEditor = () => {
|
||||||
const { roles } = useContext(AuthContext);
|
const { roles } = useContext(AuthContext);
|
||||||
@@ -22,7 +22,10 @@ const MarkdownEditor = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
fetchWithCache(`/api/markdown/${id}`)
|
fetch_(`/api/markdown/${id}`, {}, {
|
||||||
|
use_cache: true,
|
||||||
|
use_token: false
|
||||||
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setTitle(data.title);
|
setTitle(data.title);
|
||||||
setContent(data.content);
|
setContent(data.content);
|
||||||
@@ -37,26 +40,23 @@ const MarkdownEditor = () => {
|
|||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const url = id ? `${config.BACKEND_HOST}/api/markdown/${id}` : `${config.BACKEND_HOST}/api/markdown`;
|
const url = id ? `${config.BACKEND_HOST}/api/markdown/${id}` : `${config.BACKEND_HOST}/api/markdown`;
|
||||||
const method = id ? "PUT" : "POST";
|
const method = id ? "PUT" : "POST";
|
||||||
fetch(url, {
|
fetch_(url, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ title, content, path }),
|
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");
|
const hasPermission = roles.includes("admin") || roles.includes("creator");
|
||||||
|
|||||||
37
src/components/Navigations/PathNode.css
Normal file
37
src/components/Navigations/PathNode.css
Normal 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;
|
||||||
|
}
|
||||||
221
src/components/Navigations/PathNode.js
Normal file
221
src/components/Navigations/PathNode.js
Normal 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;
|
||||||
@@ -1,60 +1,47 @@
|
|||||||
/* src/components/SideNavigation.css */
|
/* src/components/SideNavigation.css */
|
||||||
.side-navigation {
|
.menu {
|
||||||
width: 250px;
|
border: 1px solid #ddd;
|
||||||
background-color: #ffffff;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-right: 1px solid #dbdbdb;
|
background-color: #f9f9f9;
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-navigation h3 {
|
.menu-label {
|
||||||
font-size: 1.2rem;
|
font-size: 1.25rem;
|
||||||
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;
|
|
||||||
color: #363636;
|
color: #363636;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 1rem;
|
||||||
display: inline-block;
|
}
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
}
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,86 +1,46 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { fetchWithCache } from "../../utils/fetchWithCache";
|
|
||||||
import PermissionGuard from "../PermissionGuard";
|
import PermissionGuard from "../PermissionGuard";
|
||||||
|
import PathNode from "./PathNode";
|
||||||
import config from "../../config";
|
import config from "../../config";
|
||||||
|
import "./SideNavigation.css";
|
||||||
|
import {fetch_} from "../../utils/requestUtils";
|
||||||
const SideNavigation = () => {
|
const SideNavigation = () => {
|
||||||
const [markdowns, setMarkdowns] = useState([]);
|
const [paths, setPaths] = useState([]);
|
||||||
const [tree, setTree] = useState(null);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchWithCache(`${config.BACKEND_HOST}/api/markdown/`)
|
setLoading(true);
|
||||||
|
fetch_(`${config.BACKEND_HOST}/api/path/`, {},{ use_cache: true, use_token:false })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setMarkdowns(data);
|
setPaths(data);
|
||||||
setTree(buildTree(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 (
|
return (
|
||||||
<aside className="menu">
|
<aside className="menu">
|
||||||
<p className="menu-label">Markdown Directory</p>
|
<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">
|
<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>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SideNavigation;
|
export default SideNavigation;
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import AuthProvider from "./AuthProvider";
|
|||||||
import "bulma/css/bulma.min.css";
|
import "bulma/css/bulma.min.css";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//ReactDOM.render(<App />, document.getElementById("root"));
|
|
||||||
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById("root"));
|
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||||||
root.render(
|
root.render(
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
|||||||
@@ -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
88
src/utils/requestUtils.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
//webpack.config.js
|
//webpack.config.js
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
const webpack = require('webpack')
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: './src/index.js',
|
entry: './src/index.js',
|
||||||
output: {
|
output: {
|
||||||
path: path.resolve(__dirname, './dist'),
|
path: path.resolve(__dirname, './dist'),
|
||||||
filename: 'bundle.js',
|
filename: 'bundle.js',
|
||||||
|
publicPath: '/',
|
||||||
clean: true,
|
clean: true,
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
@@ -29,10 +29,6 @@ module.exports = {
|
|||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
template: "./public/index.html",
|
template: "./public/index.html",
|
||||||
}),
|
}),
|
||||||
new webpack.DefinePlugin({
|
|
||||||
FRONT_END_CLIENT_ID: process.env.FRONT_END_CLIENT_ID,
|
|
||||||
SERVER_HOST: process.env.SERVER_HOST,
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
devServer: {
|
devServer: {
|
||||||
static: path.join(__dirname, 'public'),
|
static: path.join(__dirname, 'public'),
|
||||||
|
|||||||
Reference in New Issue
Block a user