Merge remote-tracking branch 'origin/main' into #93
このコミットが含まれているのは:
生成ファイル
+420
-2
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/noto-sans-jp": "^5.2.9",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
@@ -16,6 +17,7 @@
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.26",
|
||||
"humps": "^2.0.1",
|
||||
"lucide-react": "^0.511.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
@@ -25,6 +27,8 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-markdown-editor-lite": "^1.3.4",
|
||||
"react-router-dom": "^6.30.0",
|
||||
"react-youtube": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"unist-util-visit-parents": "^6.0.1"
|
||||
},
|
||||
@@ -946,6 +950,15 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/noto-sans-jp": {
|
||||
"version": "5.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-jp/-/noto-sans-jp-5.2.9.tgz",
|
||||
"integrity": "sha512-osPL5f7dvGDjuMuFwDTGPLG37030D8X5zk+3BWea6txAVDFeE/ZIrKW0DY0uSDfRn9+NiKbiFn/2QvZveKXTog==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -3376,7 +3389,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
@@ -3563,6 +3575,33 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.23.26",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
|
||||
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.23.23",
|
||||
"motion-utils": "^12.23.6",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -4169,6 +4208,12 @@
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/load-script": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz",
|
||||
"integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -4262,6 +4307,16 @@
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"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/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -4271,6 +4326,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",
|
||||
@@ -4295,6 +4378,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",
|
||||
@@ -4509,6 +4693,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",
|
||||
@@ -4940,6 +5245,21 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.23.23",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.23.6",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
|
||||
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -5015,7 +5335,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5390,6 +5709,17 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||
@@ -5499,6 +5829,12 @@
|
||||
"react": "^16.6.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
@@ -5652,6 +5988,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-youtube": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-youtube/-/react-youtube-10.1.0.tgz",
|
||||
"integrity": "sha512-ZfGtcVpk0SSZtWCSTYOQKhfx5/1cfyEW1JN/mugGNfAxT3rmVJeMbGpA9+e78yG21ls5nc/5uZJETE3cm3knBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"prop-types": "15.8.1",
|
||||
"youtube-player": "5.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.x"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=0.14.1"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -5675,6 +6028,24 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -5708,6 +6079,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": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
@@ -5872,6 +6258,12 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/sister": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sister/-/sister-3.0.2.tgz",
|
||||
"integrity": "sha512-p19rtTs+NksBRKW9qn0UhZ8/TUI9BPw9lmtHny+Y3TinWlOa9jWh9xB0AtPSdmOy49NJJJSSe0Ey4C7h0TrcYA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -6812,6 +7204,32 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/youtube-player": {
|
||||
"version": "5.5.2",
|
||||
"resolved": "https://registry.npmjs.org/youtube-player/-/youtube-player-5.5.2.tgz",
|
||||
"integrity": "sha512-ZGtsemSpXnDky2AUYWgxjaopgB+shFHgXVpiJFeNB5nWEugpW1KWYDaHKuLqh2b67r24GtP6HoSW5swvf0fFIQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"debug": "^2.6.6",
|
||||
"load-script": "^1.0.0",
|
||||
"sister": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/youtube-player/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/youtube-player/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/noto-sans-jp": "^5.2.9",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
@@ -18,6 +19,7 @@
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.26",
|
||||
"humps": "^2.0.1",
|
||||
"lucide-react": "^0.511.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
@@ -27,6 +29,8 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-markdown-editor-lite": "^1.3.4",
|
||||
"react-router-dom": "^6.30.0",
|
||||
"react-youtube": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"unist-util-visit-parents": "^6.0.1"
|
||||
},
|
||||
|
||||
@@ -19,7 +19,6 @@ const fetchPosts = async tagName => (await axios.get (`${ API_BASE_URL }/posts`,
|
||||
{ params: { ...(tagName && { tags: tagName,
|
||||
match: 'all',
|
||||
limit: '20' }) } })).data.posts
|
||||
const fetchPostIds = async () => (await fetchPosts ()).map (post => post.id)
|
||||
|
||||
const fetchTags = async () => (await axios.get (`${ API_BASE_URL }/tags`)).data
|
||||
const fetchTagNames = async () => (await fetchTags ()).map (tag => tag.name)
|
||||
@@ -33,7 +32,7 @@ const createPostListOutlet = async tagName => `
|
||||
<div class="flex gap-4"><a href="#" class="font-bold">広場</a></div>
|
||||
<div class="mt-2">
|
||||
<div class="flex flex-wrap gap-6 p-4">
|
||||
${ (await fetchPosts (tagName)).map (post => `
|
||||
${ (await fetchPosts (tagName)).slice (0, 20).map (post => `
|
||||
<a class="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"
|
||||
href="/posts/${ post.id }">
|
||||
<img alt="${ post.title }"
|
||||
@@ -42,7 +41,7 @@ const createPostListOutlet = async tagName => `
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="object-none w-full h-full"
|
||||
src="${ post.url }" />
|
||||
src="${ post.thumbnail }" />
|
||||
</a>`).join ('') }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+23
-19
@@ -9,6 +9,7 @@ import { API_BASE_URL } from '@/config'
|
||||
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
||||
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
||||
import PostListPage from '@/pages/posts/PostListPage'
|
||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||
import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
||||
@@ -20,10 +21,12 @@ import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
|
||||
import WikiNewPage from '@/pages/wiki/WikiNewPage'
|
||||
import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { User } from '@/types'
|
||||
|
||||
|
||||
export default () => {
|
||||
export default (() => {
|
||||
const [user, setUser] = useState<User | null> (null)
|
||||
const [status, setStatus] = useState (200)
|
||||
|
||||
@@ -65,30 +68,31 @@ export default () => {
|
||||
switch (status)
|
||||
{
|
||||
case 503:
|
||||
return <ServiceUnavailable />
|
||||
return <ServiceUnavailable/>
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="flex flex-col h-screen w-screen">
|
||||
<TopNav user={user} />
|
||||
<TopNav user={user}/>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/posts" replace />} />
|
||||
<Route path="/posts" element={<PostListPage />} />
|
||||
<Route path="/posts/new" element={<PostNewPage user={user} />} />
|
||||
<Route path="/posts/:id" element={<PostDetailPage user={user} />} />
|
||||
<Route path="/tags/nico" element={<NicoTagListPage user={user} />} />
|
||||
<Route path="/wiki" element={<WikiSearchPage />} />
|
||||
<Route path="/wiki/:title" element={<WikiDetailPage />} />
|
||||
<Route path="/wiki/new" element={<WikiNewPage user={user} />} />
|
||||
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user} />} />
|
||||
<Route path="/wiki/:id/diff" element={<WikiDiffPage />} />
|
||||
<Route path="/wiki/changes" element={<WikiHistoryPage />} />
|
||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser} />} />
|
||||
<Route path="/settings" element={<Navigate to="/users/settings" replace />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||
<Route path="/posts" element={<PostListPage/>}/>
|
||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||
<Route path="/posts/:id" element={<PostDetailPage user={user}/>}/>
|
||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
||||
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
||||
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
|
||||
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
|
||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
||||
<Route path="*" element={<NotFound/>}/>
|
||||
</Routes>
|
||||
</div>
|
||||
<Toaster />
|
||||
<Toaster/>
|
||||
</BrowserRouter>)
|
||||
}
|
||||
}) satisfies FC
|
||||
|
||||
@@ -5,10 +5,12 @@ import errorImg from '@/assets/images/not-found.gif'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
type Props = { status: number }
|
||||
|
||||
|
||||
export default ({ status }: Props) => {
|
||||
export default (({ status }: Props) => {
|
||||
const [message, rightMsg, leftMsg]: [string, string, string] = (() => {
|
||||
switch (status)
|
||||
{
|
||||
@@ -39,7 +41,7 @@ export default ({ status }: Props) => {
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="robots" content="noindex"/>
|
||||
<title>{title} | {SITE_TITLE}</title>
|
||||
</Helmet>
|
||||
<div className="text-6xl font-bold text-transparent
|
||||
@@ -50,10 +52,10 @@ export default ({ status }: Props) => {
|
||||
<p>{status}</p>
|
||||
<div className="flex flex-row space-x-1 sm:space-x-2 md:space-x-4">
|
||||
<p style={{ writingMode: 'vertical-rl' }}>{leftMsg}</p>
|
||||
<img className="max-w-[70vw]" src={errorImg} alt="逃げたギター" />
|
||||
<img className="max-w-[70vw]" src={errorImg} alt="逃げたギター"/>
|
||||
<p style={{ writingMode: 'vertical-rl' }}>{rightMsg}</p>
|
||||
</div>
|
||||
<p className="mr-[-.5em]">{message}</p>
|
||||
</div>
|
||||
</MainArea>)
|
||||
}
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export default () => (
|
||||
import type { FC } from 'react'
|
||||
|
||||
|
||||
export default (() => (
|
||||
<>
|
||||
<span className="hidden md:inline flex items-center px-2">|</span>
|
||||
<hr className="block md:hidden w-full opacity-25
|
||||
border-t border-black dark:border-white" />
|
||||
</>)
|
||||
border-t border-black dark:border-white"/>
|
||||
</>)) satisfies FC
|
||||
|
||||
@@ -4,10 +4,10 @@ type Props = { id: string,
|
||||
height: number,
|
||||
style?: CSSProperties }
|
||||
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { CSSProperties, FC } from 'react'
|
||||
|
||||
|
||||
export default (props: Props) => {
|
||||
export default ((props: Props) => {
|
||||
const { id, width, height, style = { } } = props
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement> (null)
|
||||
@@ -107,5 +107,5 @@ export default (props: Props) => {
|
||||
height={height}
|
||||
style={margedStyle}
|
||||
allowFullScreen
|
||||
allow="autoplay" />)
|
||||
}
|
||||
allow="autoplay"/>)
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -1,53 +1,85 @@
|
||||
import axios from 'axios'
|
||||
import toCamel from 'camelcase-keys'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import TextArea from '@/components/common/TextArea'
|
||||
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import Label from '@/components/common/Label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { API_BASE_URL } from '@/config'
|
||||
|
||||
import type { Post } from '@/types'
|
||||
import type { FC } from 'react'
|
||||
|
||||
type Props = { post: Post
|
||||
onSave: (newPost: Post) => void }
|
||||
import type { Post, Tag } from '@/types'
|
||||
|
||||
|
||||
export default ({ post, onSave }: Props) => {
|
||||
const tagsToStr = (tags: Tag[]): string => {
|
||||
const result: Tag[] = []
|
||||
|
||||
const walk = (tag: Tag) => {
|
||||
const { children, ...rest } = tag
|
||||
result.push (rest)
|
||||
children?.forEach (walk)
|
||||
}
|
||||
|
||||
tags.filter (t => t.category !== 'nico').forEach (walk)
|
||||
|
||||
return [...(new Set (result.map (t => t.name)))].join (' ')
|
||||
}
|
||||
|
||||
|
||||
type Props = { post: Post
|
||||
onSave: (newPost: Post) => void }
|
||||
|
||||
|
||||
export default (({ post, onSave }: Props) => {
|
||||
const [originalCreatedBefore, setOriginalCreatedBefore] =
|
||||
useState<string | null> (post.originalCreatedBefore)
|
||||
const [originalCreatedFrom, setOriginalCreatedFrom] =
|
||||
useState<string | null> (post.originalCreatedFrom)
|
||||
const [title, setTitle] = useState (post.title)
|
||||
const [tags, setTags] = useState<string> (post.tags
|
||||
.filter (t => t.category !== 'nico')
|
||||
.map (t => t.name)
|
||||
.join (' '))
|
||||
const [tags, setTags] = useState<string> ('')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const res = await axios.put (`${ API_BASE_URL }/posts/${ post.id }`, { title, tags },
|
||||
const res = await axios.put (
|
||||
`${ API_BASE_URL }/posts/${ post.id }`,
|
||||
{ title, tags,
|
||||
original_created_from: originalCreatedFrom,
|
||||
original_created_before: originalCreatedBefore },
|
||||
{ headers: { 'Content-Type': 'multipart/form-data',
|
||||
'X-Transfer-Code': localStorage.getItem ('user_code') || '' } } )
|
||||
'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } })
|
||||
const data = toCamel (res.data as any, { deep: true }) as Post
|
||||
onSave ({ ...post,
|
||||
title: data.title,
|
||||
tags: data.tags } as Post)
|
||||
title: data.title,
|
||||
tags: data.tags,
|
||||
originalCreatedFrom: data.originalCreatedFrom,
|
||||
originalCreatedBefore: data.originalCreatedBefore } as Post)
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
setTags(tagsToStr (post.tags))
|
||||
}, [post])
|
||||
|
||||
return (
|
||||
<div className="max-w-xl pt-2 space-y-4">
|
||||
{/* タイトル */}
|
||||
<div>
|
||||
<div className="flex gap-2 mb-1">
|
||||
<label className="flex-1 block font-semibold">タイトル</label>
|
||||
</div>
|
||||
<Label>タイトル</Label>
|
||||
<input type="text"
|
||||
className="w-full border rounded p-2"
|
||||
value={title}
|
||||
onChange={e => setTitle (e.target.value)} />
|
||||
onChange={ev => setTitle (ev.target.value)}/>
|
||||
</div>
|
||||
|
||||
{/* タグ */}
|
||||
<div>
|
||||
<label className="block font-semibold">タグ</label>
|
||||
<TextArea value={tags}
|
||||
onChange={ev => setTags (ev.target.value)} />
|
||||
</div>
|
||||
<PostFormTagsArea tags={tags} setTags={setTags}/>
|
||||
|
||||
{/* オリジナルの作成日時 */}
|
||||
<PostOriginalCreatedTimeField
|
||||
originalCreatedFrom={originalCreatedFrom}
|
||||
setOriginalCreatedFrom={setOriginalCreatedFrom}
|
||||
originalCreatedBefore={originalCreatedBefore}
|
||||
setOriginalCreatedBefore={setOriginalCreatedBefore}/>
|
||||
|
||||
{/* 送信 */}
|
||||
<Button onClick={handleSubmit}
|
||||
@@ -55,4 +87,4 @@ export default ({ post, onSave }: Props) => {
|
||||
更新
|
||||
</Button>
|
||||
</div>)
|
||||
}
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react'
|
||||
import YoutubeEmbed from 'react-youtube'
|
||||
|
||||
import NicoViewer from '@/components/NicoViewer'
|
||||
import TwitterEmbed from '@/components/TwitterEmbed'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { Post } from '@/types'
|
||||
|
||||
type Props = { post: Post }
|
||||
|
||||
|
||||
export default (({ post }: Props) => {
|
||||
const url = new URL (post.url)
|
||||
|
||||
switch (url.hostname.split ('.').slice (-2).join ('.'))
|
||||
{
|
||||
case 'nicovideo.jp':
|
||||
{
|
||||
const [videoId] = url.pathname.match (/(?<=\/watch\/)[a-zA-Z0-9]+?(?=\/|$)/)!
|
||||
return <NicoViewer id={videoId} width={640} height={360}/>
|
||||
}
|
||||
case 'twitter.com':
|
||||
case 'x.com':
|
||||
const [userId] = url.pathname.match (/(?<=\/)[^\/]+?(?=\/|$|\?)/)!
|
||||
const [statusId] = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/)!
|
||||
return <TwitterEmbed userId={userId} statusId={statusId}/>
|
||||
case 'youtube.com':
|
||||
{
|
||||
const videoId = url.searchParams.get ('v')!
|
||||
return (
|
||||
<YoutubeEmbed videoId={videoId} opts={{ playerVars: {
|
||||
playsinline: 1,
|
||||
autoplay: 1,
|
||||
mute: 0,
|
||||
loop: 1,
|
||||
width: '640',
|
||||
height: '360' } }}/>)
|
||||
}
|
||||
}
|
||||
|
||||
const [framed, setFramed] = useState (false)
|
||||
|
||||
return (
|
||||
<>
|
||||
{framed
|
||||
? (
|
||||
<iframe
|
||||
src={post.url}
|
||||
title={post.title || post.url}
|
||||
width={640}
|
||||
height={360}/>)
|
||||
: (
|
||||
<div>
|
||||
<a href="#" onClick={e => {
|
||||
e.preventDefault ()
|
||||
setFramed (confirm ('未確認の外部ページを表示します。\n'
|
||||
+ '悪意のあるスクリプトが実行される可能性があります。\n'
|
||||
+ '表示しますか?'))
|
||||
return
|
||||
}}>
|
||||
外部ページを表示
|
||||
</a>
|
||||
</div>)}
|
||||
</>)
|
||||
}) satisfies FC<Props>
|
||||
@@ -0,0 +1,84 @@
|
||||
import axios from 'axios'
|
||||
import toCamel from 'camelcase-keys'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import TagSearchBox from '@/components/TagSearchBox'
|
||||
import Label from '@/components/common/Label'
|
||||
import TextArea from '@/components/common/TextArea'
|
||||
import { API_BASE_URL } from '@/config'
|
||||
|
||||
import type { FC, SyntheticEvent } from 'react'
|
||||
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
const SEP = /\s/
|
||||
|
||||
|
||||
const getTokenAt = (value: string, pos: number) => {
|
||||
let start = pos
|
||||
while (start > 0 && !(SEP.test (value[start - 1])))
|
||||
--start
|
||||
|
||||
let end = pos
|
||||
while (end < value.length && !(SEP.test (value[end])))
|
||||
++end
|
||||
|
||||
return { start, end, token: value.slice (start, end) }
|
||||
}
|
||||
|
||||
|
||||
const replaceToken = (value: string, start: number, end: number, text: string) => (
|
||||
`${ value.slice (0, start) }${ text }${ value.slice (end) }`)
|
||||
|
||||
|
||||
type Props = {
|
||||
tags: string
|
||||
setTags: (tags: string) => void }
|
||||
|
||||
|
||||
export default (({ tags, setTags }: Props) => {
|
||||
const ref = useRef<HTMLTextAreaElement> (null)
|
||||
|
||||
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
|
||||
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||||
|
||||
const handleTagSelect = (tag: Tag) => {
|
||||
setSuggestionsVsbl (false)
|
||||
const textarea = ref.current!
|
||||
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name)
|
||||
setTags (newValue)
|
||||
requestAnimationFrame (async () => {
|
||||
const p = bounds.start + tag.name.length
|
||||
textarea.selectionStart = textarea.selectionEnd = p
|
||||
textarea.focus ()
|
||||
await recompute (p, newValue)
|
||||
})
|
||||
}
|
||||
|
||||
const recompute = async (pos: number, v: string = tags) => {
|
||||
const { start, end, token } = getTokenAt (v, pos)
|
||||
setBounds ({ start, end })
|
||||
const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q: token } })
|
||||
setSuggestions (toCamel (res.data as any, { deep: true }) as Tag[])
|
||||
setSuggestionsVsbl (suggestions.length > 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label>タグ</Label>
|
||||
<TextArea
|
||||
ref={ref}
|
||||
value={tags}
|
||||
onChange={ev => setTags (ev.target.value)}
|
||||
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
|
||||
const pos = (ev.target as HTMLTextAreaElement).selectionStart
|
||||
await recompute (pos)
|
||||
}}/>
|
||||
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length
|
||||
? suggestions
|
||||
: [] as Tag[]}
|
||||
activeIndex={-1}
|
||||
onSelect={handleTagSelect}/>
|
||||
</div>)
|
||||
}) satisfies FC<Props>
|
||||
@@ -1,25 +1,25 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { MouseEvent } from 'react'
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
|
||||
import type { Post } from '@/types'
|
||||
|
||||
type Props = { posts: Post[]
|
||||
onClick?: (event: MouseEvent<HTMLElement>) => void }
|
||||
|
||||
|
||||
export default ({ posts, onClick }: Props) => (
|
||||
export default (({ posts, onClick }: Props) => (
|
||||
<div className="flex flex-wrap gap-6 p-4">
|
||||
{posts.map ((post, i) => (
|
||||
<Link to={`/posts/${ post.id }`}
|
||||
key={i}
|
||||
key={post.id}
|
||||
className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"
|
||||
onClick={onClick}>
|
||||
<img src={post.thumbnail || post.thumbnailBase || undefined}
|
||||
alt={post.title || post.url}
|
||||
title={post.title || post.url || undefined}
|
||||
loading="eager"
|
||||
fetchPriority="high"
|
||||
loading={i < 12 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
className="object-none w-full h-full" />
|
||||
className="object-cover w-full h-full"/>
|
||||
</Link>))}
|
||||
</div>)
|
||||
</div>)) satisfies FC<Props>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import Label from '@/components/common/Label'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
originalCreatedFrom: string | null
|
||||
setOriginalCreatedFrom: (x: string | null) => void
|
||||
originalCreatedBefore: string | null
|
||||
setOriginalCreatedBefore: (x: string | null) => void }
|
||||
|
||||
|
||||
export default (({ originalCreatedFrom,
|
||||
setOriginalCreatedFrom,
|
||||
originalCreatedBefore,
|
||||
setOriginalCreatedBefore }: Props) => (
|
||||
<div>
|
||||
<Label>オリジナルの作成日時</Label>
|
||||
<div className="my-1">
|
||||
<DateTimeField
|
||||
className="mr-2"
|
||||
value={originalCreatedFrom ?? undefined}
|
||||
onChange={setOriginalCreatedFrom}
|
||||
onBlur={ev => {
|
||||
const v = ev.target.value
|
||||
if (!(v))
|
||||
return
|
||||
const d = new Date (v)
|
||||
if (d.getSeconds () === 0)
|
||||
{
|
||||
if (d.getMinutes () === 0 && d.getHours () === 0)
|
||||
d.setDate (d.getDate () + 1)
|
||||
else
|
||||
d.setMinutes (d.getMinutes () + 1)
|
||||
}
|
||||
else
|
||||
d.setSeconds (d.getSeconds () + 1)
|
||||
setOriginalCreatedBefore (d.toISOString ())
|
||||
}}/>
|
||||
以降
|
||||
</div>
|
||||
<div className="my-1">
|
||||
<DateTimeField
|
||||
className="mr-2"
|
||||
value={originalCreatedBefore ?? undefined}
|
||||
onChange={setOriginalCreatedBefore}/>
|
||||
より前
|
||||
</div>
|
||||
</div>)) satisfies FC<Props>
|
||||
@@ -1,19 +1,49 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import TagLink from '@/components/TagLink'
|
||||
import TagSearch from '@/components/TagSearch'
|
||||
import SectionTitle from '@/components/common/SectionTitle'
|
||||
import SubsectionTitle from '@/components/common/SubsectionTitle'
|
||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||
import { CATEGORIES } from '@/consts'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
import type { Category, Post, Tag } from '@/types'
|
||||
|
||||
type TagByCategory = { [key in Category]: Tag[] }
|
||||
|
||||
|
||||
const renderTagTree = (
|
||||
tag: Tag,
|
||||
nestLevel: number,
|
||||
path: string,
|
||||
): ReactNode[] => {
|
||||
const key = `${ path }-${ tag.id }`
|
||||
|
||||
const self = (
|
||||
<motion.li
|
||||
key={key}
|
||||
layout
|
||||
transition={{ duration: .2, ease: 'easeOut' }}
|
||||
className="mb-1">
|
||||
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||
</motion.li>)
|
||||
|
||||
return [self,
|
||||
...((tag.children
|
||||
?.sort ((a, b) => a.name < b.name ? -1 : 1)
|
||||
.flatMap (child => renderTagTree (child, nestLevel + 1, key)))
|
||||
?? [])]
|
||||
}
|
||||
|
||||
|
||||
type Props = { post: Post | null }
|
||||
|
||||
|
||||
export default ({ post }: Props) => {
|
||||
export default (({ post }: Props) => {
|
||||
const [tags, setTags] = useState ({ } as TagByCategory)
|
||||
|
||||
const categoryNames: Record<Category, string> = {
|
||||
@@ -46,16 +76,64 @@ export default ({ post }: Props) => {
|
||||
|
||||
return (
|
||||
<SidebarComponent>
|
||||
<TagSearch />
|
||||
{CATEGORIES.map ((cat: Category) => cat in tags && (
|
||||
<div className="my-3" key={cat}>
|
||||
<SubsectionTitle>{categoryNames[cat]}</SubsectionTitle>
|
||||
<ul>
|
||||
{tags[cat].map ((tag, i) => (
|
||||
<li key={i} className="mb-1">
|
||||
<TagLink tag={tag} />
|
||||
</li>))}
|
||||
</ul>
|
||||
</div>))}
|
||||
<TagSearch/>
|
||||
<motion.div key={post?.id ?? 0} layout>
|
||||
{CATEGORIES.map ((cat: Category) => cat in tags && (
|
||||
<motion.div layout className="my-3" key={cat}>
|
||||
<SubsectionTitle>{categoryNames[cat]}</SubsectionTitle>
|
||||
|
||||
<motion.ul layout>
|
||||
<AnimatePresence initial={false}>
|
||||
{tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))}
|
||||
</AnimatePresence>
|
||||
</motion.ul>
|
||||
</motion.div>))}
|
||||
{post && (
|
||||
<div>
|
||||
<SectionTitle>情報</SectionTitle>
|
||||
<ul>
|
||||
<li>Id.: {post.id}</li>
|
||||
{/* TODO: uploadedUser の取得を対応したらコメント外す */}
|
||||
{/*
|
||||
<li>
|
||||
<>耕作者: </>
|
||||
{post.uploadedUser
|
||||
? (
|
||||
<Link to={`/users/${ post.uploadedUser.id }`}>
|
||||
{post.uploadedUser.name || '名もなきニジラー'}
|
||||
</Link>)
|
||||
: 'bot操作'}
|
||||
</li>
|
||||
*/}
|
||||
<li>耕作日時: {(new Date (post.createdAt)).toLocaleString ()}</li>
|
||||
<li>
|
||||
<>リンク: </>
|
||||
<a
|
||||
className="break-all"
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow">
|
||||
{post.url}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
{/* TODO: 表示形式きしょすぎるので何とかする */}
|
||||
<>オリジナルの投稿日時: </>
|
||||
{!(post.originalCreatedFrom) && !(post.originalCreatedBefore)
|
||||
? '不明'
|
||||
: (
|
||||
<>
|
||||
{post.originalCreatedFrom
|
||||
&& `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `}
|
||||
{post.originalCreatedBefore
|
||||
&& `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`}
|
||||
</>)}
|
||||
</li>
|
||||
<li>
|
||||
<Link to={`/posts/changes?id=${ post.id }`}>履歴</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>)}
|
||||
</motion.div>
|
||||
</SidebarComponent>)
|
||||
}
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import axios from 'axios'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { API_BASE_URL } from '@/config'
|
||||
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ComponentProps, HTMLAttributes } from 'react'
|
||||
import type { ComponentProps, FC, HTMLAttributes } from 'react'
|
||||
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
type CommonProps = { tag: Tag
|
||||
nestLevel?: number
|
||||
withWiki?: boolean
|
||||
withCount?: boolean }
|
||||
|
||||
@@ -20,11 +24,33 @@ type PropsWithoutLink =
|
||||
type Props = PropsWithLink | PropsWithoutLink
|
||||
|
||||
|
||||
export default ({ tag,
|
||||
linkFlg = true,
|
||||
withWiki = true,
|
||||
withCount = true,
|
||||
...props }: Props) => {
|
||||
export default (({ tag,
|
||||
nestLevel = 0,
|
||||
linkFlg = true,
|
||||
withWiki = true,
|
||||
withCount = true,
|
||||
...props }: Props) => {
|
||||
const [havingWiki, setHavingWiki] = useState (true)
|
||||
|
||||
const wikiExists = async (tagName: string) => {
|
||||
try
|
||||
{
|
||||
await axios.get (`${ API_BASE_URL }/wiki/title/${ encodeURIComponent (tagName) }/exists`)
|
||||
setHavingWiki (true)
|
||||
}
|
||||
catch
|
||||
{
|
||||
setHavingWiki (false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
if (!(linkFlg) || !(withWiki))
|
||||
return
|
||||
|
||||
wikiExists (tag.name)
|
||||
}, [tag.name, linkFlg, withWiki])
|
||||
|
||||
const spanClass = cn (
|
||||
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
|
||||
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
|
||||
@@ -37,10 +63,25 @@ export default ({ tag,
|
||||
<>
|
||||
{(linkFlg && withWiki) && (
|
||||
<span className="mr-1">
|
||||
<Link to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||
className={linkClass}>
|
||||
?
|
||||
</Link>
|
||||
{havingWiki
|
||||
? (
|
||||
<Link to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||
className={linkClass}>
|
||||
?
|
||||
</Link>)
|
||||
: (
|
||||
<Link to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
||||
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
||||
title={`${ tag.name } Wiki が存在しません.`}>
|
||||
!
|
||||
</Link>)}
|
||||
</span>)}
|
||||
{nestLevel > 0 && (
|
||||
<span
|
||||
className="ml-1 mr-1"
|
||||
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
|
||||
↳
|
||||
</span>)}
|
||||
{linkFlg
|
||||
? (
|
||||
@@ -57,4 +98,4 @@ export default ({ tag,
|
||||
{withCount && (
|
||||
<span className="ml-1">{tag.postCount}</span>)}
|
||||
</>)
|
||||
}
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -6,16 +6,18 @@ import { API_BASE_URL } from '@/config'
|
||||
|
||||
import TagSearchBox from './TagSearchBox'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
|
||||
const TagSearch: React.FC = () => {
|
||||
export default (() => {
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState (-1)
|
||||
const [search, setSearch] = useState ('')
|
||||
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
||||
const [activeIndex, setActiveIndex] = useState (-1)
|
||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||||
|
||||
const whenChanged = async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -24,14 +26,14 @@ const TagSearch: React.FC = () => {
|
||||
const q = ev.target.value.trim ().split (' ').at (-1)
|
||||
if (!(q))
|
||||
{
|
||||
setSuggestions ([])
|
||||
return
|
||||
setSuggestions ([])
|
||||
return
|
||||
}
|
||||
|
||||
const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q } })
|
||||
const data = res.data as Tag[]
|
||||
setSuggestions (data)
|
||||
if (suggestions.length)
|
||||
if (suggestions.length > 0)
|
||||
setSuggestionsVsbl (true)
|
||||
}
|
||||
|
||||
@@ -52,7 +54,7 @@ const TagSearch: React.FC = () => {
|
||||
|
||||
case 'Enter':
|
||||
if (activeIndex < 0)
|
||||
break
|
||||
break
|
||||
ev.preventDefault ()
|
||||
const selected = suggestions[activeIndex]
|
||||
selected && handleTagSelect (selected)
|
||||
@@ -65,8 +67,8 @@ const TagSearch: React.FC = () => {
|
||||
}
|
||||
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
|
||||
{
|
||||
navigate (`/posts?${ (new URLSearchParams ({ tags: search })).toString () }`)
|
||||
setSuggestionsVsbl (false)
|
||||
navigate (`/posts?${ (new URLSearchParams ({ tags: search })).toString () }`)
|
||||
setSuggestionsVsbl (false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,18 +88,16 @@ const TagSearch: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<input type="text"
|
||||
placeholder="タグ検索..."
|
||||
value={search}
|
||||
onChange={whenChanged}
|
||||
onFocus={() => setSuggestionsVsbl (true)}
|
||||
onBlur={() => setSuggestionsVsbl (false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full px-3 py-2 border rounded dark:border-gray-600 dark:bg-gray-800 dark:text-white" />
|
||||
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
|
||||
activeIndex={activeIndex}
|
||||
onSelect={handleTagSelect} />
|
||||
<input type="text"
|
||||
placeholder="タグ検索..."
|
||||
value={search}
|
||||
onChange={whenChanged}
|
||||
onFocus={() => setSuggestionsVsbl (true)}
|
||||
onBlur={() => setSuggestionsVsbl (false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full px-3 py-2 border rounded dark:border-gray-600 dark:bg-gray-800 dark:text-white"/>
|
||||
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
|
||||
activeIndex={activeIndex}
|
||||
onSelect={handleTagSelect}/>
|
||||
</div>)
|
||||
}
|
||||
|
||||
export default TagSearch
|
||||
}) satisfies FC
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
type Props = { suggestions: Tag[]
|
||||
@@ -7,8 +9,8 @@ type Props = { suggestions: Tag[]
|
||||
onSelect: (tag: Tag) => void }
|
||||
|
||||
|
||||
export default ({ suggestions, activeIndex, onSelect }: Props) => {
|
||||
if (!(suggestions.length))
|
||||
export default (({ suggestions, activeIndex, onSelect }: Props) => {
|
||||
if (suggestions.length === 0)
|
||||
return
|
||||
|
||||
return (
|
||||
@@ -19,10 +21,9 @@ export default ({ suggestions, activeIndex, onSelect }: Props) => {
|
||||
<li key={tag.id}
|
||||
className={cn ('px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700',
|
||||
i === activeIndex && 'bg-gray-300 dark:bg-gray-700')}
|
||||
onMouseDown={() => onSelect (tag)}
|
||||
>
|
||||
onMouseDown={() => onSelect (tag)}>
|
||||
{tag.name}
|
||||
{<span className="ml-2 text-sm text-gray-400">{tag.postCount}</span>}
|
||||
</li>))}
|
||||
</ul>)
|
||||
}
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
@@ -8,7 +9,8 @@ import SectionTitle from '@/components/common/SectionTitle'
|
||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||
import { API_BASE_URL } from '@/config'
|
||||
import { CATEGORIES } from '@/consts'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { Post, Tag } from '@/types'
|
||||
|
||||
@@ -17,7 +19,7 @@ type TagByCategory = Record<string, Tag[]>
|
||||
type Props = { posts: Post[] }
|
||||
|
||||
|
||||
export default ({ posts }: Props) => {
|
||||
export default (({ posts }: Props) => {
|
||||
const navigate = useNavigate ()
|
||||
|
||||
const [tagsVsbl, setTagsVsbl] = useState (false)
|
||||
@@ -56,49 +58,73 @@ export default ({ posts }: Props) => {
|
||||
setTags (tagsTmp)
|
||||
}, [posts])
|
||||
|
||||
const TagBlock = (
|
||||
<>
|
||||
<SectionTitle>タグ</SectionTitle>
|
||||
<ul>
|
||||
{CATEGORIES.flatMap (cat => cat in tags ? (
|
||||
tags[cat].map (tag => (
|
||||
<li key={tag.id} className="mb-1">
|
||||
<TagLink tag={tag}/>
|
||||
</li>))) : [])}
|
||||
</ul>
|
||||
<SectionTitle>関聯</SectionTitle>
|
||||
{posts.length > 0 && (
|
||||
<a href="#"
|
||||
onClick={ev => {
|
||||
ev.preventDefault ()
|
||||
void ((async () => {
|
||||
try
|
||||
{
|
||||
const { data } = await axios.get (`${ API_BASE_URL }/posts/random`,
|
||||
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
|
||||
match: (anyFlg ? 'any' : 'all') } })
|
||||
navigate (`/posts/${ (data as Post).id }`)
|
||||
}
|
||||
catch
|
||||
{
|
||||
;
|
||||
}
|
||||
}) ())
|
||||
}}>
|
||||
ランダム
|
||||
</a>)}
|
||||
</>)
|
||||
|
||||
return (
|
||||
<SidebarComponent>
|
||||
<TagSearch />
|
||||
<div className={cn (!(tagsVsbl) && 'hidden', 'md:block mt-4')}>
|
||||
<SectionTitle>タグ</SectionTitle>
|
||||
<ul>
|
||||
{CATEGORIES.flatMap (cat => cat in tags ? (
|
||||
tags[cat].map (tag => (
|
||||
<li key={tag.id} className="mb-1">
|
||||
<TagLink tag={tag} />
|
||||
</li>))) : [])}
|
||||
</ul>
|
||||
<SectionTitle>関聯</SectionTitle>
|
||||
{posts.length > 0 && (
|
||||
<a href="#"
|
||||
onClick={ev => {
|
||||
ev.preventDefault ()
|
||||
void ((async () => {
|
||||
try
|
||||
{
|
||||
const { data } = await axios.get (`${ API_BASE_URL }/posts/random`,
|
||||
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (','),
|
||||
match: (anyFlg ? 'any' : 'all') } })
|
||||
navigate (`/posts/${ (data as Post).id }`)
|
||||
}
|
||||
catch
|
||||
{
|
||||
;
|
||||
}
|
||||
}) ())
|
||||
}}>
|
||||
ランダム
|
||||
</a>)}
|
||||
<TagSearch/>
|
||||
|
||||
<div className="hidden md:block mt-4">
|
||||
{TagBlock}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{tagsVsbl && (
|
||||
<motion.div
|
||||
key="sptags"
|
||||
className="md:hidden mt-4"
|
||||
variants={{ hidden: { clipPath: 'inset(0 0 100% 0)',
|
||||
height: 0 },
|
||||
visible: { clipPath: 'inset(0 0 0% 0)',
|
||||
height: 'auto'} }}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||||
{TagBlock}
|
||||
</motion.div>)}
|
||||
</AnimatePresence>
|
||||
|
||||
<a href="#"
|
||||
className="md:hidden block my-2 text-center text-sm
|
||||
text-gray-500 hover:text-gray-400
|
||||
dark:text-gray-300 dark:hover:text-gray-100"
|
||||
onClick={ev => {
|
||||
ev.preventDefault ()
|
||||
setTagsVsbl (!(tagsVsbl))
|
||||
setTagsVsbl (v => !(v))
|
||||
}}>
|
||||
{tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'}
|
||||
</a>
|
||||
</SidebarComponent>)
|
||||
}
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios'
|
||||
import toCamel from 'camelcase-keys'
|
||||
import { Fragment, useState, useEffect } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
|
||||
import Separator from '@/components/MenuSeparator'
|
||||
@@ -9,14 +10,38 @@ import { API_BASE_URL } from '@/config'
|
||||
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { Menu, Tag, User, WikiPage } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
|
||||
export default ({ user }: Props) => {
|
||||
export default (({ user }: Props) => {
|
||||
const location = useLocation ()
|
||||
|
||||
const dirRef = useRef<(-1) | 1> (1)
|
||||
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
|
||||
const navRef = useRef<HTMLDivElement | null> (null)
|
||||
|
||||
const measure = () => {
|
||||
const nav = navRef.current
|
||||
const el = itemsRef.current[activeIdx]
|
||||
if (!(nav) || !(el) || activeIdx < 0)
|
||||
return
|
||||
|
||||
const navRect = nav.getBoundingClientRect ()
|
||||
const elRect = el.getBoundingClientRect ()
|
||||
|
||||
setHl ({ left: elRect.left - navRect.left,
|
||||
width: elRect.width,
|
||||
visible: true })
|
||||
}
|
||||
|
||||
const [hl, setHl] = useState<{ left: number; width: number; visible: boolean }> ({
|
||||
left: 0,
|
||||
width: 0,
|
||||
visible: false })
|
||||
const [menuOpen, setMenuOpen] = useState (false)
|
||||
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
||||
const [postCount, setPostCount] = useState<number | null> (null)
|
||||
@@ -28,20 +53,20 @@ export default ({ user }: Props) => {
|
||||
{ name: '広場', to: '/posts', subMenu: [
|
||||
{ name: '一覧', to: '/posts' },
|
||||
{ name: '投稿追加', to: '/posts/new' },
|
||||
{ name: '耕作履歴', to: '/posts/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
||||
{ name: 'タグ', to: '/tags', subMenu: [
|
||||
{ name: 'タグ一覧', to: '/tags', visible: false },
|
||||
{ name: '別名タグ', to: '/tags/aliases', visible: false },
|
||||
{ name: '上位タグ', to: '/tags/implications', visible: false },
|
||||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
||||
{ name: 'タグのつけ方', to: '/wiki/ヘルプ:タグのつけ方' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
|
||||
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
|
||||
{ name: '検索', to: '/wiki' },
|
||||
{ name: '新規', to: '/wiki/new' },
|
||||
{ name: '全体履歴', to: '/wiki/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
|
||||
{ component: <Separator />, visible: wikiPageFlg },
|
||||
{ component: <Separator/>, visible: wikiPageFlg },
|
||||
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
|
||||
visible: wikiPageFlg },
|
||||
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||
@@ -51,6 +76,32 @@ export default ({ user }: Props) => {
|
||||
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
||||
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] }]
|
||||
|
||||
const activeIdx = menu.findIndex (item => location.pathname.startsWith (item.base || item.to))
|
||||
|
||||
const prevActiveIdxRef = useRef<number> (activeIdx)
|
||||
|
||||
if (activeIdx !== prevActiveIdxRef.current)
|
||||
{
|
||||
dirRef.current = activeIdx > prevActiveIdxRef.current ? 1 : -1
|
||||
prevActiveIdxRef.current = activeIdx
|
||||
}
|
||||
|
||||
const dir = dirRef.current
|
||||
|
||||
useLayoutEffect (() => {
|
||||
if (activeIdx < 0)
|
||||
return
|
||||
|
||||
const raf = requestAnimationFrame (measure)
|
||||
const onResize = () => requestAnimationFrame (measure)
|
||||
|
||||
addEventListener ('resize', onResize)
|
||||
return () => {
|
||||
cancelAnimationFrame (raf)
|
||||
removeEventListener ('resize', onResize)
|
||||
}
|
||||
}, [activeIdx])
|
||||
|
||||
useEffect (() => {
|
||||
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
||||
return () => unsubscribe ()
|
||||
@@ -96,19 +147,29 @@ export default ({ user }: Props) => {
|
||||
ぼざクリ タグ広場
|
||||
</Link>
|
||||
|
||||
{menu.map ((item, i) => (
|
||||
<Link key={i}
|
||||
to={item.to}
|
||||
className={cn ('hidden md:flex h-full items-center',
|
||||
(location.pathname.startsWith (item.base || item.to)
|
||||
? 'bg-yellow-200 dark:bg-red-950 px-4 font-bold'
|
||||
: 'px-2'))}>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
<div ref={navRef} className="relative hidden md:flex h-full items-center">
|
||||
<div aria-hidden
|
||||
className={cn ('absolute top-1/2 -translate-y-1/2 h-full',
|
||||
'bg-yellow-200 dark:bg-red-950',
|
||||
'transition-[transform,width] duration-200 ease-out')}
|
||||
style={{ width: hl.width,
|
||||
transform: `translate(${ hl.left }px, -50%)`,
|
||||
opacity: hl.visible ? 1 : 0 }}/>
|
||||
|
||||
{menu.map ((item, i) => (
|
||||
<Link key={i}
|
||||
to={item.to}
|
||||
ref={el => {
|
||||
itemsRef.current[i] = el
|
||||
}}
|
||||
className={cn ('relative z-10 flex h-full items-center px-5',
|
||||
(i === openItemIdx) && 'font-bold')}>
|
||||
{item.name}
|
||||
</Link>))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TopNavUser user={user} />
|
||||
<TopNavUser user={user}/>
|
||||
|
||||
<a href="#"
|
||||
className="md:hidden ml-auto pr-4
|
||||
@@ -122,49 +183,101 @@ export default ({ user }: Props) => {
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div className="hidden md:flex bg-yellow-200 dark:bg-red-950
|
||||
items-center w-full min-h-[40px] px-3">
|
||||
{menu.find (item => location.pathname.startsWith (item.base || item.to))?.subMenu
|
||||
.filter (item => item.visible ?? true)
|
||||
.map ((item, i) => 'component' in item ? item.component : (
|
||||
<Link key={i}
|
||||
to={item.to}
|
||||
className="h-full flex items-center px-3">
|
||||
{item.name}
|
||||
</Link>))}
|
||||
<div className="relative hidden md:flex bg-yellow-200 dark:bg-red-950
|
||||
items-center w-full min-h-[40px] overflow-hidden">
|
||||
<AnimatePresence initial={false} custom={dir}>
|
||||
<motion.div
|
||||
key={activeIdx}
|
||||
custom={dir}
|
||||
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
||||
centre: { y: 0, opacity: 1 },
|
||||
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
||||
className="absolute inset-0 flex items-center px-3"
|
||||
initial="enter"
|
||||
animate="centre"
|
||||
exit="exit"
|
||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||||
{(menu[activeIdx]?.subMenu ?? [])
|
||||
.filter (item => item.visible ?? true)
|
||||
.map ((item, i) => (
|
||||
'component' in item
|
||||
? <Fragment key={`c-${ i }`}>{item.component}</Fragment>
|
||||
: (
|
||||
<Link key={`l-${ i }`}
|
||||
to={item.to}
|
||||
className="h-full flex items-center px-3">
|
||||
{item.name}
|
||||
</Link>)))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className={cn (menuOpen ? 'flex flex-col md:hidden' : 'hidden',
|
||||
'bg-yellow-200 dark:bg-red-975 items-start')}>
|
||||
<Separator />
|
||||
{menu.map ((item, i) => (
|
||||
<Fragment key={i}>
|
||||
<Link to={i === openItemIdx ? item.to : '#'}
|
||||
className={cn ('w-full min-h-[40px] flex items-center pl-8',
|
||||
((i === openItemIdx)
|
||||
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
|
||||
onClick={ev => {
|
||||
if (i !== openItemIdx)
|
||||
{
|
||||
ev.preventDefault ()
|
||||
setOpenItemIdx (i)
|
||||
}
|
||||
}}>
|
||||
{item.name}
|
||||
</Link>
|
||||
{i === openItemIdx && (
|
||||
item.subMenu
|
||||
.filter (subItem => subItem.visible ?? true)
|
||||
.map ((subItem, j) => 'component' in subItem ? subItem.component : (
|
||||
<Link key={j}
|
||||
to={subItem.to}
|
||||
className="w-full min-h-[36px] flex items-center pl-12
|
||||
bg-yellow-50 dark:bg-red-950">
|
||||
{subItem.name}
|
||||
</Link>)))}
|
||||
</Fragment>))}
|
||||
<TopNavUser user={user} sp />
|
||||
<Separator />
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
key="spmenu"
|
||||
className={cn ('flex flex-col md:hidden',
|
||||
'bg-yellow-200 dark:bg-red-975 items-start')}
|
||||
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
|
||||
height: 0 },
|
||||
open: { clipPath: 'inset(0 0 0% 0)',
|
||||
height: 'auto' } }}
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||||
<Separator/>
|
||||
{menu.map ((item, i) => (
|
||||
<Fragment key={i}>
|
||||
<Link to={i === openItemIdx ? item.to : '#'}
|
||||
className={cn ('w-full min-h-[40px] flex items-center pl-8',
|
||||
((i === openItemIdx)
|
||||
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
|
||||
onClick={ev => {
|
||||
if (i !== openItemIdx)
|
||||
{
|
||||
ev.preventDefault ()
|
||||
setOpenItemIdx (i)
|
||||
}
|
||||
}}>
|
||||
{item.name}
|
||||
</Link>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{i === openItemIdx && (
|
||||
<motion.div
|
||||
key={`sp-sub-${ i }`}
|
||||
className="w-full bg-yellow-50 dark:bg-red-950"
|
||||
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
|
||||
height: 0,
|
||||
opacity: 0 },
|
||||
open: { clipPath: 'inset(0 0 0% 0)',
|
||||
height: 'auto',
|
||||
opacity: 1 } }}
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||||
{item.subMenu
|
||||
.filter (subItem => subItem.visible ?? true)
|
||||
.map ((subItem, j) => (
|
||||
'component' in subItem
|
||||
? (
|
||||
<Fragment key={`sp-c-${ i }-${ j }`}>
|
||||
{subItem.component}
|
||||
</Fragment>)
|
||||
: (
|
||||
<Link key={`sp-l-${ i }-${ j }`}
|
||||
to={subItem.to}
|
||||
className="w-full min-h-[36px] flex items-center pl-12">
|
||||
{subItem.name}
|
||||
</Link>)))}
|
||||
</motion.div>)}
|
||||
</AnimatePresence>
|
||||
</Fragment>))}
|
||||
<TopNavUser user={user} sp/>
|
||||
<Separator/>
|
||||
</motion.div>)}
|
||||
</AnimatePresence>
|
||||
</>)
|
||||
}
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -3,13 +3,15 @@ import { Link } from 'react-router-dom'
|
||||
import Separator from '@/components/MenuSeparator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { User } from '@/types'
|
||||
|
||||
type Props = { user: User | null,
|
||||
sp?: boolean }
|
||||
|
||||
|
||||
export default ({ user, sp }: Props) => {
|
||||
export default (({ user, sp }: Props) => {
|
||||
if (!(user))
|
||||
return
|
||||
|
||||
@@ -21,10 +23,10 @@ export default ({ user, sp }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{sp && <Separator />}
|
||||
{sp && <Separator/>}
|
||||
<Link to="/users/settings"
|
||||
className={className}>
|
||||
{user.name || '名もなきニジラー'}
|
||||
</Link>
|
||||
</>)
|
||||
}
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
userId: string
|
||||
statusId: string }
|
||||
|
||||
|
||||
export default (({ userId, statusId }: Props) => {
|
||||
const now = (new Date).toLocaleDateString ()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<blockquote className="twitter-tweet">
|
||||
<p lang="ja" dir="ltr">
|
||||
Loading...
|
||||
</p>
|
||||
— <a href={`https://twitter.com/${ userId }?ref_src=twsrc%3Etfw`}>@{userId}</a> <a href={`https://twitter.com/${ userId }/status/${ statusId }?ref_src=twsrc%5Etfw`}>{now}</a>
|
||||
</blockquote>
|
||||
<script async src="https://platform.twitter.com/widgets.js" charSet="utf-8"/>
|
||||
</div>)
|
||||
}) satisfies FC<Props>
|
||||
@@ -3,12 +3,14 @@ import toCamel from 'camelcase-keys'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Link } from 'react-router-dom'
|
||||
import remarkGFM from 'remark-gfm'
|
||||
|
||||
import SectionTitle from '@/components/common/SectionTitle'
|
||||
import SubsectionTitle from '@/components/common/SubsectionTitle'
|
||||
import { API_BASE_URL } from '@/config'
|
||||
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { Components } from 'react-markdown'
|
||||
|
||||
import type { WikiPage } from '@/types'
|
||||
@@ -31,7 +33,7 @@ const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionT
|
||||
</a>))) } as const satisfies Components
|
||||
|
||||
|
||||
export default ({ title, body }: Props) => {
|
||||
export default (({ title, body }: Props) => {
|
||||
const [pageNames, setPageNames] = useState<string[]> ([])
|
||||
|
||||
const remarkPlugins = useMemo (() => [remarkWikiAutoLink (pageNames)], [pageNames])
|
||||
@@ -58,4 +60,4 @@ export default ({ title, body }: Props) => {
|
||||
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}>
|
||||
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
|
||||
</ReactMarkdown>)
|
||||
}
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC, FocusEvent } from 'react'
|
||||
|
||||
|
||||
const pad = (n: number) => n.toString ().padStart (2, '0')
|
||||
|
||||
|
||||
const toDateTimeLocalValue = (d: Date) => {
|
||||
const y = d.getFullYear ()
|
||||
const m = pad (d.getMonth () + 1)
|
||||
const day = pad (d.getDate ())
|
||||
const h = pad (d.getHours ())
|
||||
const min = pad (d.getMinutes ())
|
||||
const s = pad (d.getSeconds ())
|
||||
return `${ y }-${ m }-${ day }T${ h }:${ min }:${ s }`
|
||||
}
|
||||
|
||||
|
||||
type Props = {
|
||||
value?: string
|
||||
onChange?: (isoUTC: string | null) => void
|
||||
className?: string
|
||||
onBlur?: (ev: FocusEvent<HTMLInputElement>) => void }
|
||||
|
||||
|
||||
export default (({ value, onChange, className, onBlur }: Props) => {
|
||||
const [local, setLocal] = useState ('')
|
||||
|
||||
useEffect (() => {
|
||||
setLocal (value ? toDateTimeLocalValue (new Date (value)) : '')
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<input
|
||||
className={cn ('border rounded p-2', className)}
|
||||
type="datetime-local"
|
||||
step={1}
|
||||
value={local}
|
||||
onChange={ev => {
|
||||
const v = ev.target.value
|
||||
setLocal (v)
|
||||
onChange?.(v ? (new Date (v)).toISOString () : null)
|
||||
}}
|
||||
onBlur={onBlur}/>)
|
||||
}) satisfies FC<Props>
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
type Props = { children: React.ReactNode }
|
||||
type Props = { children: ReactNode }
|
||||
|
||||
|
||||
export default ({ children }: Props) => (
|
||||
export default (({ children }: Props) => (
|
||||
<div className="max-w-xl mx-auto p-4 space-y-4">
|
||||
{children}
|
||||
</div>)
|
||||
</div>)) satisfies FC<Props>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default ({ children, checkBox }: Props) => {
|
||||
<label className="flex items-center block gap-1">
|
||||
<input type="checkbox"
|
||||
checked={checkBox.checked}
|
||||
onChange={checkBox.onChange} />
|
||||
onChange={checkBox.onChange}/>
|
||||
{checkBox.label}
|
||||
</label>
|
||||
</div>)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
type Props = { page: number
|
||||
totalPages: number
|
||||
siblingCount?: number }
|
||||
|
||||
|
||||
const range = (start: number, end: number): number[] =>
|
||||
[...Array (end - start + 1).keys ()].map (i => start + i)
|
||||
|
||||
|
||||
const getPages = (
|
||||
page: number,
|
||||
total: number,
|
||||
siblingCount: number,
|
||||
): (number | '…')[] => {
|
||||
if (total <= 1)
|
||||
return [1]
|
||||
|
||||
const first = 1
|
||||
const last = total
|
||||
|
||||
const left = Math.max (page - siblingCount, first)
|
||||
const right = Math.min (page + siblingCount, last)
|
||||
|
||||
const pages: (number | '…')[] = []
|
||||
|
||||
pages.push (first)
|
||||
|
||||
if (left > first + 1)
|
||||
pages.push ('…')
|
||||
|
||||
const midStart = Math.max (left, first + 1)
|
||||
const midEnd = Math.min (right, last - 1)
|
||||
pages.push (...range (midStart, midEnd))
|
||||
|
||||
if (right < last - 1)
|
||||
pages.push ('…')
|
||||
|
||||
if (last !== first)
|
||||
pages.push (last)
|
||||
|
||||
return pages.filter ((v, i, arr) => i === 0 || v !== arr[i - 1])
|
||||
}
|
||||
|
||||
|
||||
export default (({ page, totalPages, siblingCount = 4 }) => {
|
||||
const location = useLocation ()
|
||||
|
||||
const buildTo = (p: number) => {
|
||||
const qs = new URLSearchParams (location.search)
|
||||
qs.set ('page', String (p))
|
||||
return `${ location.pathname }?${ qs.toString () }`
|
||||
}
|
||||
|
||||
const pages = getPages (page, totalPages, siblingCount)
|
||||
|
||||
return (
|
||||
<nav className="mt-4 flex justify-center" aria-label="Pagination">
|
||||
<div className="flex items-center gap-2">
|
||||
{(page > 1)
|
||||
? <Link to={buildTo (page - 1)} aria-label="前のページ"><</Link>
|
||||
: <span aria-hidden><</span>}
|
||||
|
||||
{pages.map ((p, idx) => (
|
||||
(p === '…')
|
||||
? <span key={`dots-${ idx }`}>…</span>
|
||||
: ((p === page)
|
||||
? <span key={p} className="font-bold" aria-current="page">{p}</span>
|
||||
: <Link key={p} to={buildTo (p)}>{p}</Link>)))}
|
||||
|
||||
{(page < totalPages)
|
||||
? <Link to={buildTo (page + 1)} aria-label="次のページ">></Link>
|
||||
: <span aria-hidden>></span>}
|
||||
</div>
|
||||
</nav>)
|
||||
}) satisfies FC<Props>
|
||||
@@ -1,10 +1,9 @@
|
||||
import React from 'react'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
type Props = { value?: string
|
||||
onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void }
|
||||
import type { TextareaHTMLAttributes } from 'react'
|
||||
|
||||
type Props = TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
|
||||
|
||||
export default ({ value, onChange }: Props) => (
|
||||
<textarea className="rounded border w-full p-2 h-32"
|
||||
value={value}
|
||||
onChange={onChange} />)
|
||||
export default forwardRef<HTMLTextAreaElement, Props> (({ ...props }, ref) => (
|
||||
<textarea ref={ref} className="rounded border w-full p-2 h-32" {...props}/>))
|
||||
|
||||
@@ -51,7 +51,7 @@ export default ({ visible, onVisibleChange, setUser }: Props) => {
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="引継ぎコードを入力"
|
||||
value={inputCode}
|
||||
onChange={ev => setInputCode (ev.target.value)} />
|
||||
onChange={ev => setInputCode (ev.target.value)}/>
|
||||
<Button onClick={handleTransfer}>引継ぐ</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
+15
-1
@@ -1,3 +1,5 @@
|
||||
@import "@fontsource-variable/noto-sans-jp";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -21,7 +23,7 @@
|
||||
|
||||
:root
|
||||
{
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-family: "Noto Sans JP Variable", system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
@@ -94,3 +96,15 @@ button:focus-visible
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wiki-blink
|
||||
{
|
||||
0%, 100% { color: #dc2626; }
|
||||
50% { color: #2563eb; }
|
||||
}
|
||||
|
||||
@keyframes wiki-blink-dark
|
||||
{
|
||||
0%, 100% { color: #f87171; }
|
||||
50% { color: #60a5fa; }
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,5 +8,5 @@ const helmetContext = { }
|
||||
|
||||
createRoot (document.getElementById ('root')!).render (
|
||||
<HelmetProvider context={helmetContext}>
|
||||
<App />
|
||||
<App/>
|
||||
</HelmetProvider>)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ErrorScreen from '@/components/ErrorScreen'
|
||||
|
||||
|
||||
export default () => <ErrorScreen status={403} />
|
||||
export default () => <ErrorScreen status={403}/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ErrorScreen from '@/components/ErrorScreen'
|
||||
|
||||
|
||||
export default () => <ErrorScreen status={404} />
|
||||
export default () => <ErrorScreen status={404}/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ErrorScreen from '@/components/ErrorScreen'
|
||||
|
||||
|
||||
export default () => <ErrorScreen status={503} />
|
||||
export default () => <ErrorScreen status={503}/>
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useParams } from 'react-router-dom'
|
||||
|
||||
import PostList from '@/components/PostList'
|
||||
import TagDetailSidebar from '@/components/TagDetailSidebar'
|
||||
import NicoViewer from '@/components/NicoViewer'
|
||||
import PostEditForm from '@/components/PostEditForm'
|
||||
import PostEmbed from '@/components/PostEmbed'
|
||||
import TabGroup, { Tab } from '@/components/common/TabGroup'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -17,12 +17,14 @@ import { cn } from '@/lib/utils'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { Post, User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
|
||||
export default ({ user }: Props) => {
|
||||
export default (({ user }: Props) => {
|
||||
const { id } = useParams ()
|
||||
|
||||
const [post, setPost] = useState<Post | null> (null)
|
||||
@@ -72,15 +74,11 @@ export default ({ user }: Props) => {
|
||||
switch (status)
|
||||
{
|
||||
case 404:
|
||||
return <NotFound />
|
||||
return <NotFound/>
|
||||
case 503:
|
||||
return <ServiceUnavailable />
|
||||
return <ServiceUnavailable/>
|
||||
}
|
||||
|
||||
const url = post ? new URL (post.url) : null
|
||||
const nicoFlg = url?.hostname.split ('.').slice (-2).join ('.') === 'nicovideo.jp'
|
||||
const match = nicoFlg ? url.pathname.match (/(?<=\/watch\/)[a-zA-Z0-9]+?(?=\/|$)/) : null
|
||||
const videoId = match?.[0] ?? ''
|
||||
const viewedClass = (post?.viewed
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-gray-500 hover:bg-gray-600')
|
||||
@@ -89,22 +87,17 @@ export default ({ user }: Props) => {
|
||||
<div className="md:flex md:flex-1">
|
||||
<Helmet>
|
||||
{(post?.thumbnail || post?.thumbnailBase) && (
|
||||
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase} />)}
|
||||
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)}
|
||||
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
|
||||
</Helmet>
|
||||
<div className="hidden md:block">
|
||||
<TagDetailSidebar post={post} />
|
||||
<TagDetailSidebar post={post}/>
|
||||
</div>
|
||||
<MainArea>
|
||||
{post
|
||||
? (
|
||||
<>
|
||||
{nicoFlg
|
||||
? (
|
||||
<NicoViewer id={videoId}
|
||||
width={640}
|
||||
height={360} />)
|
||||
: <img src={post.thumbnail} alt={post.url} className="mb-4 w-full" />}
|
||||
<PostEmbed post={post}/>
|
||||
<Button onClick={changeViewedFlg}
|
||||
className={cn ('text-white', viewedClass)}>
|
||||
{post.viewed ? '閲覧済' : '未閲覧'}
|
||||
@@ -112,7 +105,7 @@ export default ({ user }: Props) => {
|
||||
<TabGroup>
|
||||
<Tab name="関聯">
|
||||
{post.related.length > 0
|
||||
? <PostList posts={post.related} />
|
||||
? <PostList posts={post.related}/>
|
||||
: 'まだないよ(笑)'}
|
||||
</Tab>
|
||||
{['admin', 'member'].some (r => user?.role === r) && (
|
||||
@@ -121,14 +114,14 @@ export default ({ user }: Props) => {
|
||||
onSave={newPost => {
|
||||
setPost (newPost)
|
||||
toast ({ description: '更新しました.' })
|
||||
}} />
|
||||
}}/>
|
||||
</Tab>)}
|
||||
</TabGroup>
|
||||
</>)
|
||||
: 'Loading...'}
|
||||
</MainArea>
|
||||
<div className="md:hidden">
|
||||
<TagDetailSidebar post={post} />
|
||||
<TagDetailSidebar post={post}/>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import axios from 'axios'
|
||||
import toCamel from 'camelcase-keys'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
|
||||
import TagLink from '@/components/TagLink'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import Pagination from '@/components/common/Pagination'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { API_BASE_URL, SITE_TITLE } from '@/config'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostTagChange } from '@/types'
|
||||
|
||||
|
||||
export default (() => {
|
||||
const [changes, setChanges] = useState<PostTagChange[]> ([])
|
||||
const [totalPages, setTotalPages] = useState<number> (0)
|
||||
|
||||
const location = useLocation ()
|
||||
const query = new URLSearchParams (location.search)
|
||||
const id = query.get ('id')
|
||||
const page = Number (query.get ('page') ?? 1)
|
||||
const limit = Number (query.get ('limit') ?? 20)
|
||||
|
||||
// 投稿列の結合で使用
|
||||
let rowsCnt: number
|
||||
|
||||
useEffect (() => {
|
||||
void (async () => {
|
||||
const res = await axios.get (`${ API_BASE_URL }/posts/changes`,
|
||||
{ params: { ...(id && { id }), page, limit } })
|
||||
const data = toCamel (res.data as any, { deep: true }) as {
|
||||
changes: PostTagChange[]
|
||||
count: number }
|
||||
setChanges (data.changes)
|
||||
setTotalPages (Math.ceil (data.count / limit))
|
||||
}) ()
|
||||
}, [id, page, limit])
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`耕作履歴 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
|
||||
<PageTitle>
|
||||
耕作履歴
|
||||
{id && <>: 投稿 {<Link to={`/posts/${ id }`}>#{id}</Link>}</>}
|
||||
</PageTitle>
|
||||
|
||||
<table className="table-auto w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2 text-left">投稿</th>
|
||||
<th className="p-2 text-left">変更</th>
|
||||
<th className="p-2 text-left">日時</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes.map ((change, i) => {
|
||||
let withPost = i === 0 || change.post.id !== changes[i - 1].post.id
|
||||
if (withPost)
|
||||
{
|
||||
rowsCnt = 1
|
||||
for (let j = i + 1;
|
||||
(j < changes.length
|
||||
&& change.post.id === changes[j].post.id);
|
||||
++j)
|
||||
++rowsCnt
|
||||
}
|
||||
return (
|
||||
<tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}>
|
||||
{withPost && (
|
||||
<td className="align-top" rowSpan={rowsCnt}>
|
||||
<Link to={`/posts/${ change.post.id }`}>
|
||||
<img src={change.post.thumbnail || change.post.thumbnailBase || undefined}
|
||||
alt={change.post.title || change.post.url}
|
||||
title={change.post.title || change.post.url || undefined}
|
||||
className="w-40"/>
|
||||
</Link>
|
||||
</td>)}
|
||||
<td>
|
||||
<TagLink tag={change.tag} withWiki={false} withCount={false}/>
|
||||
{`を${ change.changeType === 'add' ? '追加' : '削除' }`}
|
||||
</td>
|
||||
<td>
|
||||
{change.user ? (
|
||||
<Link to={`/users/${ change.user.id }`}>
|
||||
{change.user.name}
|
||||
</Link>) : 'bot 操作'}
|
||||
<br/>
|
||||
{change.timestamp}
|
||||
</td>
|
||||
</tr>)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Pagination page={page} totalPages={totalPages}/>
|
||||
</MainArea>)
|
||||
}) satisfies FC
|
||||
@@ -7,6 +7,7 @@ import { Link, useLocation, useNavigationType } from 'react-router-dom'
|
||||
import PostList from '@/components/PostList'
|
||||
import TagSidebar from '@/components/TagSidebar'
|
||||
import WikiBody from '@/components/WikiBody'
|
||||
import Pagination from '@/components/common/Pagination'
|
||||
import TabGroup, { Tab } from '@/components/common/TabGroup'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { API_BASE_URL, SITE_TITLE } from '@/config'
|
||||
@@ -23,6 +24,7 @@ export default () => {
|
||||
const [cursor, setCursor] = useState ('')
|
||||
const [loading, setLoading] = useState (false)
|
||||
const [posts, setPosts] = useState<Post[]> ([])
|
||||
const [totalPages, setTotalPages] = useState (0)
|
||||
const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
|
||||
|
||||
const loadMore = async (withCursor: boolean) => {
|
||||
@@ -31,15 +33,19 @@ export default () => {
|
||||
const res = await axios.get (`${ API_BASE_URL }/posts`, {
|
||||
params: { tags: tags.join (' '),
|
||||
match: anyFlg ? 'any' : 'all',
|
||||
limit: '20',
|
||||
...(page && { page }),
|
||||
...(limit && { limit }),
|
||||
...(withCursor && { cursor }) } })
|
||||
const data = toCamel (res.data as any, { deep: true }) as { posts: Post[]
|
||||
nextCursor: string }
|
||||
const data = toCamel (res.data as any, { deep: true }) as {
|
||||
posts: Post[]
|
||||
count: number
|
||||
nextCursor: string }
|
||||
setPosts (posts => (
|
||||
[...((new Map ([...(withCursor ? posts : []), ...data.posts]
|
||||
.map (post => [post.id, post])))
|
||||
.values ())]))
|
||||
setCursor (data.nextCursor)
|
||||
setTotalPages (Math.ceil (data.count / limit))
|
||||
|
||||
setLoading (false)
|
||||
}
|
||||
@@ -49,6 +55,8 @@ export default () => {
|
||||
const tagsQuery = query.get ('tags') ?? ''
|
||||
const anyFlg = query.get ('match') === 'any'
|
||||
const tags = tagsQuery.split (' ').filter (e => e !== '')
|
||||
const page = Number (query.get ('page') ?? 1)
|
||||
const limit = Number (query.get ('limit') ?? 20)
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver (entries => {
|
||||
@@ -65,7 +73,8 @@ export default () => {
|
||||
}, [loaderRef, loading])
|
||||
|
||||
useLayoutEffect (() => {
|
||||
const savedState = sessionStorage.getItem (`posts:${ tagsQuery }`)
|
||||
// TODO: 無限ロード用
|
||||
const savedState = /* sessionStorage.getItem (`posts:${ tagsQuery }`) */ null
|
||||
if (savedState && navigationType === 'POP')
|
||||
{
|
||||
const { posts, cursor, scroll } = JSON.parse (savedState)
|
||||
@@ -111,27 +120,32 @@ export default () => {
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
<TagSidebar posts={posts.slice (0, 20)} />
|
||||
<TagSidebar posts={posts.slice (0, 20)}/>
|
||||
|
||||
<MainArea>
|
||||
<TabGroup>
|
||||
<Tab name="広場">
|
||||
{posts.length
|
||||
{posts.length > 0
|
||||
? (
|
||||
<PostList posts={posts} onClick={() => {
|
||||
const statesToSave = {
|
||||
posts, cursor,
|
||||
scroll: containerRef.current?.scrollTop ?? 0 }
|
||||
sessionStorage.setItem (`posts:${ tagsQuery }`,
|
||||
JSON.stringify (statesToSave))
|
||||
}} />)
|
||||
<>
|
||||
<PostList posts={posts} onClick={() => {
|
||||
// TODO: 無限ロード用なので復活時に戻す.
|
||||
// const statesToSave = {
|
||||
// posts, cursor,
|
||||
// scroll: containerRef.current?.scrollTop ?? 0 }
|
||||
// sessionStorage.setItem (`posts:${ tagsQuery }`,
|
||||
// JSON.stringify (statesToSave))
|
||||
}}/>
|
||||
<Pagination page={page} totalPages={totalPages}/>
|
||||
</>)
|
||||
: !(loading) && '広場には何もありませんよ.'}
|
||||
{loading && 'Loading...'}
|
||||
<div ref={loaderRef} className="h-12"></div>
|
||||
{/* TODO: 無限ローディング復活までコメント・アウト */}
|
||||
{/* <div ref={loaderRef} className="h-12"/> */}
|
||||
</Tab>
|
||||
{tags.length === 1 && (
|
||||
<Tab name="Wiki">
|
||||
<WikiBody title={tags[0]} body={wikiPage?.body} />
|
||||
<WikiBody title={tags[0]} body={wikiPage?.body}/>
|
||||
<div className="my-2">
|
||||
<Link to={`/wiki/${ encodeURIComponent (tags[0]) }`}>
|
||||
Wiki を見る
|
||||
|
||||
@@ -3,36 +3,41 @@ import { useEffect, useState, useRef } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import Form from '@/components/common/Form'
|
||||
import Label from '@/components/common/Label'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import TextArea from '@/components/common/TextArea'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { API_BASE_URL, SITE_TITLE } from '@/config'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
|
||||
export default ({ user }: Props) => {
|
||||
export default (({ user }: Props) => {
|
||||
if (!(['admin', 'member'].some (r => user?.role === r)))
|
||||
return <Forbidden />
|
||||
return <Forbidden/>
|
||||
|
||||
const navigate = useNavigate ()
|
||||
|
||||
const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null)
|
||||
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
|
||||
const [tags, setTags] = useState ('')
|
||||
const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
|
||||
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
|
||||
const [thumbnailLoading, setThumbnailLoading] = useState (false)
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
|
||||
const [title, setTitle] = useState ('')
|
||||
const [titleAutoFlg, setTitleAutoFlg] = useState (true)
|
||||
const [titleLoading, setTitleLoading] = useState (false)
|
||||
const [url, setURL] = useState ('')
|
||||
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
|
||||
const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
|
||||
const [thumbnailLoading, setThumbnailLoading] = useState (false)
|
||||
const [tags, setTags] = useState ('')
|
||||
|
||||
const previousURLRef = useRef ('')
|
||||
|
||||
@@ -43,6 +48,10 @@ export default ({ user }: Props) => {
|
||||
formData.append ('tags', tags)
|
||||
if (thumbnailFile)
|
||||
formData.append ('thumbnail', thumbnailFile)
|
||||
if (originalCreatedFrom)
|
||||
formData.append ('original_created_from', originalCreatedFrom)
|
||||
if (originalCreatedBefore)
|
||||
formData.append ('original_created_before', originalCreatedBefore)
|
||||
|
||||
try
|
||||
{
|
||||
@@ -120,18 +129,18 @@ export default ({ user }: Props) => {
|
||||
{/* URL */}
|
||||
<div>
|
||||
<Label>URL</Label>
|
||||
<input type="text"
|
||||
<input type="url"
|
||||
placeholder="例:https://www.nicovideo.jp/watch/..."
|
||||
value={url}
|
||||
onChange={e => setURL (e.target.value)}
|
||||
className="w-full border p-2 rounded"
|
||||
onBlur={handleURLBlur} />
|
||||
onBlur={handleURLBlur}/>
|
||||
</div>
|
||||
|
||||
{/* タイトル */}
|
||||
<div>
|
||||
<Label checkBox={{
|
||||
label: '自動',
|
||||
label: '自動',
|
||||
checked: titleAutoFlg,
|
||||
onChange: ev => setTitleAutoFlg (ev.target.checked)}}>
|
||||
タイトル
|
||||
@@ -141,13 +150,13 @@ export default ({ user }: Props) => {
|
||||
value={title}
|
||||
placeholder={titleLoading ? 'Loading...' : ''}
|
||||
onChange={ev => setTitle (ev.target.value)}
|
||||
disabled={titleAutoFlg} />
|
||||
disabled={titleAutoFlg}/>
|
||||
</div>
|
||||
|
||||
{/* サムネール */}
|
||||
<div>
|
||||
<Label checkBox={{
|
||||
label: '自動',
|
||||
label: '自動',
|
||||
checked: thumbnailAutoFlg,
|
||||
onChange: ev => setThumbnailAutoFlg (ev.target.checked)}}>
|
||||
サムネール
|
||||
@@ -169,20 +178,22 @@ export default ({ user }: Props) => {
|
||||
setThumbnailFile (file)
|
||||
setThumbnailPreview (URL.createObjectURL (file))
|
||||
}
|
||||
}} />)}
|
||||
}}/>)}
|
||||
{thumbnailPreview && (
|
||||
<img src={thumbnailPreview}
|
||||
alt="preview"
|
||||
className="mt-2 max-h-48 rounded border" />)}
|
||||
className="mt-2 max-h-48 rounded border"/>)}
|
||||
</div>
|
||||
|
||||
{/* タグ */}
|
||||
{/* TextArea で自由形式にする */}
|
||||
<div>
|
||||
<Label>タグ</Label>
|
||||
<TextArea value={tags}
|
||||
onChange={ev => setTags (ev.target.value)} />
|
||||
</div>
|
||||
<PostFormTagsArea tags={tags} setTags={setTags}/>
|
||||
|
||||
{/* オリジナルの作成日時 */}
|
||||
<PostOriginalCreatedTimeField
|
||||
originalCreatedFrom={originalCreatedFrom}
|
||||
setOriginalCreatedFrom={setOriginalCreatedFrom}
|
||||
originalCreatedBefore={originalCreatedBefore}
|
||||
setOriginalCreatedBefore={setOriginalCreatedBefore}/>
|
||||
|
||||
{/* 送信 */}
|
||||
<Button onClick={handleSubmit}
|
||||
@@ -192,4 +203,4 @@ export default ({ user }: Props) => {
|
||||
</Button>
|
||||
</Form>
|
||||
</MainArea>)
|
||||
}
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -114,19 +114,19 @@ export default ({ user }: Props) => {
|
||||
{nicoTags.map ((tag, i) => (
|
||||
<tr key={i}>
|
||||
<td className="p-2">
|
||||
<TagLink tag={tag} withWiki={false} withCount={false} />
|
||||
<TagLink tag={tag} withWiki={false} withCount={false}/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{editing[tag.id]
|
||||
? (
|
||||
<TextArea value={rawTags[tag.id]} onChange={ev => {
|
||||
setRawTags (rawTags => ({ ...rawTags, [tag.id]: ev.target.value }))
|
||||
}} />)
|
||||
}}/>)
|
||||
: tag.linkedTags.map((lt, j) => (
|
||||
<span key={j} className="mr-2">
|
||||
<TagLink tag={lt}
|
||||
linkFlg={false}
|
||||
withCount={false} />
|
||||
withCount={false}/>
|
||||
</span>))}
|
||||
</td>
|
||||
{memberFlg && (
|
||||
|
||||
@@ -55,7 +55,7 @@ export default ({ user, setUser }: Props) => {
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="robots" content="noindex"/>
|
||||
<title>設定 | {SITE_TITLE}</title>
|
||||
</Helmet>
|
||||
|
||||
@@ -71,7 +71,7 @@ export default ({ user, setUser }: Props) => {
|
||||
className="w-full border rounded p-2"
|
||||
value={name}
|
||||
placeholder="名もなきニジラー"
|
||||
onChange={ev => setName (ev.target.value)} />
|
||||
onChange={ev => setName (ev.target.value)}/>
|
||||
{(user && !(user.name)) && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!!
|
||||
@@ -104,10 +104,10 @@ export default ({ user, setUser }: Props) => {
|
||||
<UserCodeDialogue visible={userCodeVsbl}
|
||||
onVisibleChange={setUserCodeVsbl}
|
||||
user={user}
|
||||
setUser={setUser} />
|
||||
setUser={setUser}/>
|
||||
|
||||
<InheritDialogue visible={inheritVsbl}
|
||||
onVisibleChange={setInheritVsbl}
|
||||
setUser={setUser} />
|
||||
setUser={setUser}/>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
@@ -36,9 +36,16 @@ export default () => {
|
||||
if (/^\d+$/.test (title))
|
||||
{
|
||||
void (async () => {
|
||||
const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`)
|
||||
const data = res.data as WikiPage
|
||||
navigate (`/wiki/${ data.title }`, { replace: true })
|
||||
try
|
||||
{
|
||||
const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`)
|
||||
const data = res.data as WikiPage
|
||||
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
|
||||
}
|
||||
catch
|
||||
{
|
||||
;
|
||||
}
|
||||
}) ()
|
||||
|
||||
return
|
||||
@@ -51,6 +58,8 @@ export default () => {
|
||||
`${ API_BASE_URL }/wiki/title/${ encodeURIComponent (title) }`,
|
||||
{ params: version ? { version } : { } })
|
||||
const data = toCamel (res.data as any, { deep: true }) as WikiPage
|
||||
if (data.title !== title)
|
||||
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
|
||||
setWikiPage (data)
|
||||
WikiIdBus.set (data.id)
|
||||
}
|
||||
@@ -98,6 +107,7 @@ export default () => {
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`${ title } Wiki | ${ SITE_TITLE }`}</title>
|
||||
{!(wikiPage?.body) && <meta name="robots" content="noindex"/>}
|
||||
</Helmet>
|
||||
|
||||
{(wikiPage && version) && (
|
||||
@@ -119,18 +129,18 @@ export default () => {
|
||||
<TagLink tag={tag}
|
||||
withWiki={false}
|
||||
withCount={false}
|
||||
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })} />
|
||||
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
|
||||
</PageTitle>
|
||||
<div className="prose mx-auto p-4">
|
||||
{wikiPage === undefined
|
||||
? 'Loading...'
|
||||
: <WikiBody title={title} body={wikiPage?.body} />}
|
||||
: <WikiBody title={title} body={wikiPage?.body}/>}
|
||||
</div>
|
||||
|
||||
{(!(version) && posts.length > 0) && (
|
||||
<TabGroup>
|
||||
<Tab name="広場">
|
||||
<PostList posts={posts} />
|
||||
<PostList posts={posts}/>
|
||||
</Tab>
|
||||
</TabGroup>)}
|
||||
</MainArea>)
|
||||
|
||||
@@ -40,10 +40,10 @@ export default () => {
|
||||
{diff
|
||||
? (
|
||||
diff.diff.map (d => (
|
||||
<span className={cn (d.type === 'added' && 'bg-green-200 dark:bg-green-800',
|
||||
d.type === 'removed' && 'bg-red-200 dark:bg-red-800')}>
|
||||
{d.content == '\n' ? <br /> : d.content}
|
||||
</span>)))
|
||||
<p className={cn (d.type === 'added' && 'bg-green-200 dark:bg-green-800',
|
||||
d.type === 'removed' && 'bg-red-200 dark:bg-red-800')}>
|
||||
{d.content}
|
||||
</p>)))
|
||||
: 'Loading...'}
|
||||
</div>
|
||||
</MainArea>)
|
||||
|
||||
@@ -21,7 +21,7 @@ type Props = { user: User | null }
|
||||
|
||||
export default ({ user }: Props) => {
|
||||
if (!(['admin', 'member'].some (r => user?.role === r)))
|
||||
return <Forbidden />
|
||||
return <Forbidden/>
|
||||
|
||||
const { id } = useParams ()
|
||||
|
||||
@@ -78,7 +78,7 @@ export default ({ user }: Props) => {
|
||||
<input type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle (e.target.value)}
|
||||
className="w-full border p-2 rounded" />
|
||||
className="w-full border p-2 rounded"/>
|
||||
</div>
|
||||
|
||||
{/* 本文 */}
|
||||
@@ -87,7 +87,7 @@ export default ({ user }: Props) => {
|
||||
<MdEditor value={body}
|
||||
style={{ height: '500px' }}
|
||||
renderHTML={text => mdParser.render (text)}
|
||||
onChange={({ text }) => setBody (text)} />
|
||||
onChange={({ text }) => setBody (text)}/>
|
||||
</div>
|
||||
|
||||
{/* 送信 */}
|
||||
|
||||
@@ -27,54 +27,44 @@ export default () => {
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`Wiki 変更履歴 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
<table className="table-auto w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th className="p-2 text-left">タイトル</th>
|
||||
<th className="p-2 text-left">変更</th>
|
||||
<th className="p-2 text-left">日時</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes.map (change => (
|
||||
<tr key={change.sha}>
|
||||
<td>
|
||||
{change.changeType === 'update' && (
|
||||
<Link to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.sha }`}>
|
||||
差分
|
||||
</Link>)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Link to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.sha }`}>
|
||||
{change.wikiPage.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{(() => {
|
||||
switch (change.changeType)
|
||||
{
|
||||
case 'create':
|
||||
return '新規'
|
||||
case 'update':
|
||||
return '更新'
|
||||
case 'delete':
|
||||
return '削除'
|
||||
}
|
||||
}) ()}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Link to={`/users/${ change.user.id }`}>
|
||||
{change.user.name}
|
||||
</Link>
|
||||
<br />
|
||||
{change.timestamp}
|
||||
</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Helmet>
|
||||
<title>{`Wiki 変更履歴 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
<table className="table-auto w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th className="p-2 text-left">タイトル</th>
|
||||
<th className="p-2 text-left">変更</th>
|
||||
<th className="p-2 text-left">日時</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes.map (change => (
|
||||
<tr key={change.revisionId}>
|
||||
<td>
|
||||
{change.pred != null && (
|
||||
<Link to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.revisionId }`}>
|
||||
差分
|
||||
</Link>)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Link to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}>
|
||||
{change.wikiPage.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{change.pred == null ? '新規' : '更新'}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Link to={`/users/${ change.user.id }`}>
|
||||
{change.user.name}
|
||||
</Link>
|
||||
<br/>
|
||||
{change.timestamp}
|
||||
</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
</table>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ type Props = { user: User | null }
|
||||
|
||||
export default ({ user }: Props) => {
|
||||
if (!(['admin', 'member'].some (r => user?.role === r)))
|
||||
return <Forbidden />
|
||||
return <Forbidden/>
|
||||
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
@@ -67,7 +67,7 @@ export default ({ user }: Props) => {
|
||||
<input type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle (e.target.value)}
|
||||
className="w-full border p-2 rounded" />
|
||||
className="w-full border p-2 rounded"/>
|
||||
</div>
|
||||
|
||||
{/* 本文 */}
|
||||
@@ -76,7 +76,7 @@ export default ({ user }: Props) => {
|
||||
<MdEditor value={body}
|
||||
style={{ height: '500px' }}
|
||||
renderHTML={text => mdParser.render (text)}
|
||||
onChange={({ text }) => setBody (text)} />
|
||||
onChange={({ text }) => setBody (text)}/>
|
||||
</div>
|
||||
|
||||
{/* 送信 */}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
// import { Route,
|
||||
// createBrowserRouter,
|
||||
// createRoutesFromElements } from 'react-router-dom'
|
||||
//
|
||||
// import App from '@/App'
|
||||
+50
-36
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
import { CATEGORIES, USER_ROLES, ViewFlagBehavior } from '@/consts'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type Category = typeof CATEGORIES[number]
|
||||
|
||||
export type Menu = MenuItem[]
|
||||
@@ -17,28 +17,38 @@ export type NicoTag = Tag & {
|
||||
linkedTags: Tag[] }
|
||||
|
||||
export type Post = {
|
||||
id: number
|
||||
url: string
|
||||
title: string
|
||||
thumbnail: string
|
||||
thumbnailBase: string
|
||||
tags: Tag[]
|
||||
viewed: boolean
|
||||
related: Post[] }
|
||||
id: number
|
||||
url: string
|
||||
title: string
|
||||
thumbnail: string
|
||||
thumbnailBase: string
|
||||
tags: Tag[]
|
||||
viewed: boolean
|
||||
related: Post[]
|
||||
createdAt: string
|
||||
originalCreatedFrom: string | null
|
||||
originalCreatedBefore: string | null }
|
||||
|
||||
export type SubMenuItem = {
|
||||
component: React.ReactNode
|
||||
visible: boolean
|
||||
} | {
|
||||
name: string
|
||||
to: string
|
||||
visible?: boolean }
|
||||
export type PostTagChange = {
|
||||
post: Post
|
||||
tag: Tag
|
||||
user?: User
|
||||
changeType: 'add' | 'remove'
|
||||
timestamp: string }
|
||||
|
||||
export type SubMenuItem =
|
||||
| { component: ReactNode
|
||||
visible: boolean }
|
||||
| { name: string
|
||||
to: string
|
||||
visible?: boolean }
|
||||
|
||||
export type Tag = {
|
||||
id: number
|
||||
name: string
|
||||
category: Category
|
||||
postCount: number }
|
||||
postCount: number
|
||||
children?: Tag[] }
|
||||
|
||||
export type User = {
|
||||
id: number
|
||||
@@ -49,29 +59,33 @@ export type User = {
|
||||
export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBehavior]
|
||||
|
||||
export type WikiPage = {
|
||||
id: number
|
||||
title: string
|
||||
body: string
|
||||
sha: string
|
||||
pred?: string
|
||||
succ?: string
|
||||
updatedAt?: string }
|
||||
id: number
|
||||
title: string
|
||||
createdUserId: number
|
||||
updatedUserId: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
body: string
|
||||
revisionId: number
|
||||
pred: number | null
|
||||
succ: number | null }
|
||||
|
||||
export type WikiPageChange = {
|
||||
sha: string
|
||||
pred?: string
|
||||
succ?: string
|
||||
wikiPage: WikiPage
|
||||
user: User
|
||||
changeType: string
|
||||
revisionId: number
|
||||
pred: number | null
|
||||
succ: null
|
||||
wikiPage: Pick<WikiPage, 'id' | 'title'>
|
||||
user: Pick<User, 'id' | 'name'>
|
||||
kind: 'content' | 'redirect'
|
||||
message: string | null
|
||||
timestamp: string }
|
||||
|
||||
export type WikiPageDiff = {
|
||||
wikiPageId: number
|
||||
title: string
|
||||
olderSha: string
|
||||
newerSha: string
|
||||
diff: WikiPageDiffDiff[] }
|
||||
wikiPageId: number
|
||||
title: string
|
||||
olderRevisionId: number | null
|
||||
newerRevisionId: number
|
||||
diff: WikiPageDiffDiff[] }
|
||||
|
||||
export type WikiPageDiffDiff = {
|
||||
type: 'context' | 'added' | 'removed'
|
||||
|
||||
+19
-28
@@ -2,35 +2,26 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
import { DARK_COLOUR_SHADE,
|
||||
LIGHT_COLOUR_SHADE,
|
||||
TAG_COLOUR } from './src/consts'
|
||||
LIGHT_COLOUR_SHADE,
|
||||
TAG_COLOUR } from './src/consts'
|
||||
|
||||
const colours = Object.values (TAG_COLOUR)
|
||||
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,ts,jsx,tsx}'],
|
||||
safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`),
|
||||
...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`),
|
||||
...colours.map (c => `dark:text-${ c }-${ DARK_COLOUR_SHADE }`),
|
||||
...colours.map (c => `dark:hover:text-${ c }-${ DARK_COLOUR_SHADE - 200 }`)],
|
||||
theme: {
|
||||
extend: {
|
||||
animation: {
|
||||
'rainbow-scroll': 'rainbow-scroll .25s linear infinite',
|
||||
},
|
||||
colors: {
|
||||
red: {
|
||||
925: '#5f1414',
|
||||
975: '#230505',
|
||||
}
|
||||
},
|
||||
keyframes: {
|
||||
'rainbow-scroll': {
|
||||
'0%': { backgroundPosition: '0% 50%' },
|
||||
'100%': { backgroundPosition: '200% 50%' },
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config
|
||||
content: ['./src/**/*.{html,js,ts,jsx,tsx}'],
|
||||
safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`),
|
||||
...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`),
|
||||
...colours.map (c => `dark:text-${ c }-${ DARK_COLOUR_SHADE }`),
|
||||
...colours.map (c => `dark:hover:text-${ c }-${ DARK_COLOUR_SHADE - 200 }`)],
|
||||
theme: {
|
||||
extend: {
|
||||
animation: {
|
||||
'rainbow-scroll': 'rainbow-scroll .25s linear infinite' },
|
||||
colors: {
|
||||
red: { 925: '#5f1414',
|
||||
975: '#230505' } },
|
||||
keyframes: {
|
||||
'rainbow-scroll': {
|
||||
'0%': { backgroundPosition: '0% 50%' },
|
||||
'100%': { backgroundPosition: '200% 50%' } } } } },
|
||||
plugins: [] } satisfies Config
|
||||
|
||||
新しい課題から参照
ユーザをブロックする