chore: basic abilities added
This commit is contained in:
parent
90e325d79d
commit
a7a44410fd
261
package-lock.json
generated
261
package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
|
"axios": "^1.13.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.9.4"
|
"react-router-dom": "^7.9.4"
|
||||||
|
|
@ -2048,6 +2049,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.21",
|
"version": "10.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||||
|
|
@ -2086,6 +2093,17 @@
|
||||||
"postcss": "^8.1.0"
|
"postcss": "^8.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz",
|
||||||
|
"integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.6",
|
||||||
|
"form-data": "^4.0.4",
|
||||||
|
"proxy-from-env": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
|
@ -2161,6 +2179,19 @@
|
||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/callsites": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
|
|
@ -2229,6 +2260,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "14.0.2",
|
"version": "14.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
|
||||||
|
|
@ -2309,6 +2352,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dependency-cruiser": {
|
"node_modules/dependency-cruiser": {
|
||||||
"version": "17.2.0",
|
"version": "17.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-17.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-17.2.0.tgz",
|
||||||
|
|
@ -2418,6 +2470,20 @@
|
||||||
"node": ">=8"
|
"node": ">=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/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.241",
|
"version": "1.5.241",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.241.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.241.tgz",
|
||||||
|
|
@ -2438,6 +2504,51 @@
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.11",
|
"version": "0.25.11",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz",
|
||||||
|
|
@ -2821,6 +2932,42 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||||
|
|
@ -2853,7 +3000,6 @@
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
|
@ -2869,6 +3015,43 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
|
|
@ -2911,6 +3094,18 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
|
@ -2934,11 +3129,37 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
|
|
@ -3491,6 +3712,15 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/memoize": {
|
"node_modules/memoize": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/memoize/-/memoize-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/memoize/-/memoize-10.2.0.tgz",
|
||||||
|
|
@ -3531,6 +3761,27 @@
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mimic-function": {
|
"node_modules/mimic-function": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
|
||||||
|
|
@ -3784,6 +4035,12 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
|
"axios": "^1.13.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.9.4"
|
"react-router-dom": "^7.9.4"
|
||||||
|
|
|
||||||
BIN
public/placeholders/placeholder-profile.png
Normal file
BIN
public/placeholders/placeholder-profile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 978 KiB |
44
src/App.tsx
44
src/App.tsx
|
|
@ -1,19 +1,43 @@
|
||||||
import { useState } from 'react'
|
import { useState } from "react"
|
||||||
import './App.css'
|
import { BrowserRouter, Routes, Route } from "react-router-dom"
|
||||||
import Navbar from './components/Layout/Navbar/Navbar'
|
|
||||||
|
|
||||||
|
import HomePage from "./pages/HomePage"
|
||||||
|
import DealPage from "./pages/DealPage"
|
||||||
|
import SubmitDealPage from "./pages/SubmitDealPage"
|
||||||
|
import AccountSettingsPage from "./pages/AccountSettingsPage"
|
||||||
|
import ProfilePage from "./pages/ProfilePage"
|
||||||
|
import SearchPage from "./pages/SearchPage"
|
||||||
|
|
||||||
|
import LoginModal from "./components/Auth/LoginModal"
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [showLoginModal, setShowLoginModal] = useState(false)
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [count, setCount] = useState(0)
|
|
||||||
return (
|
return (
|
||||||
<>
|
<BrowserRouter>
|
||||||
|
{showLoginModal && (
|
||||||
|
<LoginModal
|
||||||
|
onClose={() => setShowLoginModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage onRequireLogin={() => setShowLoginModal(true)} />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/deal/:id"
|
||||||
|
element={<DealPage onRequireLogin={() => setShowLoginModal(true)} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/search"
|
||||||
|
element={<SearchPage onRequireLogin={() => setShowLoginModal(true)} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path="/submit-deal" element={<SubmitDealPage />} />
|
||||||
|
<Route path="/account" element={<AccountSettingsPage />} />
|
||||||
|
<Route path="/user/:userName" element={<ProfilePage />} />
|
||||||
|
|
||||||
|
</Routes>
|
||||||
</>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
const API_URL = "http://localhost:3000/api"
|
// src/api/account/uploadAvatar.ts
|
||||||
|
import instance from "../axiosInstance"
|
||||||
|
|
||||||
export async function uploadAvatar(token: string, file: File) {
|
export async function uploadAvatar(file: File) {
|
||||||
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("file", file)
|
formData.append("file", file)
|
||||||
|
|
||||||
const res = await fetch(`${API_URL}/account/avatar`, {
|
const { data } = await instance.post("/account/avatar", formData, {
|
||||||
method: "POST",
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
"Content-Type": "multipart/form-data",
|
||||||
},
|
},
|
||||||
body: formData,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) throw new Error("Avatar yükleme hatası")
|
return data
|
||||||
return res.json()
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.error || "Avatar yükleme hatası"
|
||||||
|
console.error("Avatar yükleme hatası:", message)
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
|
// src/api/auth/login.ts
|
||||||
|
import instance from "../axiosInstance"
|
||||||
|
|
||||||
export async function login(email: string, password: string) {
|
export async function login(email: string, password: string) {
|
||||||
const res = await fetch("http://localhost:3000/api/auth/login", {
|
try {
|
||||||
method: "POST",
|
const { data } = await instance.post("/auth/login", { email, password })
|
||||||
headers: { "Content-Type": "application/json" },
|
return data // { token, user }
|
||||||
body: JSON.stringify({ email, password }),
|
} catch (error: any) {
|
||||||
})
|
const message = error.response?.data?.message || "Giriş başarısız"
|
||||||
|
console.error("Login hatası:", message)
|
||||||
if (!res.ok) {
|
throw new Error(message)
|
||||||
const data = await res.json()
|
|
||||||
throw new Error(data.message || "Giriş başarısız")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json() // { token, user }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
src/api/auth/me.ts
Normal file
13
src/api/auth/me.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// src/api/auth/me.ts
|
||||||
|
import instance from "../axiosInstance"
|
||||||
|
|
||||||
|
export async function me() {
|
||||||
|
try {
|
||||||
|
const { data } = await instance.get("/auth/me")
|
||||||
|
return data
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.error || "Kullanıcı bilgisi alınamadı"
|
||||||
|
console.error("Kullanıcı bilgisi alma hatası:", message)
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
|
// src/api/auth/register.ts
|
||||||
|
import instance from "../axiosInstance"
|
||||||
|
|
||||||
export async function register(username: string, email: string, password: string) {
|
export async function register(username: string, email: string, password: string) {
|
||||||
const res = await fetch("http://localhost:3000/api/auth/register", {
|
try {
|
||||||
method: "POST",
|
const { data } = await instance.post("/auth/register", {
|
||||||
headers: { "Content-Type": "application/json" },
|
username,
|
||||||
body: JSON.stringify({ username, email, password }),
|
email,
|
||||||
|
password,
|
||||||
})
|
})
|
||||||
|
return data // { token, user }
|
||||||
if (!res.ok) {
|
} catch (error: any) {
|
||||||
const data = await res.json()
|
const message = error.response?.data?.message || "Kayıt başarısız"
|
||||||
throw new Error(data.message || "Kayıt başarısız")
|
console.error("Register hatası:", message)
|
||||||
|
throw new Error(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json() // { token, user }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
src/api/axiosInstance.ts
Normal file
27
src/api/axiosInstance.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const instance = axios.create({
|
||||||
|
baseURL: "http://localhost:3000/api",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.clear();
|
||||||
|
window.location.href = "/" // anasayfaya yönlendir
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default instance;
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
const API_URL = "http://localhost:3000/api/comments"
|
// src/api/deal/commentApi.ts
|
||||||
|
import instance from "../axiosInstance"
|
||||||
|
|
||||||
export async function getComments(dealId: number) {
|
export async function getComments(dealId: number) {
|
||||||
const res = await fetch(`${API_URL}/${dealId}`)
|
try {
|
||||||
const data = await res.json()
|
const { data } = await instance.get(`/comments/${dealId}`)
|
||||||
if (!res.ok) throw new Error(data.error || "Yorumlar alınamadı")
|
|
||||||
return data
|
return data
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.error || "Yorumlar alınamadı"
|
||||||
|
console.error("Yorumları alma hatası:", message)
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postComment(token: string, dealId: number, text: string) {
|
export async function postComment(dealId: number, text: string) {
|
||||||
const res = await fetch(API_URL, {
|
try {
|
||||||
method: "POST",
|
const { data } = await instance.post("/comments", { dealId, text })
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ dealId, text }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) throw new Error(data.error || "Yorum gönderilemedi")
|
|
||||||
return data
|
return data
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.error || "Yorum gönderilemedi"
|
||||||
|
console.error("Yorum gönderme hatası:", message)
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
const API_URL = "http://localhost:3000/api"
|
// src/api/deal/dealApi.ts
|
||||||
|
import instance from "../axiosInstance"
|
||||||
|
|
||||||
export async function getDeals(page = 1) {
|
export async function getDeals(page = 1) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://localhost:3000/api/deals?page=${page}`)
|
const { data } = await instance.get(`/deals?page=${page}`)
|
||||||
if (!res.ok) throw new Error("Deal listesi alınamadı")
|
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
// Sadece results dizisini döndür
|
|
||||||
return data.results
|
return data.results
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
console.error("Deal listesi hatası:", err)
|
console.error("Deal listesi hatası:", error)
|
||||||
throw err
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function getDeal(id: number) {
|
export async function getDeal(id: number) {
|
||||||
const res = await fetch(`${API_URL}/deals/${id}`)
|
try {
|
||||||
if (!res.ok) throw new Error("Deal alınamadı")
|
const { data } = await instance.get(`/deals/${id}`)
|
||||||
return res.json()
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Deal alma hatası:", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,22 +1,21 @@
|
||||||
const API_URL = "http://localhost:3000/api"
|
// src/api/deal/createDeal.ts
|
||||||
|
import instance from "../axiosInstance"
|
||||||
|
|
||||||
export async function createDeal(token: string, dealData: {
|
type DealData = {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
url?: string
|
url?: string
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
price?: number
|
price?: number
|
||||||
}) {
|
}
|
||||||
const res = await fetch(`${API_URL}/deals`, {
|
|
||||||
method: "POST",
|
export async function createDeal(dealData: DealData) {
|
||||||
headers: {
|
try {
|
||||||
"Content-Type": "application/json",
|
const { data } = await instance.post("/deals", dealData)
|
||||||
Authorization: `Bearer ${token}`,
|
return data
|
||||||
},
|
} catch (error: any) {
|
||||||
body: JSON.stringify(dealData),
|
const message = error.response?.data?.error || "Fırsat eklenemedi"
|
||||||
})
|
console.error("Fırsat oluşturma hatası:", message)
|
||||||
|
throw new Error(message)
|
||||||
const data = await res.json()
|
}
|
||||||
if (!res.ok) throw new Error(data.error || "Fırsat eklenemedi")
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
src/api/deal/searchDeal.ts
Normal file
14
src/api/deal/searchDeal.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import instance from "../axiosInstance"
|
||||||
|
|
||||||
|
export async function searchDeals(query: string, page = 1) {
|
||||||
|
try {
|
||||||
|
const { data } = await instance.get(`/deals/search`, {
|
||||||
|
params: { q: query, page },
|
||||||
|
})
|
||||||
|
// backend response { results, total, totalPages } formatındaysa:
|
||||||
|
return data.results
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Deal arama hatası:", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
const API_URL = "http://localhost:3000/api"
|
// src/api/deal/voteDeal.ts
|
||||||
|
import instance from "../axiosInstance"
|
||||||
|
|
||||||
export async function voteDeal(token: string, dealId: number, type: "UP" | "DOWN") {
|
export async function voteDeal(dealId: number, type: "UP" | "DOWN") {
|
||||||
const res = await fetch(`${API_URL}/deal-votes`, {
|
try {
|
||||||
method: "POST",
|
const { data } = await instance.post("/deal-votes", {
|
||||||
headers: {
|
dealId,
|
||||||
"Content-Type": "application/json",
|
voteType: type,
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ dealId, voteType: type }),
|
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error("Vote hatası")
|
return data
|
||||||
return res.json()
|
} catch (error) {
|
||||||
|
console.error("Vote hatası:", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
src/api/user/getUser.ts
Normal file
12
src/api/user/getUser.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
// src/api/user/getUser.ts
|
||||||
|
import instance from "../axiosInstance"
|
||||||
|
|
||||||
|
export async function getUser(userName: string) {
|
||||||
|
try {
|
||||||
|
const res = await instance.get(`/user/${userName}`)
|
||||||
|
return res.data // { user, deals, comments }
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Kullanıcı bilgileri alınamadı:", err)
|
||||||
|
throw new Error(err.response?.data?.message || "Kullanıcı bilgileri alınamadı")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,31 +1,38 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react"
|
||||||
|
import { useAuth } from "../../context/AuthContext"
|
||||||
|
import { login as loginApi } from "../../api/auth/login"
|
||||||
|
import { register as registerApi } from "../../api/auth/register"
|
||||||
|
|
||||||
type LoginModalProps = {
|
type LoginModalProps = {
|
||||||
onClose: () => void;
|
onClose: () => void
|
||||||
onLogin: (email: string, password: string) => void;
|
}
|
||||||
onRegister: (username: string, email: string, password: string) => void;
|
|
||||||
};
|
export default function LoginModal({ onClose }: LoginModalProps) {
|
||||||
|
const [isRegister, setIsRegister] = useState(false)
|
||||||
export default function LoginModal({ onClose, onLogin, onRegister }: LoginModalProps) {
|
const [username, setUsername] = useState("")
|
||||||
const [isRegister, setIsRegister] = useState(false);
|
const [email, setEmail] = useState("")
|
||||||
const [username, setUsername] =useState("");
|
const [password, setPassword] = useState("")
|
||||||
const [email, setEmail] = useState("");
|
const { login } = useAuth() // global auth fonksiyonu
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
const handleSubmit = () => {
|
try {
|
||||||
if (isRegister) {
|
if (isRegister) {
|
||||||
if (!username || !email || !password) return;
|
const data = await registerApi(username, email, password)
|
||||||
onRegister(username, email, password);
|
login(data.user, data.token)
|
||||||
} else {
|
} else {
|
||||||
if (!email || !password) return;
|
const data = await loginApi(email, password)
|
||||||
onLogin(email, password);
|
login(data.user, data.token)
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
alert("Giriş/Kayıt başarısız")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex items-center justify-center bg-black/60">
|
<div className="fixed inset-0 flex items-center justify-center bg-black/60">
|
||||||
<div className="bg-background text-text p-8 rounded-2xl w-96 shadow-xl relative">
|
<div className="bg-background p-8 rounded-2xl w-96 shadow-xl relative">
|
||||||
<button onClick={onClose} className="absolute top-3 right-3 text-text-muted hover:text-text">×</button>
|
<button onClick={onClose} className="absolute top-3 right-3">×</button>
|
||||||
<h2 className="text-xl font-semibold mb-6 text-center">
|
<h2 className="text-xl font-semibold mb-6 text-center">
|
||||||
{isRegister ? "Kayıt Ol" : "Giriş Yap"}
|
{isRegister ? "Kayıt Ol" : "Giriş Yap"}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
@ -36,7 +43,7 @@ export default function LoginModal({ onClose, onLogin, onRegister }: LoginModalP
|
||||||
placeholder="Kullanıcı adı"
|
placeholder="Kullanıcı adı"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
className="w-full mb-3 p-3 rounded bg-surface border border-neutral-700 text-text placeholder-text-muted"
|
className="w-full mb-3 p-3 rounded bg-surface border"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -45,27 +52,23 @@ export default function LoginModal({ onClose, onLogin, onRegister }: LoginModalP
|
||||||
placeholder="E-posta"
|
placeholder="E-posta"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="w-full mb-3 p-3 rounded bg-surface border border-neutral-700 text-text placeholder-text-muted"
|
className="w-full mb-3 p-3 rounded bg-surface border"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Şifre"
|
placeholder="Şifre"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full mb-5 p-3 rounded bg-surface border border-neutral-700 text-text placeholder-text-muted"
|
className="w-full mb-5 p-3 rounded bg-surface border"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<button
|
<button onClick={handleSubmit} className="bg-primary text-white px-6 py-2 rounded">
|
||||||
onClick={handleSubmit}
|
|
||||||
className="bg-primary hover:bg-primary-hover text-white font-medium px-6 py-2 rounded"
|
|
||||||
>
|
|
||||||
{isRegister ? "Kayıt Ol" : "Giriş Yap"}
|
{isRegister ? "Kayıt Ol" : "Giriş Yap"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-center text-text-muted mt-5">
|
<p className="text-sm text-center mt-5">
|
||||||
{isRegister ? "Zaten hesabın var mı?" : "Hesabın yok mu?"}
|
{isRegister ? "Zaten hesabın var mı?" : "Hesabın yok mu?"}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsRegister(!isRegister)}
|
onClick={() => setIsRegister(!isRegister)}
|
||||||
|
|
@ -76,5 +79,5 @@ export default function LoginModal({ onClose, onLogin, onRegister }: LoginModalP
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,21 @@
|
||||||
import React, { useEffect, useState } from "react"
|
import React, { useEffect, useState } from "react"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
import { getComments, postComment } from "../../api/deal/commentDeal"
|
import { getComments, postComment } from "../../api/deal/commentDeal"
|
||||||
|
import { useAuth } from "../../context/AuthContext"
|
||||||
|
import { timeAgo } from "../../utils/timeAgo"
|
||||||
type Comment = {
|
import type { Comment } from "../../models/Comment"
|
||||||
id: number
|
|
||||||
user: { username: string }
|
|
||||||
text: string
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type DealCommentsProps = {
|
type DealCommentsProps = {
|
||||||
dealId: number
|
dealId: number
|
||||||
|
onRequireLogin: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DealComments({ dealId }: DealCommentsProps) {
|
export default function DealComments({ dealId, onRequireLogin }: DealCommentsProps) {
|
||||||
const [comments, setComments] = useState<Comment[]>([])
|
const [comments, setComments] = useState<Comment[]>([])
|
||||||
const [newComment, setNewComment] = useState("")
|
const [newComment, setNewComment] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const token = localStorage.getItem("token")
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
// Yorumları yükle
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadComments() {
|
async function loadComments() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -35,17 +31,15 @@ export default function DealComments({ dealId }: DealCommentsProps) {
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!newComment.trim()) return
|
if (!newComment.trim()) return
|
||||||
if (!token) {
|
if (!isAuthenticated) return onRequireLogin()
|
||||||
alert("Giriş yapmalısın.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const added = await postComment(token, dealId, newComment)
|
const added = await postComment(dealId, newComment)
|
||||||
setComments((prev) => [added, ...prev]) // yeni yorumu ekle
|
setComments((prev) => [added, ...prev])
|
||||||
setNewComment("")
|
setNewComment("")
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
console.error(err)
|
||||||
alert(err.message || "Sunucu hatası")
|
alert(err.message || "Sunucu hatası")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
@ -53,56 +47,85 @@ export default function DealComments({ dealId }: DealCommentsProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface/50 rounded-lg p-4 shadow-sm">
|
<div className="bg-surface/50 rounded-xl p-6 border border-border/40">
|
||||||
<h2 className="text-lg font-semibold mb-4">Yorumlar</h2>
|
<h2 className="text-lg font-semibold mb-6">Yorumlar</h2>
|
||||||
|
|
||||||
<ul className="space-y-3 mb-4">
|
<div className="space-y-6 mb-6">
|
||||||
{comments.length > 0 ? (
|
{comments.length > 0 ? (
|
||||||
comments.map((c) => (
|
comments.map((c) => (
|
||||||
<li key={c.id} className="border-b border-border pb-2">
|
<div
|
||||||
<p className="text-sm">
|
key={c.id}
|
||||||
<span className="font-medium">{c.user.username}</span>: {c.text}
|
className="flex gap-3 border-b border-border/30 pb-5 last:border-none"
|
||||||
</p>
|
>
|
||||||
<p className="text-xs text-muted-foreground">
|
<Link to={`/user/${c.user.username}`}>
|
||||||
{new Date(c.createdAt).toLocaleString("tr-TR")}
|
<img
|
||||||
</p>
|
src={c.user.avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`}
|
||||||
</li>
|
alt={c.user.username}
|
||||||
|
className="w-10 h-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/user/${c.user.username}`}
|
||||||
|
className="font-medium text-sm hover:underline"
|
||||||
|
>
|
||||||
|
{c.user.username}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{timeAgo(c.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-1 text-sm leading-relaxed">{c.text}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-5 mt-2 text-xs text-muted-foreground">
|
||||||
|
<button className="flex items-center gap-1 hover:text-primary transition">
|
||||||
|
<span>👍</span> <span>25</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center gap-1 hover:text-primary transition">
|
||||||
|
<span>💬</span> <span>Yanıtla</span>
|
||||||
|
</button>
|
||||||
|
<button className="hover:text-primary transition">•••</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">Henüz yorum yok.</p>
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
Henüz yorum yok.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</div>
|
||||||
|
|
||||||
{token ? (
|
{isAuthenticated ? (
|
||||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
<form onSubmit={handleSubmit} className="flex items-center gap-3 pt-4 border-t border-border/40">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newComment}
|
value={newComment}
|
||||||
onChange={(e) => setNewComment(e.target.value)}
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
placeholder="Yorum ekle..."
|
placeholder="Yorum ekle..."
|
||||||
className="flex-1 border rounded-md px-3 py-2 text-sm"
|
className="flex-1 border border-border/40 rounded-full px-4 py-2 text-sm bg-background focus:ring-2 focus:ring-primary/40 focus:outline-none"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="bg-primary text-white text-sm px-4 py-2 rounded-md hover:bg-primary/90 disabled:opacity-60"
|
className="px-5 py-2 rounded-full text-sm font-medium bg-primary text-white hover:bg-primary/90 transition disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{loading ? "Gönderiliyor..." : "Gönder"}
|
{loading ? "Gönderiliyor..." : "Gönder"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<button
|
||||||
Yorum yazmak için{" "}
|
onClick={onRequireLogin}
|
||||||
<a href="/login" className="text-primary font-medium hover:underline">
|
className="w-full text-primary font-medium hover:underline text-sm"
|
||||||
giriş yap
|
>
|
||||||
</a>{" "}
|
Yorum yazmak için giriş yap veya kayıt ol
|
||||||
veya{" "}
|
</button>
|
||||||
<a href="/register" className="text-primary font-medium hover:underline">
|
|
||||||
kayıt ol
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import React from "react"
|
|
||||||
|
|
||||||
type DealDescriptionProps = {
|
type DealDescriptionProps = {
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import UserInfo from "./UserInfo"
|
import UserInfo from "./UserInfo"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
|
import SearchBar from "../Navbar/SearchBar"
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
return (
|
return (
|
||||||
<nav className="bg-surface">
|
<nav className="bg-surface">
|
||||||
<div className="mx-auto flex justify-between items-center px-6 py-3">
|
<div className="mx-auto flex justify-between items-center px-6 py-3">
|
||||||
{/* Sol kısım: logo + menü */}
|
{/* Sol kısım: logo + menü */}
|
||||||
<div className="flex items-center gap-15">
|
<div className="flex items-center gap-10">
|
||||||
<div className="text-primary font-bold text-xl">DealHeat</div>
|
<div className="text-primary font-bold text-xl">DealHeat</div>
|
||||||
|
|
||||||
<ul className="flex gap-6 text-text items-center">
|
<ul className="flex gap-6 text-text items-center">
|
||||||
|
|
@ -37,6 +38,11 @@ export default function Navbar() {
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Orta kısım: arama kutusu */}
|
||||||
|
<div className="flex-1 flex justify-center">
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sağ kısım: kullanıcı bilgisi + buton */}
|
{/* Sağ kısım: kullanıcı bilgisi + buton */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
|
|
|
||||||
34
src/components/Layout/Navbar/SearchBar.tsx
Normal file
34
src/components/Layout/Navbar/SearchBar.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// src/components/Search/SearchBar.tsx
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
|
||||||
|
export default function SearchBar() {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (query.trim()) navigate(`/search?query=${encodeURIComponent(query)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex items-center bg-[#2A2A2A] rounded-md overflow-hidden w-full max-w-md"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Fırsat ara..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="flex-1 px-4 py-2 bg-transparent text-white placeholder-gray-400 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-primary hover:bg-primary/90 text-white font-semibold px-4 py-2 transition"
|
||||||
|
>
|
||||||
|
Ara
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,70 +1,30 @@
|
||||||
import { useState, useEffect } from "react"
|
import { useState } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { useAuth } from "../../../context/AuthContext"
|
||||||
import LoginModal from "../../Auth/LoginModal"
|
import LoginModal from "../../Auth/LoginModal"
|
||||||
import { login } from "../../../api/auth/login"
|
|
||||||
import { register } from "../../../api/auth/register"
|
|
||||||
|
|
||||||
type User = {
|
|
||||||
id: number
|
|
||||||
username: string
|
|
||||||
email?: string
|
|
||||||
avatarUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserInfo() {
|
export default function UserInfo() {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const { user, isAuthenticated, logout } = useAuth()
|
||||||
const [showModal, setShowModal] = useState(false)
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const storedUser = localStorage.getItem("user")
|
|
||||||
if (storedUser) setUser(JSON.parse(storedUser))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleRegister = async (username: string, email: string, password: string) => {
|
|
||||||
try {
|
|
||||||
const data = await register(username, email, password)
|
|
||||||
localStorage.setItem("token", data.token)
|
|
||||||
localStorage.setItem("user", JSON.stringify(data.user))
|
|
||||||
setUser(data.user)
|
|
||||||
setShowModal(false)
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogin = async (email: string, password: string) => {
|
|
||||||
try {
|
|
||||||
const data = await login(email, password)
|
|
||||||
localStorage.setItem("token", data.token)
|
|
||||||
localStorage.setItem("user", JSON.stringify(data.user))
|
|
||||||
console.log(JSON.stringify(data.user))
|
|
||||||
setUser(data.user)
|
|
||||||
setShowModal(false)
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
localStorage.removeItem("token")
|
|
||||||
localStorage.removeItem("user")
|
|
||||||
setUser(null)
|
|
||||||
setMenuOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToAccount = () => {
|
const goToAccount = () => {
|
||||||
setMenuOpen(false)
|
setMenuOpen(false)
|
||||||
navigate("/account")
|
navigate("/account")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
setMenuOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center gap-3">
|
<div className="relative flex items-center gap-3">
|
||||||
{user ? (
|
{isAuthenticated && user ? (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<img
|
<img
|
||||||
src={user.avatarUrl || "https://via.placeholder.com/32"}
|
src={user.avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`}
|
||||||
alt={user.username}
|
alt={user.username}
|
||||||
className="w-8 h-8 rounded-full cursor-pointer border border-gray-400 hover:border-orange-500 transition"
|
className="w-8 h-8 rounded-full cursor-pointer border border-gray-400 hover:border-orange-500 transition"
|
||||||
onClick={() => setMenuOpen((prev) => !prev)}
|
onClick={() => setMenuOpen((prev) => !prev)}
|
||||||
|
|
@ -98,11 +58,7 @@ export default function UserInfo() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<LoginModal
|
<LoginModal onClose={() => setShowModal(false)} />
|
||||||
onClose={() => setShowModal(false)}
|
|
||||||
onLogin={handleLogin}
|
|
||||||
onRegister={handleRegister}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
17
src/components/Profile/CommentCard.tsx
Normal file
17
src/components/Profile/CommentCard.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Comment } from "../../models"
|
||||||
|
|
||||||
|
type CommentCardProps = {
|
||||||
|
comment: Comment
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommentCard({ comment }: CommentCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-3 text-sm">
|
||||||
|
<p className="font-medium mb-1">{comment.deal.title}</p>
|
||||||
|
<p>{comment.text}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{new Date(comment.createdAt).toLocaleString("tr-TR")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
src/components/Profile/ProfileHeader.tsx
Normal file
20
src/components/Profile/ProfileHeader.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// src/components/Profile/ProfileHeader.tsx
|
||||||
|
type Props = {
|
||||||
|
username: string
|
||||||
|
avatarUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileHeader({ username, avatarUrl }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-surface/50 rounded-lg flex flex-col items-center justify-center py-12">
|
||||||
|
<div className="relative w-32 h-32 mb-4">
|
||||||
|
<img
|
||||||
|
src={avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`}
|
||||||
|
alt={username}
|
||||||
|
className="w-32 h-32 rounded-full object-cover border-4 border-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold text-center">{username}</h2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { voteDeal } from "../../api/deal/voteDeal" // yeni api dosyan
|
import { voteDeal } from "../../api/deal/voteDeal"
|
||||||
|
import { useAuth } from "../../hooks/useAuth"
|
||||||
|
|
||||||
type DealCardProps = {
|
type DealCardProps = {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -12,6 +13,7 @@ type DealCardProps = {
|
||||||
score: number
|
score: number
|
||||||
comments: number
|
comments: number
|
||||||
postedAgo: string
|
postedAgo: string
|
||||||
|
onRequireLogin: () => void // yeni prop
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DealCardMain({
|
export default function DealCardMain({
|
||||||
|
|
@ -24,10 +26,13 @@ export default function DealCardMain({
|
||||||
score,
|
score,
|
||||||
comments,
|
comments,
|
||||||
postedAgo,
|
postedAgo,
|
||||||
|
onRequireLogin,
|
||||||
}: DealCardProps) {
|
}: DealCardProps) {
|
||||||
|
|
||||||
const [currentScore, setCurrentScore] = useState<number>(score)
|
const [currentScore, setCurrentScore] = useState<number>(score)
|
||||||
const [voting, setVoting] = useState(false)
|
const [voting, setVoting] = useState(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const auth = useAuth() // her render'da güncel context
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentScore(score)
|
setCurrentScore(score)
|
||||||
|
|
@ -35,15 +40,17 @@ export default function DealCardMain({
|
||||||
|
|
||||||
const handleVote = async (e: React.MouseEvent, type: "UP" | "DOWN") => {
|
const handleVote = async (e: React.MouseEvent, type: "UP" | "DOWN") => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const token = localStorage.getItem("token")
|
|
||||||
if (!token) {
|
// Context’ten anlık token kontrolü
|
||||||
alert("Giriş yapmalısın")
|
const { token, isAuthenticated } = auth
|
||||||
|
if (!isAuthenticated || !token) {
|
||||||
|
onRequireLogin()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setVoting(true)
|
setVoting(true)
|
||||||
try {
|
try {
|
||||||
const data = await voteDeal(token, id, type)
|
const data = await voteDeal( id, type)
|
||||||
if (typeof data.score === "number") setCurrentScore(data.score)
|
if (typeof data.score === "number") setCurrentScore(data.score)
|
||||||
else alert(data.error || "Oy gönderilemedi")
|
else alert(data.error || "Oy gönderilemedi")
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -52,7 +59,6 @@ export default function DealCardMain({
|
||||||
setVoting(false)
|
setVoting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
navigate(`/deal/${id}`)
|
navigate(`/deal/${id}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
src/context/AuthContext.tsx
Normal file
61
src/context/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { createContext, useContext, useState, useEffect } from "react"
|
||||||
|
|
||||||
|
type AuthContextType = {
|
||||||
|
user: any
|
||||||
|
token: string | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
login: (userData: any, tokenData: string) => void
|
||||||
|
logout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<any>(null)
|
||||||
|
const [token, setToken] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedUser = localStorage.getItem("user")
|
||||||
|
const storedToken = localStorage.getItem("token")
|
||||||
|
if (storedUser && storedToken) {
|
||||||
|
setUser(JSON.parse(storedUser))
|
||||||
|
setToken(storedToken)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = (userData: any, tokenData: string) => {
|
||||||
|
localStorage.setItem("user", JSON.stringify(userData))
|
||||||
|
localStorage.setItem("token", tokenData)
|
||||||
|
setUser(userData)
|
||||||
|
setToken(tokenData)
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem("user")
|
||||||
|
localStorage.removeItem("token")
|
||||||
|
setUser(null)
|
||||||
|
setToken(null)
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isAuthenticated: Boolean(token),
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error("useAuth must be used within AuthProvider")
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
36
src/hooks/useAuth.ts
Normal file
36
src/hooks/useAuth.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const [user, setUser] = useState<any>(null)
|
||||||
|
const [token, setToken] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Uygulama açıldığında localStorage kontrolü
|
||||||
|
useEffect(() => {
|
||||||
|
const storedUser = localStorage.getItem("user")
|
||||||
|
const storedToken = localStorage.getItem("token")
|
||||||
|
if (storedUser && storedToken) {
|
||||||
|
setUser(JSON.parse(storedUser))
|
||||||
|
setToken(storedToken)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Giriş başarılı olduğunda çağrılır
|
||||||
|
const login = (userData: any, tokenData: string) => {
|
||||||
|
localStorage.setItem("user", JSON.stringify(userData))
|
||||||
|
localStorage.setItem("token", tokenData)
|
||||||
|
setUser(userData)
|
||||||
|
setToken(tokenData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Çıkış yapıldığında çağrılır
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem("user")
|
||||||
|
localStorage.removeItem("token")
|
||||||
|
setUser(null)
|
||||||
|
setToken(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = Boolean(token)
|
||||||
|
|
||||||
|
return { user, token, isAuthenticated, login, logout }
|
||||||
|
}
|
||||||
25
src/hooks/useAuthCheck.ts
Normal file
25
src/hooks/useAuthCheck.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
// src/hooks/useAuthCheck.ts
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useNavigate, useLocation } from "react-router-dom"
|
||||||
|
import { useAuth } from "../context/AuthContext"
|
||||||
|
import instance from "../api/axiosInstance"
|
||||||
|
|
||||||
|
export function useAuthCheck() {
|
||||||
|
const { token, logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
navigate(`/login?next=${location.pathname}`, { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
instance
|
||||||
|
.get("/auth/me")
|
||||||
|
.catch(() => {
|
||||||
|
logout()
|
||||||
|
navigate(`/login?next=${location.pathname}`, { replace: true })
|
||||||
|
})
|
||||||
|
}, [token, location.pathname, logout, navigate])
|
||||||
|
}
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
export default function DealDetailsLayout({
|
|
||||||
Image,
|
|
||||||
Details,
|
|
||||||
Description,
|
|
||||||
Comments,
|
|
||||||
}: {
|
|
||||||
Image: React.ReactNode
|
|
||||||
Details: React.ReactNode
|
|
||||||
Description: React.ReactNode
|
|
||||||
Comments: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-[1400px] mx-auto px-4 py-8 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
||||||
{/* Üst kısım: sol görsel + sağ detay */}
|
|
||||||
<div className="lg:col-span-1 flex justify-center items-start">
|
|
||||||
{Image}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="lg:col-span-3 flex flex-col gap-6">
|
|
||||||
{Details}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alt kısım: açıklama ve yorumlar tam genişlikte */}
|
|
||||||
<div className="lg:col-span-4 flex flex-col gap-6">
|
|
||||||
<section>{Description}</section>
|
|
||||||
<section>{Comments}</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
export default function PageLayout({
|
|
||||||
children,
|
|
||||||
sidebar,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
sidebar?: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-[1400px] mx-auto px-4 py-8 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
||||||
{/* SOL: 3/4 - deal listesi */}
|
|
||||||
<div className="lg:col-span-3">{children}</div>
|
|
||||||
|
|
||||||
{/* SAĞ: 1/4 - isteğe bağlı alan */}
|
|
||||||
<aside className="hidden lg:block bg-surface/50 rounded-lg p-4">
|
|
||||||
{sidebar}
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
33
src/main.tsx
33
src/main.tsx
|
|
@ -1,25 +1,14 @@
|
||||||
import React from "react";
|
import React from "react"
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client"
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom"; // <-- Burası önemli
|
import App from "./App"
|
||||||
import HomePage from "./pages/HomePage";
|
import "./global.css"
|
||||||
import "./global.css";
|
import { AuthProvider } from "./context/AuthContext"
|
||||||
import DealPage from "./pages/DealPage";
|
|
||||||
import SubmitDealPage from "./pages/SubmitDealPage";
|
|
||||||
|
|
||||||
|
|
||||||
import AccountSettingsPage from "./pages/AccountSettingsPage";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<AuthProvider>
|
||||||
<Routes>
|
<App />
|
||||||
<Route path="/" element={<HomePage />} />
|
|
||||||
<Route path="/deal/:id" element={<DealPage />} />
|
</AuthProvider>
|
||||||
<Route path="/submit-deal" element={<SubmitDealPage />} />
|
|
||||||
<Route path="/account" element={<AccountSettingsPage />} />
|
|
||||||
</Routes>
|
|
||||||
</BrowserRouter>
|
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
)
|
||||||
10
src/models/Comment.ts
Normal file
10
src/models/Comment.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { Deal } from "./Deal"
|
||||||
|
import type { User } from "./User"
|
||||||
|
|
||||||
|
export type Comment = {
|
||||||
|
id: number
|
||||||
|
text: string
|
||||||
|
createdAt: string
|
||||||
|
user:Pick<User,"username" | "avatarUrl">
|
||||||
|
deal: Pick<Deal, "id" | "title"> // sadece id ve title yeterli
|
||||||
|
}
|
||||||
22
src/models/Deal.ts
Normal file
22
src/models/Deal.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { DealImage } from "./DealImage"
|
||||||
|
import type { Comment } from "./Comment"
|
||||||
|
import type { DealVote } from "./DealVote"
|
||||||
|
import type { User } from "./User"
|
||||||
|
|
||||||
|
export type Deal = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
url?: string
|
||||||
|
price?: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
score: number
|
||||||
|
userId: number
|
||||||
|
|
||||||
|
// ilişkiler
|
||||||
|
user?: Pick<User, "id" | "username" | "avatarUrl">
|
||||||
|
images?: DealImage[]
|
||||||
|
votes?: DealVote[]
|
||||||
|
comments?: Comment[]
|
||||||
|
}
|
||||||
6
src/models/DealImage.ts
Normal file
6
src/models/DealImage.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export type DealImage = {
|
||||||
|
id: number
|
||||||
|
imageUrl: string
|
||||||
|
order: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
7
src/models/DealVote.ts
Normal file
7
src/models/DealVote.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export type DealVote = {
|
||||||
|
id: number
|
||||||
|
dealId: number
|
||||||
|
userId: number
|
||||||
|
voteType: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
11
src/models/User.ts
Normal file
11
src/models/User.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// src/models/User.ts
|
||||||
|
export type User = {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
avatarUrl?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicUser = Pick<User, "username" | "avatarUrl" | "createdAt">
|
||||||
5
src/models/index.ts
Normal file
5
src/models/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export * from "./User"
|
||||||
|
export * from "./Deal"
|
||||||
|
export * from "./DealImage"
|
||||||
|
export * from "./DealVote"
|
||||||
|
export * from "./Comment"
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import MainLayout from "../layouts/MainLayout";
|
import MainLayout from "../layouts/MainLayout";
|
||||||
import { uploadAvatar } from "../api/account/uploadAvatar";
|
import { uploadAvatar } from "../api/account/uploadAvatar";
|
||||||
|
import { me } from "../api/auth/me"; // bu fonksiyon token ile kullanıcıyı döndürecek
|
||||||
|
|
||||||
export default function AccountSettingsPage() {
|
export default function AccountSettingsPage() {
|
||||||
const [image, setImage] = useState<File | null>(null);
|
const [image, setImage] = useState<File | null>(null);
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const user = JSON.parse(localStorage.getItem("user") || "{}");
|
||||||
|
if (user?.avatarUrl) setPreview(user.avatarUrl);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
@ -16,11 +22,13 @@ export default function AccountSettingsPage() {
|
||||||
const handleUpload = async () => {
|
const handleUpload = async () => {
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("token")||""
|
const token = localStorage.getItem("token") || "";
|
||||||
const data = await uploadAvatar(token,image);
|
await uploadAvatar( image);
|
||||||
const user = JSON.parse(localStorage.getItem("user") || "{}");
|
|
||||||
user.avatarUrl = data.url;
|
// avatar yüklendikten sonra güncel kullanıcıyı backend’den çek
|
||||||
localStorage.setItem("user", JSON.stringify(user));
|
const updatedUser = await me();
|
||||||
|
localStorage.setItem("user", JSON.stringify(updatedUser));
|
||||||
|
setPreview(updatedUser.avatarUrl);
|
||||||
alert("Profil fotoğrafı güncellendi");
|
alert("Profil fotoğrafı güncellendi");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
@ -37,7 +45,7 @@ export default function AccountSettingsPage() {
|
||||||
{preview ? (
|
{preview ? (
|
||||||
<img
|
<img
|
||||||
src={preview}
|
src={preview}
|
||||||
alt="Preview"
|
alt="Profil Fotoğrafı"
|
||||||
className="w-32 h-32 rounded-full object-cover mb-3"
|
className="w-32 h-32 rounded-full object-cover mb-3"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,19 @@
|
||||||
|
// src/pages/DealPage.tsx
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useParams } from "react-router-dom"
|
import { useParams } from "react-router-dom"
|
||||||
import { getDeal } from "../api/deal/getDeal"
|
import { getDeal } from "../api/deal/getDeal"
|
||||||
|
|
||||||
import MainLayout from "../layouts/MainLayout"
|
import MainLayout from "../layouts/MainLayout"
|
||||||
import DealDetailsLayout from "../layouts/DealDetailsLayout"
|
|
||||||
import DealImages from "../components/DealScreen/DealImages"
|
import DealImages from "../components/DealScreen/DealImages"
|
||||||
import DealDetails from "../components/DealScreen/DealDetails"
|
import DealDetails from "../components/DealScreen/DealDetails"
|
||||||
import DealDescription from "../components/DealScreen/DealDescription"
|
import DealDescription from "../components/DealScreen/DealDescription"
|
||||||
import DealComments from "../components/DealScreen/DealComments"
|
import DealComments from "../components/DealScreen/DealComments"
|
||||||
|
import type { Deal } from "../models/Deal"
|
||||||
|
|
||||||
type Deal = {
|
type DealPageProps = {
|
||||||
id: number
|
onRequireLogin: () => void
|
||||||
title: string
|
|
||||||
price: string
|
|
||||||
store: string
|
|
||||||
link: string
|
|
||||||
image: string
|
|
||||||
description: string
|
|
||||||
postedBy: string
|
|
||||||
postedAgo: string
|
|
||||||
comments: {
|
|
||||||
id: number
|
|
||||||
author: string
|
|
||||||
text: string
|
|
||||||
postedAgo: string
|
|
||||||
}[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DealPage() {
|
export default function DealPage({ onRequireLogin }: DealPageProps) {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const [deal, setDeal] = useState<Deal | null>(null)
|
const [deal, setDeal] = useState<Deal | null>(null)
|
||||||
|
|
||||||
|
|
@ -35,28 +21,41 @@ export default function DealPage() {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
getDeal(Number(id))
|
getDeal(Number(id))
|
||||||
.then(setDeal)
|
.then(setDeal)
|
||||||
.catch((err) => console.error(err))
|
.catch((err) => console.error("Deal yüklenemedi:", err))
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
if (!deal) return <p className="p-4">Yükleniyor...</p>
|
if (!deal) return <p className="p-4">Yükleniyor...</p>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<DealDetailsLayout
|
<div className="max-w-[1400px] mx-auto px-4 py-8 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
Image={<DealImages imageUrl={deal.image} />}
|
{/* Sol: görsel */}
|
||||||
Details={
|
<div className="lg:col-span-1 flex justify-center items-start">
|
||||||
|
<DealImages imageUrl={deal.images?.[0]?.imageUrl || "/placeholder.png"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sağ: detaylar */}
|
||||||
|
<div className="lg:col-span-3 flex flex-col gap-6">
|
||||||
<DealDetails
|
<DealDetails
|
||||||
title={deal.title}
|
title={deal.title}
|
||||||
price={deal.price}
|
price={deal.price?.toString() || "-"}
|
||||||
store={deal.store}
|
store={deal.url || ""}
|
||||||
link={deal.link}
|
link={deal.url || ""}
|
||||||
postedBy={deal.postedBy}
|
postedBy={deal.user?.username || "Anonim"}
|
||||||
postedAgo={deal.postedAgo}
|
postedAgo={deal.createdAt}
|
||||||
/>
|
|
||||||
}
|
|
||||||
Description={<DealDescription description={deal.description} />}
|
|
||||||
Comments={<DealComments dealId={deal.id} />}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alt: açıklama + yorumlar */}
|
||||||
|
<div className="lg:col-span-4 flex flex-col gap-6">
|
||||||
|
<section>
|
||||||
|
<DealDescription description={deal.description || ""} />
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<DealComments dealId={deal.id} onRequireLogin={onRequireLogin} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import MainLayout from "../layouts/MainLayout";
|
|
||||||
import PageLayout from "../layouts/HomeLayout";
|
|
||||||
|
|
||||||
export default function DealsPage() {
|
|
||||||
const [deals, setDeals] = useState([]);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
// src/pages/Home.tsx
|
||||||
import { useEffect, useState, useRef } from "react"
|
import { useEffect, useState, useRef } from "react"
|
||||||
import MainLayout from "../layouts/MainLayout"
|
import MainLayout from "../layouts/MainLayout"
|
||||||
import HomeLayout from "../layouts/HomeLayout"
|
|
||||||
import DealCardMain from "../components/Shared/DealCardMain"
|
import DealCardMain from "../components/Shared/DealCardMain"
|
||||||
import { getDeals } from "../api/deal/getDeal"
|
import { getDeals } from "../api/deal/getDeal"
|
||||||
import { timeAgo } from "../utils/timeAgo"
|
import { timeAgo } from "../utils/timeAgo"
|
||||||
|
|
@ -10,15 +10,18 @@ type Deal = {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
url: string
|
url: string
|
||||||
imageUrl: string
|
images: { imageUrl: string }[]
|
||||||
price: number
|
price: number
|
||||||
score: number
|
score: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
user?: { username: string }
|
user?: { username: string }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
type HomeProps = {
|
||||||
|
onRequireLogin: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage({ onRequireLogin }: HomeProps) {
|
||||||
const [deals, setDeals] = useState<Deal[]>([])
|
const [deals, setDeals] = useState<Deal[]>([])
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
|
@ -26,7 +29,6 @@ export default function Home() {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const observerRef = useRef<HTMLDivElement | null>(null)
|
const observerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
// Sayfa değiştiğinde yeni verileri çek
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDeals = async () => {
|
const loadDeals = async () => {
|
||||||
if (loading || !hasMore) return
|
if (loading || !hasMore) return
|
||||||
|
|
@ -36,7 +38,6 @@ export default function Home() {
|
||||||
if (newDeals.length === 0) {
|
if (newDeals.length === 0) {
|
||||||
setHasMore(false)
|
setHasMore(false)
|
||||||
} else {
|
} else {
|
||||||
// yinelenen veriyi engelle
|
|
||||||
setDeals((prev) => {
|
setDeals((prev) => {
|
||||||
const existingIds = new Set(prev.map((d) => d.id))
|
const existingIds = new Set(prev.map((d) => d.id))
|
||||||
const filtered = newDeals.filter((d: Deal) => !existingIds.has(d.id))
|
const filtered = newDeals.filter((d: Deal) => !existingIds.has(d.id))
|
||||||
|
|
@ -50,9 +51,8 @@ export default function Home() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadDeals()
|
loadDeals()
|
||||||
}, [page]) // page değişince tekrar çalışır
|
}, [page])
|
||||||
|
|
||||||
// Scroll gözlemleyici
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
|
|
@ -70,12 +70,14 @@ export default function Home() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<HomeLayout>
|
<div className="max-w-[1400px] mx-auto px-4 py-8 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
{/* SOL: 3/4 - Deal listesi */}
|
||||||
|
<div className="lg:col-span-3 space-y-4">
|
||||||
{deals.map((deal) => (
|
{deals.map((deal) => (
|
||||||
<DealCardMain
|
<DealCardMain
|
||||||
key={deal.id}
|
key={deal.id}
|
||||||
id={deal.id}
|
id={deal.id}
|
||||||
image={deal.images[0].imageUrl}
|
image={deal.images[0]?.imageUrl || "/placeholder.png"}
|
||||||
title={deal.title}
|
title={deal.title}
|
||||||
price={`${deal.price}₺`}
|
price={`${deal.price}₺`}
|
||||||
store={deal.url ? new URL(deal.url).hostname : "Bilinmiyor"}
|
store={deal.url ? new URL(deal.url).hostname : "Bilinmiyor"}
|
||||||
|
|
@ -83,6 +85,7 @@ export default function Home() {
|
||||||
score={deal.score}
|
score={deal.score}
|
||||||
comments={0}
|
comments={0}
|
||||||
postedAgo={timeAgo(deal.createdAt)}
|
postedAgo={timeAgo(deal.createdAt)}
|
||||||
|
onRequireLogin={onRequireLogin}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{loading && <p className="text-center py-4">Yükleniyor...</p>}
|
{loading && <p className="text-center py-4">Yükleniyor...</p>}
|
||||||
|
|
@ -92,7 +95,13 @@ export default function Home() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div ref={observerRef} className="h-8" />
|
<div ref={observerRef} className="h-8" />
|
||||||
</HomeLayout>
|
</div>
|
||||||
|
|
||||||
|
{/* SAĞ: sidebar alanı */}
|
||||||
|
<aside className="hidden lg:block bg-surface/50 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">Yan içerik / filtre / reklam alanı</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
130
src/pages/ProfilePage.tsx
Normal file
130
src/pages/ProfilePage.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
import MainLayout from "../layouts/MainLayout"
|
||||||
|
import DealCardMain from "../components/Shared/DealCardMain"
|
||||||
|
import CommentCard from "../components/Profile/CommentCard"
|
||||||
|
import { fetchUserProfile } from "../services/userService"
|
||||||
|
import type { Deal, Comment } from "../models"
|
||||||
|
import type { PublicUser } from "../models/User"
|
||||||
|
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const { userName } = useParams<{ userName: string }>()
|
||||||
|
const [activeTab, setActiveTab] = useState<"deals" | "comments">("deals")
|
||||||
|
const [user, setUser] = useState<PublicUser | null>(null)
|
||||||
|
const [deals, setDeals] = useState<Deal[]>([])
|
||||||
|
const [comments, setComments] = useState<Comment[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userName) return
|
||||||
|
const loadUser = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const { user, deals, comments } = await fetchUserProfile(userName)
|
||||||
|
setUser(user)
|
||||||
|
setDeals(deals)
|
||||||
|
setComments(comments)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadUser()
|
||||||
|
}, [userName])
|
||||||
|
|
||||||
|
if (loading) return <p className="p-4 text-center">Yükleniyor...</p>
|
||||||
|
if (error) return <p className="p-4 text-center text-red-600">{error}</p>
|
||||||
|
if (!user) return <p className="p-4 text-center">Kullanıcı bulunamadı.</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainLayout>
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-8 space-y-8">
|
||||||
|
{/* ÜST: profil bilgisi */}
|
||||||
|
<div className="bg-surface/50 rounded-lg p-6 flex flex-col items-center text-center shadow-sm">
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`}
|
||||||
|
alt="avatar"
|
||||||
|
className="w-24 h-24 rounded-full mb-3 border"
|
||||||
|
/>
|
||||||
|
<h1 className="text-xl font-semibold">{user.username}</h1>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Katılma: {new Date(user.createdAt).toLocaleDateString("tr-TR")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MENÜ */}
|
||||||
|
<div className="border-b border-border flex justify-center gap-8 text-sm font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("deals")}
|
||||||
|
className={`py-2 ${
|
||||||
|
activeTab === "deals"
|
||||||
|
? "text-primary border-b-2 border-primary"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Paylaşımlar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("comments")}
|
||||||
|
className={`py-2 ${
|
||||||
|
activeTab === "comments"
|
||||||
|
? "text-primary border-b-2 border-primary"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Yorumlar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* İÇERİK */}
|
||||||
|
<div className="min-h-[300px]">
|
||||||
|
{activeTab === "deals" ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{deals.length > 0 ? (
|
||||||
|
deals.map((deal) => {
|
||||||
|
const firstImage = deal.images?.[0]?.imageUrl || "/placeholder.png"
|
||||||
|
const postedAgo = new Date(deal.createdAt).toLocaleDateString("tr-TR")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DealCardMain
|
||||||
|
key={deal.id}
|
||||||
|
id={deal.id}
|
||||||
|
image={firstImage}
|
||||||
|
title={deal.title}
|
||||||
|
price={`${deal.price ?? 0}₺`}
|
||||||
|
store={ "Bilinmiyor"}
|
||||||
|
postedBy={user.username}
|
||||||
|
score={deal.score}
|
||||||
|
comments={0}
|
||||||
|
postedAgo={postedAgo}
|
||||||
|
onRequireLogin={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-muted-foreground py-8">
|
||||||
|
Henüz paylaşım yok.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{comments.length > 0 ? (
|
||||||
|
comments.map((c) => <CommentCard key={c.id} comment={c} />)
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-muted-foreground py-8">
|
||||||
|
Henüz yorum yok.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MainLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
src/pages/SearchPage.tsx
Normal file
94
src/pages/SearchPage.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
// src/pages/SearchPage.tsx
|
||||||
|
import { useEffect, useState, useRef } from "react"
|
||||||
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
import MainLayout from "../layouts/MainLayout"
|
||||||
|
import DealCardMain from "../components/Shared/DealCardMain"
|
||||||
|
import { timeAgo } from "../utils/timeAgo"
|
||||||
|
import { searchDeals } from "../api/deal/searchDeal"
|
||||||
|
|
||||||
|
export default function SearchPage({ onRequireLogin }: { onRequireLogin: () => void }) {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const query = searchParams.get("query") || ""
|
||||||
|
|
||||||
|
const [deals, setDeals] = useState<any[]>([])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const observerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDeals([])
|
||||||
|
setPage(1)
|
||||||
|
setHasMore(true)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
if (!query || loading || !hasMore) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const newDeals = await searchDeals(query, page)
|
||||||
|
if (newDeals.length === 0) setHasMore(false)
|
||||||
|
else {
|
||||||
|
setDeals((prev) => {
|
||||||
|
const ids = new Set(prev.map((d) => d.id))
|
||||||
|
return [...prev, ...newDeals.filter((d: any) => !ids.has(d.id))]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [page, query])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasMore && !loading) {
|
||||||
|
setPage((p) => p + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 1 }
|
||||||
|
)
|
||||||
|
if (observerRef.current) observer.observe(observerRef.current)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [hasMore, loading])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainLayout>
|
||||||
|
<div className="max-w-[1400px] mx-auto px-4 py-8 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
|
||||||
|
<aside className="hidden lg:block bg-surface/50 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">Yan içerik / filtre / reklam alanı</p>
|
||||||
|
</aside>
|
||||||
|
<div className="lg:col-span-3 space-y-4">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{deals.map((deal) => (
|
||||||
|
<DealCardMain
|
||||||
|
key={deal.id}
|
||||||
|
id={deal.id}
|
||||||
|
image={deal.images[0]?.imageUrl || "/placeholder.png"}
|
||||||
|
title={deal.title}
|
||||||
|
price={`${deal.price}₺`}
|
||||||
|
store={deal.url ? new URL(deal.url).hostname : "Bilinmiyor"}
|
||||||
|
postedBy={deal.user?.username || "unknown"}
|
||||||
|
score={deal.score}
|
||||||
|
comments={0}
|
||||||
|
postedAgo={timeAgo(deal.createdAt)}
|
||||||
|
onRequireLogin={onRequireLogin}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{loading && <p className="text-center py-4">Yükleniyor...</p>}
|
||||||
|
{!hasMore && <p className="text-center py-4 text-muted-foreground">Tüm sonuçlar yüklendi.</p>}
|
||||||
|
<div ref={observerRef} className="h-8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</MainLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ export default function SubmitDealPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createDeal(token, {
|
await createDeal( {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
url,
|
url,
|
||||||
|
|
|
||||||
41
src/services/userService.ts
Normal file
41
src/services/userService.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { getUser } from "../api/user/getUser"
|
||||||
|
import type { PublicUser, Deal, Comment } from "../models"
|
||||||
|
|
||||||
|
export type UserProfile = {
|
||||||
|
user: PublicUser
|
||||||
|
deals: Deal[]
|
||||||
|
comments: Comment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserProfile(userName: string): Promise<UserProfile> {
|
||||||
|
const data = await getUser(userName)
|
||||||
|
|
||||||
|
const user: PublicUser = {
|
||||||
|
username: data.user.username,
|
||||||
|
avatarUrl: data.user.avatarUrl,
|
||||||
|
createdAt: data.user.createdAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
const deals: Deal[] = data.deals.map((d: any) => ({
|
||||||
|
id: d.id,
|
||||||
|
title: d.title,
|
||||||
|
price: d.price,
|
||||||
|
store: d.store,
|
||||||
|
score: d.score,
|
||||||
|
createdAt: d.createdAt,
|
||||||
|
images:
|
||||||
|
d.images?.map((img: any) => ({
|
||||||
|
imageUrl: img.imageUrl,
|
||||||
|
order: img.order,
|
||||||
|
})) || [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const comments: Comment[] = data.comments.map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
text: c.text,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
deal: { title: c.deal?.title || "Bilinmeyen paylaşım" },
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { user, deals, comments }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user