mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-27 12:17:24 +00:00
Compare commits
78 Commits
f8c4509633
...
master
Author | SHA1 | Date | |
---|---|---|---|
bc8d59f7a9 | |||
c8eb519f7e | |||
e117a2e708 | |||
c131f48448 | |||
45beaced89 | |||
af81f66f25 | |||
ff1f6e7340 | |||
0a88a65846 | |||
316929db33 | |||
844233e70d | |||
f5c39e0315 | |||
ed5843cd54 | |||
c0d904e035 | |||
abbd7ed006 | |||
24ba97817a | |||
2848e4c5e1 | |||
d64b1e78ef | |||
5bf2544282 | |||
faa802dd72 | |||
5fe45611c9 | |||
b4a63d3935 | |||
cf5a600372 | |||
8e14a53351 | |||
1ee5d0c9b7 | |||
d1da0dc948 | |||
62d10a989d | |||
f8fa9069a9 | |||
a51d4ec598 | |||
b4e00814bf | |||
b8acd97c11 | |||
8f240823ef | |||
a33171fb20 | |||
993e7f488d | |||
ebfe25e6d8 | |||
f0079003f2 | |||
4e709dd952 | |||
a3fc1cd801 | |||
b804741e27 | |||
44b876ba0e | |||
f067e802a2 | |||
634d5a348a | |||
488a91e651 | |||
3dd042a752 | |||
43af0412ef | |||
b17fa45d79 | |||
1925cf404e | |||
77ad261670 | |||
f3b3f2bd5a | |||
a5364ae7ac | |||
7189baf7c4 | |||
481ba68d88 | |||
8389ab4bd7 | |||
0e69d787e3 | |||
0c841f2723 | |||
63b3a075c8 | |||
d35473c905 | |||
25720f5e49 | |||
2288926e31 | |||
a79c92f9e7 | |||
5ef6b091e8 | |||
619dc01bf4 | |||
4eede5e76a | |||
f762e74e4d | |||
63ebbebb02 | |||
4ae7a19cc9 | |||
3359a5a9e4 | |||
eed2af4278 | |||
08c70a0b52 | |||
1e5b12f531 | |||
724f96beb8 | |||
fd7c3797ea | |||
40fb3a93b6 | |||
470324221d | |||
12237ee0a4 | |||
15db44e956 | |||
ef2c6d630f | |||
a56eb559ba | |||
17b40f2214 |
@@ -12,7 +12,7 @@ COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# 第二阶段:构建Go应用
|
||||
FROM golang:1.24-alpine AS backend-builder
|
||||
FROM golang:1.25-alpine AS backend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
306
frontend/package-lock.json
generated
306
frontend/package-lock.json
generated
@@ -12,8 +12,6 @@
|
||||
"@tailwindcss/vite": "^4.1.5",
|
||||
"axios": "^1.9.0",
|
||||
"framer-motion": "^12.23.5",
|
||||
"i18next": "^25.1.1",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"masonic": "^4.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -21,6 +19,7 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.5.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"spark-md5": "^3.0.2",
|
||||
"tailwindcss": "^4.1.5"
|
||||
},
|
||||
@@ -3690,6 +3689,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.10"
|
||||
},
|
||||
@@ -3702,15 +3702,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.1.0.tgz",
|
||||
"integrity": "sha512-mHZxNx1Lq09xt5kCauZ/4bsXOEA2pfpwSoU11/QTJB+pD94iONFwp+ohqi///PwiFvjFOxe1akYCdHyFo1ng5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -4258,6 +4249,16 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/masonic": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/masonic/-/masonic-4.1.0.tgz",
|
||||
@@ -4289,6 +4290,34 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-find-and-replace": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
|
||||
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"unist-util-is": "^6.0.0",
|
||||
"unist-util-visit-parents": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -4313,6 +4342,107 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
|
||||
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-gfm-autolink-literal": "^2.0.0",
|
||||
"mdast-util-gfm-footnote": "^2.0.0",
|
||||
"mdast-util-gfm-strikethrough": "^2.0.0",
|
||||
"mdast-util-gfm-table": "^2.0.0",
|
||||
"mdast-util-gfm-task-list-item": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-autolink-literal": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
|
||||
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"ccount": "^2.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"mdast-util-find-and-replace": "^3.0.0",
|
||||
"micromark-util-character": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-footnote": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
|
||||
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.1.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0",
|
||||
"micromark-util-normalize-identifier": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-strikethrough": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
|
||||
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-table": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
|
||||
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"markdown-table": "^3.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-task-list-item": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
|
||||
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-mdx-expression": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
|
||||
@@ -4544,6 +4674,127 @@
|
||||
"micromark-util-types": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
|
||||
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-extension-gfm-autolink-literal": "^2.0.0",
|
||||
"micromark-extension-gfm-footnote": "^2.0.0",
|
||||
"micromark-extension-gfm-strikethrough": "^2.0.0",
|
||||
"micromark-extension-gfm-table": "^2.0.0",
|
||||
"micromark-extension-gfm-tagfilter": "^2.0.0",
|
||||
"micromark-extension-gfm-task-list-item": "^2.0.0",
|
||||
"micromark-util-combine-extensions": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-autolink-literal": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
|
||||
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-sanitize-uri": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-footnote": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
|
||||
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-core-commonmark": "^2.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-normalize-identifier": "^2.0.0",
|
||||
"micromark-util-sanitize-uri": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-strikethrough": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
|
||||
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-util-chunked": "^2.0.0",
|
||||
"micromark-util-classify-character": "^2.0.0",
|
||||
"micromark-util-resolve-all": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-table": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
|
||||
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-tagfilter": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
|
||||
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-task-list-item": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
|
||||
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-factory-destination": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
||||
@@ -5534,6 +5785,24 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-gfm": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-gfm": "^3.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"unified": "^11.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-parse": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||
@@ -5567,6 +5836,21 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-stringify": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
|
||||
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0",
|
||||
"unified": "^11.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
|
@@ -19,8 +19,6 @@
|
||||
"@tailwindcss/vite": "^4.1.5",
|
||||
"axios": "^1.9.0",
|
||||
"framer-motion": "^12.23.5",
|
||||
"i18next": "^25.1.1",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"masonic": "^4.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -28,6 +26,7 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.5.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"spark-md5": "^3.0.2",
|
||||
"tailwindcss": "^4.1.5"
|
||||
},
|
||||
|
@@ -15,29 +15,43 @@ import TagsPage from "./pages/tags_page.tsx";
|
||||
import RandomPage from "./pages/random_page.tsx";
|
||||
import ActivitiesPage from "./pages/activities_page.tsx";
|
||||
import CommentPage from "./pages/comment_page.tsx";
|
||||
import CreateCollectionPage from "./pages/create_collection_page.tsx";
|
||||
import CollectionPage from "./pages/collection_page.tsx";
|
||||
import { i18nData } from "./i18n.ts";
|
||||
import { i18nContext } from "./utils/i18n.ts";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path={"/login"} element={<LoginPage />} />
|
||||
<Route path={"/register"} element={<RegisterPage />} />
|
||||
<Route element={<Navigator />}>
|
||||
<Route path={"/"} element={<HomePage />} />
|
||||
<Route path={"/publish"} element={<PublishPage />} />
|
||||
<Route path={"/search"} element={<SearchPage />} />
|
||||
<Route path={"/resources/:id"} element={<ResourcePage />} />
|
||||
<Route path={"/manage"} element={<ManagePage />} />
|
||||
<Route path={"/tag/:tag"} element={<TaggedResourcesPage />} />
|
||||
<Route path={"/user/:username"} element={<UserPage />} />
|
||||
<Route path={"/resource/edit/:rid"} element={<EditResourcePage />} />
|
||||
<Route path={"/about"} element={<AboutPage />} />
|
||||
<Route path={"/tags"} element={<TagsPage />} />
|
||||
<Route path={"/random"} element={<RandomPage />} />
|
||||
<Route path={"/activity"} element={<ActivitiesPage />} />
|
||||
<Route path={"/comments/:id"} element={<CommentPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<i18nContext.Provider value={i18nData}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path={"/login"} element={<LoginPage />} />
|
||||
<Route path={"/register"} element={<RegisterPage />} />
|
||||
<Route element={<Navigator />}>
|
||||
<Route path={"/"} element={<HomePage />} />
|
||||
<Route path={"/publish"} element={<PublishPage />} />
|
||||
<Route path={"/search"} element={<SearchPage />} />
|
||||
<Route path={"/resources/:id"} element={<ResourcePage />} />
|
||||
<Route path={"/manage"} element={<ManagePage />} />
|
||||
<Route path={"/tag/:tag"} element={<TaggedResourcesPage />} />
|
||||
<Route path={"/user/:username"} element={<UserPage />} />
|
||||
<Route
|
||||
path={"/resource/edit/:rid"}
|
||||
element={<EditResourcePage />}
|
||||
/>
|
||||
<Route path={"/about"} element={<AboutPage />} />
|
||||
<Route path={"/tags"} element={<TagsPage />} />
|
||||
<Route path={"/random"} element={<RandomPage />} />
|
||||
<Route path={"/activity"} element={<ActivitiesPage />} />
|
||||
<Route path={"/comments/:id"} element={<CommentPage />} />
|
||||
<Route
|
||||
path={"/create-collection"}
|
||||
element={<CreateCollectionPage />}
|
||||
/>
|
||||
<Route path={"/collection/:id"} element={<CollectionPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</i18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
@@ -4,14 +4,16 @@ export default function Badge({
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
selectable = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
selectable?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`badge ${!className?.includes("badge-") && "badge-primary"} select-none ${className}`}
|
||||
className={`badge ${!className?.includes("badge-") && "badge-primary"} ${className} ${!selectable && "select-none"}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import showToast from "./toast";
|
||||
import { network } from "../network/network";
|
||||
import { InfoAlert } from "./alert";
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { useNavigate } from "react-router";
|
||||
import { MdOutlineComment } from "react-icons/md";
|
||||
import { Comment } from "../network/models";
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { MdAdd } from "react-icons/md";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { network } from "../network/network.ts";
|
||||
import showToast from "./toast.ts";
|
||||
import { useState } from "react";
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
|
||||
export default function Loading() {
|
||||
const { t } = useTranslation();
|
||||
|
@@ -3,7 +3,7 @@ import { network } from "../network/network.ts";
|
||||
import { useNavigate, useOutlet } from "react-router";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { MdArrowUpward, MdOutlinePerson, MdSearch } from "react-icons/md";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import UploadingSideBar from "./uploading_side_bar.tsx";
|
||||
import { ThemeSwitcher } from "./theme_switcher.tsx";
|
||||
import { IoLogoGithub } from "react-icons/io";
|
||||
|
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { i18nContext } from "../utils/i18n";
|
||||
import { i18nData } from "../i18n.ts";
|
||||
|
||||
export default function showPopup(
|
||||
content: React.ReactNode,
|
||||
@@ -9,10 +11,18 @@ export default function showPopup(
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.style.position = "fixed";
|
||||
if (eRect.x > window.innerWidth / 2) {
|
||||
div.style.right = `${window.innerWidth - eRect.x}px`;
|
||||
if (window.innerWidth > 400) {
|
||||
if (eRect.x > window.innerWidth / 2) {
|
||||
div.style.right = `${window.innerWidth - eRect.x}px`;
|
||||
} else {
|
||||
div.style.left = `${eRect.x}px`;
|
||||
}
|
||||
} else {
|
||||
div.style.left = `${eRect.x}px`;
|
||||
if (eRect.x > window.innerWidth / 2) {
|
||||
div.style.right = `8px`;
|
||||
} else {
|
||||
div.style.left = `8px`;
|
||||
}
|
||||
}
|
||||
if (eRect.y > window.innerHeight / 2) {
|
||||
div.style.bottom = `${window.innerHeight - eRect.y}px`;
|
||||
@@ -43,7 +53,9 @@ export default function showPopup(
|
||||
document.body.appendChild(mask);
|
||||
|
||||
createRoot(div).render(
|
||||
<context.Provider value={close}>{content}</context.Provider>,
|
||||
<context.Provider value={close}>
|
||||
<i18nContext.Provider value={i18nData}>{content}</i18nContext.Provider>
|
||||
</context.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -2,8 +2,15 @@ import { Resource } from "../network/models.ts";
|
||||
import { network } from "../network/network.ts";
|
||||
import { useNavigate } from "react-router";
|
||||
import Badge from "./badge.tsx";
|
||||
import React from "react";
|
||||
|
||||
export default function ResourceCard({ resource }: { resource: Resource }) {
|
||||
export default function ResourceCard({
|
||||
resource,
|
||||
action,
|
||||
}: {
|
||||
resource: Resource;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
let tags = resource.tags;
|
||||
@@ -58,6 +65,8 @@ export default function ResourceCard({ resource }: { resource: Resource }) {
|
||||
</div>
|
||||
<div className="w-2"></div>
|
||||
<div className="text-sm">{resource.author.username}</div>
|
||||
<div className="flex-1"></div>
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -9,9 +9,11 @@ import { useAppContext } from "./AppContext.tsx";
|
||||
export default function ResourcesView({
|
||||
loader,
|
||||
storageKey,
|
||||
actionBuilder,
|
||||
}: {
|
||||
loader: (page: number) => Promise<PageResponse<Resource>>;
|
||||
storageKey?: string;
|
||||
actionBuilder?: (resource: Resource) => React.ReactNode;
|
||||
}) {
|
||||
const [data, setData] = useState<Resource[]>([]);
|
||||
const pageRef = useRef(1);
|
||||
@@ -54,7 +56,8 @@ export default function ResourcesView({
|
||||
isLoadingRef.current = false;
|
||||
pageRef.current = pageRef.current + 1;
|
||||
totalPagesRef.current = res.totalPages ?? 1;
|
||||
setData((prev) => [...prev, ...res.data!]);
|
||||
let data = res.data ?? [];
|
||||
setData((prev) => [...prev, ...data]);
|
||||
}
|
||||
}, [loader]);
|
||||
|
||||
@@ -71,7 +74,13 @@ export default function ResourcesView({
|
||||
columnWidth={300}
|
||||
items={data}
|
||||
render={(e) => {
|
||||
return <ResourceCard resource={e.data} key={e.data.id} />;
|
||||
return (
|
||||
<ResourceCard
|
||||
resource={e.data}
|
||||
key={e.data.id}
|
||||
action={actionBuilder?.(e.data)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
></Masonry>
|
||||
{pageRef.current <= totalPagesRef.current && <Loading />}
|
||||
|
@@ -1,12 +1,13 @@
|
||||
import { Tag } from "../network/models.ts";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { network } from "../network/network.ts";
|
||||
import { LuInfo } from "react-icons/lu";
|
||||
import { MdSearch } from "react-icons/md";
|
||||
import Button from "./button.tsx";
|
||||
import Input, { TextArea } from "./input.tsx";
|
||||
import { ErrorAlert } from "./alert.tsx";
|
||||
import { Debounce } from "../utils/debounce.ts";
|
||||
|
||||
export default function TagInput({
|
||||
onAdd,
|
||||
@@ -177,31 +178,6 @@ export default function TagInput({
|
||||
);
|
||||
}
|
||||
|
||||
class Debounce {
|
||||
private timer: number | null = null;
|
||||
private readonly delay: number;
|
||||
|
||||
constructor(delay: number) {
|
||||
this.delay = delay;
|
||||
}
|
||||
|
||||
run(callback: () => void) {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
callback();
|
||||
}, this.delay);
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function QuickAddTagDialog({
|
||||
onAdded,
|
||||
}: {
|
||||
@@ -227,7 +203,11 @@ export function QuickAddTagDialog({
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const names = text.split(separator).filter((n) => n.length > 0);
|
||||
let sep: string | RegExp = separator;
|
||||
if (sep === " ") {
|
||||
sep = /\s+/;
|
||||
}
|
||||
const names = text.split(sep).filter((n) => n.length > 0);
|
||||
setLoading(true);
|
||||
const res = await network.getOrCreateTags(names, type);
|
||||
setLoading(false);
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { MdPalette } from "react-icons/md";
|
||||
|
||||
interface ThemeOption {
|
||||
|
@@ -10,7 +10,7 @@ export default function showToast({
|
||||
type = type || "info";
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = `
|
||||
<div class="toast toast-center">
|
||||
<div class="toast toast-center z-10">
|
||||
<div class="alert shadow ${type === "success" && "alert-success"} ${type === "error" && "alert-error"} ${type === "warning" && "alert-warning"} ${type === "info" && "alert-info"}">
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
|
@@ -1,243 +1,4 @@
|
||||
export const i18nData = {
|
||||
"en": {
|
||||
translation: {
|
||||
"My Profile": "My Profile",
|
||||
"Publish": "Publish",
|
||||
"Log out": "Log out",
|
||||
"Are you sure you want to log out?": "Are you sure you want to log out?",
|
||||
"Cancel": "Cancel",
|
||||
"Confirm": "Confirm",
|
||||
"Search": "Search",
|
||||
"Login": "Login",
|
||||
"Register": "Register",
|
||||
"Username": "Username",
|
||||
"Password": "Password",
|
||||
"Confirm Password": "Confirm Password",
|
||||
"Username and password cannot be empty":
|
||||
"Username and password cannot be empty",
|
||||
"Passwords do not match": "Passwords do not match",
|
||||
"Continue": "Continue",
|
||||
"Don't have an account? Register": "Don't have an account? Register",
|
||||
"Already have an account? Login": "Already have an account? Login",
|
||||
"Publish Resource": "Publish Resource",
|
||||
"All information can be modified after publishing":
|
||||
"All information can be modified after publishing",
|
||||
"Title": "Title",
|
||||
"Alternative Titles": "Alternative Titles",
|
||||
"Add Alternative Title": "Add Alternative Title",
|
||||
"Tags": "Tags",
|
||||
"Description": "Description",
|
||||
"Use Markdown format": "Use Markdown format",
|
||||
"Images": "Images",
|
||||
"Images will not be displayed automatically, you need to reference them in the description":
|
||||
"Images will not be displayed automatically, you need to reference them in the description",
|
||||
"Preview": "Preview",
|
||||
"Link": "Link",
|
||||
"Action": "Action",
|
||||
"Upload Image": "Upload Image",
|
||||
"Error": "Error",
|
||||
"Title cannot be empty": "Title cannot be empty",
|
||||
"Alternative title cannot be empty": "Alternative title cannot be empty",
|
||||
"At least one tag required": "At least one tag required",
|
||||
"Description cannot be empty": "Description cannot be empty",
|
||||
"Loading": "Loading",
|
||||
"Enter a search keyword to continue":
|
||||
"Enter a search keyword to continue",
|
||||
"My Info": "My Info",
|
||||
"Server": "Server",
|
||||
|
||||
// Management page translations
|
||||
"Settings": "Settings",
|
||||
"Manage": "Manage",
|
||||
"Storage": "Storage",
|
||||
"Users": "Users",
|
||||
"You are not logged in. Please log in to access this page.":
|
||||
"You are not logged in. Please log in to access this page.",
|
||||
"You are not authorized to access this page.":
|
||||
"You are not authorized to access this page.",
|
||||
|
||||
// Storage management
|
||||
"No storage found. Please create a new storage.":
|
||||
"No storage found. Please create a new storage.",
|
||||
"Name": "Name",
|
||||
"Created At": "Created At",
|
||||
"Actions": "Actions",
|
||||
"Delete Storage": "Delete Storage",
|
||||
"Are you sure you want to delete this storage? This action cannot be undone.":
|
||||
"Are you sure you want to delete this storage? This action cannot be undone.",
|
||||
"Delete": "Delete",
|
||||
"Storage deleted successfully": "Storage deleted successfully",
|
||||
"New Storage": "New Storage",
|
||||
"Type": "Type",
|
||||
"Local": "Local",
|
||||
"S3": "S3",
|
||||
"Path": "Path",
|
||||
"Max Size (MB)": "Max Size (MB)",
|
||||
"Endpoint": "Endpoint",
|
||||
"Access Key ID": "Access Key ID",
|
||||
"Secret Access Key": "Secret Access Key",
|
||||
"Bucket Name": "Bucket Name",
|
||||
"All fields are required": "All fields are required",
|
||||
"Storage created successfully": "Storage created successfully",
|
||||
"Close": "Close",
|
||||
"Submit": "Submit",
|
||||
|
||||
// User management
|
||||
"Admin": "Admin",
|
||||
"Can Upload": "Can Upload",
|
||||
"Yes": "Yes",
|
||||
"No": "No",
|
||||
"Delete User": "Delete User",
|
||||
"Are you sure you want to delete user":
|
||||
"Are you sure you want to delete user",
|
||||
"This action cannot be undone.": "This action cannot be undone.",
|
||||
"User deleted successfully": "User deleted successfully",
|
||||
"Set as user": "Set as user",
|
||||
"Set as admin": "Set as admin",
|
||||
"Remove upload permission": "Remove upload permission",
|
||||
"Grant upload permission": "Grant upload permission",
|
||||
"User set as admin successfully": "User set as admin successfully",
|
||||
"User set as user successfully": "User set as user successfully",
|
||||
"User set as upload permission successfully":
|
||||
"User set as upload permission successfully",
|
||||
"User removed upload permission successfully":
|
||||
"User removed upload permission successfully",
|
||||
|
||||
// Resource details page
|
||||
"Resource ID is required": "Resource ID is required",
|
||||
"Files": "Files",
|
||||
"Comments": "Comments",
|
||||
"Upload": "Upload",
|
||||
"Create File": "Create File",
|
||||
"Please select a file type": "Please select a file type",
|
||||
"Please fill in all fields": "Please fill in all fields",
|
||||
"File created successfully": "File created successfully",
|
||||
"Successfully create uploading task.":
|
||||
"Successfully create uploading task.",
|
||||
"Please select a file and storage": "Please select a file and storage",
|
||||
"Redirect": "Redirect",
|
||||
"User who click the file will be redirected to the URL":
|
||||
"User who click the file will be redirected to the URL",
|
||||
"File Name": "File Name",
|
||||
"URL": "URL",
|
||||
"Upload a file to server, then the file will be moved to the selected storage.":
|
||||
"Upload a file to server, then the file will be moved to the selected storage.",
|
||||
"Select Storage": "Select Storage",
|
||||
"Resource Details": "Resource Details",
|
||||
"Delete Resource": "Delete Resource",
|
||||
"Are you sure you want to delete the resource":
|
||||
"Are you sure you want to delete the resource",
|
||||
"Delete File": "Delete File",
|
||||
"Are you sure you want to delete the file":
|
||||
"Are you sure you want to delete the file",
|
||||
|
||||
// 评论删除相关
|
||||
"Delete Comment": "Delete Comment",
|
||||
"Are you sure you want to delete this comment? This action cannot be undone.":
|
||||
"Are you sure you want to delete this comment? This action cannot be undone.",
|
||||
"Comment deleted successfully": "Comment deleted successfully",
|
||||
|
||||
// New translations
|
||||
"Change Avatar": "Change Avatar",
|
||||
"Change Username": "Change Username",
|
||||
"Change Password": "Change Password",
|
||||
"New Username": "New Username",
|
||||
"Enter new username": "Enter new username",
|
||||
"Save": "Save",
|
||||
"Current Password": "Current Password",
|
||||
"Enter current password": "Enter current password",
|
||||
"New Password": "New Password",
|
||||
"Enter new password": "Enter new password",
|
||||
"Confirm New Password": "Confirm New Password",
|
||||
"Confirm new password": "Confirm new password",
|
||||
"Avatar changed successfully": "Avatar changed successfully",
|
||||
"Username changed successfully": "Username changed successfully",
|
||||
"Password changed successfully": "Password changed successfully",
|
||||
|
||||
// Manage server config page translations
|
||||
"Update server config successfully": "Update server config successfully",
|
||||
"Max uploading size (MB)": "Max uploading size (MB)",
|
||||
"Max file size (MB)": "Max file size (MB)",
|
||||
"Max downloads per day for single IP":
|
||||
"Max downloads per day for single IP",
|
||||
"Allow register": "Allow register",
|
||||
"Server name": "Server name",
|
||||
"Server description": "Server description",
|
||||
"Cloudflare Turnstile Site Key": "Cloudflare Turnstile Site Key",
|
||||
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile Secret Key",
|
||||
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.":
|
||||
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.",
|
||||
"The first image will be used as the cover image":
|
||||
"The first image will be used as the cover image",
|
||||
"Please enter a search keyword": "Please enter a search keyword",
|
||||
"Searching...": "Searching...",
|
||||
"Create Tag": "Create Tag",
|
||||
"Search Tags": "Search Tags",
|
||||
"Edit Resource": "Edit Resource",
|
||||
"Change Bio": "Change Bio",
|
||||
"About this site": "About this site",
|
||||
"Tag not found": "Tag not found",
|
||||
"Description is too long": "Description is too long",
|
||||
"Unknown error": "Unknown error",
|
||||
"Edit": "Edit",
|
||||
"Edit Tag": "Edit Tag",
|
||||
"Set the description of the tag.": "Set the description of the tag.",
|
||||
"Tag: ": "Tag: ",
|
||||
"Select a Order": "Select a Order",
|
||||
"Time Ascending": "Time Ascending",
|
||||
"Time Descending": "Time Descending",
|
||||
"Views Ascending": "Views Ascending",
|
||||
"Views Descending": "Views Descending",
|
||||
"Downloads Ascending": "Downloads Ascending",
|
||||
"Downloads Descending": "Downloads Descending",
|
||||
"File Url": "File Url",
|
||||
"Provide a file url for the server to download, and the file will be moved to the selected storage.":
|
||||
"Provide a file url for the server to download, and the file will be moved to the selected storage.",
|
||||
"Verifying your request": "Verifying your request",
|
||||
"Please check your network if the verification takes too long or the captcha does not appear.":
|
||||
"Please check your network if the verification takes too long or the captcha does not appear.",
|
||||
"About": "About",
|
||||
"Home": "Home",
|
||||
"Other": "Other",
|
||||
"Quick Add": "Quick Add",
|
||||
"Add Tags": "Add Tags",
|
||||
"Input tags separated by separator.":
|
||||
"Input tags separated by separator.",
|
||||
"If the tag does not exist, it will be created automatically.":
|
||||
"If the tag does not exist, it will be created automatically.",
|
||||
"Optionally, you can specify a type for the new tags.":
|
||||
"Optionally, you can specify a type for the new tags.",
|
||||
"Upload Clipboard Image": "Upload Clipboard Image",
|
||||
"Show more": "Show more",
|
||||
"Show less": "Show less",
|
||||
"You need to log in to comment": "You need to log in to comment",
|
||||
|
||||
// Color Scheme Translation
|
||||
"Light Pink": "Light Pink",
|
||||
"Ocean Breeze": "Ocean Breeze",
|
||||
"Mint Leaf": "Mint Leaf",
|
||||
"Golden Glow": "Golden Glow",
|
||||
"Random": "Random",
|
||||
|
||||
// Activity Page
|
||||
"Activity": "Activity",
|
||||
"Published a resource": "Published a resource",
|
||||
"Updated a resource": "Updated a resource",
|
||||
"Commented on a resource": "Commented on a resource",
|
||||
|
||||
"Comment": "Comment",
|
||||
"Replies": "Replies",
|
||||
"Reply": "Reply",
|
||||
"Commented on": "Commented on",
|
||||
"Write down your comment": "Write down your comment",
|
||||
"Click to view more": "Click to view more",
|
||||
"Comment Details": "Comment Details",
|
||||
"Posted a comment": "Posted a comment",
|
||||
"Resources": "Resources",
|
||||
"Added a new file": "Added a new file",
|
||||
"Data from": "Data from",
|
||||
},
|
||||
},
|
||||
"zh-CN": {
|
||||
translation: {
|
||||
"My Profile": "我的资料",
|
||||
@@ -466,6 +227,33 @@ export const i18nData = {
|
||||
"Added a new file": "添加了新文件",
|
||||
|
||||
"Data from": "数据来源",
|
||||
|
||||
"Collections": "合集",
|
||||
"Create Collection": "创建合集",
|
||||
"Create": "创建",
|
||||
"Image size exceeds 5MB limit": "图片大小超过5MB限制",
|
||||
"Title and description cannot be empty": "标题和描述不能为空",
|
||||
"Collection created successfully": "合集创建成功",
|
||||
"Collection deleted successfully": "合集删除成功",
|
||||
"Remove Resource": "移除资源",
|
||||
"Are you sure you want to remove this resource?":
|
||||
"您确定要移除此资源吗?",
|
||||
"Resource deleted successfully": "资源移除成功",
|
||||
"Edit Collection": "编辑合集",
|
||||
"Edit successful": "编辑成功",
|
||||
"Failed to save changes": "保存更改失败",
|
||||
|
||||
"Collect": "收藏",
|
||||
"Add to Collection": "添加到合集",
|
||||
"Add": "添加",
|
||||
"Resource added to collection successfully": "资源已成功添加到合集",
|
||||
"No patches found for this VN.": "未找到该作品的补丁。",
|
||||
"Update File Info": "更新文件信息",
|
||||
"File info updated successfully": "文件信息更新成功",
|
||||
"File URL": "文件URL",
|
||||
"You do not have permission to upload files, please contact the administrator.":
|
||||
"您没有上传文件的权限,请联系管理员。",
|
||||
"Private": "私有",
|
||||
},
|
||||
},
|
||||
"zh-TW": {
|
||||
@@ -696,6 +484,33 @@ export const i18nData = {
|
||||
"Added a new file": "添加了新檔案",
|
||||
|
||||
"Data from": "數據來源",
|
||||
|
||||
"Collections": "合集",
|
||||
"Create Collection": "創建合集",
|
||||
"Create": "創建",
|
||||
"Image size exceeds 5MB limit": "圖片大小超過5MB限制",
|
||||
"Title and description cannot be empty": "標題和描述不能為空",
|
||||
"Collection created successfully": "合集創建成功",
|
||||
"Collection deleted successfully": "合集刪除成功",
|
||||
"Remove Resource": "移除資源",
|
||||
"Are you sure you want to remove this resource?":
|
||||
"您確定要移除此資源嗎?",
|
||||
"Resource deleted successfully": "資源移除成功",
|
||||
"Edit Collection": "編輯合集",
|
||||
"Edit successful": "編輯成功",
|
||||
"Failed to save changes": "保存更改失敗",
|
||||
|
||||
"Collect": "收藏",
|
||||
"Add to Collection": "添加到合集",
|
||||
"Add": "添加",
|
||||
"Resource added to collection successfully": "資源已成功添加到合集",
|
||||
"No patches found for this VN.": "未找到該作品的補丁。",
|
||||
"Update File Info": "更新檔案信息",
|
||||
"File info updated successfully": "檔案信息更新成功",
|
||||
"File URL": "檔案URL",
|
||||
"You do not have permission to upload files, please contact the administrator.":
|
||||
"您沒有上傳檔案的權限,請聯繫管理員。",
|
||||
"Private": "私有",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@@ -1,6 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
|
||||
@theme {
|
||||
--breakpoint-xs: 30rem;
|
||||
}
|
||||
|
||||
/* Pink Theme */
|
||||
@plugin "daisyui/theme" {
|
||||
name: "pink";
|
||||
@@ -325,4 +329,4 @@ body {
|
||||
.lg\:bg-base-100-tr82 {
|
||||
background-color: rgb(var(--color-base-100-rgb) / 0.82);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,29 +2,12 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./app.tsx";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { i18nData } from "./i18n.ts";
|
||||
import AppContext from "./components/AppContext.tsx";
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.use(LanguageDetector)
|
||||
.init({
|
||||
resources: i18nData,
|
||||
debug: true,
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<AppContext>
|
||||
<App />
|
||||
</AppContext>
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<AppContext>
|
||||
<App />
|
||||
</AppContext>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
@@ -115,7 +115,7 @@ article {
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, system-ui;
|
||||
}
|
||||
|
||||
code {
|
||||
pre code {
|
||||
width: 100%;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
|
@@ -104,6 +104,7 @@ export interface RFile {
|
||||
is_redirect: boolean;
|
||||
user: User;
|
||||
resource?: Resource;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
export interface UploadingFile {
|
||||
@@ -162,6 +163,7 @@ export interface ServerConfig {
|
||||
allow_normal_user_upload: boolean;
|
||||
max_normal_user_upload_size_in_mb: number;
|
||||
upload_prompt: string;
|
||||
pinned_resources: number[];
|
||||
}
|
||||
|
||||
export enum RSort {
|
||||
@@ -191,3 +193,13 @@ export interface Activity {
|
||||
comment?: Comment;
|
||||
file?: RFile;
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: number;
|
||||
title: string;
|
||||
article: string;
|
||||
user: User;
|
||||
resources_count: number;
|
||||
images: Image[];
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
@@ -19,6 +19,7 @@ import {
|
||||
TagWithCount,
|
||||
Activity,
|
||||
CommentWithRef,
|
||||
Collection,
|
||||
} from "./models.ts";
|
||||
|
||||
class Network {
|
||||
@@ -360,7 +361,7 @@ class Network {
|
||||
page: number,
|
||||
): Promise<PageResponse<Resource>> {
|
||||
return this._callApi(() =>
|
||||
axios.get(`${this.apiBaseUrl}/resource/tag/${tag}`, {
|
||||
axios.get(`${this.apiBaseUrl}/resource/tag/${encodeURIComponent(tag)}`, {
|
||||
params: {
|
||||
page,
|
||||
},
|
||||
@@ -414,6 +415,10 @@ class Network {
|
||||
);
|
||||
}
|
||||
|
||||
async getPinnedResources(): Promise<Response<Resource[]>> {
|
||||
return this._callApi(() => axios.get(`${this.apiBaseUrl}/resource/pinned`));
|
||||
}
|
||||
|
||||
async createS3Storage(
|
||||
name: string,
|
||||
endPoint: string,
|
||||
@@ -688,6 +693,108 @@ class Network {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async createCollection(
|
||||
title: string,
|
||||
article: string,
|
||||
isPublic: boolean,
|
||||
): Promise<Response<Collection>> {
|
||||
return this._callApi(() =>
|
||||
axios.postForm(`${this.apiBaseUrl}/collection/create`, {
|
||||
title,
|
||||
article,
|
||||
public: isPublic,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async updateCollection(
|
||||
id: number,
|
||||
title: string,
|
||||
article: string,
|
||||
isPublic: boolean,
|
||||
): Promise<Response<any>> {
|
||||
return this._callApi(() =>
|
||||
axios.postForm(`${this.apiBaseUrl}/collection/update`, {
|
||||
id,
|
||||
title,
|
||||
article,
|
||||
public: isPublic,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteCollection(id: number): Promise<Response<any>> {
|
||||
return this._callApi(() =>
|
||||
axios.postForm(`${this.apiBaseUrl}/collection/delete`, {
|
||||
id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getCollection(id: number): Promise<Response<Collection>> {
|
||||
return this._callApi(() =>
|
||||
axios.get(`${this.apiBaseUrl}/collection/${id}`),
|
||||
);
|
||||
}
|
||||
|
||||
async listUserCollections(
|
||||
username: string,
|
||||
page: number = 1,
|
||||
): Promise<PageResponse<Collection>> {
|
||||
return this._callApi(() =>
|
||||
axios.get(`${this.apiBaseUrl}/collection/list`, {
|
||||
params: { username, page },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async listCollectionResources(
|
||||
collectionId: number,
|
||||
page: number = 1,
|
||||
): Promise<PageResponse<Resource>> {
|
||||
return this._callApi(() =>
|
||||
axios.get(`${this.apiBaseUrl}/collection/${collectionId}/resources`, {
|
||||
params: { page },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async addResourceToCollection(
|
||||
collectionId: number,
|
||||
resourceId: number,
|
||||
): Promise<Response<any>> {
|
||||
return this._callApi(() =>
|
||||
axios.postForm(`${this.apiBaseUrl}/collection/add_resource`, {
|
||||
collection_id: collectionId,
|
||||
resource_id: resourceId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async removeResourceFromCollection(
|
||||
collectionId: number,
|
||||
resourceId: number,
|
||||
): Promise<Response<any>> {
|
||||
return this._callApi(() =>
|
||||
axios.postForm(`${this.apiBaseUrl}/collection/remove_resource`, {
|
||||
collection_id: collectionId,
|
||||
resource_id: resourceId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async searchUserCollections(
|
||||
username: string,
|
||||
keyword: string,
|
||||
excludedRID?: number,
|
||||
): Promise<Response<Collection[]>> {
|
||||
return this._callApi(() =>
|
||||
axios.get(`${this.apiBaseUrl}/collection/search`, {
|
||||
params: { username, keyword, excludedRID },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const network = new Network();
|
||||
|
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Activity, ActivityType } from "../network/models.ts";
|
||||
import { network } from "../network/network.ts";
|
||||
import showToast from "../components/toast.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { useNavigate } from "react-router";
|
||||
import Loading from "../components/loading.tsx";
|
||||
import { CommentContent } from "../components/comment_tile.tsx";
|
||||
@@ -95,7 +95,9 @@ function ActivityCard({ activity }: { activity: Activity }) {
|
||||
) {
|
||||
content = (
|
||||
<div className={"mx-1"}>
|
||||
<div className={"font-bold my-4"}>{activity.resource?.title}</div>
|
||||
<div className={"font-bold my-4 break-all"}>
|
||||
{activity.resource?.title}
|
||||
</div>
|
||||
{activity.resource?.image && (
|
||||
<div>
|
||||
<img
|
||||
@@ -116,7 +118,9 @@ function ActivityCard({ activity }: { activity: Activity }) {
|
||||
} else if (activity.type === ActivityType.NewFile) {
|
||||
content = (
|
||||
<div>
|
||||
<h4 className={"font-bold py-2"}>{activity.file!.filename}</h4>
|
||||
<h4 className={"font-bold py-2 break-all"}>
|
||||
{activity.file!.filename}
|
||||
</h4>
|
||||
<div className={"text-sm my-1 comment_tile"}>
|
||||
<Markdown>
|
||||
{activity.file!.description.replaceAll("\n", " \n")}
|
||||
@@ -170,8 +174,12 @@ function ActivityCard({ activity }: { activity: Activity }) {
|
||||
src={network.getUserAvatar(activity.user!)}
|
||||
/>
|
||||
</div>
|
||||
<span className={"mx-2 font-bold"}>{activity.user?.username}</span>
|
||||
<span className={"ml-2 badge badge-primary badge-soft"}>
|
||||
<span className={"mx-2 font-bold text-sm"}>
|
||||
{activity.user?.username}
|
||||
</span>
|
||||
<span
|
||||
className={"ml-2 badge-sm sm:badge-md badge badge-primary badge-soft"}
|
||||
>
|
||||
{messages[activity.type]}
|
||||
</span>
|
||||
</div>
|
||||
|
377
frontend/src/pages/collection_page.tsx
Normal file
377
frontend/src/pages/collection_page.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router"; // 新增 useNavigate
|
||||
import showToast from "../components/toast";
|
||||
import { network } from "../network/network";
|
||||
import { Collection } from "../network/models";
|
||||
import Markdown from "react-markdown";
|
||||
import ResourcesView from "../components/resources_view";
|
||||
import Loading from "../components/loading";
|
||||
import { MdOutlineDelete, MdOutlineEdit, MdOutlineLock } from "react-icons/md";
|
||||
import { app } from "../app";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import Button from "../components/button";
|
||||
import Badge from "../components/badge";
|
||||
|
||||
export default function CollectionPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [collection, setCollection] = useState<Collection | null>(null);
|
||||
const [resourcesKey, setResourcesKey] = useState(0);
|
||||
const { t } = useTranslation();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const idInt = parseInt(id || "0", 10);
|
||||
if (isNaN(idInt)) {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: "Invalid collection ID",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const prefetchData = app.getPreFetchData();
|
||||
if (prefetchData?.collection?.id === idInt) {
|
||||
setCollection(prefetchData.collection);
|
||||
return;
|
||||
}
|
||||
|
||||
network.getCollection(idInt).then((res) => {
|
||||
if (res.success) {
|
||||
setCollection(res.data!);
|
||||
} else {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: res.message || "Failed to load collection",
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!collection) return;
|
||||
document.title = collection.title;
|
||||
}, [collection]);
|
||||
|
||||
const toBeDeletedRID = useRef<number | null>(null);
|
||||
|
||||
const handleDeleteResource = (resourceId: number) => {
|
||||
toBeDeletedRID.current = resourceId;
|
||||
const dialog = document.getElementById(
|
||||
"deleteResourceDialog",
|
||||
) as HTMLDialogElement | null;
|
||||
if (dialog) {
|
||||
dialog.showModal();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletedResourceConfirmed = () => {
|
||||
if (toBeDeletedRID.current === null) return;
|
||||
network
|
||||
.removeResourceFromCollection(collection!.id, toBeDeletedRID.current)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
showToast({
|
||||
type: "success",
|
||||
message: "Resource deleted successfully",
|
||||
});
|
||||
setResourcesKey((prev) => prev + 1); // Trigger re-render of ResourcesView
|
||||
} else {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: res.message || "Failed to delete resource",
|
||||
});
|
||||
}
|
||||
});
|
||||
toBeDeletedRID.current = null;
|
||||
const dialog = document.getElementById(
|
||||
"deleteResourceDialog",
|
||||
) as HTMLDialogElement | null;
|
||||
if (dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCollection = () => setDeleteOpen(true);
|
||||
const handleDeleteCollectionConfirmed = async () => {
|
||||
if (!collection) return;
|
||||
setIsDeleting(true);
|
||||
const res = await network.deleteCollection(collection.id);
|
||||
setIsDeleting(false);
|
||||
if (res.success) {
|
||||
showToast({
|
||||
type: "success",
|
||||
message: "Collection deleted successfully",
|
||||
});
|
||||
setDeleteOpen(false);
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
} else {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: res.message || "Failed to delete collection",
|
||||
});
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isOwner = collection?.user?.id === app?.user?.id;
|
||||
|
||||
const openEditDialog = () => setEditOpen(true);
|
||||
|
||||
const handleEditSaved = (newCollection: Collection) => {
|
||||
setCollection(newCollection);
|
||||
setEditOpen(false);
|
||||
};
|
||||
|
||||
if (!collection) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-4 mt-4 p-4 bg-base-100-tr82 shadow rounded-xl">
|
||||
<h1 className="text-2xl font-bold">{collection?.title}</h1>
|
||||
<article>
|
||||
<CollectionContent content={collection?.article || ""} />
|
||||
</article>
|
||||
<div className="flex items-center flex-row-reverse">
|
||||
{isOwner && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost ml-2"
|
||||
onClick={openEditDialog}
|
||||
>
|
||||
<MdOutlineEdit size={16} />
|
||||
{t("Edit")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-error btn-ghost ml-2"
|
||||
onClick={handleDeleteCollection}
|
||||
>
|
||||
<MdOutlineDelete size={16} />
|
||||
{t("Delete")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{!collection.isPublic && (
|
||||
<Badge className="badge-soft badge-error text-xs mr-2 shadow-xs">
|
||||
<MdOutlineLock size={16} className="inline-block" />{" "}
|
||||
{t("Private")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ResourcesView
|
||||
loader={(page) => {
|
||||
return network.listCollectionResources(collection!.id, page);
|
||||
}}
|
||||
actionBuilder={
|
||||
isOwner
|
||||
? (r) => {
|
||||
return (
|
||||
<button
|
||||
className="btn btn-sm btn-rounded btn-error btn-ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleDeleteResource(r.id);
|
||||
}}
|
||||
>
|
||||
<MdOutlineDelete size={16} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
key={resourcesKey}
|
||||
/>
|
||||
<dialog id="deleteResourceDialog" className="modal">
|
||||
<div className="modal-box">
|
||||
<h2 className="font-bold text-lg">Remove Resource</h2>
|
||||
<p>Are you sure you want to remove this resource?</p>
|
||||
<div className="modal-action">
|
||||
<Button
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
"deleteResourceDialog",
|
||||
) as HTMLDialogElement | null;
|
||||
if (dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="btn-error"
|
||||
onClick={handleDeletedResourceConfirmed}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
{deleteOpen && (
|
||||
<div className="modal modal-open">
|
||||
<div className="modal-box">
|
||||
<h2 className="font-bold text-lg mb-2">{t("Delete Collection")}</h2>
|
||||
<p>
|
||||
{t(
|
||||
"Are you sure you want to delete this collection? This action cannot be undone.",
|
||||
)}
|
||||
</p>
|
||||
<div className="modal-action">
|
||||
<Button className="btn" onClick={() => setDeleteOpen(false)}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
className="btn btn-error"
|
||||
onClick={handleDeleteCollectionConfirmed}
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
{t("Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{editOpen && collection && (
|
||||
<EditCollectionDialog
|
||||
open={editOpen}
|
||||
collection={collection}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSaved={handleEditSaved}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionContent({ content }: { content: string }) {
|
||||
const lines = content.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i];
|
||||
if (!line.endsWith(" ")) {
|
||||
// Ensure that each line ends with two spaces for Markdown to recognize it as a line break
|
||||
lines[i] = line + " ";
|
||||
}
|
||||
}
|
||||
content = lines.join("\n");
|
||||
|
||||
return <Markdown>{content}</Markdown>;
|
||||
}
|
||||
|
||||
function EditCollectionDialog({
|
||||
open,
|
||||
collection,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
collection: Collection;
|
||||
onClose: () => void;
|
||||
onSaved: (newCollection: Collection) => void;
|
||||
}) {
|
||||
const [editTitle, setEditTitle] = useState(collection.title);
|
||||
const [editArticle, setEditArticle] = useState(collection.article);
|
||||
const [editIsPublic, setEditIsPublic] = useState(collection.isPublic);
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (editTitle.trim() === "" || editArticle.trim() === "") {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: t("Title and description cannot be empty"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setEditLoading(true);
|
||||
const res = await network.updateCollection(
|
||||
collection.id,
|
||||
editTitle,
|
||||
editArticle,
|
||||
editIsPublic,
|
||||
);
|
||||
setEditLoading(false);
|
||||
if (res.success) {
|
||||
showToast({ type: "success", message: t("Edit successful") });
|
||||
const getRes = await network.getCollection(collection.id);
|
||||
if (getRes.success) {
|
||||
onSaved(getRes.data!);
|
||||
} else {
|
||||
onSaved({
|
||||
...collection,
|
||||
title: editTitle,
|
||||
article: editArticle,
|
||||
isPublic: editIsPublic,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: res.message || t("Failed to save changes"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="modal modal-open">
|
||||
<div className="modal-box">
|
||||
<h2 className="font-bold text-lg mb-2">{t("Edit Collection")}</h2>
|
||||
<label className="block mb-1">{t("Title")}</label>
|
||||
<input
|
||||
className="input w-full mb-2"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
<label className="block mb-1">{t("Description")}</label>
|
||||
<textarea
|
||||
className="textarea w-full min-h-32 mb-2"
|
||||
value={editArticle}
|
||||
onChange={(e) => setEditArticle(e.target.value)}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
<label className="flex items-center mb-4 mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!editIsPublic}
|
||||
onChange={(e) => setEditIsPublic(!e.target.checked)}
|
||||
className="checkbox mr-2"
|
||||
disabled={editLoading}
|
||||
/>
|
||||
{t("Private")}
|
||||
</label>
|
||||
<div className="modal-action">
|
||||
<button className="btn" onClick={onClose} disabled={editLoading}>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleEditSave}
|
||||
disabled={editLoading}
|
||||
>
|
||||
{editLoading ? (
|
||||
<span className="loading loading-spinner loading-sm"></span>
|
||||
) : (
|
||||
t("Save")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { network } from "../network/network";
|
||||
import showToast from "../components/toast";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { CommentWithRef, Resource } from "../network/models";
|
||||
import Loading from "../components/loading";
|
||||
import Markdown from "react-markdown";
|
||||
@@ -91,7 +91,9 @@ export default function CommentPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (comment?.resource && comment.resource.image) {
|
||||
navigator.setBackground(network.getResampledImageUrl(comment.resource.image.id));
|
||||
navigator.setBackground(
|
||||
network.getResampledImageUrl(comment.resource.image.id),
|
||||
);
|
||||
} else if (comment?.images?.length) {
|
||||
// comment images are not resampled
|
||||
navigator.setBackground(network.getImageUrl(comment.images[0].id));
|
||||
@@ -109,36 +111,39 @@ export default function CommentPage() {
|
||||
<div className="h-2"></div>
|
||||
<div className="bg-base-100-tr82 rounded-2xl p-4 shadow">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(`/user/${encodeURIComponent(comment.user.username)}`);
|
||||
}}
|
||||
className="border-b-2 py-1 cursor-pointer border-transparent hover:border-primary transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="avatar">
|
||||
<div className="w-6 rounded-full">
|
||||
<img src={network.getUserAvatar(comment.user)} alt={"avatar"} />
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(`/user/${encodeURIComponent(comment.user.username)}`);
|
||||
}}
|
||||
className="border-b-2 py-1 cursor-pointer border-transparent hover:border-primary transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="avatar">
|
||||
<div className="w-6 rounded-full">
|
||||
<img
|
||||
src={network.getUserAvatar(comment.user)}
|
||||
alt={"avatar"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2"></div>
|
||||
<div className="text-sm">{comment.user.username}</div>
|
||||
</div>
|
||||
<div className="w-2"></div>
|
||||
<div className="text-sm">{comment.user.username}</div>
|
||||
</div>
|
||||
</button>
|
||||
<span className="text-xs text-base-content/80 ml-2">
|
||||
{t("Commented on")}
|
||||
{new Date(comment.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<article>
|
||||
<CommentContent content={comment.content} />
|
||||
</article>
|
||||
{app.user?.id === comment.user.id && (
|
||||
<div className="flex flex-row justify-end mt-2">
|
||||
<EditCommentDialog comment={comment} onUpdated={onUpdated} />
|
||||
<DeleteCommentDialog commentId={comment.id} onUpdated={onDeleted} />
|
||||
</button>
|
||||
<span className="text-xs text-base-content/80 ml-2">
|
||||
{t("Commented on")}
|
||||
{new Date(comment.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<article>
|
||||
<CommentContent content={comment.content} />
|
||||
</article>
|
||||
{app.user?.id === comment.user.id && (
|
||||
<div className="flex flex-row justify-end mt-2">
|
||||
<EditCommentDialog comment={comment} onUpdated={onUpdated} />
|
||||
<DeleteCommentDialog commentId={comment.id} onUpdated={onDeleted} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-4" />
|
||||
<div className="border-t border-base-300" />
|
||||
|
150
frontend/src/pages/create_collection_page.tsx
Normal file
150
frontend/src/pages/create_collection_page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { MdOutlineImage, MdOutlineInfo } from "react-icons/md";
|
||||
import showToast from "../components/toast";
|
||||
import { network } from "../network/network";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export default function CreateCollectionPage() {
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [article, setArticle] = useState<string>("");
|
||||
const [isPublic, setIsPublic] = useState<boolean>(true);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [isUploadingimage, setUploadingImage] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleAddImage = () => {
|
||||
if (isUploadingimage) {
|
||||
return;
|
||||
}
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.multiple = true;
|
||||
input.onchange = async (e) => {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (files) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (files[i].size > 8 * 1024 * 1024) {
|
||||
showToast({
|
||||
message: t("Image size exceeds 5MB limit"),
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setUploadingImage(true);
|
||||
const imageIds: number[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const res = await network.uploadImage(file);
|
||||
if (res.success) {
|
||||
imageIds.push(res.data!);
|
||||
} else {
|
||||
showToast({ message: res.message, type: "error" });
|
||||
setUploadingImage(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (imageIds.length > 0) {
|
||||
setArticle((prev) => {
|
||||
return (
|
||||
prev +
|
||||
"\n" +
|
||||
imageIds.map((id) => ``).join(" ")
|
||||
);
|
||||
});
|
||||
}
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const createCollection = async () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
if (title.trim() === "" || article.trim() === "") {
|
||||
showToast({
|
||||
message: t("Title and description cannot be empty"),
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const res = await network.createCollection(title, article, isPublic);
|
||||
if (res.success) {
|
||||
showToast({
|
||||
message: t("Collection created successfully"),
|
||||
type: "success",
|
||||
});
|
||||
navigate(`/collection/${res.data?.id}`, { replace: true });
|
||||
} else {
|
||||
showToast({ message: res.message, type: "error" });
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-base-100-tr82 shadow m-4 p-4 rounded-lg">
|
||||
<h1 className="text-xl font-bold">{t("Create Collection")}</h1>
|
||||
<div className="mt-4">
|
||||
<label className="block">{t("Title")}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
<label className="mt-8 flex items-center">
|
||||
{t("Description")}
|
||||
<span className="w-2"></span>
|
||||
<div className="badge badge-info badge-soft badge-sm">
|
||||
<MdOutlineInfo className="inline-block" size={16} />
|
||||
<span className="text-sm">Markdown</span>
|
||||
</div>
|
||||
</label>
|
||||
<textarea
|
||||
value={article}
|
||||
onChange={(e) => setArticle(e.target.value)}
|
||||
className="textarea mt-1 w-full min-h-80"
|
||||
/>
|
||||
<div className="mt-4 mx-1">
|
||||
<label className="flex items-center py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!isPublic}
|
||||
onChange={(e) => setIsPublic(!e.target.checked)}
|
||||
className="checkbox mr-2 checkbox-primary"
|
||||
/>
|
||||
{t("Private")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex items-center mt-4"}>
|
||||
<button
|
||||
className={"btn btn-sm btn-circle mr-2"}
|
||||
onClick={handleAddImage}
|
||||
>
|
||||
{isUploadingimage ? (
|
||||
<span className={"loading loading-spinner loading-sm"}></span>
|
||||
) : (
|
||||
<MdOutlineImage size={18} />
|
||||
)}
|
||||
</button>
|
||||
<span className={"grow"} />
|
||||
<button
|
||||
onClick={createCollection}
|
||||
className={`btn btn-primary h-8 text-sm mx-2 ${article === "" && "btn-disabled"}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className={"loading loading-spinner loading-sm"}></span>
|
||||
) : null}
|
||||
{t("Submit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -10,7 +10,7 @@ import { Tag } from "../network/models.ts";
|
||||
import { network } from "../network/network.ts";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import showToast from "../components/toast.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { app } from "../app.ts";
|
||||
import { ErrorAlert } from "../components/alert.tsx";
|
||||
import Loading from "../components/loading.tsx";
|
||||
|
@@ -2,10 +2,12 @@ import { useEffect, useState } from "react";
|
||||
import ResourcesView from "../components/resources_view.tsx";
|
||||
import { network } from "../network/network.ts";
|
||||
import { app } from "../app.ts";
|
||||
import { RSort } from "../network/models.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Resource, RSort } from "../network/models.ts";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { useAppContext } from "../components/AppContext.tsx";
|
||||
import Select from "../components/select.tsx";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useNavigator } from "../components/navigator.tsx";
|
||||
|
||||
export default function HomePage() {
|
||||
useEffect(() => {
|
||||
@@ -31,6 +33,7 @@ export default function HomePage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PinnedResources />
|
||||
<div className={"flex pt-4 px-4 items-center"}>
|
||||
<Select
|
||||
values={[
|
||||
@@ -58,3 +61,82 @@ export default function HomePage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let cachedPinnedResources: Resource[] | null = null;
|
||||
|
||||
function PinnedResources() {
|
||||
const [pinnedResources, setPinnedResources] = useState<Resource[]>([]);
|
||||
const navigator = useNavigator();
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedPinnedResources != null) {
|
||||
setPinnedResources(cachedPinnedResources);
|
||||
return;
|
||||
}
|
||||
const prefetchData = app.getPreFetchData();
|
||||
if (prefetchData && prefetchData.background) {
|
||||
navigator.setBackground(
|
||||
network.getResampledImageUrl(prefetchData.background),
|
||||
);
|
||||
}
|
||||
if (prefetchData && prefetchData.pinned) {
|
||||
cachedPinnedResources = prefetchData.pinned;
|
||||
setPinnedResources(cachedPinnedResources!);
|
||||
return;
|
||||
}
|
||||
const fetchPinnedResources = async () => {
|
||||
const res = await network.getPinnedResources();
|
||||
if (res.success) {
|
||||
cachedPinnedResources = res.data ?? [];
|
||||
setPinnedResources(res.data ?? []);
|
||||
}
|
||||
};
|
||||
fetchPinnedResources();
|
||||
}, []);
|
||||
|
||||
if (pinnedResources.length == 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4">
|
||||
{pinnedResources.map((resource) => (
|
||||
<PinnedResourceItem key={resource.id} resource={resource} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PinnedResourceItem({ resource }: { resource: Resource }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/resources/${resource.id}`}
|
||||
className={"cursor-pointer block"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(`/resources/${resource.id}`);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"shadow hover:shadow-md transition-shadow rounded-2xl overflow-clip relative"
|
||||
}
|
||||
>
|
||||
{resource.image != null && (
|
||||
<figure>
|
||||
<img
|
||||
src={network.getResampledImageUrl(resource.image.id)}
|
||||
alt="cover"
|
||||
className="w-full aspect-[7/3] object-cover"
|
||||
/>
|
||||
</figure>
|
||||
)}
|
||||
<div className="p-4 absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent">
|
||||
<h2 className="break-all card-title text-white">{resource.title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@ import { FormEvent, useEffect, useState } from "react";
|
||||
import { network } from "../network/network.ts";
|
||||
import { app } from "../app.ts";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { app } from "../app";
|
||||
import { ErrorAlert } from "../components/alert";
|
||||
import { network } from "../network/network";
|
||||
|
@@ -7,7 +7,7 @@ import {
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import StorageView from "./manage_storage_page.tsx";
|
||||
import UserView from "./manage_user_page.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { ManageMePage } from "./manage_me_page.tsx";
|
||||
import ManageServerConfigPage from "./manage_server_config_page.tsx";
|
||||
|
||||
@@ -70,9 +70,7 @@ export default function ManagePage() {
|
||||
return (
|
||||
<div className="drawer lg:drawer-open lg:pl-4">
|
||||
<input id="my-drawer-2" type="checkbox" className="drawer-toggle" />
|
||||
<div
|
||||
className="drawer-content overflow-y-auto bg-base-100-tr82 lg:m-4 rounded-md lg:p-2 h-[calc(100vh-64px)] lg:h-[calc(100vh-96px)]"
|
||||
>
|
||||
<div className="drawer-content overflow-y-auto bg-base-100-tr82 lg:m-4 rounded-md lg:p-2 h-[calc(100vh-64px)] lg:h-[calc(100vh-96px)]">
|
||||
<div className={"flex w-full h-14 items-center gap-2 px-4"}>
|
||||
<label
|
||||
className={"btn btn-square btn-ghost lg:hidden"}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { app } from "../app";
|
||||
import { ErrorAlert, InfoAlert } from "../components/alert";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -14,12 +14,15 @@ export default function ManageServerConfigPage() {
|
||||
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
|
||||
const [pinnedResources, setPinnedResources] = useState("");
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
network.getServerConfig().then((res) => {
|
||||
if (res.success) {
|
||||
setConfig(res.data!);
|
||||
setPinnedResources(res.data!.pinned_resources.join(","));
|
||||
} else {
|
||||
showToast({
|
||||
message: res.message,
|
||||
@@ -56,8 +59,25 @@ export default function ManageServerConfigPage() {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
function isPositiveInteger(str: string) {
|
||||
return /^[1-9]\d*$/.test(str);
|
||||
}
|
||||
for (const e of pinnedResources.split(",")) {
|
||||
if (!isPositiveInteger(e)) {
|
||||
showToast({
|
||||
message: "Pinned resources must be a comma separated list of numbers",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
let pinned = pinnedResources.split(",").map((id) => parseInt(id));
|
||||
setConfig({ ...config, pinned_resources: pinned });
|
||||
setIsLoading(true);
|
||||
const res = await network.setServerConfig(config);
|
||||
const res = await network.setServerConfig({
|
||||
...config,
|
||||
pinned_resources: pinned,
|
||||
});
|
||||
if (res.success) {
|
||||
showToast({
|
||||
message: t("Update server config successfully"),
|
||||
@@ -197,6 +217,14 @@ export default function ManageServerConfigPage() {
|
||||
setConfig({ ...config, upload_prompt: e.target.value });
|
||||
}}
|
||||
></Input>
|
||||
<Input
|
||||
type="text"
|
||||
value={pinnedResources}
|
||||
label="Pinned resources"
|
||||
onChange={(e) => {
|
||||
setPinnedResources(e.target.value);
|
||||
}}
|
||||
></Input>
|
||||
<InfoAlert
|
||||
className="my-2"
|
||||
message="If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download."
|
||||
|
@@ -5,7 +5,7 @@ import showToast from "../components/toast.ts";
|
||||
import Loading from "../components/loading.tsx";
|
||||
import { MdAdd, MdMoreHoriz } from "react-icons/md";
|
||||
import { ErrorAlert } from "../components/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { app } from "../app.ts";
|
||||
import showPopup, { PopupMenuItem } from "../components/popup.tsx";
|
||||
import Badge from "../components/badge.tsx";
|
||||
@@ -181,7 +181,7 @@ export default function StorageView() {
|
||||
handleSetDefault(s.id);
|
||||
}}
|
||||
>
|
||||
<a>t("Set as Default")</a>
|
||||
<a>{t("Set as Default")}</a>
|
||||
</PopupMenuItem>
|
||||
)}
|
||||
</ul>,
|
||||
|
@@ -6,7 +6,7 @@ import Loading from "../components/loading";
|
||||
import { MdMoreHoriz, MdSearch } from "react-icons/md";
|
||||
import Pagination from "../components/pagination";
|
||||
import showPopup, { PopupMenuItem } from "../components/popup";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { app } from "../app";
|
||||
import { ErrorAlert } from "../components/alert";
|
||||
|
||||
|
@@ -9,7 +9,7 @@ import {
|
||||
import { Tag } from "../network/models.ts";
|
||||
import { network } from "../network/network.ts";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { app } from "../app.ts";
|
||||
import { ErrorAlert } from "../components/alert.tsx";
|
||||
import { useAppContext } from "../components/AppContext.tsx";
|
||||
|
@@ -2,7 +2,7 @@ import { FormEvent, useEffect, useState } from "react";
|
||||
import { network } from "../network/network.ts";
|
||||
import { app } from "../app.ts";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
|
||||
export default function RegisterPage() {
|
||||
|
@@ -17,6 +17,7 @@ import {
|
||||
Comment,
|
||||
Tag,
|
||||
Resource,
|
||||
Collection,
|
||||
} from "../network/models.ts";
|
||||
import { network } from "../network/network.ts";
|
||||
import showToast from "../components/toast.ts";
|
||||
@@ -25,6 +26,7 @@ import "../markdown.css";
|
||||
import Loading from "../components/loading.tsx";
|
||||
import {
|
||||
MdAdd,
|
||||
MdOutlineAdd,
|
||||
MdOutlineArchive,
|
||||
MdOutlineArticle,
|
||||
MdOutlineComment,
|
||||
@@ -32,13 +34,15 @@ import {
|
||||
MdOutlineDelete,
|
||||
MdOutlineDownload,
|
||||
MdOutlineEdit,
|
||||
MdOutlineFolderSpecial,
|
||||
MdOutlineLink,
|
||||
MdOutlineOpenInNew,
|
||||
MdOutlineVerifiedUser,
|
||||
} from "react-icons/md";
|
||||
import { app } from "../app.ts";
|
||||
import { uploadingManager } from "../network/uploading.ts";
|
||||
import { ErrorAlert } from "../components/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import Pagination from "../components/pagination.tsx";
|
||||
import showPopup, { useClosePopup } from "../components/popup.tsx";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
@@ -57,6 +61,8 @@ import KunApi, {
|
||||
kunPlatformToString,
|
||||
kunResourceTypeToString,
|
||||
} from "../network/kun.ts";
|
||||
import { Debounce } from "../utils/debounce.ts";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
export default function ResourcePage() {
|
||||
const params = useParams();
|
||||
@@ -213,9 +219,10 @@ export default function ResourcePage() {
|
||||
</div>
|
||||
</button>
|
||||
<Tags tags={resource.tags} />
|
||||
{resource.links && (
|
||||
<p className={"px-3 mt-2"}>
|
||||
{resource.links.map((l) => {
|
||||
|
||||
<div className={"px-3 mt-2 flex flex-wrap"}>
|
||||
{resource.links &&
|
||||
resource.links.map((l) => {
|
||||
return (
|
||||
<a href={l.url} target={"_blank"}>
|
||||
<span
|
||||
@@ -233,8 +240,8 @@ export default function ResourcePage() {
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<CollectionDialog rid={resource.id} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="tabs tabs-box my-4 mx-2 p-4 shadow"
|
||||
@@ -344,7 +351,7 @@ function Tags({ tags }: { tags: Tag[] }) {
|
||||
"m-1 cursor-pointer badge-soft badge-primary shadow-xs"
|
||||
}
|
||||
onClick={() => {
|
||||
navigate(`/tag/${tag.name}`);
|
||||
navigate(`/tag/${encodeURIComponent(tag.name)}`);
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
@@ -442,6 +449,7 @@ function Article({ resource }: { resource: ResourceDetails }) {
|
||||
return (
|
||||
<article>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ node, ...props }) => {
|
||||
if (
|
||||
@@ -680,6 +688,7 @@ function fileSizeToString(size: number) {
|
||||
|
||||
function FileTile({ file }: { file: RFile }) {
|
||||
const buttonRef = createRef<HTMLButtonElement>();
|
||||
const buttonRef2 = createRef<HTMLButtonElement>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -688,8 +697,8 @@ function FileTile({ file }: { file: RFile }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className={"card shadow bg-base-100 mb-4"}>
|
||||
<div className={"p-4 flex flex-row items-center"}>
|
||||
<div className={"card shadow bg-base-100 mb-4 p-4"}>
|
||||
<div className={"flex flex-row items-center"}>
|
||||
<div className={"grow"}>
|
||||
<h4 className={"font-bold break-all"}>{file.filename}</h4>
|
||||
<div className={"text-sm my-1 comment_tile"}>
|
||||
@@ -720,11 +729,22 @@ function FileTile({ file }: { file: RFile }) {
|
||||
<MdOutlineArchive size={16} className={"inline-block"} />
|
||||
{file.is_redirect ? t("Redirect") : fileSizeToString(file.size)}
|
||||
</Badge>
|
||||
{file.hash && (
|
||||
<Badge
|
||||
className={
|
||||
"badge-soft badge-accent text-xs mr-2 break-all hidden sm:inline-flex"
|
||||
}
|
||||
selectable={true}
|
||||
>
|
||||
<MdOutlineVerifiedUser size={16} className={"inline-block"} />
|
||||
Md5: {file.hash}
|
||||
</Badge>
|
||||
)}
|
||||
<DeleteFileDialog fileId={file.id} uploaderId={file.user.id} />
|
||||
<UpdateFileInfoDialog file={file} />
|
||||
</p>
|
||||
</div>
|
||||
<div className={"flex flex-row items-center"}>
|
||||
<div className={`flex-row items-center hidden xs:flex`}>
|
||||
{file.size > 10 * 1024 * 1024 ? (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
@@ -754,6 +774,32 @@ function FileTile({ file }: { file: RFile }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse xs:hidden p-2">
|
||||
{file.size > 10 * 1024 * 1024 ? (
|
||||
<button
|
||||
ref={buttonRef2}
|
||||
className={"btn btn-primary btn-soft btn-sm"}
|
||||
onClick={() => {
|
||||
if (!app.cloudflareTurnstileSiteKey) {
|
||||
const link = network.getFileDownloadLink(file.id, "");
|
||||
window.open(link, "_blank");
|
||||
} else {
|
||||
showPopup(<CloudflarePopup file={file} />, buttonRef2.current!);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MdOutlineDownload size={20} />
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={network.getFileDownloadLink(file.id, "")}
|
||||
target="_blank"
|
||||
className={"btn btn-primary btn-soft btn-sm"}
|
||||
>
|
||||
<MdOutlineDownload size={24} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1581,3 +1627,196 @@ function KunFile({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionDialog({ rid }: { rid: number }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
|
||||
const [realSearchKeyword, setRealSearchKeyword] = useState("");
|
||||
|
||||
const [dialogVisited, setDialogVisited] = useState(false);
|
||||
|
||||
const [selectedCID, setSelectedCID] = useState<number | null>(null);
|
||||
|
||||
const debounce = new Debounce(500);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const delayedSetSearchKeyword = (keyword: string) => {
|
||||
setSearchKeyword(keyword);
|
||||
debounce.run(() => {
|
||||
setSelectedCID(null);
|
||||
setRealSearchKeyword(keyword);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddToCollection = () => {
|
||||
if (selectedCID == null) {
|
||||
return;
|
||||
}
|
||||
network.addResourceToCollection(selectedCID, rid).then((res) => {
|
||||
if (res.success) {
|
||||
showToast({
|
||||
message: t("Resource added to collection successfully"),
|
||||
type: "success",
|
||||
});
|
||||
setSelectedCID(null);
|
||||
setRealSearchKeyword("");
|
||||
setSearchKeyword("");
|
||||
setDialogVisited(false);
|
||||
const dialog = document.getElementById(
|
||||
"collection_dialog",
|
||||
) as HTMLDialogElement;
|
||||
dialog.close();
|
||||
} else {
|
||||
showToast({
|
||||
message: res.message,
|
||||
type: "error",
|
||||
parent: document.getElementById("collection_dialog_content"),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!app.isLoggedIn()) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={
|
||||
"py-1 px-3 inline-flex items-center m-1 border border-base-300 bg-base-100 opacity-90 rounded-2xl hover:bg-base-200 transition-colors cursor-pointer select-none"
|
||||
}
|
||||
onClick={() => {
|
||||
setDialogVisited(true);
|
||||
const dialog = document.getElementById(
|
||||
"collection_dialog",
|
||||
) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}
|
||||
>
|
||||
<MdOutlineFolderSpecial size={20} />
|
||||
<span className={"ml-2 text-sm"}>{t("Collect")}</span>
|
||||
</span>
|
||||
<dialog id="collection_dialog" className="modal">
|
||||
<div className="modal-box" id="collection_dialog_content">
|
||||
<h3 className="font-bold text-lg mb-2">{t("Add to Collection")}</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
className="input input-bordered w-full max-w-2xs mr-2"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => delayedSetSearchKeyword(e.target.value)}
|
||||
/>
|
||||
{dialogVisited && (
|
||||
<CollectionSelector
|
||||
resourceId={rid}
|
||||
keyword={realSearchKeyword}
|
||||
seletedID={selectedCID}
|
||||
selectCallback={(collection) => {
|
||||
if (selectedCID === collection.id) {
|
||||
setSelectedCID(null);
|
||||
} else {
|
||||
setSelectedCID(collection.id);
|
||||
}
|
||||
}}
|
||||
key={realSearchKeyword}
|
||||
/>
|
||||
)}
|
||||
<div className="modal-action">
|
||||
<Button
|
||||
className="btn-ghost"
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
"collection_dialog",
|
||||
) as HTMLDialogElement;
|
||||
dialog.close();
|
||||
navigate("/create-collection");
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<MdOutlineAdd size={20} className={"inline-block mr-1"} />
|
||||
{t("Create")}
|
||||
</div>
|
||||
</Button>
|
||||
<span className="flex-1"></span>
|
||||
<form method="dialog">
|
||||
<Button className="btn">{t("Cancel")}</Button>
|
||||
</form>
|
||||
<Button
|
||||
className="btn-primary"
|
||||
disabled={selectedCID == null}
|
||||
onClick={handleAddToCollection}
|
||||
>
|
||||
{t("Add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionSelector({
|
||||
resourceId,
|
||||
keyword,
|
||||
seletedID: selectedID,
|
||||
selectCallback,
|
||||
}: {
|
||||
resourceId: number;
|
||||
keyword: string;
|
||||
seletedID?: number | null;
|
||||
selectCallback: (collection: Collection) => void;
|
||||
}) {
|
||||
const [collections, setCollections] = useState<Collection[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCollections(null);
|
||||
network
|
||||
.searchUserCollections(app.user!.username, keyword, resourceId)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
setCollections(res.data! || []);
|
||||
} else {
|
||||
showToast({
|
||||
message: res.message,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [keyword]);
|
||||
|
||||
if (collections == null) {
|
||||
return (
|
||||
<div className={"w-full"}>
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-2 max-h-80 overflow-y-auto w-full overflow-x-clip">
|
||||
{collections.map((collection) => {
|
||||
return (
|
||||
<div
|
||||
className={`${selectedID === collection.id && "bg-base-200 shadow"} rounded-lg transition-all p-2 hover:bg-base-200 w-full overflow-ellipsis hover:cursor-pointer`}
|
||||
key={collection.id}
|
||||
onClick={() => {
|
||||
selectCallback(collection);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary mr-2"
|
||||
checked={selectedID === collection.id}
|
||||
readOnly
|
||||
/>
|
||||
{collection.title}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@ import { useSearchParams } from "react-router";
|
||||
import { network } from "../network/network.ts";
|
||||
import ResourcesView from "../components/resources_view.tsx";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
|
||||
export default function SearchPage() {
|
||||
const [params, _] = useSearchParams();
|
||||
|
@@ -3,7 +3,7 @@ import { ErrorAlert } from "../components/alert.tsx";
|
||||
import ResourcesView from "../components/resources_view.tsx";
|
||||
import { network } from "../network/network.ts";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { Tag } from "../network/models.ts";
|
||||
import Button from "../components/button.tsx";
|
||||
import Markdown from "react-markdown";
|
||||
|
@@ -62,7 +62,7 @@ export default function TagsPage() {
|
||||
{tags.map((tag) => (
|
||||
<Badge
|
||||
onClick={() => {
|
||||
navigate(`/tag/${tag.name}`);
|
||||
navigate(`/tag/${encodeURIComponent(tag.name)}`);
|
||||
}}
|
||||
key={tag.name}
|
||||
className={
|
||||
|
@@ -1,5 +1,11 @@
|
||||
import { useParams, useLocation, useNavigate } from "react-router";
|
||||
import { CommentWithResource, RFile, User } from "../network/models";
|
||||
import {
|
||||
Collection,
|
||||
CommentWithResource,
|
||||
PageResponse,
|
||||
RFile,
|
||||
User,
|
||||
} from "../network/models";
|
||||
import { network } from "../network/network";
|
||||
import showToast from "../components/toast";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@@ -9,13 +15,16 @@ import Pagination from "../components/pagination";
|
||||
import { CommentTile } from "../components/comment_tile.tsx";
|
||||
import Badge from "../components/badge.tsx";
|
||||
import {
|
||||
MdOutlineAdd,
|
||||
MdOutlineArchive,
|
||||
MdOutlineComment,
|
||||
MdOutlineLock,
|
||||
MdOutlinePhotoAlbum,
|
||||
} from "react-icons/md";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { app } from "../app.ts";
|
||||
import Markdown from "react-markdown";
|
||||
import { Debounce } from "../utils/debounce.ts";
|
||||
|
||||
export default function UserPage() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
@@ -24,15 +33,17 @@ export default function UserPage() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 解码用户名,确保特殊字符被还原
|
||||
const username = rawUsername ? decodeURIComponent(rawUsername) : "";
|
||||
|
||||
// 从 hash 中获取当前页面,默认为 resources
|
||||
// 从 hash 中获取当前页面,默认为 collections
|
||||
const getPageFromHash = useCallback(() => {
|
||||
const hash = location.hash.slice(1); // 移除 # 号
|
||||
if (hash === "comments") return 1;
|
||||
if (hash === "files") return 2;
|
||||
return 0; // 默认为 resources
|
||||
const hashs = ["collections", "resources", "comments", "files"];
|
||||
const index = hashs.indexOf(hash);
|
||||
return index !== -1 ? index : 0; // 如果 hash 不在预定义的列表中,默认为 0
|
||||
}, [location.hash]);
|
||||
|
||||
const [page, setPage] = useState(getPageFromHash());
|
||||
@@ -44,8 +55,8 @@ export default function UserPage() {
|
||||
|
||||
// 更新 hash 的函数
|
||||
const updateHash = (newPage: number) => {
|
||||
const hashs = ["resources", "comments", "files"];
|
||||
const newHash = hashs[newPage] || "resources";
|
||||
const hashs = ["collections", "resources", "comments", "files"];
|
||||
const newHash = hashs[newPage] || "collections";
|
||||
if (location.hash.slice(1) !== newHash) {
|
||||
navigate(`/user/${username}#${newHash}`, { replace: true });
|
||||
}
|
||||
@@ -93,27 +104,35 @@ export default function UserPage() {
|
||||
className={`tab ${page === 0 ? "tab-active" : ""} `}
|
||||
onClick={() => updateHash(0)}
|
||||
>
|
||||
Resources
|
||||
{t("Collections")}
|
||||
</div>
|
||||
<div
|
||||
role="tab"
|
||||
className={`tab ${page === 1 ? "tab-active" : ""}`}
|
||||
className={`tab ${page === 1 ? "tab-active" : ""} `}
|
||||
onClick={() => updateHash(1)}
|
||||
>
|
||||
Comments
|
||||
{t("Resources")}
|
||||
</div>
|
||||
<div
|
||||
role="tab"
|
||||
className={`tab ${page === 2 ? "tab-active" : ""}`}
|
||||
onClick={() => updateHash(2)}
|
||||
>
|
||||
Files
|
||||
{t("Comments")}
|
||||
</div>
|
||||
<div
|
||||
role="tab"
|
||||
className={`tab ${page === 3 ? "tab-active" : ""}`}
|
||||
onClick={() => updateHash(3)}
|
||||
>
|
||||
{t("Files")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
{page === 0 && <UserResources user={user} />}
|
||||
{page === 1 && <UserComments user={user} />}
|
||||
{page === 2 && <UserFiles user={user} />}
|
||||
{page === 0 && <Collections username={username} />}
|
||||
{page === 1 && <UserResources user={user} />}
|
||||
{page === 2 && <UserComments user={user} />}
|
||||
{page === 3 && <UserFiles user={user} />}
|
||||
</div>
|
||||
<div className="h-16"></div>
|
||||
</div>
|
||||
@@ -365,3 +384,168 @@ function FilesList({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Collections({ username }: { username?: string }) {
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
|
||||
const [realSearchKeyword, setRealSearchKeyword] = useState("");
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const debounce = new Debounce(500);
|
||||
|
||||
const delayedSetSearchKeyword = (keyword: string) => {
|
||||
setSearchKeyword(keyword);
|
||||
debounce.run(() => {
|
||||
setRealSearchKeyword(keyword);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex m-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
className="input input-bordered w-full max-w-2xs mr-2"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => delayedSetSearchKeyword(e.target.value)}
|
||||
/>
|
||||
<span className="flex-1" />
|
||||
{username == app.user?.username && (
|
||||
<button
|
||||
className="btn btn-primary btn-soft"
|
||||
onClick={() => {
|
||||
navigate("/create-collection");
|
||||
}}
|
||||
>
|
||||
<MdOutlineAdd size={20} className="inline-block mr-1" />
|
||||
{t("Create")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<CollectionsList
|
||||
username={username}
|
||||
keyword={realSearchKeyword}
|
||||
key={realSearchKeyword}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function getOrSearchUserCollections(
|
||||
username: string,
|
||||
keyword: string,
|
||||
page: number,
|
||||
): Promise<PageResponse<Collection>> {
|
||||
if (keyword.trim() === "") {
|
||||
return network.listUserCollections(username, page);
|
||||
} else {
|
||||
let res = await network.searchUserCollections(username, keyword);
|
||||
return {
|
||||
success: res.success,
|
||||
data: res.data || [],
|
||||
totalPages: 1,
|
||||
message: res.message || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function CollectionsList({
|
||||
username,
|
||||
keyword,
|
||||
}: {
|
||||
username?: string;
|
||||
keyword: string;
|
||||
}) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [maxPage, setMaxPage] = useState(1);
|
||||
const [collections, setCollections] = useState<Collection[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
setCollections(null);
|
||||
getOrSearchUserCollections(username, keyword, page).then((res) => {
|
||||
if (res.success) {
|
||||
setCollections(res.data! || []);
|
||||
setMaxPage(res.totalPages || 1);
|
||||
} else {
|
||||
showToast({
|
||||
message: res.message,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [username, keyword, page]);
|
||||
|
||||
if (collections == null) {
|
||||
return (
|
||||
<div className={"w-full"}>
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{collections.map((collection) => {
|
||||
return <CollectionCard collection={collection} key={collection.id} />;
|
||||
})}
|
||||
{maxPage > 1 ? (
|
||||
<div className={"w-full flex justify-center"}>
|
||||
<Pagination page={page} setPage={setPage} totalPages={maxPage} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionCard({ collection }: { collection: Collection }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"card m-4 p-2 bg-base-100-tr82 shadow hover:shadow-md transition-shadow cursor-pointer"
|
||||
}
|
||||
onClick={() => {
|
||||
navigate(`/collection/${collection.id}`);
|
||||
}}
|
||||
>
|
||||
<h3 className={"card-title mx-2 mt-2"}>{collection.title}</h3>
|
||||
<div className={"p-2 comment_tile"}>
|
||||
<CollectionContent content={collection.article} />
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Badge className="badge-soft badge-primary text-xs mr-2">
|
||||
<MdOutlinePhotoAlbum size={16} className="inline-block" />
|
||||
{collection.resources_count} {t("Resources")}
|
||||
</Badge>
|
||||
<span className="flex-1" />
|
||||
{!collection.isPublic && (
|
||||
<Badge className="badge-soft badge-error text-xs mr-2 shadow-xs">
|
||||
<MdOutlineLock size={16} className="inline-block" /> {t("Private")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionContent({ content }: { content: string }) {
|
||||
const lines = content.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i];
|
||||
if (!line.endsWith(" ")) {
|
||||
// Ensure that each line ends with two spaces for Markdown to recognize it as a line break
|
||||
lines[i] = line + " ";
|
||||
}
|
||||
}
|
||||
content = lines.join("\n");
|
||||
|
||||
return <Markdown>{content}</Markdown>;
|
||||
}
|
||||
|
24
frontend/src/utils/debounce.ts
Normal file
24
frontend/src/utils/debounce.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export class Debounce {
|
||||
private timer: number | null = null;
|
||||
private readonly delay: number;
|
||||
|
||||
constructor(delay: number) {
|
||||
this.delay = delay;
|
||||
}
|
||||
|
||||
run(callback: () => void) {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
callback();
|
||||
}, this.delay);
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
21
frontend/src/utils/i18n.ts
Normal file
21
frontend/src/utils/i18n.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
function t(data: any, language: string) {
|
||||
return (key: string) => {
|
||||
return data[language]?.["translation"]?.[key] || key;
|
||||
};
|
||||
}
|
||||
|
||||
export const i18nContext = createContext<any>({});
|
||||
|
||||
export function useTranslation() {
|
||||
const data = useContext(i18nContext);
|
||||
const userLang = navigator.language;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
t: t(data, userLang),
|
||||
}),
|
||||
[data, userLang],
|
||||
);
|
||||
}
|
66
go.mod
66
go.mod
@@ -1,23 +1,47 @@
|
||||
module nysoure
|
||||
|
||||
go 1.24
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gofiber/fiber/v3 v3.0.0-beta.4
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
golang.org/x/crypto v0.37.0
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.26.1
|
||||
github.com/gofiber/fiber/v3 v3.0.0-rc.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
golang.org/x/crypto v0.42.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/blevesearch/bleve v1.0.14
|
||||
github.com/chai2010/webp v1.4.0
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/RoaringBitmap/roaring v0.4.23 // indirect
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
|
||||
github.com/blevesearch/mmap-go v1.0.2 // indirect
|
||||
github.com/blevesearch/segment v0.9.0 // indirect
|
||||
github.com/blevesearch/snowballstem v0.9.0 // indirect
|
||||
github.com/blevesearch/zap/v11 v11.0.14 // indirect
|
||||
github.com/blevesearch/zap/v12 v12.0.14 // indirect
|
||||
github.com/blevesearch/zap/v13 v13.0.6 // indirect
|
||||
github.com/blevesearch/zap/v14 v14.0.5 // indirect
|
||||
github.com/blevesearch/zap/v15 v15.0.3 // indirect
|
||||
github.com/couchbase/vellum v1.0.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/golang/protobuf v1.3.2 // indirect
|
||||
github.com/golang/snappy v0.0.1 // indirect
|
||||
github.com/mschoch/smat v0.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/steveyen/gtreap v0.1.0 // indirect
|
||||
github.com/willf/bitset v1.1.10 // indirect
|
||||
go.etcd.io/bbolt v1.3.5 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -32,11 +56,10 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
|
||||
github.com/gofiber/schema v1.3.0 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.0-beta.8 // indirect
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/gofiber/schema v1.6.0 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.1 // indirect
|
||||
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -45,13 +68,12 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.28 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
github.com/tinylib/msgp v1.2.5 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.61.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/image v0.28.0
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
github.com/valyala/fasthttp v1.65.0 // indirect
|
||||
golang.org/x/image v0.31.0
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
|
204
go.sum
204
go.sum
@@ -1,39 +1,102 @@
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/RoaringBitmap/roaring v0.4.23 h1:gpyfd12QohbqhFO4NVDUdoPOCXsyahYRQhINmlHxKeo=
|
||||
github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/blevesearch/bleve v1.0.14 h1:Q8r+fHTt35jtGXJUM0ULwM3Tzg+MRfyai4ZkWDy2xO4=
|
||||
github.com/blevesearch/bleve v1.0.14/go.mod h1:e/LJTr+E7EaoVdkQZTfoz7dt4KoDNvDbLb8MSKuNTLQ=
|
||||
github.com/blevesearch/blevex v1.0.0 h1:pnilj2Qi3YSEGdWgLj1Pn9Io7ukfXPoQcpAI1Bv8n/o=
|
||||
github.com/blevesearch/blevex v1.0.0/go.mod h1:2rNVqoG2BZI8t1/P1awgTKnGlx5MP9ZbtEciQaNhswc=
|
||||
github.com/blevesearch/cld2 v0.0.0-20200327141045-8b5f551d37f5/go.mod h1:PN0QNTLs9+j1bKy3d/GB/59wsNBFC4sWLWG3k69lWbc=
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
|
||||
github.com/blevesearch/mmap-go v1.0.2 h1:JtMHb+FgQCTTYIhtMvimw15dJwu1Y5lrZDMOFXVWPk0=
|
||||
github.com/blevesearch/mmap-go v1.0.2/go.mod h1:ol2qBqYaOUsGdm7aRMRrYGgPvnwLe6Y+7LMvAB5IbSA=
|
||||
github.com/blevesearch/segment v0.9.0 h1:5lG7yBCx98or7gK2cHMKPukPZ/31Kag7nONpoBt22Ac=
|
||||
github.com/blevesearch/segment v0.9.0/go.mod h1:9PfHYUdQCgHktBgvtUOF4x+pc4/l8rdH0u5spnW85UQ=
|
||||
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
|
||||
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
|
||||
github.com/blevesearch/zap/v11 v11.0.14 h1:IrDAvtlzDylh6H2QCmS0OGcN9Hpf6mISJlfKjcwJs7k=
|
||||
github.com/blevesearch/zap/v11 v11.0.14/go.mod h1:MUEZh6VHGXv1PKx3WnCbdP404LGG2IZVa/L66pyFwnY=
|
||||
github.com/blevesearch/zap/v12 v12.0.14 h1:2o9iRtl1xaRjsJ1xcqTyLX414qPAwykHNV7wNVmbp3w=
|
||||
github.com/blevesearch/zap/v12 v12.0.14/go.mod h1:rOnuZOiMKPQj18AEKEHJxuI14236tTQ1ZJz4PAnWlUg=
|
||||
github.com/blevesearch/zap/v13 v13.0.6 h1:r+VNSVImi9cBhTNNR+Kfl5uiGy8kIbb0JMz/h8r6+O4=
|
||||
github.com/blevesearch/zap/v13 v13.0.6/go.mod h1:L89gsjdRKGyGrRN6nCpIScCvvkyxvmeDCwZRcjjPCrw=
|
||||
github.com/blevesearch/zap/v14 v14.0.5 h1:NdcT+81Nvmp2zL+NhwSvGSLh7xNgGL8QRVZ67njR0NU=
|
||||
github.com/blevesearch/zap/v14 v14.0.5/go.mod h1:bWe8S7tRrSBTIaZ6cLRbgNH4TUDaC9LZSpRGs85AsGY=
|
||||
github.com/blevesearch/zap/v15 v15.0.3 h1:Ylj8Oe+mo0P25tr9iLPp33lN6d4qcztGjaIsP51UxaY=
|
||||
github.com/blevesearch/zap/v15 v15.0.3/go.mod h1:iuwQrImsh1WjWJ0Ue2kBqY83a0rFtJTqfa9fp1rbVVU=
|
||||
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
|
||||
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/couchbase/ghistogram v0.1.0/go.mod h1:s1Jhy76zqfEecpNWJfWUiKZookAFaiGOEoyzgHt9i7k=
|
||||
github.com/couchbase/moss v0.1.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs=
|
||||
github.com/couchbase/vellum v1.0.2 h1:BrbP0NKiyDdndMPec8Jjhy0U47CZ0Lgx3xUC2r9rZqw=
|
||||
github.com/couchbase/vellum v1.0.2/go.mod h1:FcwrEivFpNi24R3jLOs3n+fs5RnuQnQqCLBJ1uAg1W4=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d h1:SwD98825d6bdB+pEuTxWOXiSjBrHdOl/UVp75eI7JT8=
|
||||
github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
|
||||
github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
|
||||
github.com/cznic/strutil v0.0.0-20181122101858-275e90344537/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
|
||||
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
|
||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
|
||||
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2 h1:Ujru1hufTHVb++eG6OuNDKMxZnGIvF6o/u8q/8h2+I4=
|
||||
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31 h1:gclg6gY70GLy3PbkQ1AERPfmLMMagS60DKF78eWwLn8=
|
||||
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gofiber/fiber/v3 v3.0.0-beta.4 h1:KzDSavvhG7m81NIsmnu5l3ZDbVS4feCidl4xlIfu6V0=
|
||||
github.com/gofiber/fiber/v3 v3.0.0-beta.4/go.mod h1:/WFUoHRkZEsGHyy2+fYcdqi109IVOFbVwxv1n1RU+kk=
|
||||
github.com/gofiber/schema v1.3.0 h1:K3F3wYzAY+aivfCCEHPufCthu5/13r/lzp1nuk6mr3Q=
|
||||
github.com/gofiber/schema v1.3.0/go.mod h1:YYwj01w3hVfaNjhtJzaqetymL56VW642YS3qZPhuE6c=
|
||||
github.com/gofiber/utils/v2 v2.0.0-beta.8 h1:ZifwbHZqZO3YJsx1ZhDsWnPjaQ7C0YD20LHt+DQeXOU=
|
||||
github.com/gofiber/utils/v2 v2.0.0-beta.8/go.mod h1:1lCBo9vEF4RFEtTgWntipnaScJZQiM8rrsYycLZ4n9c=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gofiber/fiber/v3 v3.0.0-rc.1 h1:034MxesK6bqGkidP+QR+Ysc1ukOacBWOHCarCKC1xfg=
|
||||
github.com/gofiber/fiber/v3 v3.0.0-rc.1/go.mod h1:hFdT00oT0XVuQH1/z2i5n1pl/msExHDUie1SsLOkCuM=
|
||||
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
|
||||
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.1 h1:b77K5Rk9+Pjdxz4HlwEBnS7u5nikhx7armQB8xPds4s=
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.1/go.mod h1:Y1g08g7gvST49bbjHJ1AVqcsmg93912R/tbKWhn6V3E=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99 h1:twflg0XRTjwKpxb/jFExr4HGq6on2dEOmnL6FV+fgPw=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ikawaha/kagome.ipadic v1.1.2/go.mod h1:DPSBbU0czaJhAb/5uKQZHMc9MTVRpDugJfX+HddPHHg=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jmhodges/levigo v1.0.0 h1:q5EC36kV79HWeTBWsod3mG11EgStG3qArTKcvlksN1U=
|
||||
github.com/jmhodges/levigo v1.0.0/go.mod h1:Q6Qx+uH3RAqyK4rFQroq9RL7mdkABMcfhEI+nNuzMJQ=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY=
|
||||
@@ -43,6 +106,8 @@ github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYW
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kljensen/snowball v0.6.0/go.mod h1:27N7E8fVU5H68RlUmnWwZCfxgt4POBJfENGMvNRhldw=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -55,55 +120,102 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.91 h1:tWLZnEfo3OZl5PoXQwcwTAPNNrjyWwOh6cbZitW5JQc=
|
||||
github.com/minio/minio-go/v7 v7.0.91/go.mod h1:uvMUcGrpgeSAAI6+sD3818508nUyMULw94j2Nxku/Go=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
|
||||
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/shamaton/msgpack/v2 v2.3.0 h1:eawIa7lQmwRv0V6rdmL/5Ev9KdJHk07eQH3ceJi3BUw=
|
||||
github.com/shamaton/msgpack/v2 v2.3.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
|
||||
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/steveyen/gtreap v0.1.0 h1:CjhzTa274PyJLJuMZwIzCO1PfC00oRa8d1Kc78bFXJM=
|
||||
github.com/steveyen/gtreap v0.1.0/go.mod h1:kl/5J7XbrOmlIbYIXdRHDDE5QxHqpk0cmkT7Z4dM9/Y=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
||||
github.com/tebeka/snowball v0.4.2/go.mod h1:4IfL14h1lvwZcp1sfXuuc7/7yCsvVffTWxWxCLfFpYg=
|
||||
github.com/tecbot/gorocksdb v0.0.0-20191217155057-f0fad39f321c h1:g+WoO5jjkqGAzHWCjJB1zZfXPIAaDpzXIEJ0eS6B5Ok=
|
||||
github.com/tecbot/gorocksdb v0.0.0-20191217155057-f0fad39f321c/go.mod h1:ahpPrc7HpcfEWDQRZEmnXMzHY03mLDYMCxeDzy46i+8=
|
||||
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.61.0 h1:VV08V0AfoRaFurP1EWKvQQdPTZHiUzaVoulX1aBDgzU=
|
||||
github.com/valyala/fasthttp v1.61.0/go.mod h1:wRIV/4cMwUPWnRcDno9hGnYZGh78QzODFfo1LTUhBog=
|
||||
github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
|
||||
github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4=
|
||||
github.com/willf/bitset v1.1.10 h1:NotGKqX0KwQ72NUzqrjZq5ipPNDQex9lo3WpaS8L2sc=
|
||||
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
|
||||
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
||||
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
||||
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
||||
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
|
||||
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
|
||||
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
|
8
main.go
8
main.go
@@ -1,11 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/logger"
|
||||
"log"
|
||||
"nysoure/server/api"
|
||||
"nysoure/server/middleware"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -20,6 +21,8 @@ func main() {
|
||||
|
||||
app.Use(middleware.ErrorHandler)
|
||||
|
||||
app.Use(middleware.RealUserMiddleware)
|
||||
|
||||
app.Use(middleware.JwtMiddleware)
|
||||
|
||||
app.Use(middleware.FrontendMiddleware)
|
||||
@@ -35,6 +38,7 @@ func main() {
|
||||
api.AddCommentRoutes(apiG)
|
||||
api.AddConfigRoutes(apiG)
|
||||
api.AddActivityRoutes(apiG)
|
||||
api.AddCollectionRoutes(apiG) // 新增
|
||||
}
|
||||
|
||||
log.Fatal(app.Listen(":3000"))
|
||||
|
260
server/api/collection.go
Normal file
260
server/api/collection.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"nysoure/server/model"
|
||||
"nysoure/server/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func handleCreateCollection(c fiber.Ctx) error {
|
||||
uid, ok := c.Locals("uid").(uint)
|
||||
if !ok {
|
||||
return model.NewUnAuthorizedError("Unauthorized")
|
||||
}
|
||||
title := c.FormValue("title")
|
||||
article := c.FormValue("article")
|
||||
publicStr := c.FormValue("public")
|
||||
public := false
|
||||
if publicStr == "true" || publicStr == "1" {
|
||||
public = true
|
||||
}
|
||||
if title == "" || article == "" {
|
||||
return model.NewRequestError("Title and article are required")
|
||||
}
|
||||
host := c.Hostname()
|
||||
col, err := service.CreateCollection(uid, title, article, host, public)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(model.Response[model.CollectionView]{
|
||||
Success: true,
|
||||
Data: *col,
|
||||
Message: "Collection created successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func handleUpdateCollection(c fiber.Ctx) error {
|
||||
uid, ok := c.Locals("uid").(uint)
|
||||
if !ok {
|
||||
return model.NewUnAuthorizedError("Unauthorized")
|
||||
}
|
||||
idStr := c.FormValue("id")
|
||||
title := c.FormValue("title")
|
||||
article := c.FormValue("article")
|
||||
publicStr := c.FormValue("public")
|
||||
public := false
|
||||
if publicStr == "true" || publicStr == "1" {
|
||||
public = true
|
||||
}
|
||||
if idStr == "" || title == "" || article == "" {
|
||||
return model.NewRequestError("ID, title and article are required")
|
||||
}
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
return model.NewRequestError("Invalid collection ID")
|
||||
}
|
||||
host := c.Hostname()
|
||||
if err := service.UpdateCollection(uid, uint(id), title, article, host, public); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
|
||||
Success: true,
|
||||
Message: "Collection updated successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func handleDeleteCollection(c fiber.Ctx) error {
|
||||
uid, ok := c.Locals("uid").(uint)
|
||||
if !ok {
|
||||
return model.NewUnAuthorizedError("Unauthorized")
|
||||
}
|
||||
idStr := c.FormValue("id")
|
||||
if idStr == "" {
|
||||
return model.NewRequestError("ID is required")
|
||||
}
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
return model.NewRequestError("Invalid collection ID")
|
||||
}
|
||||
if err := service.DeleteCollection(uid, uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
|
||||
Success: true,
|
||||
Message: "Collection deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetCollection(c fiber.Ctx) error {
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
return model.NewRequestError("Invalid collection ID")
|
||||
}
|
||||
|
||||
// Get viewer UID (0 if not authenticated)
|
||||
viewerUID, _ := c.Locals("uid").(uint)
|
||||
|
||||
col, err := service.GetCollectionByID(uint(id), viewerUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(model.Response[model.CollectionView]{
|
||||
Success: true,
|
||||
Data: *col,
|
||||
Message: "Collection retrieved successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func handleListUserCollections(c fiber.Ctx) error {
|
||||
pageStr := c.Query("page", "1")
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil || page < 1 {
|
||||
page = 1
|
||||
}
|
||||
username := c.Query("username", "")
|
||||
if username == "" {
|
||||
return model.NewRequestError("Username is required")
|
||||
}
|
||||
|
||||
// Get viewer UID (0 if not authenticated)
|
||||
viewerUID, _ := c.Locals("uid").(uint)
|
||||
|
||||
cols, total, err := service.ListUserCollections(username, page, viewerUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(model.PageResponse[*model.CollectionView]{
|
||||
Success: true,
|
||||
TotalPages: int(total),
|
||||
Data: cols,
|
||||
Message: "Collections retrieved successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func handleListCollectionResources(c fiber.Ctx) error {
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
return model.NewRequestError("Invalid collection ID")
|
||||
}
|
||||
pageStr := c.Query("page", "1")
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil || page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
// Get viewer UID (0 if not authenticated)
|
||||
viewerUID, _ := c.Locals("uid").(uint)
|
||||
|
||||
res, total, err := service.ListCollectionResources(uint(id), page, viewerUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(model.PageResponse[*model.ResourceView]{
|
||||
Success: true,
|
||||
TotalPages: int(total),
|
||||
Data: res,
|
||||
Message: "Resources retrieved successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func handleAddResourceToCollection(c fiber.Ctx) error {
|
||||
uid, ok := c.Locals("uid").(uint)
|
||||
if !ok {
|
||||
return model.NewUnAuthorizedError("Unauthorized")
|
||||
}
|
||||
collectionIDStr := c.FormValue("collection_id")
|
||||
resourceIDStr := c.FormValue("resource_id")
|
||||
if collectionIDStr == "" || resourceIDStr == "" {
|
||||
return model.NewRequestError("collection_id and resource_id are required")
|
||||
}
|
||||
collectionID, err := strconv.Atoi(collectionIDStr)
|
||||
if err != nil {
|
||||
return model.NewRequestError("Invalid collection_id")
|
||||
}
|
||||
resourceID, err := strconv.Atoi(resourceIDStr)
|
||||
if err != nil {
|
||||
return model.NewRequestError("Invalid resource_id")
|
||||
}
|
||||
if err := service.AddResourceToCollection(uid, uint(collectionID), uint(resourceID)); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
|
||||
Success: true,
|
||||
Message: "Resource added to collection successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func handleRemoveResourceFromCollection(c fiber.Ctx) error {
|
||||
uid, ok := c.Locals("uid").(uint)
|
||||
if !ok {
|
||||
return model.NewUnAuthorizedError("Unauthorized")
|
||||
}
|
||||
collectionIDStr := c.FormValue("collection_id")
|
||||
resourceIDStr := c.FormValue("resource_id")
|
||||
if collectionIDStr == "" || resourceIDStr == "" {
|
||||
return model.NewRequestError("collection_id and resource_id are required")
|
||||
}
|
||||
collectionID, err := strconv.Atoi(collectionIDStr)
|
||||
if err != nil {
|
||||
return model.NewRequestError("Invalid collection_id")
|
||||
}
|
||||
resourceID, err := strconv.Atoi(resourceIDStr)
|
||||
if err != nil {
|
||||
return model.NewRequestError("Invalid resource_id")
|
||||
}
|
||||
if err := service.RemoveResourceFromCollection(uid, uint(collectionID), uint(resourceID)); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
|
||||
Success: true,
|
||||
Message: "Resource removed from collection successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func handleSearchUserCollections(c fiber.Ctx) error {
|
||||
keyword := c.Query("keyword", "")
|
||||
// if keyword == "" {
|
||||
// return model.NewRequestError("keyword is required")
|
||||
// }
|
||||
username := c.Query("username", "")
|
||||
if username == "" {
|
||||
return model.NewRequestError("username is required")
|
||||
}
|
||||
excludedRIDStr := c.Query("excludedRID", "")
|
||||
var excludedRID uint = 0
|
||||
if excludedRIDStr != "" {
|
||||
if rid, err := strconv.Atoi(excludedRIDStr); err == nil && rid > 0 {
|
||||
excludedRID = uint(rid)
|
||||
}
|
||||
}
|
||||
|
||||
// Get viewer UID (0 if not authenticated)
|
||||
viewerUID, _ := c.Locals("uid").(uint)
|
||||
|
||||
cols, err := service.SearchUserCollections(username, keyword, excludedRID, viewerUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(model.Response[[]*model.CollectionView]{
|
||||
Success: true,
|
||||
Data: cols,
|
||||
Message: "Collections found successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func AddCollectionRoutes(r fiber.Router) {
|
||||
cg := r.Group("collection")
|
||||
cg.Post("/create", handleCreateCollection)
|
||||
cg.Post("/update", handleUpdateCollection)
|
||||
cg.Post("/delete", handleDeleteCollection)
|
||||
cg.Get("/list", handleListUserCollections)
|
||||
cg.Post("/add_resource", handleAddResourceToCollection)
|
||||
cg.Post("/remove_resource", handleRemoveResourceFromCollection)
|
||||
cg.Get("/search", handleSearchUserCollections)
|
||||
cg.Get("/:id/resources", handleListCollectionResources)
|
||||
cg.Get("/:id", handleGetCollection)
|
||||
}
|
@@ -48,7 +48,13 @@ func setServerConfig(c fiber.Ctx) error {
|
||||
return model.NewRequestError("Invalid request parameters")
|
||||
}
|
||||
|
||||
config.SetConfig(sc)
|
||||
if err := config.SetConfig(sc); err != nil {
|
||||
return model.NewInternalServerError("Failed to save configuration")
|
||||
}
|
||||
|
||||
if err := sc.Validate(); err != nil {
|
||||
return model.NewRequestError(err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(model.Response[any]{
|
||||
Success: true,
|
||||
|
@@ -205,7 +205,7 @@ func deleteFile(c fiber.Ctx) error {
|
||||
|
||||
func downloadFile(c fiber.Ctx) error {
|
||||
cfToken := c.Query("cf_token")
|
||||
s, filename, err := service.DownloadFile(c.Params("id"), cfToken)
|
||||
s, filename, err := service.DownloadFile(c.Params("id"), cfToken, c.Locals("real_user") == true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@@ -56,7 +56,7 @@ func handleGetResource(c fiber.Ctx) error {
|
||||
return model.NewRequestError("Invalid resource ID")
|
||||
}
|
||||
host := c.Hostname()
|
||||
resource, err := service.GetResource(uint(id), host)
|
||||
resource, err := service.GetResource(uint(id), host, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -267,6 +267,21 @@ func handleGetRandomResource(c fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetPinnedResources(c fiber.Ctx) error {
|
||||
views, err := service.GetPinnedResources()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if views == nil {
|
||||
views = []model.ResourceView{}
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(model.Response[[]model.ResourceView]{
|
||||
Success: true,
|
||||
Data: views,
|
||||
Message: "Pinned resources retrieved successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func AddResourceRoutes(api fiber.Router) {
|
||||
resource := api.Group("/resource")
|
||||
{
|
||||
@@ -274,6 +289,7 @@ func AddResourceRoutes(api fiber.Router) {
|
||||
resource.Get("/search", handleSearchResources)
|
||||
resource.Get("/", handleListResources)
|
||||
resource.Get("/random", handleGetRandomResource)
|
||||
resource.Get("/pinned", handleGetPinnedResources)
|
||||
resource.Get("/:id", handleGetResource)
|
||||
resource.Delete("/:id", handleDeleteResource)
|
||||
resource.Get("/tag/:tag", handleListResourcesWithTag)
|
||||
|
@@ -1,12 +1,17 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"net/url"
|
||||
"nysoure/server/model"
|
||||
"nysoure/server/service"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTagNameLength = 20
|
||||
)
|
||||
|
||||
func handleCreateTag(c fiber.Ctx) error {
|
||||
@@ -15,6 +20,9 @@ func handleCreateTag(c fiber.Ctx) error {
|
||||
return model.NewRequestError("name is required")
|
||||
}
|
||||
tag = strings.TrimSpace(tag)
|
||||
if len([]rune(tag)) > maxTagNameLength {
|
||||
return model.NewRequestError("Tag name too long")
|
||||
}
|
||||
uid, ok := c.Locals("uid").(uint)
|
||||
if !ok {
|
||||
return model.NewUnAuthorizedError("You must be logged in to create a tag")
|
||||
@@ -159,6 +167,9 @@ func getOrCreateTags(c fiber.Ctx) error {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if len([]rune(name)) > maxTagNameLength {
|
||||
return model.NewRequestError("Tag name too long: " + name)
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
|
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"nysoure/server/utils"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -34,6 +35,30 @@ type ServerConfig struct {
|
||||
MaxNormalUserUploadSizeInMB int `json:"max_normal_user_upload_size_in_mb"`
|
||||
// Prompt for upload page
|
||||
UploadPrompt string `json:"upload_prompt"`
|
||||
// PinnedResources is a list of resource IDs that are pinned to the top of the page.
|
||||
PinnedResources []uint `json:"pinned_resources"`
|
||||
}
|
||||
|
||||
func (c *ServerConfig) Validate() error {
|
||||
if c.MaxUploadingSizeInMB <= 0 {
|
||||
return errors.New("MaxUploadingSizeInMB must be positive")
|
||||
}
|
||||
if c.MaxFileSizeInMB <= 0 {
|
||||
return errors.New("MaxFileSizeInMB must be positive")
|
||||
}
|
||||
if c.MaxDownloadsPerDayForSingleIP <= 0 {
|
||||
return errors.New("MaxDownloadsPerDayForSingleIP must be positive")
|
||||
}
|
||||
if c.ServerName == "" {
|
||||
return errors.New("ServerName must not be empty")
|
||||
}
|
||||
if c.ServerDescription == "" {
|
||||
return errors.New("ServerDescription must not be empty")
|
||||
}
|
||||
if len(c.PinnedResources) > 8 {
|
||||
return errors.New("PinnedResources must not exceed 8 items")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -51,6 +76,7 @@ func init() {
|
||||
AllowNormalUserUpload: true,
|
||||
MaxNormalUserUploadSizeInMB: 16,
|
||||
UploadPrompt: "You can upload your files here.",
|
||||
PinnedResources: []uint{},
|
||||
}
|
||||
} else {
|
||||
data, err := os.ReadFile(p)
|
||||
@@ -68,16 +94,17 @@ func GetConfig() ServerConfig {
|
||||
return *config
|
||||
}
|
||||
|
||||
func SetConfig(newConfig ServerConfig) {
|
||||
func SetConfig(newConfig ServerConfig) error {
|
||||
config = &newConfig
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return err
|
||||
}
|
||||
p := filepath.Join(utils.GetStoragePath(), "config.json")
|
||||
if err := os.WriteFile(p, data, 0644); err != nil {
|
||||
panic(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MaxUploadingSize() int64 {
|
||||
@@ -127,3 +154,7 @@ func MaxNormalUserUploadSize() int64 {
|
||||
func UploadPrompt() string {
|
||||
return config.UploadPrompt
|
||||
}
|
||||
|
||||
func PinnedResources() []uint {
|
||||
return config.PinnedResources
|
||||
}
|
||||
|
237
server/dao/collection.go
Normal file
237
server/dao/collection.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"nysoure/server/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func CreateCollection(uid uint, title string, article string, images []uint, public bool) (model.Collection, error) {
|
||||
var collection model.Collection
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
collection = model.Collection{
|
||||
UserID: uid,
|
||||
Title: title,
|
||||
Article: article,
|
||||
Public: public,
|
||||
}
|
||||
|
||||
if err := tx.Create(&collection).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&collection).Association("Images").Replace(images); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return model.Collection{}, err
|
||||
}
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
func UpdateCollection(id uint, title string, article string, images []uint, public bool) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
collection := &model.Collection{}
|
||||
|
||||
// First find the existing collection
|
||||
if err := tx.Where("id = ?", id).First(collection).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the fields
|
||||
updates := map[string]interface{}{
|
||||
"title": title,
|
||||
"article": article,
|
||||
"public": public,
|
||||
}
|
||||
|
||||
if err := tx.Model(collection).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(collection).Association("Images").Replace(images); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteCollection(id uint) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
collection := &model.Collection{}
|
||||
|
||||
if err := tx.Where("id = ?", id).First(collection).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(collection).Association("Images").Clear(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.CollectionResource{}).Where("collection_id = ?", id).Delete(&model.CollectionResource{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Delete(collection).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func AddResourceToCollection(collectionID uint, resourceID uint) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
collection := &model.Collection{}
|
||||
|
||||
if err := tx.Where("id = ?", collectionID).First(collection).Error; err != nil {
|
||||
return model.NewRequestError("Invalid collection ID")
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.Resource{}).Where("id = ?", resourceID).First(&model.Resource{}).Error; err != nil {
|
||||
return model.NewRequestError("Invalid resource ID")
|
||||
}
|
||||
|
||||
collectionResource := &model.CollectionResource{
|
||||
CollectionID: collectionID,
|
||||
ResourceID: resourceID,
|
||||
}
|
||||
|
||||
if err := tx.Save(collectionResource).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(collection).UpdateColumn("resources_count", gorm.Expr("resources_count + ?", 1)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func RemoveResourceFromCollection(collectionID uint, resourceID uint) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
collection := &model.Collection{}
|
||||
|
||||
if err := tx.Where("id = ?", collectionID).First(collection).Error; err != nil {
|
||||
return model.NewRequestError("Invalid collection ID")
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.Resource{}).Where("id = ?", resourceID).First(&model.Resource{}).Error; err != nil {
|
||||
return model.NewRequestError("Invalid resource ID")
|
||||
}
|
||||
|
||||
if err := tx.Where("collection_id = ? AND resource_id = ?", collectionID, resourceID).Delete(&model.CollectionResource{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(collection).UpdateColumn("resources_count", gorm.Expr("resources_count - ?", 1)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func GetCollectionByID(id uint) (*model.Collection, error) {
|
||||
collection := &model.Collection{}
|
||||
if err := db.Preload("Images").Preload("User").Where("id = ?", id).First(collection).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
func ListUserCollections(uid uint, page int, pageSize int, showPrivate bool) ([]*model.Collection, int64, error) {
|
||||
var collections []*model.Collection
|
||||
var total int64
|
||||
|
||||
query := db.Model(&model.Collection{}).Where("user_id = ?", uid)
|
||||
if !showPrivate {
|
||||
query = query.Where("public = ?", true)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err := query.
|
||||
Preload("Images").
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Find(&collections).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
totalPages := (total + int64(pageSize) - 1) / int64(pageSize)
|
||||
|
||||
return collections, totalPages, nil
|
||||
}
|
||||
|
||||
func ListCollectionResources(collectionID uint, page int, pageSize int) ([]*model.Resource, int64, error) {
|
||||
var resources []*model.Resource
|
||||
var total int64
|
||||
|
||||
if err := db.
|
||||
Model(&model.Resource{}).
|
||||
Joins("JOIN collection_resources ON collection_resources.resource_id = resources.id").
|
||||
Where("collection_resources.collection_id = ?", collectionID).
|
||||
Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err := db.
|
||||
Model(&model.Resource{}).
|
||||
Preload("User").
|
||||
Preload("Images").
|
||||
Preload("Tags").
|
||||
Joins("JOIN collection_resources ON collection_resources.resource_id = resources.id").
|
||||
Where("collection_resources.collection_id = ?", collectionID).
|
||||
Order("collection_resources.created_at DESC").
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Find(&resources).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
totalPages := (total + int64(pageSize) - 1) / int64(pageSize)
|
||||
|
||||
return resources, totalPages, nil
|
||||
}
|
||||
|
||||
// SearchUserCollections searches for collections by user ID and keyword limited to 10 results.
|
||||
// excludedRID: if >0, only return collections not containing this resource.
|
||||
func SearchUserCollections(uid uint, keyword string, excludedRID uint, showPrivate bool) ([]*model.Collection, error) {
|
||||
var collections []*model.Collection
|
||||
|
||||
query := db.Model(&model.Collection{}).
|
||||
Where("user_id = ?", uid)
|
||||
|
||||
if !showPrivate {
|
||||
query = query.Where("public = ?", true)
|
||||
}
|
||||
|
||||
if keyword != "" {
|
||||
query = query.Where("title LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
if excludedRID > 0 {
|
||||
// Use LEFT JOIN with IS NULL for better performance
|
||||
query = query.
|
||||
Joins("LEFT JOIN collection_resources cr ON collections.id = cr.collection_id AND cr.resource_id = ?", excludedRID).
|
||||
Where("cr.collection_id IS NULL")
|
||||
}
|
||||
|
||||
if err := query.
|
||||
Preload("Images").
|
||||
Limit(10).
|
||||
Find(&collections).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return collections, nil
|
||||
}
|
@@ -1,16 +1,21 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"nysoure/server/model"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var db *gorm.DB
|
||||
|
||||
var (
|
||||
ready = false
|
||||
)
|
||||
|
||||
func init() {
|
||||
if os.Getenv("DB_PORT") != "" {
|
||||
host := os.Getenv("DB_HOST")
|
||||
@@ -21,10 +26,18 @@ func init() {
|
||||
dsn := user + ":" + password + "@tcp(" + host + ":" + port + ")/" + dbName + "?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
var err error
|
||||
// wait for mysql to be ready
|
||||
time.Sleep(5 * time.Second)
|
||||
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("failed to connect database")
|
||||
retrys := 5
|
||||
for {
|
||||
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err == nil {
|
||||
ready = true
|
||||
break
|
||||
}
|
||||
retrys--
|
||||
if retrys < 0 {
|
||||
panic("failed to connect database: " + err.Error())
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
@@ -45,9 +58,23 @@ func init() {
|
||||
&model.Statistic{},
|
||||
&model.Comment{},
|
||||
&model.Activity{},
|
||||
&model.Collection{},
|
||||
&model.CollectionResource{},
|
||||
)
|
||||
}
|
||||
|
||||
func GetDB() *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func IsReady() bool {
|
||||
return ready
|
||||
}
|
||||
|
||||
func Close() error {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
@@ -73,7 +73,7 @@ func GetUploadingFilesOlderThan(time time.Time) ([]model.UploadingFile, error) {
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func CreateFile(filename string, description string, resourceID uint, storageID *uint, storageKey string, redirectUrl string, size int64, userID uint) (*model.File, error) {
|
||||
func CreateFile(filename string, description string, resourceID uint, storageID *uint, storageKey string, redirectUrl string, size int64, userID uint, hash string) (*model.File, error) {
|
||||
if storageID == nil && redirectUrl == "" {
|
||||
return nil, errors.New("storageID and redirectUrl cannot be both empty")
|
||||
}
|
||||
@@ -88,6 +88,7 @@ func CreateFile(filename string, description string, resourceID uint, storageID
|
||||
StorageKey: storageKey,
|
||||
Size: size,
|
||||
UserID: userID,
|
||||
Hash: hash,
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -99,6 +100,11 @@ func CreateFile(filename string, description string, resourceID uint, storageID
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = tx.Model(&model.Resource{}).Where("id = ?", resourceID).
|
||||
UpdateColumn("modified_time", time.Now()).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -200,13 +206,14 @@ func SetFileStorageKey(id string, storageKey string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetFileStorageKeyAndSize(id string, storageKey string, size int64) error {
|
||||
func SetFileStorageKeyAndSize(id string, storageKey string, size int64, hash string) error {
|
||||
f := &model.File{}
|
||||
if err := db.Where("uuid = ?", id).First(f).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
f.StorageKey = storageKey
|
||||
f.Size = size
|
||||
f.Hash = hash
|
||||
if err := db.Save(f).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.NewNotFoundError("file not found")
|
||||
|
@@ -2,9 +2,10 @@ package dao
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"gorm.io/gorm"
|
||||
"nysoure/server/model"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func CreateImage(name string, width, height int) (model.Image, error) {
|
||||
@@ -45,6 +46,7 @@ func GetUnusedImages() ([]model.Image, error) {
|
||||
if err := db.
|
||||
Where("NOT EXISTS (SELECT 1 FROM resource_images WHERE image_id = images.id)").
|
||||
Where("NOT EXISTS (SELECT 1 FROM comment_images WHERE image_id = images.id)").
|
||||
Where("NOT EXISTS (SELECT 1 FROM collection_images WHERE image_id = images.id)").
|
||||
Where("created_at < ?", oneDayAgo).
|
||||
Find(&images).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
|
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
"nysoure/server/model"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -16,6 +15,7 @@ import (
|
||||
|
||||
func CreateResource(r model.Resource) (model.Resource, error) {
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
r.ModifiedTime = time.Now()
|
||||
err := tx.Create(&r).Error
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -36,7 +36,9 @@ func GetResourceByID(id uint) (model.Resource, error) {
|
||||
var r model.Resource
|
||||
if err := db.Preload("User").
|
||||
Preload("Images").
|
||||
Preload("Tags").
|
||||
Preload("Tags", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Select("id", "name", "type", "alias_of")
|
||||
}).
|
||||
Preload("Files").
|
||||
Preload("Files.User").
|
||||
First(&r, id).Error; err != nil {
|
||||
@@ -70,9 +72,9 @@ func GetResourceList(page, pageSize int, sort model.RSort) ([]model.Resource, in
|
||||
order := ""
|
||||
switch sort {
|
||||
case model.RSortTimeAsc:
|
||||
order = "created_at ASC"
|
||||
order = "modified_time ASC"
|
||||
case model.RSortTimeDesc:
|
||||
order = "created_at DESC"
|
||||
order = "modified_time DESC"
|
||||
case model.RSortViewsAsc:
|
||||
order = "views ASC"
|
||||
case model.RSortViewsDesc:
|
||||
@@ -82,7 +84,7 @@ func GetResourceList(page, pageSize int, sort model.RSort) ([]model.Resource, in
|
||||
case model.RSortDownloadsDesc:
|
||||
order = "downloads DESC"
|
||||
default:
|
||||
order = "created_at DESC" // Default sort order
|
||||
order = "modified_time DESC" // Default sort order
|
||||
}
|
||||
|
||||
if err := db.Offset((page - 1) * pageSize).Limit(pageSize).Preload("User").Preload("Images").Preload("Tags").Order(order).Find(&resources).Error; err != nil {
|
||||
@@ -101,6 +103,7 @@ func UpdateResource(r model.Resource) error {
|
||||
r.Images = nil
|
||||
r.Tags = nil
|
||||
r.Files = nil
|
||||
r.ModifiedTime = time.Now()
|
||||
if err := db.Save(&r).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -141,180 +144,6 @@ func DeleteResource(id uint) error {
|
||||
})
|
||||
}
|
||||
|
||||
func splitQuery(query string) []string {
|
||||
var keywords []string
|
||||
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return keywords
|
||||
}
|
||||
|
||||
l, r := 0, 0
|
||||
inQuote := false
|
||||
quoteChar := byte(0)
|
||||
|
||||
for r < len(query) {
|
||||
if (query[r] == '"' || query[r] == '\'') && (r == 0 || query[r-1] != '\\') {
|
||||
if !inQuote {
|
||||
inQuote = true
|
||||
quoteChar = query[r]
|
||||
l = r + 1
|
||||
} else if query[r] == quoteChar {
|
||||
if r > l {
|
||||
keywords = append(keywords, strings.TrimSpace(query[l:r]))
|
||||
}
|
||||
inQuote = false
|
||||
r++
|
||||
l = r
|
||||
continue
|
||||
}
|
||||
} else if !inQuote && query[r] == ' ' {
|
||||
if r > l {
|
||||
keywords = append(keywords, strings.TrimSpace(query[l:r]))
|
||||
}
|
||||
for r < len(query) && query[r] == ' ' {
|
||||
r++
|
||||
}
|
||||
l = r
|
||||
continue
|
||||
}
|
||||
|
||||
r++
|
||||
}
|
||||
|
||||
if l < len(query) {
|
||||
keywords = append(keywords, strings.TrimSpace(query[l:r]))
|
||||
}
|
||||
|
||||
return keywords
|
||||
}
|
||||
|
||||
func Search(query string, page, pageSize int) ([]model.Resource, int, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
|
||||
keywords := splitQuery(query)
|
||||
if len(keywords) == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
resource, err := searchWithKeyword(keywords[0])
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(keywords) > 1 {
|
||||
for _, keyword := range keywords[1:] {
|
||||
r := make([]model.Resource, 0, len(resource))
|
||||
for _, res := range resource {
|
||||
if strings.Contains(res.Title, keyword) {
|
||||
r = append(r, res)
|
||||
continue
|
||||
}
|
||||
ok := false
|
||||
for _, at := range res.AlternativeTitles {
|
||||
if strings.Contains(at, keyword) {
|
||||
r = append(r, res)
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
for _, tag := range res.Tags {
|
||||
if tag.Name == keyword {
|
||||
r = append(r, res)
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
resource = r
|
||||
}
|
||||
}
|
||||
|
||||
startIndex := (page - 1) * pageSize
|
||||
endIndex := startIndex + pageSize
|
||||
if startIndex > len(resource) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
if endIndex > len(resource) {
|
||||
endIndex = len(resource)
|
||||
}
|
||||
totalPages := (len(resource) + pageSize - 1) / pageSize
|
||||
|
||||
result := make([]model.Resource, 0, endIndex-startIndex)
|
||||
for i := startIndex; i < endIndex; i++ {
|
||||
var r model.Resource
|
||||
if err := db.Model(&r).Preload("User").Preload("Images").Preload("Tags").Where("id=?", resource[i].ID).First(&r).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
|
||||
return result, totalPages, nil
|
||||
}
|
||||
|
||||
func searchWithKeyword(keyword string) ([]model.Resource, error) {
|
||||
if len(keyword) == 0 {
|
||||
return nil, nil
|
||||
} else if len([]rune(keyword)) > 100 {
|
||||
return nil, model.NewRequestError("Keyword is too long")
|
||||
}
|
||||
|
||||
var resources []model.Resource
|
||||
|
||||
if len([]rune(keyword)) < 20 {
|
||||
var tag model.Tag
|
||||
var err error
|
||||
if tag, err = GetTagByName(keyword); err != nil {
|
||||
if !model.IsNotFoundError(err) {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if tag.AliasOf != nil {
|
||||
tag, err = GetTagByID(*tag.AliasOf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var tagIds []uint
|
||||
tagIds = append(tagIds, tag.ID)
|
||||
for _, alias := range tag.Aliases {
|
||||
tagIds = append(tagIds, alias.ID)
|
||||
}
|
||||
subQuery := db.Table("resource_tags").
|
||||
Select("resource_id").
|
||||
Where("tag_id IN ?", tagIds).
|
||||
Group("resource_id")
|
||||
if err := db.Where("id IN (?)", subQuery).Select("id", "title", "alternative_titles").Preload("Tags").Find(&resources).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var titleResult []model.Resource
|
||||
if err := db.Where("title LIKE ?", "%"+keyword+"%").Or("alternative_titles LIKE ?", "%"+keyword+"%").Select("id", "title", "alternative_titles").Preload("Tags").Find(&titleResult).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(titleResult) > 0 {
|
||||
if len(resources) == 0 {
|
||||
resources = titleResult
|
||||
} else {
|
||||
resourceMap := make(map[uint]model.Resource)
|
||||
for _, res := range resources {
|
||||
resourceMap[res.ID] = res
|
||||
}
|
||||
for _, res := range titleResult {
|
||||
if _, exists := resourceMap[res.ID]; !exists {
|
||||
resources = append(resources, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func GetResourceByTag(tagID uint, page int, pageSize int) ([]model.Resource, int, error) {
|
||||
tag, err := GetTagByID(tagID)
|
||||
if err != nil {
|
||||
@@ -563,3 +392,72 @@ func RandomResource() (model.Resource, error) {
|
||||
return resource, nil // Return the found resource
|
||||
}
|
||||
}
|
||||
|
||||
func GetResourcesIdWithTag(tagID uint) ([]uint, error) {
|
||||
tag, err := GetTagByID(tagID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tag.AliasOf != nil {
|
||||
tag, err = GetTagByID(*tag.AliasOf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var tagIds []uint
|
||||
tagIds = append(tagIds, tag.ID)
|
||||
for _, alias := range tag.Aliases {
|
||||
tagIds = append(tagIds, alias.ID)
|
||||
}
|
||||
var result []model.Resource
|
||||
subQuery := db.Table("resource_tags").
|
||||
Select("resource_id").
|
||||
Where("tag_id IN ?", tagIds).
|
||||
Group("resource_id")
|
||||
if err := db.Model(&model.Resource{}).
|
||||
Where("id IN (?)", subQuery).
|
||||
Order("created_at DESC").
|
||||
Limit(10000).
|
||||
Select("id", "created_at").
|
||||
Find(&result).
|
||||
Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]uint, len(result))
|
||||
for i, r := range result {
|
||||
ids[i] = r.ID
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func BatchGetResources(ids []uint) ([]model.Resource, error) {
|
||||
var resources []model.Resource
|
||||
|
||||
for _, id := range ids {
|
||||
var r model.Resource
|
||||
if err := db.
|
||||
Preload("User").
|
||||
Preload("Images").
|
||||
Preload("Tags").
|
||||
First(&r, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for i, tag := range r.Tags {
|
||||
if tag.AliasOf != nil {
|
||||
t, err := GetTagByID(*tag.AliasOf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
r.Tags[i].Type = t.Type
|
||||
}
|
||||
}
|
||||
}
|
||||
resources = append(resources, r)
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
@@ -2,10 +2,11 @@ package dao
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gofiber/fiber/v3/log"
|
||||
"nysoure/server/model"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v3/log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -18,7 +19,7 @@ func CreateTag(tag string) (model.Tag, error) {
|
||||
if err := db.Create(&t).Error; err != nil {
|
||||
return model.Tag{}, err
|
||||
}
|
||||
return t, nil
|
||||
return GetTagByID(t.ID)
|
||||
}
|
||||
|
||||
func CreateTagWithType(tag string, tagType string) (model.Tag, error) {
|
||||
@@ -81,13 +82,34 @@ func GetTagByName(name string) (model.Tag, error) {
|
||||
}
|
||||
|
||||
func SetTagInfo(id uint, description string, aliasOf *uint, tagType string) error {
|
||||
// Get the tag information
|
||||
old, err := GetTagByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if aliasOf != nil && len(old.Aliases) > 0 {
|
||||
return model.NewRequestError("Tag already has aliases, cannot set alias_of")
|
||||
|
||||
// If the alias tag is an alias itself, we need to find its root tag
|
||||
if aliasOf != nil {
|
||||
tag, err := GetTagByID(*aliasOf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.AliasOf != nil {
|
||||
aliasOf = tag.AliasOf
|
||||
}
|
||||
}
|
||||
|
||||
// If the tag has aliases, we need to update their alias_of field
|
||||
if aliasOf != nil && len(old.Aliases) > 0 {
|
||||
for _, alias := range old.Aliases {
|
||||
err := db.Model(&alias).Update("alias_of", *aliasOf).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the tag information
|
||||
t := model.Tag{Model: gorm.Model{
|
||||
ID: id,
|
||||
}, Description: description, Type: tagType, AliasOf: aliasOf}
|
||||
@@ -112,24 +134,51 @@ func ListTags() ([]model.Tag, error) {
|
||||
|
||||
// SetTagAlias sets a tag with the given ID having the given alias.
|
||||
func SetTagAlias(tagID uint, alias string) error {
|
||||
// Set a tag as an alias of another tag
|
||||
var t model.Tag
|
||||
if err := db.Where("name = ?", alias).First(&t).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// create
|
||||
newTag, err := CreateTag(alias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t = newTag
|
||||
} else {
|
||||
exists, err := ExistsTagByID(tagID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return model.NewNotFoundError("Tag not found")
|
||||
}
|
||||
|
||||
exists, err = ExistsTag(alias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
// Create the alias tag if it does not exist
|
||||
_, err := CreateTag(alias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if t.ID == tagID {
|
||||
return model.NewRequestError("Tag cannot be an alias of itself")
|
||||
// Get the alias tag
|
||||
tag, err := GetTagByName(alias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&t).Update("alias_of", tagID).Error
|
||||
// If the alias tag is an alias itself, we need to find its root tag
|
||||
if tag.AliasOf != nil {
|
||||
tag, err = GetTagByID(*tag.AliasOf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// If the tag has aliases, we need to update their alias_of field
|
||||
for _, alias := range tag.Aliases {
|
||||
err := db.Model(&alias).Update("alias_of", tagID).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tag.Aliases = nil
|
||||
// A tag cannot be an alias of itself
|
||||
if tag.ID == tagID {
|
||||
return model.NewRequestError("A tag cannot be an alias of itself")
|
||||
}
|
||||
// Set the alias_of field of the tag
|
||||
return db.Model(&tag).Update("alias_of", tagID).Error
|
||||
}
|
||||
|
||||
// RemoveTagAliasOf sets a tag is an independent tag, removing its alias relationship.
|
||||
@@ -171,3 +220,19 @@ func ClearUnusedTags() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ExistsTag(name string) (bool, error) {
|
||||
var count int64
|
||||
if err := db.Model(&model.Tag{}).Where("name = ?", name).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func ExistsTagByID(id uint) (bool, error) {
|
||||
var count int64
|
||||
if err := db.Model(&model.Tag{}).Where("id = ?", id).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
109
server/dao/tag_test.go
Normal file
109
server/dao/tag_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTag(t *testing.T) {
|
||||
// Create tags
|
||||
tag1, err := CreateTag("test1")
|
||||
assert.Nil(t, err)
|
||||
tag2, err := CreateTag("test2")
|
||||
assert.Nil(t, err)
|
||||
tag3, err := CreateTagWithType("test3", "type1")
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Get tag by ID
|
||||
fetchedTag, err := GetTagByID(tag1.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, tag1.Name, fetchedTag.Name)
|
||||
|
||||
// Get tag by Name
|
||||
fetchedTag, err = GetTagByName(tag2.Name)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, tag2.ID, fetchedTag.ID)
|
||||
|
||||
// Search tags
|
||||
tags, err := SearchTag("test", true)
|
||||
assert.Nil(t, err)
|
||||
assert.GreaterOrEqual(t, len(tags), 3)
|
||||
|
||||
// Update tag
|
||||
err = SetTagInfo(tag1.ID, "updated description", nil, "updated type")
|
||||
assert.Nil(t, err)
|
||||
updatedTag, err := GetTagByID(tag1.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "updated description", updatedTag.Description)
|
||||
assert.Equal(t, "updated type", updatedTag.Type)
|
||||
|
||||
// Set tag alias
|
||||
err = SetTagAlias(tag1.ID, tag2.Name)
|
||||
assert.Nil(t, err)
|
||||
err = SetTagAlias(tag1.ID, tag3.Name)
|
||||
assert.Nil(t, err)
|
||||
err = SetTagAlias(tag1.ID, "test4")
|
||||
assert.Nil(t, err)
|
||||
tag4, err := GetTagByName("test4")
|
||||
assert.Nil(t, err)
|
||||
tag1, err = GetTagByID(tag1.ID)
|
||||
assert.Nil(t, err)
|
||||
aliasesIDs := []uint{}
|
||||
for _, alias := range tag1.Aliases {
|
||||
aliasesIDs = append(aliasesIDs, alias.ID)
|
||||
}
|
||||
assert.Equal(t, []uint{tag2.ID, tag3.ID, tag4.ID}, aliasesIDs)
|
||||
|
||||
// let a tag which has alias point to another tag
|
||||
tag5, err := CreateTag("test5")
|
||||
assert.Nil(t, err)
|
||||
err = SetTagAlias(tag5.ID, tag1.Name)
|
||||
assert.Nil(t, err)
|
||||
tag1, err = GetTagByID(tag1.ID)
|
||||
assert.Nil(t, err)
|
||||
tag2, err = GetTagByID(tag2.ID)
|
||||
assert.Nil(t, err)
|
||||
tag3, err = GetTagByID(tag3.ID)
|
||||
assert.Nil(t, err)
|
||||
tag4, err = GetTagByID(tag4.ID)
|
||||
assert.Nil(t, err)
|
||||
tag5, err = GetTagByID(tag5.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.Empty(t, tag1.Aliases)
|
||||
assert.Equal(t, &tag5.ID, tag1.AliasOf)
|
||||
assert.Equal(t, &tag5.ID, tag2.AliasOf)
|
||||
assert.Equal(t, &tag5.ID, tag3.AliasOf)
|
||||
assert.Equal(t, &tag5.ID, tag4.AliasOf)
|
||||
assert.Nil(t, tag5.AliasOf)
|
||||
|
||||
// Same operation as above, but using `SetTagInfo`
|
||||
tag6, err := CreateTag("test6")
|
||||
assert.Nil(t, err)
|
||||
err = SetTagInfo(tag5.ID, "", &tag6.ID, "")
|
||||
assert.Nil(t, err)
|
||||
tag1, err = GetTagByID(tag1.ID)
|
||||
assert.Nil(t, err)
|
||||
tag2, err = GetTagByID(tag2.ID)
|
||||
assert.Nil(t, err)
|
||||
tag3, err = GetTagByID(tag3.ID)
|
||||
assert.Nil(t, err)
|
||||
tag4, err = GetTagByID(tag4.ID)
|
||||
assert.Nil(t, err)
|
||||
tag5, err = GetTagByID(tag5.ID)
|
||||
assert.Nil(t, err)
|
||||
tag6, err = GetTagByID(tag6.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, &tag6.ID, tag1.AliasOf)
|
||||
assert.Equal(t, &tag6.ID, tag2.AliasOf)
|
||||
assert.Equal(t, &tag6.ID, tag3.AliasOf)
|
||||
assert.Equal(t, &tag6.ID, tag4.AliasOf)
|
||||
assert.Equal(t, &tag6.ID, tag5.AliasOf)
|
||||
assert.Empty(t, tag5.Aliases)
|
||||
assert.Nil(t, tag6.AliasOf)
|
||||
|
||||
// cleanup
|
||||
_ = Close()
|
||||
_ = os.Remove("test.db")
|
||||
}
|
@@ -5,6 +5,7 @@ import (
|
||||
"nysoure/server/model"
|
||||
|
||||
"github.com/gofiber/fiber/v3/log"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
@@ -55,6 +56,12 @@ func ErrorHandler(c fiber.Ctx) error {
|
||||
Data: nil,
|
||||
Message: fiberErr.Message,
|
||||
})
|
||||
} else if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(model.Response[any]{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
Message: "Not found",
|
||||
})
|
||||
} else {
|
||||
var fiberErr *fiber.Error
|
||||
if errors.As(err, &fiberErr) {
|
||||
@@ -66,6 +73,7 @@ func ErrorHandler(c fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
}
|
||||
log.Error("Internal Server Error: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(model.Response[any]{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
|
@@ -79,7 +79,7 @@ func serveIndexHtml(c fiber.Ctx) error {
|
||||
idStr := strings.TrimPrefix(path, "/resources/")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err == nil {
|
||||
r, err := service.GetResource(uint(id), c.Hostname())
|
||||
r, err := service.GetResource(uint(id), c.Hostname(), c)
|
||||
if err == nil {
|
||||
if len(r.Images) > 0 {
|
||||
preview = fmt.Sprintf("%s/api/image/%d", serverBaseURL, r.Images[0].ID)
|
||||
@@ -133,6 +133,38 @@ func serveIndexHtml(c fiber.Ctx) error {
|
||||
preFetchData = url.PathEscape(string(preFetchDataJson))
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(path, "/collection/") {
|
||||
collectionIDStr := strings.TrimPrefix(path, "/collection/")
|
||||
collectionID, err := strconv.Atoi(collectionIDStr)
|
||||
if err == nil {
|
||||
coll, err := service.GetCollectionByID(uint(collectionID), 0)
|
||||
if err == nil {
|
||||
title = coll.Title
|
||||
description = utils.ArticleToDescription(coll.Article, 256)
|
||||
if len(coll.Images) > 0 {
|
||||
preview = fmt.Sprintf("%s/api/image/%d", serverBaseURL, coll.Images[0].ID)
|
||||
} else {
|
||||
preview = fmt.Sprintf("%s/api/avatar/%d", serverBaseURL, coll.User.ID)
|
||||
}
|
||||
if len(coll.Images) > 0 {
|
||||
preview = fmt.Sprintf("%s/api/image/%d", serverBaseURL, coll.Images[0].ID)
|
||||
}
|
||||
preFetchDataJson, _ := json.Marshal(map[string]interface{}{
|
||||
"collection": coll,
|
||||
})
|
||||
preFetchData = url.PathEscape(string(preFetchDataJson))
|
||||
}
|
||||
}
|
||||
} else if path == "/" || path == "" {
|
||||
pinned, err := service.GetPinnedResources()
|
||||
random, err1 := service.RandomCover()
|
||||
if err == nil && err1 == nil {
|
||||
preFetchDataJson, _ := json.Marshal(map[string]interface{}{
|
||||
"pinned": pinned,
|
||||
"background": random,
|
||||
})
|
||||
preFetchData = url.PathEscape(string(preFetchDataJson))
|
||||
}
|
||||
}
|
||||
|
||||
content = strings.ReplaceAll(content, "{{SiteName}}", siteName)
|
||||
|
17
server/middleware/real_user_middleware.go
Normal file
17
server/middleware/real_user_middleware.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func RealUserMiddleware(c fiber.Ctx) error {
|
||||
userAgent := c.Get("User-Agent")
|
||||
if strings.Contains(userAgent, "Mozilla") || strings.Contains(userAgent, "AppleWebKit") {
|
||||
c.Locals("real_user", true)
|
||||
} else {
|
||||
c.Locals("real_user", false)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
36
server/model/collection.go
Normal file
36
server/model/collection.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package model
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Collection struct {
|
||||
gorm.Model
|
||||
Title string `gorm:"not null"`
|
||||
Article string `gorm:"not null"`
|
||||
UserID uint `gorm:"not null"`
|
||||
User User `gorm:"foreignKey:UserID;references:ID"`
|
||||
ResourcesCount int `gorm:"default:0"`
|
||||
Images []Image `gorm:"many2many:collection_images;"`
|
||||
Public bool `gorm:"default:false"` // 新增公开/私有字段
|
||||
}
|
||||
|
||||
type CollectionView struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Article string `json:"article"`
|
||||
User UserView `json:"user"`
|
||||
ResourcesCount int `json:"resources_count"`
|
||||
Images []Image `json:"images"`
|
||||
IsPublic bool `json:"isPublic"` // 新增公开/私有字段
|
||||
}
|
||||
|
||||
func (c Collection) ToView() *CollectionView {
|
||||
return &CollectionView{
|
||||
ID: c.ID,
|
||||
Title: c.Title,
|
||||
Article: c.Article,
|
||||
User: c.User.ToView(),
|
||||
ResourcesCount: c.ResourcesCount,
|
||||
Images: c.Images,
|
||||
IsPublic: c.Public, // 新增
|
||||
}
|
||||
}
|
9
server/model/collection_resource.go
Normal file
9
server/model/collection_resource.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type CollectionResource struct {
|
||||
CollectionID uint `gorm:"primaryKey"`
|
||||
ResourceID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
}
|
@@ -18,6 +18,7 @@ type File struct {
|
||||
UserID uint
|
||||
User User `gorm:"foreignKey:UserID"`
|
||||
Size int64
|
||||
Hash string `gorm:"default:null"`
|
||||
}
|
||||
|
||||
type FileView struct {
|
||||
@@ -28,6 +29,7 @@ type FileView struct {
|
||||
IsRedirect bool `json:"is_redirect"`
|
||||
User UserView `json:"user"`
|
||||
Resource *ResourceView `json:"resource,omitempty"`
|
||||
Hash string `json:"hash,omitempty"`
|
||||
}
|
||||
|
||||
func (f *File) ToView() *FileView {
|
||||
@@ -38,6 +40,7 @@ func (f *File) ToView() *FileView {
|
||||
Size: f.Size,
|
||||
IsRedirect: f.RedirectUrl != "",
|
||||
User: f.User.ToView(),
|
||||
Hash: f.Hash,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,5 +59,6 @@ func (f *File) ToViewWithResource() *FileView {
|
||||
IsRedirect: f.RedirectUrl != "",
|
||||
User: f.User.ToView(),
|
||||
Resource: resource,
|
||||
Hash: f.Hash,
|
||||
}
|
||||
}
|
||||
|
@@ -20,6 +20,7 @@ type Resource struct {
|
||||
Views uint
|
||||
Downloads uint
|
||||
Comments uint
|
||||
ModifiedTime time.Time
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
|
114
server/search/resource.go
Normal file
114
server/search/resource.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"nysoure/server/dao"
|
||||
"nysoure/server/model"
|
||||
"nysoure/server/utils"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/blevesearch/bleve"
|
||||
)
|
||||
|
||||
type ResourceParams struct {
|
||||
Id uint
|
||||
Title string
|
||||
Subtitles []string
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
var index bleve.Index
|
||||
|
||||
func AddResourceToIndex(r model.Resource) error {
|
||||
return index.Index(fmt.Sprintf("%d", r.ID), ResourceParams{
|
||||
Id: r.ID,
|
||||
Title: r.Title,
|
||||
Subtitles: r.AlternativeTitles,
|
||||
Time: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func RemoveResourceFromIndex(id uint) error {
|
||||
return index.Delete(fmt.Sprintf("%d", id))
|
||||
}
|
||||
|
||||
func createIndex() error {
|
||||
for !dao.IsReady() {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
page := 1
|
||||
total := 1
|
||||
for page <= total {
|
||||
res, totalPages, err := dao.GetResourceList(page, 100, model.RSortTimeAsc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range res {
|
||||
err := AddResourceToIndex(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
page++
|
||||
total = totalPages
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
indexPath := utils.GetStoragePath() + "/resource_index.bleve"
|
||||
|
||||
var err error
|
||||
index, err = bleve.Open(indexPath)
|
||||
if errors.Is(err, bleve.ErrorIndexPathDoesNotExist) {
|
||||
mapping := bleve.NewIndexMapping()
|
||||
index, err = bleve.New(indexPath, mapping)
|
||||
if err != nil {
|
||||
panic("Failed to create search index: " + err.Error())
|
||||
}
|
||||
go func() {
|
||||
err := createIndex()
|
||||
if err != nil {
|
||||
panic("Failed to create search index: " + err.Error())
|
||||
}
|
||||
}()
|
||||
} else if err != nil {
|
||||
panic("Failed to open search index: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func SearchResource(keyword string) ([]uint, error) {
|
||||
query := bleve.NewMatchQuery(keyword)
|
||||
searchRequest := bleve.NewSearchRequest(query)
|
||||
searchResults, err := index.Search(searchRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]uint, 0)
|
||||
for _, hit := range searchResults.Hits {
|
||||
if hit.Score < 0.2 {
|
||||
break
|
||||
}
|
||||
id, err := strconv.ParseUint(hit.ID, 10, 32)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, uint(id))
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func IsStopWord(word string) bool {
|
||||
mapping := bleve.NewIndexMapping()
|
||||
analyzerName := mapping.DefaultAnalyzer
|
||||
analyzer := mapping.AnalyzerNamed(analyzerName)
|
||||
if analyzer == nil {
|
||||
return false
|
||||
}
|
||||
tokens := analyzer.Analyze([]byte(word))
|
||||
return len(tokens) == 0
|
||||
}
|
185
server/search/search_test.go
Normal file
185
server/search/search_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"nysoure/server/dao"
|
||||
"nysoure/server/model"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/blevesearch/bleve"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
err := index.Close()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_ = os.RemoveAll("search_test.bleve")
|
||||
mapper := bleve.NewIndexMapping()
|
||||
index, err = bleve.New("search_test.bleve", mapper)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TearDown() {
|
||||
err := index.Close()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
dao.Close()
|
||||
os.RemoveAll("search_test.bleve")
|
||||
os.Remove("test.db")
|
||||
}
|
||||
|
||||
func TestSearchResource(t *testing.T) {
|
||||
Init()
|
||||
defer TearDown()
|
||||
|
||||
resources := []model.Resource{
|
||||
// normal cases
|
||||
{Model: gorm.Model{ID: 1}, Title: "The Great Adventure", AlternativeTitles: []string{"Adventure Time", "The Big Adventure"}},
|
||||
{Model: gorm.Model{ID: 2}, Title: "Mystery of the Lost City", AlternativeTitles: []string{"Lost City Chronicles"}},
|
||||
{Model: gorm.Model{ID: 3}, Title: "Romance in Paris", AlternativeTitles: []string{"Love in Paris", "Parisian Romance"}},
|
||||
{Model: gorm.Model{ID: 4}, Title: "Sci-Fi Extravaganza", AlternativeTitles: []string{"Future World", "Sci-Fi Saga"}},
|
||||
{Model: gorm.Model{ID: 5}, Title: "Comedy Nights", AlternativeTitles: []string{"Laugh Out Loud", "Comedy Central"}},
|
||||
// With special characters
|
||||
{Model: gorm.Model{ID: 6}, Title: "Action & Adventure", AlternativeTitles: []string{"Action-Packed", "Adventure Time!"}},
|
||||
{Model: gorm.Model{ID: 7}, Title: "Horror: The Awakening", AlternativeTitles: []string{"Scary Movie", "Horror Nights"}},
|
||||
{Model: gorm.Model{ID: 8}, Title: "Drama @ Home", AlternativeTitles: []string{"Home Stories", "Dramatic Tales"}},
|
||||
{Model: gorm.Model{ID: 9}, Title: "Fantasy #1", AlternativeTitles: []string{"Fantasy World", "Magical Tales"}},
|
||||
{Model: gorm.Model{ID: 10}, Title: "Thriller ~Uncut~"},
|
||||
{Model: gorm.Model{ID: 11}, Title: "Epic ~ Saga ~"},
|
||||
{Model: gorm.Model{ID: 12}, Title: "Journey - Dawn -", AlternativeTitles: []string{"Dawn Adventures", "Journey Chronicles"}},
|
||||
{Model: gorm.Model{ID: 13}, Title: "Legends -Rise-", AlternativeTitles: []string{"Rise of Legends", "Legendary Tales"}},
|
||||
{Model: gorm.Model{ID: 14}, Title: "Chronicles: Time", AlternativeTitles: []string{"Time Chronicles", "Chronicles of Ages"}},
|
||||
}
|
||||
|
||||
// Add resources to index
|
||||
for _, r := range resources {
|
||||
err := AddResourceToIndex(r)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add resource ID %d to index: %v", r.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
query string
|
||||
expectedIDs []uint
|
||||
unexpectedID uint
|
||||
}{
|
||||
// Basic searches
|
||||
{query: "Adventure", expectedIDs: []uint{1, 6}},
|
||||
{query: "Mystery", expectedIDs: []uint{2}},
|
||||
{query: "Romance", expectedIDs: []uint{3}},
|
||||
{query: "Sci-Fi", expectedIDs: []uint{4}},
|
||||
{query: "Comedy", expectedIDs: []uint{5}},
|
||||
// Exact matches
|
||||
{query: "The Great Adventure", expectedIDs: []uint{1}},
|
||||
{query: "Mystery of the Lost City", expectedIDs: []uint{2}},
|
||||
{query: "Romance in Paris", expectedIDs: []uint{3}},
|
||||
{query: "Sci-Fi Extravaganza", expectedIDs: []uint{4}},
|
||||
{query: "Comedy Nights", expectedIDs: []uint{5}},
|
||||
{query: "Action & Adventure", expectedIDs: []uint{6}},
|
||||
{query: "Horror: The Awakening", expectedIDs: []uint{7}},
|
||||
{query: "Drama @ Home", expectedIDs: []uint{8}},
|
||||
{query: "Fantasy #1", expectedIDs: []uint{9}},
|
||||
{query: "Thriller ~Uncut~", expectedIDs: []uint{10}},
|
||||
{query: "Epic ~ Saga ~", expectedIDs: []uint{11}},
|
||||
{query: "Journey - Dawn -", expectedIDs: []uint{12}},
|
||||
{query: "Legends -Rise-", expectedIDs: []uint{13}},
|
||||
{query: "Chronicles: Time", expectedIDs: []uint{14}},
|
||||
// Searches with special characters
|
||||
{query: "Action & Adventure", expectedIDs: []uint{6}},
|
||||
{query: "Horror: The Awakening", expectedIDs: []uint{7}},
|
||||
{query: "Drama @ Home", expectedIDs: []uint{8}},
|
||||
{query: "Fantasy #1", expectedIDs: []uint{9}},
|
||||
{query: "Thriller ~Uncut~", expectedIDs: []uint{10}},
|
||||
{query: "Epic ~ Saga ~", expectedIDs: []uint{11}},
|
||||
{query: "Journey - Dawn -", expectedIDs: []uint{12}},
|
||||
{query: "Legends -Rise-", expectedIDs: []uint{13}},
|
||||
{query: "Chronicles: Time", expectedIDs: []uint{14}},
|
||||
// Case insensitivity
|
||||
{query: "adventure", expectedIDs: []uint{1, 6}},
|
||||
{query: "MYSTERY", expectedIDs: []uint{2}},
|
||||
{query: "rOmAnCe", expectedIDs: []uint{3}},
|
||||
// Searches using alternative titles
|
||||
{query: "Adventure Time", expectedIDs: []uint{1, 6}},
|
||||
{query: "Lost City Chronicles", expectedIDs: []uint{2}},
|
||||
{query: "Love in Paris", expectedIDs: []uint{3}},
|
||||
{query: "Future World", expectedIDs: []uint{4}},
|
||||
{query: "Laugh Out Loud", expectedIDs: []uint{5}},
|
||||
// Searches with special characters in alternative titles
|
||||
{query: "Action-Packed", expectedIDs: []uint{6}},
|
||||
{query: "Scary Movie", expectedIDs: []uint{7}},
|
||||
{query: "Home Stories", expectedIDs: []uint{8}},
|
||||
{query: "Fantasy World", expectedIDs: []uint{9}},
|
||||
{query: "Epic Tales", expectedIDs: []uint{11}},
|
||||
{query: "Dawn Adventures", expectedIDs: []uint{12}},
|
||||
{query: "Rise of Legends", expectedIDs: []uint{13}},
|
||||
{query: "Time Chronicles", expectedIDs: []uint{14}},
|
||||
// Searches with special characters in queries
|
||||
{query: "Horror:", expectedIDs: []uint{7}},
|
||||
{query: "@ Home", expectedIDs: []uint{8}},
|
||||
{query: "#1", expectedIDs: []uint{9}},
|
||||
{query: "~Uncut~", expectedIDs: []uint{10}},
|
||||
{query: "Uncut", expectedIDs: []uint{10}},
|
||||
{query: "~ Saga ~", expectedIDs: []uint{11}},
|
||||
{query: "Saga", expectedIDs: []uint{11}},
|
||||
{query: "- Dawn -", expectedIDs: []uint{12}},
|
||||
{query: "-Rise-", expectedIDs: []uint{13}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.query, func(t *testing.T) {
|
||||
resultIDs, err := SearchResource(test.query)
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed for query '%s': %v", test.query, err)
|
||||
}
|
||||
|
||||
// Check for expected IDs
|
||||
for _, expectedID := range test.expectedIDs {
|
||||
found := false
|
||||
for _, resultID := range resultIDs {
|
||||
if resultID == expectedID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected ID %d not found in results for query '%s'", expectedID, test.query)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unexpected ID
|
||||
if test.unexpectedID != 0 {
|
||||
for _, resultID := range resultIDs {
|
||||
if resultID == test.unexpectedID {
|
||||
t.Errorf("Unexpected ID %d found in results for query '%s'", test.unexpectedID, test.query)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStopWord(t *testing.T) {
|
||||
Init()
|
||||
defer TearDown()
|
||||
|
||||
stopWords := []string{"the", "is", "at", "which", "on", "and", "a", "an", "in", "to", "of"}
|
||||
nonStopWords := []string{"adventure", "mystery", "romance", "sci-fi", "comedy", "action", "horror"}
|
||||
|
||||
for _, word := range stopWords {
|
||||
if !IsStopWord(word) {
|
||||
t.Errorf("Expected '%s' to be identified as a stop word", word)
|
||||
}
|
||||
}
|
||||
|
||||
for _, word := range nonStopWords {
|
||||
if IsStopWord(word) {
|
||||
t.Errorf("Expected '%s' to not be identified as a stop word", word)
|
||||
}
|
||||
}
|
||||
}
|
180
server/service/collection.go
Normal file
180
server/service/collection.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"nysoure/server/dao"
|
||||
"nysoure/server/model"
|
||||
)
|
||||
|
||||
// Create a new collection.
|
||||
func CreateCollection(uid uint, title, article string, host string, public bool) (*model.CollectionView, error) {
|
||||
if uid == 0 || title == "" || article == "" {
|
||||
return nil, model.NewRequestError("invalid parameters")
|
||||
}
|
||||
c, err := dao.CreateCollection(uid, title, article, findImagesInContent(article, host), public)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view := c.ToView()
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// Update an existing collection with user validation.
|
||||
func UpdateCollection(uid, id uint, title, article string, host string, public bool) error {
|
||||
if uid == 0 || id == 0 || title == "" || article == "" {
|
||||
return model.NewRequestError("invalid parameters")
|
||||
}
|
||||
collection, err := dao.GetCollectionByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if collection.UserID != uid {
|
||||
return model.NewUnAuthorizedError("user does not have permission to update this collection")
|
||||
}
|
||||
return dao.UpdateCollection(id, title, article, findImagesInContent(article, host), public)
|
||||
}
|
||||
|
||||
// Delete a collection by ID.
|
||||
func DeleteCollection(uid, id uint) error {
|
||||
user, err := dao.GetUserByID(uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collection, err := dao.GetCollectionByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user.ID != collection.UserID && !user.IsAdmin {
|
||||
return model.NewUnAuthorizedError("user does not have permission to delete this collection")
|
||||
}
|
||||
|
||||
return dao.DeleteCollection(id)
|
||||
}
|
||||
|
||||
// Add a resource to a collection with user validation.
|
||||
func AddResourceToCollection(uid, collectionID, resourceID uint) error {
|
||||
if uid == 0 || collectionID == 0 || resourceID == 0 {
|
||||
return model.NewRequestError("invalid parameters")
|
||||
}
|
||||
collection, err := dao.GetCollectionByID(collectionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if collection.UserID != uid {
|
||||
return model.NewUnAuthorizedError("user does not have permission to modify this collection")
|
||||
}
|
||||
return dao.AddResourceToCollection(collectionID, resourceID)
|
||||
}
|
||||
|
||||
// Remove a resource from a collection with user validation.
|
||||
func RemoveResourceFromCollection(uid, collectionID, resourceID uint) error {
|
||||
if uid == 0 || collectionID == 0 || resourceID == 0 {
|
||||
return model.NewRequestError("invalid parameters")
|
||||
}
|
||||
collection, err := dao.GetCollectionByID(collectionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if collection.UserID != uid {
|
||||
return model.NewUnAuthorizedError("user does not have permission to modify this collection")
|
||||
}
|
||||
return dao.RemoveResourceFromCollection(collectionID, resourceID)
|
||||
}
|
||||
|
||||
// Get a collection by ID.
|
||||
func GetCollectionByID(id uint, viewerUID uint) (*model.CollectionView, error) {
|
||||
if id == 0 {
|
||||
return nil, model.NewRequestError("invalid collection id")
|
||||
}
|
||||
c, err := dao.GetCollectionByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if collection is private and viewer is not the owner
|
||||
if !c.Public && c.UserID != viewerUID {
|
||||
return nil, model.NewUnAuthorizedError("you do not have permission to view this private collection")
|
||||
}
|
||||
|
||||
return c.ToView(), nil
|
||||
}
|
||||
|
||||
// List collections of a user with pagination.
|
||||
func ListUserCollections(username string, page int, viewerUID uint) ([]*model.CollectionView, int64, error) {
|
||||
if username == "" || page < 1 {
|
||||
return nil, 0, model.NewRequestError("invalid parameters")
|
||||
}
|
||||
user, err := dao.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
uid := user.ID
|
||||
|
||||
// Check if viewer can see private collections (only owner can see their private collections)
|
||||
showPrivate := uid == viewerUID
|
||||
|
||||
collections, total, err := dao.ListUserCollections(uid, page, pageSize, showPrivate)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var views []*model.CollectionView
|
||||
for _, c := range collections {
|
||||
views = append(views, c.ToView())
|
||||
}
|
||||
return views, total, nil
|
||||
}
|
||||
|
||||
// List resources in a collection with pagination.
|
||||
func ListCollectionResources(collectionID uint, page int, viewerUID uint) ([]*model.ResourceView, int64, error) {
|
||||
if collectionID == 0 || page < 1 {
|
||||
return nil, 0, model.NewRequestError("invalid parameters")
|
||||
}
|
||||
|
||||
// Check collection privacy first
|
||||
collection, err := dao.GetCollectionByID(collectionID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if !collection.Public && collection.UserID != viewerUID {
|
||||
return nil, 0, model.NewUnAuthorizedError("you do not have permission to view this private collection")
|
||||
}
|
||||
|
||||
resources, total, err := dao.ListCollectionResources(collectionID, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var views []*model.ResourceView
|
||||
for _, r := range resources {
|
||||
v := r.ToView()
|
||||
views = append(views, &v)
|
||||
}
|
||||
return views, total, nil
|
||||
}
|
||||
|
||||
// Search user collections by keyword, limited to 10 results.
|
||||
// excludedRID: if >0, only return collections not containing this resource.
|
||||
func SearchUserCollections(username string, keyword string, excludedRID uint, viewerUID uint) ([]*model.CollectionView, error) {
|
||||
if username == "" {
|
||||
return nil, model.NewRequestError("invalid parameters")
|
||||
}
|
||||
user, err := dao.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uid := user.ID
|
||||
|
||||
// Check if viewer can see private collections
|
||||
showPrivate := uid == viewerUID
|
||||
|
||||
collections, err := dao.SearchUserCollections(uid, keyword, excludedRID, showPrivate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var views []*model.CollectionView
|
||||
for _, c := range collections {
|
||||
views = append(views, c.ToView())
|
||||
}
|
||||
return views, nil
|
||||
}
|
@@ -4,7 +4,6 @@ import (
|
||||
"nysoure/server/dao"
|
||||
"nysoure/server/model"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v3/log"
|
||||
@@ -22,39 +21,6 @@ type CommentRequest struct {
|
||||
// Images []uint `json:"images"` // Unrequired after new design
|
||||
}
|
||||
|
||||
func findImagesInContent(content string, host string) []uint {
|
||||
// Handle both absolute and relative URLs
|
||||
absolutePattern := `!\[.*?\]\((?:https?://` + host + `)?/api/image/(\d+)(?:\s+["'].*?["'])?\)`
|
||||
relativePattern := `!\[.*?\]\(/api/image/(\d+)(?:\s+["'].*?["'])?\)`
|
||||
|
||||
// Combine patterns and compile regex
|
||||
patterns := []string{absolutePattern, relativePattern}
|
||||
|
||||
// Store unique image IDs to avoid duplicates
|
||||
imageIDs := make(map[uint]struct{})
|
||||
|
||||
for _, pattern := range patterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(content, -1)
|
||||
|
||||
for _, match := range matches {
|
||||
if len(match) >= 2 {
|
||||
if id, err := strconv.ParseUint(match[1], 10, 32); err == nil {
|
||||
imageIDs[uint(id)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map keys to slice
|
||||
result := make([]uint, 0, len(imageIDs))
|
||||
for id := range imageIDs {
|
||||
result = append(result, id)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType model.CommentType, host string) (*model.CommentView, error) {
|
||||
if len(req.Content) == 0 {
|
||||
return nil, model.NewRequestError("Content cannot be empty")
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
@@ -235,7 +236,7 @@ func FinishUploadingFile(uid uint, fid uint, md5Str string) (*model.FileView, er
|
||||
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
|
||||
}
|
||||
|
||||
dbFile, err := dao.CreateFile(uploadingFile.Filename, uploadingFile.Description, uploadingFile.TargetResourceID, &uploadingFile.TargetStorageID, storageKeyUnavailable, "", uploadingFile.TotalSize, uid)
|
||||
dbFile, err := dao.CreateFile(uploadingFile.Filename, uploadingFile.Description, uploadingFile.TargetResourceID, &uploadingFile.TargetStorageID, storageKeyUnavailable, "", uploadingFile.TotalSize, uid, sumStr)
|
||||
if err != nil {
|
||||
log.Error("failed to create file in db: ", err)
|
||||
_ = os.Remove(resultFilePath)
|
||||
@@ -309,7 +310,7 @@ func CreateRedirectFile(uid uint, filename string, description string, resourceI
|
||||
return nil, model.NewUnAuthorizedError("user cannot upload file")
|
||||
}
|
||||
|
||||
file, err := dao.CreateFile(filename, description, resourceID, nil, "", redirectUrl, 0, uid)
|
||||
file, err := dao.CreateFile(filename, description, resourceID, nil, "", redirectUrl, 0, uid, "")
|
||||
if err != nil {
|
||||
log.Error("failed to create file in db: ", err)
|
||||
return nil, model.NewInternalServerError("failed to create file in db")
|
||||
@@ -389,7 +390,7 @@ func GetFile(fid string) (*model.FileView, error) {
|
||||
}
|
||||
|
||||
// DownloadFile handles the file download request. Return a presigned URL or a direct file path.
|
||||
func DownloadFile(fid, cfToken string) (string, string, error) {
|
||||
func DownloadFile(fid, cfToken string, isRealUser bool) (string, string, error) {
|
||||
file, err := dao.GetFile(fid)
|
||||
if err != nil {
|
||||
log.Error("failed to get file: ", err)
|
||||
@@ -436,91 +437,120 @@ func DownloadFile(fid, cfToken string) (string, string, error) {
|
||||
return "", "", model.NewInternalServerError("failed to download file from storage")
|
||||
}
|
||||
|
||||
err = dao.AddResourceDownloadCount(file.ResourceID)
|
||||
if err != nil {
|
||||
log.Errorf("failed to add resource download count: %v", err)
|
||||
if isRealUser {
|
||||
err = dao.AddResourceDownloadCount(file.ResourceID)
|
||||
if err != nil {
|
||||
log.Errorf("failed to add resource download count: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return path, file.Filename, nil
|
||||
}
|
||||
|
||||
func testFileUrl(url string) (int64, error) {
|
||||
client := http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
client := http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// Try HEAD request first, fallback to GET
|
||||
for _, method := range []string{"HEAD", "GET"} {
|
||||
req, err := http.NewRequest(method, url, nil)
|
||||
if err != nil {
|
||||
return 0, model.NewRequestError("failed to create HTTP request")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if method == "GET" {
|
||||
return 0, model.NewRequestError("failed to send HTTP request")
|
||||
}
|
||||
continue // Try GET if HEAD fails
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if method == "GET" {
|
||||
return 0, model.NewRequestError("URL is not accessible, status code: " + resp.Status)
|
||||
}
|
||||
continue // Try GET if HEAD fails
|
||||
}
|
||||
|
||||
contentLengthStr := resp.Header.Get("Content-Length")
|
||||
if contentLengthStr == "" {
|
||||
if method == "GET" {
|
||||
return 0, model.NewRequestError("URL does not provide content length")
|
||||
}
|
||||
continue // Try GET if HEAD doesn't provide Content-Length
|
||||
}
|
||||
|
||||
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
||||
if err != nil || contentLength <= 0 {
|
||||
if method == "GET" {
|
||||
return 0, model.NewRequestError("Content-Length is not valid")
|
||||
}
|
||||
continue // Try GET if HEAD has invalid Content-Length
|
||||
}
|
||||
|
||||
return contentLength, nil
|
||||
}
|
||||
req, err := http.NewRequest("HEAD", url, nil)
|
||||
if err != nil {
|
||||
return 0, model.NewRequestError("failed to create HTTP request")
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, model.NewRequestError("failed to send HTTP request")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, model.NewRequestError("URL is not accessible, status code: " + resp.Status)
|
||||
}
|
||||
if resp.Header.Get("Content-Length") == "" {
|
||||
return 0, model.NewRequestError("URL does not provide content length")
|
||||
}
|
||||
contentLength, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
|
||||
if err != nil {
|
||||
return 0, model.NewRequestError("failed to parse Content-Length header")
|
||||
}
|
||||
if contentLength <= 0 {
|
||||
return 0, model.NewRequestError("Content-Length is not valid")
|
||||
}
|
||||
return contentLength, nil
|
||||
|
||||
return 0, model.NewRequestError("failed to get valid content length")
|
||||
}
|
||||
|
||||
// downloadFile return nil if the download is successful or the context is cancelled
|
||||
func downloadFile(ctx context.Context, url string, path string) error {
|
||||
func downloadFile(ctx context.Context, url string, path string) (string, error) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
_ = os.Remove(path) // Remove the file if it already exists
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return model.NewRequestError("failed to create HTTP request")
|
||||
return "", model.NewRequestError("failed to create HTTP request")
|
||||
}
|
||||
client := http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// Check if the error is due to context cancellation
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
return "", nil
|
||||
}
|
||||
return model.NewRequestError("failed to send HTTP request")
|
||||
return "", model.NewRequestError("failed to send HTTP request")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return model.NewRequestError("URL is not accessible, status code: " + resp.Status)
|
||||
return "", model.NewRequestError("URL is not accessible, status code: " + resp.Status)
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, os.ModePerm)
|
||||
if err != nil {
|
||||
return model.NewInternalServerError("failed to open file for writing")
|
||||
return "", model.NewInternalServerError("failed to open file for writing")
|
||||
}
|
||||
defer file.Close()
|
||||
writer := bufio.NewWriter(file)
|
||||
|
||||
h := md5.New()
|
||||
|
||||
buf := make([]byte, 64*1024)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
return "", nil
|
||||
default:
|
||||
n, readErr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, writeErr := file.Write(buf[:n]); writeErr != nil {
|
||||
return model.NewInternalServerError("failed to write to file")
|
||||
if _, writeErr := writer.Write(buf[:n]); writeErr != nil {
|
||||
return "", model.NewInternalServerError("failed to write to file")
|
||||
}
|
||||
h.Write(buf[:n])
|
||||
}
|
||||
if readErr != nil {
|
||||
if readErr == io.EOF {
|
||||
return nil // Download completed successfully
|
||||
if err := writer.Flush(); err != nil {
|
||||
return "", model.NewInternalServerError("failed to flush writer")
|
||||
}
|
||||
md5Sum := hex.EncodeToString(h.Sum(nil))
|
||||
return md5Sum, nil // Download completed successfully
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil // Context cancelled, return nil
|
||||
return "", nil // Context cancelled, return nil
|
||||
}
|
||||
return model.NewInternalServerError("failed to read response body")
|
||||
return "", model.NewInternalServerError("failed to read response body")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,7 +577,7 @@ func CreateServerDownloadTask(uid uint, url, filename, description string, resou
|
||||
return nil, model.NewRequestError("server is busy, please try again later")
|
||||
}
|
||||
|
||||
file, err := dao.CreateFile(filename, description, resourceID, &storageID, storageKeyUnavailable, "", 0, uid)
|
||||
file, err := dao.CreateFile(filename, description, resourceID, &storageID, storageKeyUnavailable, "", 0, uid, "")
|
||||
if err != nil {
|
||||
log.Error("failed to create file in db: ", err)
|
||||
return nil, model.NewInternalServerError("failed to create file in db")
|
||||
@@ -598,11 +628,14 @@ func CreateServerDownloadTask(uid uint, url, filename, description string, resou
|
||||
}
|
||||
}()
|
||||
|
||||
hash := ""
|
||||
|
||||
for i := range 3 {
|
||||
if done.Load() {
|
||||
return
|
||||
}
|
||||
if err := downloadFile(ctx, url, tempPath); err != nil {
|
||||
hash, err = downloadFile(ctx, url, tempPath)
|
||||
if err != nil {
|
||||
log.Error("failed to download file: ", err)
|
||||
if i == 2 {
|
||||
_ = dao.DeleteFile(file.UUID)
|
||||
@@ -663,7 +696,7 @@ func CreateServerDownloadTask(uid uint, url, filename, description string, resou
|
||||
_ = os.Remove(tempPath)
|
||||
return
|
||||
}
|
||||
if err := dao.SetFileStorageKeyAndSize(file.UUID, storageKey, size); err != nil {
|
||||
if err := dao.SetFileStorageKeyAndSize(file.UUID, storageKey, size, hash); err != nil {
|
||||
log.Error("failed to set file storage key: ", err)
|
||||
_ = dao.DeleteFile(file.UUID)
|
||||
_ = iStorage.Delete(storageKey)
|
||||
|
@@ -2,16 +2,24 @@ package service
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"nysoure/server/config"
|
||||
"nysoure/server/dao"
|
||||
"nysoure/server/model"
|
||||
"nysoure/server/search"
|
||||
"nysoure/server/utils"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSearchQueryLength = 100
|
||||
)
|
||||
|
||||
type ResourceParams struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
AlternativeTitles []string `json:"alternative_titles"`
|
||||
@@ -66,6 +74,9 @@ func CreateResource(uid uint, params *ResourceParams) (uint, error) {
|
||||
if err != nil {
|
||||
log.Error("AddNewResourceActivity error: ", err)
|
||||
}
|
||||
if err := search.AddResourceToIndex(r); err != nil {
|
||||
log.Error("AddResourceToIndex error: ", err)
|
||||
}
|
||||
return r.ID, nil
|
||||
}
|
||||
|
||||
@@ -121,14 +132,16 @@ func parseResourceIfPresent(line string, host string) *model.ResourceView {
|
||||
return &v
|
||||
}
|
||||
|
||||
func GetResource(id uint, host string) (*model.ResourceDetailView, error) {
|
||||
func GetResource(id uint, host string, ctx fiber.Ctx) (*model.ResourceDetailView, error) {
|
||||
r, err := dao.GetResourceByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = dao.AddResourceViewCount(id)
|
||||
if err != nil {
|
||||
log.Error("AddResourceViewCount error: ", err)
|
||||
if ctx != nil && ctx.Locals("real_user") == true {
|
||||
err = dao.AddResourceViewCount(id)
|
||||
if err != nil {
|
||||
log.Error("AddResourceViewCount error: ", err)
|
||||
}
|
||||
}
|
||||
v := r.ToDetailView()
|
||||
if host != "" {
|
||||
@@ -150,13 +163,209 @@ func GetResourceList(page int, sort model.RSort) ([]model.ResourceView, int, err
|
||||
return views, totalPages, nil
|
||||
}
|
||||
|
||||
func SearchResource(keyword string, page int) ([]model.ResourceView, int, error) {
|
||||
resources, totalPages, err := dao.Search(keyword, page, pageSize)
|
||||
// splitQuery splits the input query string into keywords, treating quoted substrings (single or double quotes)
|
||||
// as single keywords and supporting escape characters for quotes. Spaces outside quotes are used as separators.
|
||||
func splitQuery(query string) []string {
|
||||
var keywords []string
|
||||
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return keywords
|
||||
}
|
||||
|
||||
l, r := 0, 0
|
||||
inQuote := false
|
||||
quoteChar := byte(0)
|
||||
|
||||
for r < len(query) {
|
||||
if (query[r] == '"' || query[r] == '\'') && (r == 0 || query[r-1] != '\\') {
|
||||
if !inQuote {
|
||||
inQuote = true
|
||||
quoteChar = query[r]
|
||||
l = r + 1
|
||||
} else if query[r] == quoteChar {
|
||||
if r > l {
|
||||
keywords = append(keywords, strings.TrimSpace(query[l:r]))
|
||||
}
|
||||
inQuote = false
|
||||
r++
|
||||
l = r
|
||||
continue
|
||||
}
|
||||
} else if !inQuote && query[r] == ' ' {
|
||||
if r > l {
|
||||
keywords = append(keywords, strings.TrimSpace(query[l:r]))
|
||||
}
|
||||
for r < len(query) && query[r] == ' ' {
|
||||
r++
|
||||
}
|
||||
l = r
|
||||
continue
|
||||
}
|
||||
|
||||
r++
|
||||
}
|
||||
|
||||
if l < len(query) {
|
||||
keywords = append(keywords, strings.TrimSpace(query[l:r]))
|
||||
}
|
||||
|
||||
return keywords
|
||||
}
|
||||
|
||||
func searchWithKeyword(keyword string) ([]uint, error) {
|
||||
resources := make([]uint, 0)
|
||||
|
||||
if len([]rune(keyword)) <= maxTagLength {
|
||||
exists, err := dao.ExistsTag(keyword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
t, err := dao.GetTagByName(keyword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := dao.GetResourcesIdWithTag(t.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, res...)
|
||||
}
|
||||
}
|
||||
|
||||
searchResult, err := search.SearchResource(keyword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resources = append(resources, searchResult...)
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func SearchResource(query string, page int) ([]model.ResourceView, int, error) {
|
||||
if len([]rune(query)) > maxSearchQueryLength {
|
||||
return nil, 0, model.NewRequestError("Search query is too long")
|
||||
}
|
||||
|
||||
start := (page - 1) * pageSize
|
||||
end := start + pageSize
|
||||
resources := make([]uint, 0)
|
||||
|
||||
checkTag := func(tag string) error {
|
||||
if len([]rune(tag)) > maxTagLength {
|
||||
return nil
|
||||
}
|
||||
exists, err := dao.ExistsTag(tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
t, err := dao.GetTagByName(tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := dao.GetResourcesIdWithTag(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resources = append(resources, res...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// check tag
|
||||
if err := checkTag(query); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// check tag after removing spaces
|
||||
trimmed := utils.RemoveSpaces(query)
|
||||
if trimmed != query {
|
||||
if err := checkTag(trimmed); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
// split query to search
|
||||
keywords := splitQuery(query)
|
||||
var temp []uint
|
||||
haveTag := false
|
||||
for _, keyword := range keywords {
|
||||
if len([]rune(keyword)) <= maxTagLength {
|
||||
exists, err := dao.ExistsTag(keyword)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if exists {
|
||||
haveTag = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if haveTag {
|
||||
first := true
|
||||
for _, keyword := range keywords {
|
||||
if keyword == "" {
|
||||
continue
|
||||
}
|
||||
if utils.OnlyPunctuation(keyword) {
|
||||
continue
|
||||
}
|
||||
|
||||
res, err := searchWithKeyword(keyword)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(res) == 0 && search.IsStopWord(keyword) {
|
||||
continue
|
||||
}
|
||||
if first {
|
||||
temp = utils.RemoveDuplicate(res)
|
||||
first = false
|
||||
} else {
|
||||
temp1 := make([]uint, 0)
|
||||
for _, id := range temp {
|
||||
for _, id2 := range res {
|
||||
if id == id2 {
|
||||
temp1 = append(temp1, id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
temp = temp1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
res, err := searchWithKeyword(query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
temp = res
|
||||
}
|
||||
resources = append(resources, temp...)
|
||||
resources = utils.RemoveDuplicate(resources)
|
||||
|
||||
if start >= len(resources) {
|
||||
return []model.ResourceView{}, 0, nil
|
||||
}
|
||||
|
||||
total := len(resources)
|
||||
totalPages := (total + pageSize - 1) / pageSize
|
||||
if start >= total {
|
||||
return []model.ResourceView{}, totalPages, nil
|
||||
}
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
idsPage := resources[start:end]
|
||||
|
||||
resourcesPage, err := dao.BatchGetResources(idsPage)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var views []model.ResourceView
|
||||
for _, r := range resources {
|
||||
for _, r := range resourcesPage {
|
||||
views = append(views, r.ToView())
|
||||
}
|
||||
return views, totalPages, nil
|
||||
@@ -176,7 +385,7 @@ func DeleteResource(uid, id uint) error {
|
||||
return model.NewUnAuthorizedError("You have not permission to delete this resource")
|
||||
}
|
||||
}
|
||||
r, err := GetResource(id, "")
|
||||
r, err := GetResource(id, "", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -190,6 +399,9 @@ func DeleteResource(uid, id uint) error {
|
||||
if err != nil {
|
||||
log.Error("Error updating cached tag list:", err)
|
||||
}
|
||||
if err := search.RemoveResourceFromIndex(id); err != nil {
|
||||
log.Error("RemoveResourceFromIndex error: ", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -271,6 +483,9 @@ func EditResource(uid, rid uint, params *ResourceParams) error {
|
||||
if err != nil {
|
||||
log.Error("AddUpdateResourceActivity error: ", err)
|
||||
}
|
||||
if err := search.AddResourceToIndex(r); err != nil {
|
||||
log.Error("AddResourceToIndex error: ", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -286,3 +501,32 @@ func RandomResource(host string) (*model.ResourceDetailView, error) {
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
var lastSuccessCover uint
|
||||
|
||||
func RandomCover() (uint, error) {
|
||||
for retries := 0; retries < 5; retries++ {
|
||||
v, err := dao.RandomResource()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(v.Images) > 0 {
|
||||
lastSuccessCover = v.Images[0].ID
|
||||
return v.Images[0].ID, nil
|
||||
}
|
||||
}
|
||||
return lastSuccessCover, nil
|
||||
}
|
||||
|
||||
func GetPinnedResources() ([]model.ResourceView, error) {
|
||||
ids := config.PinnedResources()
|
||||
var views []model.ResourceView
|
||||
for _, id := range ids {
|
||||
r, err := dao.GetResourceByID(id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
views = append(views, r.ToView())
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
@@ -9,6 +9,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTagLength = 20
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Start a goroutine to delete unused tags every hour
|
||||
go func() {
|
||||
@@ -25,6 +29,9 @@ func init() {
|
||||
}
|
||||
|
||||
func CreateTag(uid uint, name string) (*model.TagView, error) {
|
||||
if len([]rune(name)) > maxTagLength {
|
||||
return nil, model.NewRequestError("Tag name too long")
|
||||
}
|
||||
canUpload, err := checkUserCanUpload(uid)
|
||||
if err != nil {
|
||||
log.Error("Error checking user permissions:", err)
|
||||
|
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"nysoure/server/config"
|
||||
"nysoure/server/dao"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func checkUserCanUpload(uid uint) (bool, error) {
|
||||
@@ -58,3 +60,36 @@ func verifyCfToken(cfToken string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func findImagesInContent(content string, host string) []uint {
|
||||
// Handle both absolute and relative URLs
|
||||
absolutePattern := `!\[.*?\]\((?:https?://` + host + `)?/api/image/(\d+)(?:\s+["'].*?["'])?\)`
|
||||
relativePattern := `!\[.*?\]\(/api/image/(\d+)(?:\s+["'].*?["'])?\)`
|
||||
|
||||
// Combine patterns and compile regex
|
||||
patterns := []string{absolutePattern, relativePattern}
|
||||
|
||||
// Store unique image IDs to avoid duplicates
|
||||
imageIDs := make(map[uint]struct{})
|
||||
|
||||
for _, pattern := range patterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(content, -1)
|
||||
|
||||
for _, match := range matches {
|
||||
if len(match) >= 2 {
|
||||
if id, err := strconv.ParseUint(match[1], 10, 32); err == nil {
|
||||
imageIDs[uint(id)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map keys to slice
|
||||
result := make([]uint, 0, len(imageIDs))
|
||||
for id := range imageIDs {
|
||||
result = append(result, id)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
13
server/utils/slice.go
Normal file
13
server/utils/slice.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package utils
|
||||
|
||||
func RemoveDuplicate[T comparable](slice []T) []T {
|
||||
seen := make(map[T]struct{})
|
||||
var result []T
|
||||
for _, v := range slice {
|
||||
if _, ok := seen[v]; !ok {
|
||||
seen[v] = struct{}{}
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
21
server/utils/string.go
Normal file
21
server/utils/string.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func RemoveSpaces(s string) string {
|
||||
reg := regexp.MustCompile(`\s+`)
|
||||
return reg.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
func OnlyPunctuation(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.IsPunct(r) || unicode.IsSpace(r) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
Reference in New Issue
Block a user