improve: use react-query for caching

This commit is contained in:
h z
2024-12-08 17:11:14 +00:00
parent a31cec7ef0
commit 0e6fd8409a
15 changed files with 640 additions and 353 deletions

View File

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

220
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-query": "^3.39.3",
"react-router-dom": "^7.0.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
@@ -2824,12 +2825,25 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/batch": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz",
"integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==",
"dev": true
},
"node_modules/big-integer": {
"version": "1.6.52",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
"engines": {
"node": ">=0.6"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2882,6 +2896,15 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"dev": true
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -2894,6 +2917,21 @@
"node": ">=8"
}
},
"node_modules/broadcast-channel": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz",
"integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==",
"dependencies": {
"@babel/runtime": "^7.7.2",
"detect-node": "^2.1.0",
"js-sha3": "0.8.0",
"microseconds": "0.2.0",
"nano-time": "1.0.0",
"oblivious-set": "1.0.0",
"rimraf": "3.0.2",
"unload": "2.2.0"
}
},
"node_modules/browserslist": {
"version": "4.24.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz",
@@ -3171,6 +3209,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
},
"node_modules/connect-history-api-fallback": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz",
@@ -3468,8 +3511,7 @@
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"dev": true
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="
},
"node_modules/devlop": {
"version": "1.1.0",
@@ -3752,9 +3794,9 @@
}
},
"node_modules/express": {
"version": "4.21.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz",
"integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==",
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"dev": true,
"dependencies": {
"accepts": "~1.3.8",
@@ -3776,7 +3818,7 @@
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.10",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
@@ -3791,6 +3833,10 @@
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/cookie": {
@@ -4057,6 +4103,11 @@
"node": ">= 0.6"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -4108,6 +4159,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -4821,11 +4892,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/inline-style-parser": {
"version": "0.2.4",
@@ -5066,6 +5146,11 @@
"node": ">= 10.13.0"
}
},
"node_modules/js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5241,6 +5326,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/match-sorter": {
"version": "6.3.4",
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.4.tgz",
"integrity": "sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==",
"dependencies": {
"@babel/runtime": "^7.23.8",
"remove-accents": "0.5.0"
}
},
"node_modules/mdast-util-from-markdown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
@@ -5929,6 +6023,11 @@
"node": ">=8.6"
}
},
"node_modules/microseconds": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz",
"integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA=="
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
@@ -5968,6 +6067,17 @@
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"dev": true
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -5987,6 +6097,14 @@
"multicast-dns": "cli.js"
}
},
"node_modules/nano-time": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz",
"integrity": "sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA==",
"dependencies": {
"big-integer": "^1.6.16"
}
},
"node_modules/nanoid": {
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
@@ -6085,6 +6203,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oblivious-set": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz",
"integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw=="
},
"node_modules/obuf": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
@@ -6123,6 +6246,14 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/open": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz",
@@ -6278,6 +6409,14 @@
"node": ">=8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -6294,9 +6433,9 @@
"dev": true
},
"node_modules/path-to-regexp": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==",
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"dev": true
},
"node_modules/picocolors": {
@@ -6595,6 +6734,31 @@
"react": ">=18"
}
},
"node_modules/react-query": {
"version": "3.39.3",
"resolved": "https://registry.npmjs.org/react-query/-/react-query-3.39.3.tgz",
"integrity": "sha512-nLfLz7GiohKTJDuT4us4X3h/8unOh+00MLb2yJoGTPjxKs2bc1iDhkNx2bd5MKklXnOD3NrVZ+J2UXujA5In4g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"broadcast-channel": "^3.4.1",
"match-sorter": "^6.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.0.1.tgz",
@@ -6947,6 +7111,11 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remove-accents": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="
},
"node_modules/renderkid": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz",
@@ -7022,6 +7191,21 @@
"node": ">= 4"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/run-applescript": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz",
@@ -7959,6 +8143,15 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/unload": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz",
"integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==",
"dependencies": {
"@babel/runtime": "^7.6.2",
"detect-node": "^2.0.4"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -8475,6 +8668,11 @@
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
"dev": true
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",

View File

@@ -18,6 +18,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-query": "^3.39.3",
"react-router-dom": "^7.0.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",

View File

@@ -1,8 +1,7 @@
import React, {createContext, useEffect, useMemo, useState} from "react";
import React, { createContext, useEffect, useMemo, useState } from "react";
import { UserManager } from "oidc-client-ts";
import config from "./config";
export const AuthContext = createContext({
user: null,
login: () => {},
@@ -13,8 +12,7 @@ export const AuthContext = createContext({
const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [roles, setRoles] = useState([]);
const userManager =
useMemo(() => new UserManager(config.OIDC_CONFIG), []);
const userManager = useMemo(() => new UserManager(config.OIDC_CONFIG), []);
useEffect(() => {
userManager.getUser()
@@ -30,27 +28,54 @@ const AuthProvider = ({ children }) => {
.then((newUser) => {
setUser(newUser);
localStorage.setItem("accessToken", newUser.access_token);
const clientRoles =
newUser?.profile?.resource_access?.[config.KC_CLIENT_ID]?.roles || [];
const clientRoles = newUser?.profile?.resource_access?.[config.KC_CLIENT_ID]?.roles || [];
setRoles(clientRoles);
})
.catch((err) => {
console.error(err);
})
logout();
});
}
})
.catch((err) => {
console.error(err);
logout();
});
}, [userManager]);
const onUserLoaded = (loadedUser) => {
setUser(loadedUser);
localStorage.setItem("accessToken", loadedUser.access_token);
const clientRoles = loadedUser?.profile?.resource_access?.[config.KC_CLIENT_ID]?.roles || [];
setRoles(clientRoles);
};
const onUserUnloaded = () => {
setUser(null);
setRoles([]);
localStorage.removeItem("accessToken");
};
userManager.events.addUserLoaded(onUserLoaded);
userManager.events.addUserUnloaded(onUserUnloaded);
return () => {
userManager.events.removeUserLoaded(onUserLoaded);
userManager.events.removeUserUnloaded(onUserUnloaded);
};
}, [userManager]);
const login = () => {
userManager
.signinRedirect()
.catch(
(err) => {
console.log(config);
console.log(err);
});
}
const logout = () => userManager.signoutRedirect();
.catch((err) => {
console.log(config);
console.log(err);
});
};
const logout = () => {
userManager.signoutRedirect();
};
return (
<AuthContext.Provider value={{ user, roles, login, logout }}>

View File

@@ -2,50 +2,37 @@ import React, { useEffect, useState } from "react";
import {Link, useParams} from "react-router-dom";
import "katex/dist/katex.min.css";
import "./MarkdownContent.css";
import { fetch_ } from "../../utils/requestUtils";
import config from "../../config";
import MarkdownView from "./MarkdownView";
import PermissionGuard from "../PermissionGuard";
import {useMarkdown} from "../../utils/markdown-queries";
import {usePath} from "../../utils/path-queries";
const MarkdownContent = () => {
const { id } = useParams();
const [content, setContent] = useState(null);
const [title, setTitle] = useState(null);
const [error, setError] = useState(null);
const [indexTitle, setIndexTitle] = useState(null);
const {data: markdown, isLoading, error} = useMarkdown(id);
const {data: path, isFetching: isPathFetching} = usePath(markdown?.path_id);
useEffect(() => {
fetch_(`${config.BACKEND_HOST}/api/markdown/${id}`, {}, {
use_cache: true,
use_token: false
})
.then((data) => {
setTitle(data.title);
setContent(data.content);
if(data.title === "index"){
fetch_(`${config.BACKEND_HOST}/api/path/${data.path_id}`, {}, {
use_cache: true,
use_token: false
}).then((path_data) => {
setIndexTitle(path_data.id === 1 ? "Home" : path_data.name);
}).catch((err) => setError(error));
}
})
.catch((error) => setError(error));
}, [id]);
if(markdown && markdown.title === "index" && path){
setIndexTitle(path.id === 1 ? "Home" : path.name);
}
}, [markdown, path]);
if (isLoading || isPathFetching) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error.message || "Failed to load content"}</div>;
}
if (!content) {
return <div>Loading...</div>;
}
return (
<div className="markdown-content-container">
<div className="field has-addons markdown-content-container-header">
<h1 className="title control">{title === "index" ? indexTitle : title}</h1>
<h1 className="title control">{markdown.title === "index" ? indexTitle : markdown.title}</h1>
<PermissionGuard rolesRequired={['admin']}>
<Link to={`/markdown/edit/${id}`} className="control button is-primary is-light">
Edit
@@ -53,7 +40,7 @@ const MarkdownContent = () => {
</PermissionGuard>
</div>
<MarkdownView content={content}/>
<MarkdownView content={markdown.content}/>
</div>
);
};

View File

@@ -3,10 +3,9 @@ import { AuthContext } from "../../AuthProvider";
import { useNavigate, useParams } from "react-router-dom";
import "katex/dist/katex.min.css";
import "./MarkdownEditor.css";
import config from "../../config";
import { fetch_ } from "../../utils/requestUtils";
import PathManager from "../PathManager";
import MarkdownView from "./MarkdownView";
import { useMarkdown, useSaveMarkdown } from "../../utils/markdown-queries";
const MarkdownEditor = () => {
const { roles } = useContext(AuthContext);
@@ -15,55 +14,45 @@ const MarkdownEditor = () => {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [pathId, setPathId] = useState(1);
const [loading, setLoading] = useState(false);
const {data: markdown, isLoading, error} = useMarkdown(id);
const saveMarkdown = useSaveMarkdown();
useEffect(() => {
if (id) {
setLoading(true);
fetch_(`${config.BACKEND_HOST}/api/markdown/${id}`, {}, {
use_cache: true,
use_token: false
})
.then((data) => {
setTitle(data.title);
setContent(data.content);
setPathId(data.path_id);
})
.catch((err) => {
console.error("Failed to load markdown", err);
})
.finally(() => {
setLoading(false);
});
if(markdown){
setTitle(markdown.title);
setContent(markdown.content);
setPathId(markdown.path_id);
}
}, [id]);
}, [markdown]);
const handleSave = () => {
const url = id ? `${config.BACKEND_HOST}/api/markdown/${id}` : `${config.BACKEND_HOST}/api/markdown/`;
const method = id ? "PUT" : "POST";
fetch_(url, {
method,
body: JSON.stringify({ title, content, path_id: pathId }),
}, {
use_cache: false,
use_token: true,
}).then((data) => {
if(data.error)
throw new Error(data.error.message);
navigate("/");
}).catch((err) => {
console.error("Failed to load markdown", err);
});
saveMarkdown.mutate(
{id, data: {title, content, path_id: pathId}},
{
onSuccess: () => {
navigate("/");
},
onError: () => {
alert("Error saving markdown file");
}
});
};
const hasPermission = roles.includes("admin") || roles.includes("creator");
if (!hasPermission) {
return <div className="notification is-danger">Permission Denied</div>;
}
return loading ? (
<p>loading</p>
) : (
if(isLoading)
return <p>Loading...</p>;
if(error)
return <p>{error.message || "Failed to load markdown"}</p>;
return (
<div className="container mt-5 markdown-editor-container">
<h2 className="title is-4">{id ? "Edit Markdown" : "Create Markdown"}</h2>
<div className="columns">
@@ -114,8 +103,9 @@ const MarkdownEditor = () => {
className="button is-primary"
type="button"
onClick={handleSave}
disabled={saveMarkdown.isLoading}
>
Save
{saveMarkdown.isLoading ? "Saving..." : "Save"}
</button>
</div>
</div>

View File

@@ -1,71 +1,51 @@
import React, {useEffect, useState} from "react";
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";
import {useDeletePath, usePaths, useUpdatePath} from "../../utils/path-queries";
import {useIndexMarkdown, useMarkdownsByPath} from "../../utils/markdown-queries";
const PathNode = ({ path, isRoot = false, onDelete, onSave }) => {
const [children, setChildren] = useState([]);
const [markdowns, setMarkdowns] = useState([]);
const PathNode = ({ path, isRoot = false }) => {
const [isExpanded, setIsExpanded] = useState(isRoot);
const [loading, setLoading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(path.name);
const [indexMarkdownId, setIndexMarkdownId] = useState(null);
useEffect(() => {
fetch_(`${config.BACKEND_HOST}/api/markdown/get_index/${path.id}`, {}, {
use_cache: true,
use_token: false
})
.then((data) => setIndexMarkdownId(data.id))
.catch((error) => setIndexMarkdownId(null) );
}, [path]);
const { data: childPaths, isLoading: isChildLoading, error: childError } = usePaths(path.id);
const { data: markdowns, isLoading: isMarkdownLoading, error: markdownError } = useMarkdownsByPath(path.id);
const deletePath = useDeletePath();
const updatePath = useUpdatePath();
const handleSave = () => {
if (onSave) {
onSave(path.id, newName);
setIsEditing(false);
} else {
console.error("onSave is not defined");
}
const {data: indexMarkdown, isLoading: isIndexLoading, error: indexMarkdownError} = useIndexMarkdown(path.id);
const toggleExpand = () => {
setIsExpanded(!isExpanded);
};
const handleSave = () => {
console.log(`handleSave ${path.id}`);
updatePath.mutate({id: path.id, data: {name: newName}}, {
onsuccess: () => setIsEditing(false),
onError: err => alert("failed to update this path"),
})
};
const handleDelete = () => {
if(window.confirm("Are you sure?")) {
deletePath.mutate(path.id, {
onError: err => alert("failed to delete this path"),
})
}
};
const handleEdit = () => {
setIsEditing(true);
};
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) => {
const filteredMarkdowns = markdownData
.filter(markdown => markdown.title !== "index");
setMarkdowns(filteredMarkdowns);
})
.catch((error) => console.error(error))
.finally(() => setLoading(false));
}
if(childError || markdownError){
return <li>Error...</li>;
}
};
return (
<li>
@@ -83,8 +63,8 @@ const PathNode = ({ path, isRoot = false, onDelete, onSave }) => {
) : (
<span className="path-name has-text-weight-bold control">
{
indexMarkdownId ? (
<Link to={`/markdown/${indexMarkdownId}`} className="is-link">
indexMarkdown ? (
<Link to={`/markdown/${indexMarkdown.id}`} className="is-link">
{path.name}
</Link>
) : (
@@ -125,7 +105,7 @@ const PathNode = ({ path, isRoot = false, onDelete, onSave }) => {
<p className="control">
<button
className="button is-danger is-small"
onClick={() => onDelete(path.id)}
onClick={handleDelete}
type="button">
<span className="icon">
<i className="fas fa-trash"></i>
@@ -138,17 +118,15 @@ const PathNode = ({ path, isRoot = false, onDelete, onSave }) => {
{isExpanded && (
<ul>
{loading && <p>Loading...</p>}
{children.map((child) => (
{ isChildLoading && <p>Loading...</p>}
{ childPaths.map((child) => (
<PathNode
key={child.id}
path={child}
onSave={onSave}
onDelete={onDelete}
/>
))}
{markdowns.map((markdown) => (
{ markdowns.filter(md => md.title !== "index").map((markdown) => (
<li key={markdown.id}>
<Link to={`/markdown/${markdown.id}`} className="is-link">
{markdown.title}

View File

@@ -1,55 +1,38 @@
import React, { useEffect, useState } from "react";
import PermissionGuard from "../PermissionGuard";
import PathNode from "./PathNode";
import config from "../../config";
import "./SideNavigation.css";
import { fetch_ } from "../../utils/requestUtils";
import {useDeletePath, usePaths, useUpdatePath} from "../../utils/path-queries";
const SideNavigation = () => {
const [paths, setPaths] = useState([]);
const [loading, setLoading] = useState(false);
const {data: paths, isLoading, error} = usePaths(1);
const deletePath = useDeletePath();
const updatePath = useUpdatePath();
const handleDelete = (id) => {
fetch_(`${config.BACKEND_HOST}/api/path/${id}`, {
method: "DELETE",
},{
use_cache: false,
use_token: true,
}).then(() => {
setPaths((prevPaths) => prevPaths.filter((path) => path.id !== id));
})
.catch((error) => console.error(error));
if (window.confirm("Are you sure you want to delete this path?")){
deletePath.mutate(id, {
onError: (err) => {
alert("Failed to delete path");
},
});
}
};
const handleSave = (id, newName) => {
fetch_(`${config.BACKEND_HOST}/api/path/${id}`, {
method: "PUT",
body: JSON.stringify({ name: newName, parent_id: 1 }), // Update with actual parent_id
}, {
use_cache: false,
use_token: true,
}).then(() => {
setPaths((prevPaths) =>
prevPaths.map((path) =>
path.id === id ? { ...path, name: newName } : path
)
);
})
.catch((error) => console.error("Failed to update path", error));
updatePath.mutate({ id, data: {name: newName}} , {
onError: (err) => {
alert("Failed to update path");
}
});
};
if(isLoading){
return <aside className="menu"><p>Loading...</p></aside>;
}
useEffect(() => {
setLoading(true);
fetch_(`${config.BACKEND_HOST}/api/path/`, {},{
use_cache: true,
use_token: false,
})
.then((data) => {
setPaths(data);
})
.catch((error) => console.log(error))
.finally(() => setLoading(false));
}, []);
if(error){
return <aside className="menu"><p>Error...</p></aside>;
}
return (
<aside className="menu">
@@ -63,7 +46,7 @@ const SideNavigation = () => {
</a>
</PermissionGuard>
<ul className="menu-list">
{loading && <p>Loading...</p>}
{isLoading && <p>Loading...</p>}
{paths.map((path) => (
<PathNode
key={path.id}

View File

@@ -1,70 +1,60 @@
// src/components/PathManager.js
import React, { useEffect, useState, useRef } from "react";
import { fetch_ } from "../utils/requestUtils";
import config from "../config";
import { useCreatePath, usePaths } from "../utils/path-queries";
import { useQueryClient } from "react-query";
import "./PathManager.css";
import {fetch_} from "../utils/request-utils";
import config from "../config";
const PathManager = ({ currentPathId = 1, onPathChange }) => {
const [currentPath, setCurrentPath] = useState([{ name: "Root", id: 1 }]);
//const [currentPath, setCurrentPath] = useState(buildPath(currentPathId));
const [currentId, setCurrentId] = useState(currentPathId);
const [subPaths, setSubPaths] = useState([]);
const [searchTerm, setSearchTerm] = useState("");
const [loading, setLoading] = useState(false);
const [dropdownActive, setDropdownActive] = useState(false);
const inputRef = useRef();
const buildPath = async (path_id) => {
const path = [];
let current_id = path_id;
while (current_id) {
const pathData = await fetch_(`${config.BACKEND_HOST}/api/path/${current_id}`, {},{
use_cache: true,
use_token: false,
});
current_id = pathData.parent_id;
path.unshift(pathData);
const queryClient = useQueryClient();
const { data: subPaths, isLoading: isSubPathsLoading, error: subPathsError } = usePaths(currentPathId);
const createPath = useCreatePath();
const buildPath = async (pathId) => {
const path = [];
let current_id = pathId;
while (current_id) {
try {
const pathData = await queryClient.fetchQuery(
["path", current_id],
() => fetch_(`${config.BACKEND_HOST}/api/path/${current_id}`)
);
if (!pathData) break;
path.unshift({ name: pathData.name, id: pathData.id });
current_id = pathData.parent_id;
} catch (error) {
console.error(`Failed to fetch path with id ${current_id}:`, error);
break;
}
}
return path;
}
useEffect(() => {
fetchSubPaths(currentId);
}, [currentId]);
};
useEffect(() => {
const init = async () => {
const pth = await buildPath(currentPathId);
setCurrentPath(pth);
const path = await buildPath(currentPathId);
setCurrentPath(path);
};
init();
}, [currentPathId]);
const fetchSubPaths = (pathId) => {
setLoading(true);
fetch_(`${config.BACKEND_HOST}/api/path/parent/${pathId}`, {}, {
use_cache: false,
use_token: true
})
.then((data) => setSubPaths(data))
.catch((error) => console.error("Failed to fetch subdirectories:", error))
.finally(() => setLoading(false));
};
}, [currentPathId, queryClient]);
const handlePathClick = (pathId, pathIndex) => {
const newPath = currentPath.slice(0, pathIndex + 1);
setCurrentPath(newPath);
setCurrentId(pathId);
onPathChange(pathId);
};
const handleSubPathSelect = (subPath) => {
const updatedPath = [...currentPath, { name: subPath.name, id: subPath.id }];
setCurrentPath(updatedPath);
setCurrentId(subPath.id);
onPathChange(subPath.id);
setSearchTerm("");
setDropdownActive(false);
@@ -75,32 +65,44 @@ const PathManager = ({ currentPathId = 1, onPathChange }) => {
alert("Directory name cannot be empty.");
return;
}
fetch_(`${config.BACKEND_HOST}/api/path/`, {
method: "POST",
body: JSON.stringify({ name: searchTerm.trim(), parent_id: currentId }),
}, { use_cache: false, use_token: true })
.then((newDir) => {
setSubPaths([...subPaths, newDir]);
setSearchTerm("");
alert("Directory created successfully!");
})
.catch((error) => {
console.error("Failed to create directory:", error);
alert("Failed to create directory.");
});
createPath.mutate(
{ name: searchTerm.trim(), parent_id: currentPathId },
{
onSuccess: (newDir) => {
queryClient.setQueryData(["path", newDir.id], newDir);
queryClient.invalidateQueries(["paths", currentPathId]);
setSearchTerm("");
alert("Directory created successfully.");
},
onError: (error) => {
console.error("Failed to create directory:", error);
alert("Failed to create directory.");
},
}
);
};
const handleInputFocus = () => setDropdownActive(true);
const handleInputBlur = () => {
setTimeout(() => setDropdownActive(false), 150);
};
const filteredSubPaths = subPaths.filter((path) =>
path.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const filteredSubPaths = subPaths
? subPaths.filter((path) =>
path.name.toLowerCase().includes(searchTerm.toLowerCase())
)
: [];
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddDirectory();
}
};
return (
<div className="path-manager">
<div className="path-manager-header">
<div className="current-path">
@@ -129,13 +131,14 @@ const PathManager = ({ currentPathId = 1, onPathChange }) => {
onChange={(e) => setSearchTerm(e.target.value)}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
/>
</div>
<div className="control">
<button
className="button is-small is-primary"
onClick={handleAddDirectory}
disabled={loading || !searchTerm.trim()}
disabled={isSubPathsLoading || !searchTerm.trim()}
type="button"
>
Create "{searchTerm}"
@@ -163,6 +166,8 @@ const PathManager = ({ currentPathId = 1, onPathChange }) => {
</div>
</div>
)}
{isSubPathsLoading && <p>Loading...</p>}
{subPathsError && <p>Error loading subdirectories.</p>}
</div>
</div>
);

View File

@@ -3,8 +3,6 @@ const config = {
FRONTEND_HOST: null,
KC_CLIENT_ID: null,
OIDC_CONFIG: {},
TEST_PASS1: null,
TEST_PASS2: null
}
export default config;

View File

@@ -1,13 +1,56 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import AuthProvider from "./AuthProvider";
import AuthProvider, {AuthContext} from "./AuthProvider";
import "bulma/css/bulma.min.css";
import {QueryClient, QueryClientProvider} from "react-query"
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
refetchOnWindowFocus: false,
staleTimeout: 5 * 60 * 1000,
onError: (error) => {
if (error.message === "Unauthorized"){
const {logout} = queryClient
.getQueryCache()
.getAll()
.find(query => query.queryKey.includes("auth"))?.state?.context || {};
if (logout) {
logout();
}
}
}
},
mutations: {
retry: 1,
}
}
});
const EnhancedAuthProvider = ({children}) => {
const auth = React.useContext(AuthContext);
const logout = () => {
auth.logout();
};
React.useEffect(() => {
queryClient.setQueryDefaults("auths", {
context: {logout}
});
}, [logout]);
return children;
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<AuthProvider>
<App />
</AuthProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<EnhancedAuthProvider>
<App />
</EnhancedAuthProvider>
</AuthProvider>
</QueryClientProvider>
);

View File

@@ -0,0 +1,56 @@
import {useQuery, useMutation, useQueryClient} from 'react-query';
import {fetch_} from "./request-utils";
import config from "../config";
export const useMarkdown = (id) => {
return useQuery(
["markdown", id],
() => fetch_(`${config.BACKEND_HOST}/api/markdown/${id}`),
{
enabled: !!id,
});
};
export const useIndexMarkdown = (path_id) => {
const queryClient = useQueryClient();
return useQuery(
["index_markdown", path_id],
() => fetch_(`${config.BACKEND_HOST}/api/markdown/get_index/${path_id}`),{
enabled: !!path_id,
onSuccess: (data) => {
if(data && data.id){
queryClient.setQueryData(["markdown", data.id], data);
}
}
});
};
export const useMarkdownsByPath = (pathId) => {
return useQuery(
["markdownsByPath", pathId],
() => fetch_(`${config.BACKEND_HOST}/api/markdown/by_path/${pathId}`),
{
enabled: !!pathId
});
};
export const useSaveMarkdown = () => {
const queryClient = useQueryClient();
return useMutation(({id, data}) => {
const url = id
? `${config.BACKEND_HOST}/api/markdown/${id}`
: `${config.BACKEND_HOST}/api/markdown/`;
const method = id ? "PUT" : "POST";
return fetch_(url, {
method,
body: JSON.stringify(data),
})
},{
onSuccess: (res, variables) => {
queryClient.invalidateQueries(["markdownsByPath", variables.data.parent_id]);
queryClient.invalidateQueries(["markdown", variables.data.id]);
},
});
};

90
src/utils/path-queries.js Normal file
View File

@@ -0,0 +1,90 @@
import { useQuery, useMutation, useQueryClient } from "react-query";
import { fetch_ } from "./request-utils";
import config from "../config";
export const usePaths = (parent_id) => {
const queryClient = useQueryClient();
return useQuery(
["paths", parent_id],
() => fetch_(`${config.BACKEND_HOST}/api/path/parent/${parent_id}`),
{
enabled: !!parent_id,
onSuccess: (data) => {
if(data) {
for (const pth of data)
{
queryClient.setQueryData(["path", pth.id], pth);
}
}
}
}
);
}
export const usePath = (id) => {
const queryClient = useQueryClient();
const cachedData = queryClient.getQueryData(["path", id]);
return useQuery(
["path", id],
() => fetch_(`${config.BACKEND_HOST}/api/path/${id}`),
{
enabled: !!id,
onSuccess: (data) => {
console.log(`path ${id} - ${cachedData}` );
}
}
);
};
export const useCreatePath = () => {
const queryClient = useQueryClient();
return useMutation(
(data) => fetch_(`${config.BACKEND_HOST}/api/path/`, {
method: "POST",
body: JSON.stringify(data),
}),
{
onSuccess: (res, variables) => {
console.log(JSON.stringify(variables));
queryClient.invalidateQueries(["paths", variables.parent_id]);
},
}
);
};
export const useUpdatePath = () => {
const queryClient = useQueryClient();
return useMutation(
({ id, data }) => fetch_(`${config.BACKEND_HOST}/api/path/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
{
onSuccess: (res, variables) => {
queryClient.invalidateQueries(["paths", res.parent_id]);
queryClient.invalidateQueries(["path", variables.data.id]);
},
}
);
};
export const useDeletePath = () => {
const queryClient = useQueryClient();
return useMutation(
(id) => fetch_(`${config.BACKEND_HOST}/api/path/${id}`, {
method: "DELETE",
}),
{
onSuccess: () => {
queryClient.invalidateQueries("paths");
},
}
);
};

View File

@@ -0,0 +1,28 @@
export async function fetch_(url, init = {}) {
const token = localStorage.getItem("accessToken");
const headers = {
'Content-Type': 'application/json',
...(init.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const response = await fetch(url, { ...init, headers });
if (response.status === 304) {
return Promise.reject(new Error("Not Modified"));
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const error = new Error(response.statusText);
error.data = errorData;
throw error;
}
if (response.status === 204) {
return null;
}
return response.json();
}

View File

@@ -1,95 +0,0 @@
import {data} from "react-router-dom";
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}`} : {}),
...(init.method && ['PUT', 'POST'].includes(init.method.toUpperCase())
? {'Content-Type': 'application/json'}
: {}),
}
};
if(options.use_cache && ongoingRequests.has(options.cache_key)){
return ongoingRequests.get(options.cache_key);
}
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;
}
}