initialized web page (React + Tailwind CSS)

This commit is contained in:
Penguin-71630
2025-12-07 16:46:44 +08:00
parent 3092f95652
commit f6db3c2ccd
50 changed files with 6879 additions and 0 deletions

962
package-lock.json generated Normal file
View File

@@ -0,0 +1,962 @@
{
"name": "final-project",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "final-project",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"express": "^5.2.1",
"swagger-ui-express": "^5.0.1",
"yamljs": "^0.3.0"
}
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
"integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"mime-types": "^3.0.1",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.30.3",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.3.tgz",
"integrity": "sha512-giQl7/ToPxCqnUAx2wpnSnDNGZtGzw1LyUw6ZitIpTmdrvpxKFY/94v1hihm0zYNpgp1/VY0jTDk//R0BBgnRQ==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
},
"node_modules/swagger-ui-express": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
"integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
"license": "MIT",
"dependencies": {
"swagger-ui-dist": ">=5.0.0"
},
"engines": {
"node": ">= v0.10.32"
},
"peerDependencies": {
"express": ">=4.0.0 || >=5.0.0-beta"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yamljs": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz",
"integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"glob": "^7.0.5"
},
"bin": {
"json2yaml": "bin/json2yaml",
"yaml2json": "bin/yaml2json"
}
}
}
}

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "final-project",
"version": "1.0.0",
"description": "2025 Golang Final Project",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Penguin-71630/meme-bot-frontend.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"bugs": {
"url": "https://github.com/Penguin-71630/meme-bot-frontend/issues"
},
"homepage": "https://github.com/Penguin-71630/meme-bot-frontend#readme",
"dependencies": {
"express": "^5.2.1",
"swagger-ui-express": "^5.0.1",
"yamljs": "^0.3.0"
}
}

24
webpage/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
webpage/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
webpage/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
webpage/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webpage</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4129
webpage/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
webpage/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "webpage",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1,7 @@
export default {
plugins: {
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {},
},
}

1
webpage/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

100
webpage/spec.md Normal file
View File

@@ -0,0 +1,100 @@
# Webpage for Memebot
This webpage is used to manage the images and aliases used by our Discord memebot.
Relationship between images and aliases: many to many.
## Layout
React + TypeScript (with Tailwind CSS)
The webpage layout is roughly as follows:
```
Memebot [Upload-Image] [Login]
-----------------------------------------------------------
alias_01 | ( Search Bar )
alias_02 |
alias_03 | Alias: alias_01
alias_04 | [ Img Box1 ] [ Img Box2 ] [ Img Box3 ]
alias_05 |
alias_06 | Alias: alias_02
alias_07 | [ Img Box1 ] [ Img Box2 ]
alias_08 |
alias_09 | Alias: alias_03
alias_10 | [ Img Box1 ] [ Img Box2 ] [ Img Box3 ]
alias_11 | [ Img Box4 ]
Img w/o alias |
```
The top is the Navbar:
- The left side of the Navbar shows our project name.
- The right side of the Navbar contains function buttons, currently only Upload Image and Login.
### Find
Find (Viewing and Searching)
- The left sidebar serves as a table of content for all aliases, similar to the navigation feature in HackMD or mdBook.
- The last entry is "Images without aliases" (Img w/o alias).
- The search bar is located directly below the Navbar.
- The search bar provides instant feedback; the system automatically lists matching aliases and corresponding images without the user needing to press enter.
- The content below the search bar displays images grouped by their respective alias.
- Clicking an image brings up a floating window (modal) showing the image and related information (including who uploaded the image, when the image was uploaded, and what aliases it has). Within this modal, the user can edit aliases and delete the image.
```
======================================
| |--------------------------------| |
| | | |
| | | |
| | Show Image Here | |
| | | |
| | | |
| |--------------------------------| |
| |
| Uploaded Time: 2023-10-20.12:00 |
| Uploaded By: konchin.shih |
| |
| Aliases of this image: |
| [-] [ alias_01 ] |
| [-] [ alias_02 ] |
| [+] [ (Add a new alias) ] |
| |
| <Save> |
======================================
```
### Upload Image
When the user clicks this button, a floating window (modal) appears:
```
======================================
| |--------------------------------| |
| | | |
| | | |
| | Drag your image here | |
| | | |
| | | |
| |--------------------------------| |
| |
| Aliases of this image: |
| [-] [ alias_01 ] |
| [-] [ alias_02 ] |
| [+] [ (Add a new alias) ] |
| |
| <Upload> |
======================================
```
## Login (Authentication)
Authentication is used to verify the current Discord user operating the frontend. Users who are not logged in can only perform viewing-related operations; they cannot edit, upload, or delete.
<!-- Modify the webpage: We don't want one page shows only an alias.
We want a paging feature on aliases, so that each page shows a fixed number of aliases and their corresponding images (the fixed number can be stored in a variable in api.ts). And the "next page" and "previous page" buttons should be located at both the top (below search bar) and the bottom of the page.
The sidebar is also modified to show the current page number, the aliases on the current page, and the total number of pages. -->

268
webpage/src/App.tsx Normal file
View File

@@ -0,0 +1,268 @@
import { useState, useEffect } from 'react';
import Navbar from './components/Navbar';
import Sidebar from './components/Sidebar';
import SearchBar from './components/SearchBar';
import ImageGrid from './components/ImageGrid';
import ImageModal from './components/ImageModal';
import UploadModal from './components/UploadModal';
import Pagination from './components/Pagination';
import { api, ALIASES_PER_PAGE } from './api-mock';
import type { Image } from './types';
function App() {
const [images, setImages] = useState<Image[]>([]);
const [allAliases, setAllAliases] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [selectedImage, setSelectedImage] = useState<Image | null>(null);
const [showUploadModal, setShowUploadModal] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, [searchQuery]);
useEffect(() => {
// Reset to page 1 when search query changes
setCurrentPage(1);
}, [searchQuery]);
const loadData = async () => {
setLoading(true);
try {
const [imagesData, aliasesData] = await Promise.all([
api.getImages({
search: searchQuery || undefined,
}),
api.getAllAliases(),
]);
setImages(imagesData);
setAllAliases(aliasesData);
} catch (error) {
console.error('Failed to load data:', error);
} finally {
setLoading(false);
}
};
const handleUpload = async (file: File, aliases: string[]) => {
await api.uploadImage(file, aliases);
await loadData();
};
const handleSaveImage = async (imageId: string, aliases: string[]) => {
await api.updateImageAliases(imageId, aliases);
await loadData();
};
const handleDeleteImage = async (imageId: string) => {
await api.deleteImage(imageId);
await loadData();
};
// Filter aliases based on search
const filteredAliases = searchQuery
? allAliases.filter(alias =>
alias.toLowerCase().includes(searchQuery.toLowerCase())
)
: allAliases;
// Pagination calculations
const totalPages = Math.max(1, Math.ceil(filteredAliases.length / ALIASES_PER_PAGE));
const startIndex = (currentPage - 1) * ALIASES_PER_PAGE;
const endIndex = startIndex + ALIASES_PER_PAGE;
const currentPageAliases = filteredAliases.slice(startIndex, endIndex);
// Filter images to only show those with current page aliases
const currentPageImages = images.filter(img =>
img.aliases.some(alias => currentPageAliases.includes(alias)) ||
(img.aliases.length === 0 && currentPageAliases.length > 0)
);
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
setCurrentPage(newPage);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
return (
<div className="h-screen flex flex-col">
<Navbar onUploadClick={() => setShowUploadModal(true)} />
<div className="flex-1 flex overflow-hidden">
<Sidebar
aliases={currentPageAliases}
currentPage={currentPage}
totalPages={totalPages}
/>
<main className="flex-1 overflow-y-auto">
<SearchBar value={searchQuery} onChange={setSearchQuery} />
{/* Top Pagination */}
{!loading && totalPages > 1 && (
<div className="border-b border-gray-200">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</div>
)}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<ImageGrid
images={currentPageImages}
aliases={currentPageAliases}
onImageClick={setSelectedImage}
/>
)}
{/* Bottom Pagination */}
{!loading && totalPages > 1 && (
<div className="border-t border-gray-200">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</div>
)}
</main>
</div>
{selectedImage && (
<ImageModal
image={selectedImage}
onClose={() => setSelectedImage(null)}
onSave={handleSaveImage}
onDelete={handleDeleteImage}
/>
)}
{showUploadModal && (
<UploadModal
onClose={() => setShowUploadModal(false)}
onUpload={handleUpload}
/>
)}
</div>
);
}
export default App;
// import { useState, useEffect } from 'react';
// import Navbar from './components/Navbar';
// import Sidebar from './components/Sidebar';
// import SearchBar from './components/SearchBar';
// import ImageGrid from './components/ImageGrid';
// import ImageModal from './components/ImageModal';
// import UploadModal from './components/UploadModal';
// import { api } from './api-mock';
// import type { Image } from './types';
// function App() {
// const [images, setImages] = useState<Image[]>([]);
// const [allAliases, setAllAliases] = useState<string[]>([]);
// const [searchQuery, setSearchQuery] = useState('');
// const [selectedAlias, setSelectedAlias] = useState<string | null>(null);
// const [selectedImage, setSelectedImage] = useState<Image | null>(null);
// const [showUploadModal, setShowUploadModal] = useState(false);
// const [loading, setLoading] = useState(true);
// useEffect(() => {
// loadData();
// }, [searchQuery, selectedAlias]);
// const loadData = async () => {
// setLoading(true);
// try {
// const [imagesData, aliasesData] = await Promise.all([
// api.getImages({
// search: searchQuery || undefined,
// null_alias: selectedAlias === null ? true : undefined,
// }),
// api.getAllAliases(),
// ]);
// let filteredImages = imagesData;
// if (selectedAlias) {
// filteredImages = imagesData.filter((img) =>
// img.aliases.includes(selectedAlias)
// );
// } else if (selectedAlias === null) {
// filteredImages = imagesData.filter((img) => img.aliases.length === 0);
// }
// setImages(filteredImages);
// setAllAliases(aliasesData);
// } catch (error) {
// console.error('Failed to load data:', error);
// } finally {
// setLoading(false);
// }
// };
// const handleUpload = async (file: File, aliases: string[]) => {
// await api.uploadImage(file, aliases);
// await loadData();
// };
// const handleSaveImage = async (imageId: string, aliases: string[]) => {
// await api.updateImageAliases(imageId, aliases);
// await loadData();
// };
// const handleDeleteImage = async (imageId: string) => {
// await api.deleteImage(imageId);
// await loadData();
// };
// return (
// <div className="h-screen flex flex-col">
// <Navbar
// onUploadClick={() => setShowUploadModal(true)}
// />
// <div className="flex-1 flex overflow-hidden">
// <Sidebar
// aliases={allAliases}
// selectedAlias={selectedAlias}
// onAliasClick={setSelectedAlias}
// />
// <main className="flex-1 overflow-y-auto">
// <SearchBar value={searchQuery} onChange={setSearchQuery} />
// {loading ? (
// <div className="flex items-center justify-center h-64">
// <div className="text-gray-500">Loading...</div>
// </div>
// ) : (
// <ImageGrid images={images} onImageClick={setSelectedImage} />
// )}
// </main>
// </div>
// {selectedImage && (
// <ImageModal
// image={selectedImage}
// onClose={() => setSelectedImage(null)}
// onSave={handleSaveImage}
// onDelete={handleDeleteImage}
// />
// )}
// {showUploadModal && (
// <UploadModal
// onClose={() => setShowUploadModal(false)}
// onUpload={handleUpload}
// />
// )}
// </div>
// );
// }
// export default App;

141
webpage/src/api-mock.ts Normal file
View File

@@ -0,0 +1,141 @@
import type { Image } from './types';
import fakeDb from './assets/tests/fakedb.json';
export const ALIASES_PER_PAGE = 5;
class MockApiService {
private images: Image[] = [];
private nextId: number = 18;
constructor() {
// Load initial data from fakedb.json
this.images = JSON.parse(JSON.stringify(fakeDb.images));
}
// Helper to simulate network delay
private delay(ms: number = 300): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Images
async getImages(params?: {
search?: string;
null_alias?: boolean;
limit?: number;
page?: number;
}): Promise<Image[]> {
await this.delay();
let filtered = [...this.images];
// Filter by search query (search in aliases)
if (params?.search) {
const searchLower = params.search.toLowerCase();
filtered = filtered.filter(img =>
img.aliases.some(alias => alias.toLowerCase().includes(searchLower))
);
}
// Filter images without aliases
if (params?.null_alias) {
filtered = filtered.filter(img => img.aliases.length === 0);
}
// Pagination
const page = params?.page || 1;
const limit = params?.limit || 20;
const start = (page - 1) * limit;
const end = start + limit;
return filtered.slice(start, end);
}
async getImage(id: string): Promise<Image> {
await this.delay();
const image = this.images.find(img => img.id === id);
if (!image) {
throw new Error('Image not found');
}
return { ...image };
}
async uploadImage(file: File, aliases: string[]): Promise<Image> {
await this.delay(500);
// Create a new image object
const newImage: Image = {
id: String(this.nextId++),
uploaded_user_id: 'konchin.shih',
uploaded_at: new Date().toISOString(),
aliases: aliases,
url: `/api/images/${this.nextId - 1}/file`,
};
this.images.push(newImage);
return { ...newImage };
}
async deleteImage(id: string): Promise<void> {
await this.delay();
const index = this.images.findIndex(img => img.id === id);
if (index === -1) {
throw new Error('Image not found');
}
this.images.splice(index, 1);
}
// Aliases
async getAllAliases(): Promise<string[]> {
await this.delay();
// Get unique aliases from all images
const aliasSet = new Set<string>();
this.images.forEach(img => {
img.aliases.forEach(alias => aliasSet.add(alias));
});
return Array.from(aliasSet).sort();
}
async updateImageAliases(id: string, aliases: string[]): Promise<Image> {
await this.delay();
const image = this.images.find(img => img.id === id);
if (!image) {
throw new Error('Image not found');
}
image.aliases = [...aliases];
return { ...image };
}
async addImageAlias(id: string, alias: string): Promise<Image> {
await this.delay();
const image = this.images.find(img => img.id === id);
if (!image) {
throw new Error('Image not found');
}
if (!image.aliases.includes(alias)) {
image.aliases.push(alias);
}
return { ...image };
}
async removeImageAlias(id: string, alias: string): Promise<void> {
await this.delay();
const image = this.images.find(img => img.id === id);
if (!image) {
throw new Error('Image not found');
}
image.aliases = image.aliases.filter(a => a !== alias);
}
getImageUrl(id: string): string {
// For mock, return a placeholder image URL
return `https://picsum.photos/seed/${id}/400/400`;
}
}
export const api = new MockApiService();

103
webpage/src/api.ts Normal file
View File

@@ -0,0 +1,103 @@
import type { Image } from './types';
const API_BASE_URL = 'http://localhost:8080';
// Pagination configuration
export const ALIASES_PER_PAGE = 5; // Number of aliases to show per page
class ApiService {
// Images
async getImages(params?: {
search?: string;
null_alias?: boolean;
limit?: number;
page?: number;
}): Promise<Image[]> {
const queryParams = new URLSearchParams();
if (params?.search) queryParams.append('search', params.search);
if (params?.null_alias) queryParams.append('null_alias', 'true');
if (params?.limit) queryParams.append('limit', params.limit.toString());
if (params?.page) queryParams.append('page', params.page.toString());
const response = await fetch(
`${API_BASE_URL}/api/images?${queryParams}`
);
if (!response.ok) throw new Error('Failed to fetch images');
const data = await response.json();
return data.images || [];
}
async getImage(id: string): Promise<Image> {
const response = await fetch(`${API_BASE_URL}/api/images/${id}`);
if (!response.ok) throw new Error('Failed to fetch image');
return response.json();
}
async uploadImage(file: File, aliases: string[]): Promise<Image> {
const formData = new FormData();
formData.append('imgfile', file);
aliases.forEach((alias) => formData.append('aliases', alias));
const response = await fetch(`${API_BASE_URL}/api/images`, {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Failed to upload image');
return response.json();
}
async deleteImage(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/api/images/${id}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete image');
}
// Aliases
async getAllAliases(): Promise<string[]> {
const response = await fetch(`${API_BASE_URL}/api/aliases`);
if (!response.ok) throw new Error('Failed to fetch aliases');
const data = await response.json();
return data.aliases || [];
}
async updateImageAliases(id: string, aliases: string[]): Promise<Image> {
const response = await fetch(`${API_BASE_URL}/api/images/${id}/aliases`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ aliases }),
});
if (!response.ok) throw new Error('Failed to update aliases');
return response.json();
}
async addImageAlias(id: string, alias: string): Promise<Image> {
const response = await fetch(`${API_BASE_URL}/api/images/${id}/aliases`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ alias }),
});
if (!response.ok) throw new Error('Failed to add alias');
return response.json();
}
async removeImageAlias(id: string, alias: string): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/api/images/${id}/aliases?alias=${encodeURIComponent(alias)}`,
{
method: 'DELETE',
}
);
if (!response.ok) throw new Error('Failed to remove alias');
}
getImageUrl(id: string): string {
return `${API_BASE_URL}/api/images/${id}/file`;
}
}
export const api = new ApiService();

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

View File

@@ -0,0 +1,137 @@
{
"images": [
{
"id": "1",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-20T12:00:00Z",
"aliases": ["daisuke"],
"url": "/api/images/1/file"
},
{
"id": "2",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-20T12:05:00Z",
"aliases": ["huh"],
"url": "/api/images/2/file"
},
{
"id": "3",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-21T09:00:00Z",
"aliases": ["killme"],
"url": "/api/images/3/file"
},
{
"id": "4",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-21T09:30:00Z",
"aliases": ["nofriend"],
"url": "/api/images/4/file"
},
{
"id": "5",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-21T10:00:00Z",
"aliases": ["orz"],
"url": "/api/images/5/file"
},
{
"id": "6",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-22T11:20:00Z",
"aliases": ["ramen"],
"url": "/api/images/6/file"
},
{
"id": "7",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-22T14:00:00Z",
"aliases": ["rrrr"],
"url": "/api/images/7/file"
},
{
"id": "8",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-23T08:00:00Z",
"aliases": ["sleep"],
"url": "/api/images/8/file"
},
{
"id": "9",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-23T08:05:00Z",
"aliases": ["sleep"],
"url": "/api/images/9/file"
},
{
"id": "10",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-24T15:00:00Z",
"aliases": ["你要出多少"],
"url": "/api/images/10/file"
},
{
"id": "11",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-24T16:00:00Z",
"aliases": ["好ㄘ"],
"url": "/api/images/11/file"
},
{
"id": "12",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-25T10:00:00Z",
"aliases": ["宅斃了"],
"url": "/api/images/12/file"
},
{
"id": "13",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-25T10:10:00Z",
"aliases": ["宅斃了"],
"url": "/api/images/13/file"
},
{
"id": "14",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-25T10:20:00Z",
"aliases": ["宅斃了"],
"url": "/api/images/14/file"
},
{
"id": "15",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-26T12:00:00Z",
"aliases": ["幹波大的"],
"url": "/api/images/15/file"
},
{
"id": "16",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-26T13:00:00Z",
"aliases": ["我什麼都沒有"],
"url": "/api/images/16/file"
},
{
"id": "17",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-27T09:00:00Z",
"aliases": ["欸嘿"],
"url": "/api/images/17/file"
},
{
"id": "18",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-25T10:20:00Z",
"aliases": ["宅斃了"],
"url": "/api/images/18/file"
},
{
"id": "19",
"uploaded_user_id": "konchin.shih",
"uploaded_at": "2023-10-25T10:20:00Z",
"aliases": ["宅斃了"],
"url": "/api/images/19/file"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -0,0 +1,115 @@
import type { Image } from '../types';
interface ImageGridProps {
images: Image[];
aliases: string[];
onImageClick: (image: Image) => void;
}
export default function ImageGrid({ images, aliases, onImageClick }: ImageGridProps) {
// Group images by alias
const groupedImages = images.reduce((acc, image) => {
if (image.aliases.length === 0) {
if (!acc['__no_alias__']) acc['__no_alias__'] = [];
acc['__no_alias__'].push(image);
} else {
image.aliases.forEach((alias) => {
if (!acc[alias]) acc[alias] = [];
acc[alias].push(image);
});
}
return acc;
}, {} as Record<string, Image[]>);
// Filter to only show aliases on current page
const filteredGroups = Object.entries(groupedImages).filter(([alias]) =>
aliases.includes(alias) || alias === '__no_alias__'
);
return (
<div className="p-6 space-y-8">
{filteredGroups.map(([alias, imgs]) => (
<div key={alias}>
<h3 className="text-lg font-semibold text-gray-800 mb-4">
{alias === '__no_alias__' ? 'Images without aliases' : `Alias: ${alias}`}
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{imgs.map((image) => (
<button
key={image.id}
onClick={() => onImageClick(image)}
className="aspect-square rounded-lg overflow-hidden bg-gray-100 hover:ring-2 hover:ring-blue-500 transition-all"
>
<img
src={`http://localhost:8080${image.url}`}
alt={image.aliases.join(', ')}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
</div>
))}
{filteredGroups.length === 0 && (
<div className="text-center py-12 text-gray-500">
No images found
</div>
)}
</div>
);
}
// import type { Image } from '../types';
// interface ImageGridProps {
// images: Image[];
// onImageClick: (image: Image) => void;
// }
// export default function ImageGrid({ images, onImageClick }: ImageGridProps) {
// // Group images by alias
// const groupedImages = images.reduce((acc, image) => {
// if (image.aliases.length === 0) {
// if (!acc['__no_alias__']) acc['__no_alias__'] = [];
// acc['__no_alias__'].push(image);
// } else {
// image.aliases.forEach((alias) => {
// if (!acc[alias]) acc[alias] = [];
// acc[alias].push(image);
// });
// }
// return acc;
// }, {} as Record<string, Image[]>);
// return (
// <div className="p-6 space-y-8">
// {Object.entries(groupedImages).map(([alias, imgs]) => (
// <div key={alias}>
// <h3 className="text-lg font-semibold text-gray-800 mb-4">
// {alias === '__no_alias__' ? 'Images without aliases' : `Alias: ${alias}`}
// </h3>
// <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
// {imgs.map((image) => (
// <button
// key={image.id}
// onClick={() => onImageClick(image)}
// className="aspect-square rounded-lg overflow-hidden bg-gray-100 hover:ring-2 hover:ring-blue-500 transition-all"
// >
// <img
// src={`http://localhost:8080${image.url}`}
// alt={image.aliases.join(', ')}
// className="w-full h-full object-cover"
// />
// </button>
// ))}
// </div>
// </div>
// ))}
// {Object.keys(groupedImages).length === 0 && (
// <div className="text-center py-12 text-gray-500">
// No images found
// </div>
// )}
// </div>
// );
// }

View File

@@ -0,0 +1,191 @@
import { useState, useEffect } from 'react';
import type { Image } from '../types';
interface ImageModalProps {
image: Image;
onClose: () => void;
onSave: (imageId: string, aliases: string[]) => Promise<void>;
onDelete: (imageId: string) => Promise<void>;
}
export default function ImageModal({
image,
onClose,
onSave,
onDelete,
}: ImageModalProps) {
const [aliases, setAliases] = useState<string[]>(image.aliases);
const [newAlias, setNewAlias] = useState('');
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
setAliases(image.aliases);
}, [image]);
const handleAddAlias = () => {
if (newAlias.trim() && !aliases.includes(newAlias.trim())) {
setAliases([...aliases, newAlias.trim()]);
setNewAlias('');
}
};
const handleRemoveAlias = (alias: string) => {
setAliases(aliases.filter((a) => a !== alias));
};
const handleSave = async () => {
setIsSaving(true);
try {
await onSave(image.id, aliases);
onClose();
} catch (error) {
alert('Failed to save changes');
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this image?')) {
try {
await onDelete(image.id);
onClose();
} catch (error) {
alert('Failed to delete image');
}
}
};
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
onClick={onClose}
>
<div
className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
<div className="mb-6 rounded-lg overflow-hidden bg-gray-100">
<img
src={`http://localhost:8080${image.url}`}
alt={image.aliases.join(', ')}
className="w-full h-auto"
/>
</div>
<div className="space-y-4 mb-6">
<div className="text-sm text-gray-600">
<p>
<span className="font-medium">Uploaded Time:</span>{' '}
{new Date(image.uploaded_at).toLocaleString()}
</p>
<p>
<span className="font-medium">Uploaded By:</span>{' '}
{image.uploaded_user_id}
</p>
</div>
<div>
<h3 className="font-medium text-gray-900 mb-3">
Aliases of this image:
</h3>
<div className="space-y-2">
{aliases.map((alias) => (
<div key={alias} className="flex items-center gap-2">
{(
<button
onClick={() => handleRemoveAlias(alias)}
className="text-red-600 hover:text-red-700"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
<input
type="text"
value={alias}
readOnly
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg bg-gray-50"
/>
</div>
))}
{(
<div className="flex items-center gap-2">
<button
onClick={handleAddAlias}
className="text-green-600 hover:text-green-700"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
<input
type="text"
placeholder="Add a new alias"
value={newAlias}
onChange={(e) => setNewAlias(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleAddAlias()}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
)}
</div>
</div>
</div>
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
<div>
{(
<button
onClick={handleDelete}
className="px-4 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors font-medium"
>
Delete Image
</button>
)}
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors font-medium"
>
Close
</button>
{(
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50"
>
{isSaving ? 'Saving...' : 'Save'}
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { useState } from 'react';
interface NavbarProps {
onUploadClick: () => void;
}
export default function Navbar({
onUploadClick,
}: NavbarProps) {
return (
<nav className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<div className="flex items-center">
<h1 className="text-2xl font-bold text-gray-800">Memebot</h1>
</div>
<div className="flex items-center gap-3">
{(
<button
onClick={onUploadClick}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Upload Image
</button>
)}
</div>
</nav>
);
}

View File

@@ -0,0 +1,33 @@
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export default function Pagination({
currentPage,
totalPages,
onPageChange,
}: PaginationProps) {
return (
<div className="flex items-center justify-center gap-4 py-4">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-gray-700 font-medium">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
);
}

View File

@@ -0,0 +1,33 @@
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
}
export default function SearchBar({ value, onChange }: SearchBarProps) {
return (
<div className="px-6 py-4 bg-white border-b border-gray-200">
<div className="relative">
<input
type="text"
placeholder="Search aliases..."
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-4 py-3 pl-11 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
interface SidebarProps {
aliases: string[];
currentPage: number;
totalPages: number;
}
export default function Sidebar({
aliases,
currentPage,
totalPages,
}: SidebarProps) {
return (
<aside className="w-64 bg-gray-50 border-r border-gray-200 overflow-y-auto">
<div className="p-4">
<div className="mb-4 pb-3 border-b border-gray-200">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2">
Aliases
</h2>
<div className="text-xs text-gray-600">
<p>Page {currentPage} of {totalPages}</p>
<p className="mt-1">{aliases.length} aliases on this page</p>
</div>
</div>
<ul className="space-y-1">
{aliases.map((alias) => (
<li key={alias}>
<div className="w-full text-left px-3 py-2 rounded-lg bg-blue-50 text-blue-700">
{alias}
</div>
</li>
))}
</ul>
</div>
</aside>
);
}
// interface SidebarProps {
// aliases: string[];
// selectedAlias: string | null;
// onAliasClick: (alias: string | null) => void;
// }
// export default function Sidebar({
// aliases,
// selectedAlias,
// onAliasClick,
// }: SidebarProps) {
// return (
// <aside className="w-64 bg-gray-50 border-r border-gray-200 overflow-y-auto">
// <div className="p-4">
// <h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">
// Aliases
// </h2>
// <ul className="space-y-1">
// {aliases.map((alias) => (
// <li key={alias}>
// <button
// onClick={() => onAliasClick(alias)}
// className={`w-full text-left px-3 py-2 rounded-lg transition-colors ${
// selectedAlias === alias
// ? 'bg-blue-100 text-blue-700 font-medium'
// : 'text-gray-700 hover:bg-gray-100'
// }`}
// >
// {alias}
// </button>
// </li>
// ))}
// <li className="pt-2 border-t border-gray-200 mt-2">
// <button
// onClick={() => onAliasClick(null)}
// className={`w-full text-left px-3 py-2 rounded-lg transition-colors ${
// selectedAlias === null
// ? 'bg-blue-100 text-blue-700 font-medium'
// : 'text-gray-700 hover:bg-gray-100'
// }`}
// >
// Images without aliases
// </button>
// </li>
// </ul>
// </div>
// </aside>
// );
// }

View File

@@ -0,0 +1,245 @@
import { useState } from 'react';
interface UploadModalProps {
onClose: () => void;
onUpload: (file: File, aliases: string[]) => Promise<void>;
}
export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [aliases, setAliases] = useState<string[]>([]);
const [newAlias, setNewAlias] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const handleFileChange = (selectedFile: File) => {
setFile(selectedFile);
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(selectedFile);
};
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFileChange(e.dataTransfer.files[0]);
}
};
const handleAddAlias = () => {
if (newAlias.trim() && !aliases.includes(newAlias.trim())) {
setAliases([...aliases, newAlias.trim()]);
setNewAlias('');
}
};
const handleRemoveAlias = (alias: string) => {
setAliases(aliases.filter((a) => a !== alias));
};
const handleUpload = async () => {
if (!file) {
alert('Please select a file');
return;
}
setIsUploading(true);
try {
await onUpload(file, aliases);
onClose();
} catch (error) {
alert('Failed to upload image');
} finally {
setIsUploading(false);
}
};
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
onClick={onClose}
>
<div
className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Upload Image
</h2>
<div
className={`mb-6 border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
dragActive
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 bg-gray-50'
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{preview ? (
<div className="relative">
<img
src={preview}
alt="Preview"
className="max-h-64 mx-auto rounded-lg"
/>
<button
onClick={() => {
setFile(null);
setPreview(null);
}}
className="absolute top-2 right-2 bg-red-600 text-white p-2 rounded-full hover:bg-red-700"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
) : (
<>
<svg
className="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-gray-600 mb-2">
Drag your image here or click to browse
</p>
<input
type="file"
accept="image/*"
onChange={(e) =>
e.target.files && handleFileChange(e.target.files[0])
}
className="hidden"
id="file-upload"
/>
<label
htmlFor="file-upload"
className="inline-block px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer transition-colors"
>
Choose File
</label>
</>
)}
</div>
<div className="mb-6">
<h3 className="font-medium text-gray-900 mb-3">
Aliases of this image:
</h3>
<div className="space-y-2">
{aliases.map((alias) => (
<div key={alias} className="flex items-center gap-2">
<button
onClick={() => handleRemoveAlias(alias)}
className="text-red-600 hover:text-red-700"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<input
type="text"
value={alias}
readOnly
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg bg-gray-50"
/>
</div>
))}
<div className="flex items-center gap-2">
<button
onClick={handleAddAlias}
className="text-green-600 hover:text-green-700"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
<input
type="text"
placeholder="Add a new alias"
value={newAlias}
onChange={(e) => setNewAlias(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleAddAlias()}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200">
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors font-medium"
>
Cancel
</button>
<button
onClick={handleUpload}
disabled={!file || isUploading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50"
>
{isUploading ? 'Uploading...' : 'Upload'}
</button>
</div>
</div>
</div>
</div>
);
}

4
webpage/src/index.css Normal file
View File

@@ -0,0 +1,4 @@
/* File: webpage/src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

10
webpage/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

12
webpage/src/types.ts Normal file
View File

@@ -0,0 +1,12 @@
export interface Image {
id: string;
uploaded_user_id: string;
uploaded_at: string;
aliases: string[];
url: string;
}
export interface User {
id: string;
username: string;
}

View File

@@ -0,0 +1,12 @@
// File: webpage/tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}", // 確保掃描所有 src 裡面的元件檔案
],
theme: {
extend: {},
},
plugins: [],
}

28
webpage/tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
webpage/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

9
webpage/vite.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
],
})