This commit is contained in:
='fauz 2025-10-16 13:43:57 +07:00
parent 19134918ac
commit f4799104aa
35 changed files with 2051 additions and 186 deletions

3
.env Normal file
View File

@ -0,0 +1,3 @@
VUE_APP_BASE_URL=http://localhost:8000
VUE_APP_CLIENT_ID=zR2I6g5hLfCN6a1WO2PgGOlbUWckjn7yx1Y9bJhj
VUE_APP_CLIENT_SECRET=3Ki1DjszDOab9dtKdDaViijYRckTaTzwbKSstftxNOGkYNlnOo4Ez4OtHJjoPbjRyl4Pbvq04h9lXzUHNMZlrKN5WvqGJy3FCZ2DthmZbqwttHCFglJmcIEMUOb5ZA51

306
package-lock.json generated
View File

@ -8,9 +8,12 @@
"name": "furikake-web",
"version": "0.1.0",
"dependencies": {
"axios": "^1.12.2",
"core-js": "^3.8.3",
"pinia": "^3.0.3",
"primeicons": "^7.0.0",
"vue": "^3.2.13",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^4.5.1"
},
"devDependencies": {
@ -2526,6 +2529,18 @@
"@types/node": "*"
}
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.16",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.16.tgz",
"integrity": "sha512-gRNWaC3YW5EzhPRjVYy7BnxCbtLGqsgu+uTkmV/IxOF1bllFD+FAJ1KBdsDFsuJB+F+CE+nWmMlWt8vaZ3yYXA==",
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.41.4",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.4.tgz",
"integrity": "sha512-ECf0qTibhAi2Z0K6FIY96CvBTVkVIuVunOfbTUgbaAmGmbwsc33dbK9KZPROWsmzHotddy6C5pIqYqOmsBoJEw==",
"license": "MIT"
},
"node_modules/@types/eslint": {
"version": "8.56.12",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz",
@ -3350,6 +3365,30 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/devtools-kit": {
"version": "7.7.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz",
"integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.7",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/@vue/devtools-shared": {
"version": "7.7.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz",
"integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.21.tgz",
@ -3923,6 +3962,12 @@
"dev": true,
"license": "MIT"
},
"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/at-least-node": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
@ -3971,6 +4016,17 @@
"postcss": "^8.1.0"
}
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-loader": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.4.1.tgz",
@ -4065,6 +4121,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/barcode-detector": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-2.2.2.tgz",
"integrity": "sha512-JcSekql+EV93evfzF9zBr+Y6aRfkR+QFvgyzbwQ0dbymZXoAI9+WgT7H1E429f+3RKNncHz2CW98VQtaaKpmfQ==",
"license": "MIT",
"dependencies": {
"@types/dom-webcodecs": "^0.1.11",
"zxing-wasm": "1.1.3"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -4126,6 +4192,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/birpc": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.6.1.tgz",
"integrity": "sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@ -4328,7 +4403,6 @@
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -4683,6 +4757,18 @@
"dev": true,
"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": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
@ -4827,6 +4913,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/copy-anything": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
"integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
"license": "MIT",
"dependencies": {
"is-what": "^4.1.8"
},
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/copy-webpack-plugin": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-9.1.0.tgz",
@ -5501,6 +5602,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"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/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -5703,7 +5813,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@ -5853,7 +5962,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -5863,7 +5971,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -5880,7 +5987,6 @@
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@ -5889,6 +5995,21 @@
"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/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -6809,7 +6930,6 @@
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"dev": true,
"funding": [
{
"type": "individual",
@ -6920,6 +7040,22 @@
"node": ">= 8"
}
},
"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/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -7003,7 +7139,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@ -7040,7 +7175,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@ -7065,7 +7199,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@ -7184,7 +7317,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -7250,7 +7382,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -7259,6 +7390,21 @@
"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/hash-sum": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz",
@ -7270,7 +7416,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@ -7299,6 +7444,12 @@
"node": "*"
}
},
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/hosted-git-info": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
@ -7842,6 +7993,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-what": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz",
"integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
"license": "MIT",
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@ -8706,7 +8869,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -8820,7 +8982,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@ -8830,7 +8991,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@ -9000,6 +9160,12 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mkdirp": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
@ -9699,6 +9865,12 @@
"node": ">=8"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -9728,6 +9900,36 @@
"node": ">=0.10.0"
}
},
"node_modules/pinia": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz",
"integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.2"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz",
"integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.7"
}
},
"node_modules/pirates": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
@ -10676,6 +10878,12 @@
"node": ">= 0.10"
}
},
"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/pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
@ -11060,6 +11268,12 @@
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@ -11148,6 +11362,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/sdp": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
"license": "MIT"
},
"node_modules/select-hose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
@ -11653,6 +11873,15 @@
"wbuf": "^1.7.3"
}
},
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@ -11916,6 +12145,18 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/superjson": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz",
"integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==",
"license": "MIT",
"dependencies": {
"copy-anything": "^3.0.2"
},
"engines": {
"node": ">=16"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@ -12776,6 +13017,19 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/vue-qrcode-reader": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.7.3.tgz",
"integrity": "sha512-iSGko42FsEvdHyizBMBs/X+HMO9Z5ONDxjW+mQdoraOR5emRNedmjC5SEJdYzGz8ZP5ME3lwB4iHy3S7MOt5Qw==",
"license": "MIT",
"dependencies": {
"barcode-detector": "2.2.2",
"webrtc-adapter": "8.2.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/vue-router": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
@ -13280,6 +13534,19 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/webrtc-adapter": {
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.3.tgz",
"integrity": "sha512-gnmRz++suzmvxtp3ehQts6s2JtAGPuDPjA1F3a9ckNpG1kYdYuHWYpazoAnL9FS5/B21tKlhkorbdCXat0+4xQ==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/websocket-driver": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
@ -13560,6 +13827,15 @@
"integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==",
"dev": true,
"license": "ISC"
},
"node_modules/zxing-wasm": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-1.1.3.tgz",
"integrity": "sha512-MYm9k/5YVs4ZOTIFwlRjfFKD0crhefgbnt1+6TEpmKUDFp3E2uwqGSKwQOd2hOIsta/7Usq4hnpNRYTLoljnfA==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "^1.39.10"
}
}
}
}

View File

@ -9,9 +9,12 @@
"clean": "rimraf dist node_modules/.cache node_modules/.vite"
},
"dependencies": {
"axios": "^1.12.2",
"core-js": "^3.8.3",
"pinia": "^3.0.3",
"primeicons": "^7.0.0",
"vue": "^3.2.13",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^4.5.1"
},
"devDependencies": {

BIN
public/images/manga.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

BIN
public/images/manga1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

BIN
public/images/manga2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

View File

@ -29,7 +29,7 @@
</router-link>
<router-link
to="/hiburan"
to="/entertainment"
class="flex flex-col items-center text-gray-600 hover:text-green-500 p-2"
active-class="text-green-600 font-bold"
>

View File

@ -2,7 +2,8 @@ import { createApp } from 'vue'
import App from './App.vue'
import './assets/tailwind.css'
import router from './router'
import { createPinia } from 'pinia'
createApp(App).use(router).mount('#app')
const pinia = createPinia()
createApp(App).use(pinia).use(router).mount('#app')

148
src/pages/ChapterList.vue Normal file
View File

@ -0,0 +1,148 @@
<template>
<div
class="min-h-screen flex flex-col bg-gradient-to-b from-emerald-100 via-lime-50 to-white
text-gray-800 relative overflow-hidden"
@scroll.passive="handleScroll"
>
<!-- Header -->
<div
class="w-full px-6 py-4 flex items-center justify-between
backdrop-blur-md bg-white/30 border-b border-emerald-100 z-20 sticky top-0"
>
<button
@click="goBack"
class="flex items-center gap-2 text-emerald-600 hover:text-emerald-800 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span class="font-semibold">Kembali</span>
</button>
<!-- Breadcrumb -->
<div class="text-sm text-gray-600">
<span class="text-emerald-600 font-medium cursor-pointer hover:underline" @click="goHome">Beranda</span>
<span class="mx-2"></span>
<span class="text-gray-700 font-semibold cursor-pointer hover:underline" @click="toMangas">Manga</span>
<span class="mx-2"></span>
<span class="text-gray-700 font-semibold">Baca Manga</span>
</div>
</div>
<!-- Judul -->
<div class="text-center mt-8">
<h1 class="text-3xl font-extrabold bg-gradient-to-r from-emerald-500 to-lime-500 bg-clip-text text-transparent">
{{ mangaTitle }}
</h1>
<p class="text-gray-500 mt-2">Chapter {{ chapter_id }}</p>
</div>
<!-- Manga Scroll Viewer -->
<div ref="scrollContainer" class="flex flex-col items-center justify-start w-full mt-6 px-4 pb-20 overflow-y-auto">
<transition-group name="fade" tag="div" class="w-full max-w-2xl space-y-8">
<div
v-for="(page, index) in pages"
:key="index"
class="flex flex-col items-center"
>
<img
:src="page.image"
:alt="'Halaman ' + (index + 1)"
class="w-full rounded-xl shadow-lg border border-white/40 object-contain"
/>
<p class="text-sm text-gray-500 mt-2">Halaman {{ index + 1 }}</p>
</div>
</transition-group>
<!-- Loading Spinner -->
<div v-if="isLoadingNext" class="flex justify-center mt-6 mb-10">
<svg class="animate-spin h-6 w-6 text-emerald-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
</div>
</div>
</div>
</template>
<script>
import router from '@/router'
import api from '@/util/api'
export default {
name: "MangaPageScrollReader",
props: ["manga_id", "chapter_id"],
data() {
return {
pages: [],
mangaTitle: "Loading...",
nextUrl: null,
isLoadingNext: false,
scrollThreshold: 400, // jarak px dari bawah sebelum trigger load berikutnya
}
},
methods: {
goBack() {
router.back()
},
goHome() {
router.push('/')
},
toMangas() {
router.push('/entertainment/mangas')
},
async loadPages(url = null) {
try {
this.isLoadingNext = true
const endpoint = url || `/entertainment/manga/${this.manga_id}/chapters/${this.chapter_id}/pages/`
const res = await api.get(endpoint)
const data = res.data
// kalau backend pakai pagination
if (data.results) {
this.pages.push(...data.results)
this.nextUrl = data.next
} else {
this.pages.push(...data)
this.nextUrl = null
}
// ambil nama manga
if (this.pages.length > 0 && this.pages[0].chapter?.manga?.title) {
this.mangaTitle = this.pages[0].chapter.manga.title
}
console.log(this.pages)
} catch (err) {
console.error("Gagal memuat halaman:", err)
} finally {
this.isLoadingNext = false
}
},
handleScroll() {
const container = document.documentElement
const scrollBottom = container.scrollHeight - (window.scrollY + window.innerHeight)
if (scrollBottom < this.scrollThreshold && this.nextUrl && !this.isLoadingNext) {
this.loadPages(this.nextUrl)
}
},
},
mounted() {
this.loadPages()
window.addEventListener('scroll', this.handleScroll)
},
beforeUnmount() {
window.removeEventListener('scroll', this.handleScroll)
}
}
</script>
<style scoped>
.fade-enter-active, .fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>

137
src/pages/Chapters.vue Normal file
View File

@ -0,0 +1,137 @@
<template>
<div
class="min-h-screen flex flex-col bg-gradient-to-b from-emerald-100 via-lime-50 to-white
text-gray-800 relative overflow-hidden mb-[-10px]"
>
<div class="w-full px-6 py-4 flex items-center justify-between backdrop-blur-md bg-white/30 border-b border-emerald-100 z-20">
<button
@click="goBack"
class="flex items-center gap-2 text-emerald-600 hover:text-emerald-800 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span class="font-semibold">Kembali</span>
</button>
<!-- Breadcrumb / Path -->
<div class="text-sm text-gray-600">
<span class="text-emerald-600 font-medium cursor-pointer hover:underline" @click="goHome">Beranda</span>
<span class="mx-2"></span>
<span class="text-gray-700 font-semibold">Manga</span>
<span class="mx-2"></span>
<span class="text-gray-700 font-semibold">Chapters</span>
</div>
</div>
<!-- Title -->
<div class="text-center mt-8">
<h1 class="text-4xl font-extrabold bg-gradient-to-r from-emerald-500 to-lime-500 bg-clip-text text-transparent">
Daftar Manga
</h1>
<p class="text-gray-500 mt-2">Temukan bacaan favoritmu di sini 📖</p>
</div>
<!-- Manga Grid -->
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6 mt-10 px-6 pb-20">
<div
v-for="chapter in chapters"
:key="chapter.chapter"
class="group cursor-pointer rounded-2xl bg-white/60 backdrop-blur-lg p-4 shadow-md border border-white/40
hover:shadow-2xl hover:-translate-y-2 transition-all duration-300 relative overflow-hidden"
>
<div class="mt-0 text-center">
<h3 class="font-semibold text-lg text-gray-800 truncate">Chapter {{ chapter.chapter }}</h3>
</div>
<!-- Manga Cover -->
<div class="relative" @click="toPage(chapter.id,chapter.chapter)">
<img
:src="images.find(image => image.id === chapter.id).image"
:alt="chapter.status"
class="w-full h-48 object-cover rounded-xl group-hover:scale-105 transition-transform duration-500"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent rounded-xl opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
<!-- Manga Info -->
<div class="mt-3 text-center">
<!-- <h3 class="font-semibold text-lg text-gray-800 truncate">Chapter {{ chapter.chapter }}</h3> -->
<p class="text-sm text-gray-500">
<button @click="toSynopsis(chapter.manga)"
class="text-gray-900 bg-white hover:bg-gray-100 border border-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-100 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:focus:ring-gray-600 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:bg-gray-700 me-2 mb-2">
<h3 class="font-semibold text-lg text-gray-800 truncate">📖 Synopsis</h3>
</button>
</p>
</div>
<!-- <button @click="toSynopsis(chapter.manga)">
Synopsis
</button> -->
</div>
</div>
</div>
</template>
<script>
import router from '@/router';
import { getChapters } from '@/services/manga';
export default {
name: "manga-chapter",
props: ["id"],
data() {
return {
genres : [],
chapters: [],
images: [
{id:1, image:'/images/manga.png'},
{id:2, image:'/images/manga1.png'},
{id:3, image:'/images/manga2.png'}
],
};
},
methods: {
goBack() {
router.back();
},
toPage(manga_id,chapter_id) {
router.push(`/entertainment/manga/${manga_id}/chapters/${chapter_id}/`);
},
async loadContent() {
const res = await getChapters(this.id);
this.chapters = res.results;
console.log(this.id)
console.log(this.props)
console.log(res.results);
},
toSynopsis( manga) {
console.log(manga)
router.push({
name: 'manga-sinopsis',
params: { id: manga.id },
query: { synopsis: manga.synopsis }
});
}
},
mounted() {
this.loadContent();
}
};
</script>
<style scoped>
@keyframes pulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.05); }
}
/* Tambahan efek glow lembut di hover */
.group:hover img {
filter: drop-shadow(0 0 10px rgba(72, 187, 120, 0.5));
}
</style>
<script setup>
</script>

View File

@ -1,6 +1,13 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-green-100 to-green-300 pb-28 pr-2 pl-2">
<div class="w-full max-w-md flex flex-col items-center z-60 " >
<div class="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-green-100
to-green-300 pb-28 pr-2 pl-2 mb-[-10px]"
style="background-image:url('/images/footer.png');
background-repeat:no-repeat;
background-position:bottom;
background-size: 100% 200px;"
margin-bottom=-10px;
>
<div class="w-full max-w-md flex flex-col items-center z-60 mb-8" >
<div class="relative flex items-end justify-center z-30 px-[10px] mt-[40px]">
<img
src="/images/logo.png"
@ -8,7 +15,7 @@
class="object-contain relative z-30 pb-0"
/>
</div>
<h2 class="mt-6 text-xl font-bold text-gray-700">
<!-- <h2 class="mt-6 text-xl font-bold text-gray-700">
{{ selectedCharacter.name }}
</h2>
<div class="relative w-40 h-50 flex items-end justify-center z-30 px-[10px]">
@ -27,8 +34,7 @@
"
@click="changeAva('profile')"
></div>
</div>
</div> -->
</div>
<div class="w-full max-w-md flex flex-col items-center z-60">
<div class="bg-lime-200 shadow-xl rounded-2xl p-6 flex flex-col items-center
@ -64,17 +70,18 @@
</div>
<!-- Character Selection -->
<div class="w-full max-w-md flex flex-col items-center z-60 " >
<div class="w-full max-w-md flex flex-col items-center z-60" >
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-6 w-full max-w-2xl">
<div
v-for="char in characters"
:key="char.name"
@click="selectedCharacter = char"
class="cursor-pointer flex flex-col items-center p-4 rounded-xl shadow-md hover:shadow-lg transition bg-white"
class="cursor-pointer flex flex-col items-center p-4 rounded-xl
shadow-md hover:shadow-lg transition bg-white duration-300 hover:-translate-y-2
"
:class="{'ring-4 ring-green-400': selectedCharacter.name === char.name}"
>
<img :src="char.img" :alt="char.name" class="w-20 h-20 object-contain" />
<img :src="char.img" :alt="char.name" class="w-20 h-20 object-contain img-hover:scale-100 transition-transform duration-300"/>
<!-- <p class="mt-2 text-gray-600 font-medium">{{ char.name }}</p> -->
</div>
</div>
@ -135,3 +142,22 @@ export default {
},
};
</script>
<style scope>
@keyframes zoom {
0%, 100% {
opacity: 0.5;
transform: scale(1);
color: red;
}
50% {
opacity: 0.8;
transform: scale(1.05);
color: green;
}
}
.img-hover {
filter: drop-shadow(0 0 10px rgba(72, 187, 120, 0.5));
}
</style>

182
src/pages/Entertainment.vue Normal file
View File

@ -0,0 +1,182 @@
<template>
<div
class="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-emerald-200 via-teal-100 to-lime-200
relative overflow-hidden text-gray-800 mb-[-10px]"
style="background-image:url('/images/footer.png');
background-repeat:no-repeat;
background-position:bottom;
background-size: 100% 200px;"
margin-bottom=-10px;
>
<!-- Background Glow & Floating Elements -->
<div class="absolute inset-0 z-0">
<div class="absolute top-10 left-10 w-72 h-72 bg-emerald-300/30 blur-3xl rounded-full animate-pulse"></div>
<div class="absolute bottom-10 right-10 w-96 h-96 bg-lime-400/30 blur-3xl rounded-full animate-pulse"></div>
</div>
<!-- Logo Section -->
<div class="w-full max-w-md flex flex-col items-center z-20 mt-10">
<img
src="/images/logo.png"
alt="Logo"
class="w-48 h-auto drop-shadow-xl hover:scale-105 transition-transform duration-500"
/>
</div>
<!-- Main Card -->
<div
class="backdrop-blur-xl bg-white/30 border border-white/40 rounded-3xl shadow-2xl p-6 mt-10 w-[90%] max-w-md
flex flex-col items-center text-center relative overflow-hidden"
>
<div
class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-lime-400 via-emerald-300 to-cyan-300 rounded-t-3xl animate-[pulse_3s_infinite]"
></div>
<img
src="/images/but-nav-hiburan.png"
alt="Hiburan"
class="w-40 h-auto mt-6 transition-transform duration-500 hover:scale-110"
/>
<h2 class="text-3xl font-extrabold mt-4 bg-gradient-to-r from-emerald-500 to-lime-500 bg-clip-text text-transparent tracking-wide">
Hiburan
</h2>
<p class="text-gray-600 mt-2 text-sm">Pilih hiburan favoritmu dan nikmati keseruannya</p>
</div>
<!-- Character Grid -->
<!-- <div class="w-[90%] max-w-md grid grid-cols-2 gap-6 mt-10 px-2 z-20 pb-[40px]"
>
<div
v-for="hiburan in hiburans"
:key="hiburan.name"
@click="toMangas(hiburan.name)"
class="group cursor-pointer flex flex-col items-center p-1 rounded-2xl
backdrop-blur-md bg-white/40 border border-white/30 shadow-lg
hover:shadow-2xl transition-all duration-300 hover:-translate-y-2"
>
<img
:src="hiburan.img"
:alt="hiburan.name"
class="w-20 h-20 object-contain group-hover:scale-110 transition-transform duration-300"
/>
<p class="mt-3 text-emerald-700 font-semibold group-hover:text-emerald-500 transition-colors duration-300">
{{ hiburan.name }}
</p>
</div>
</div> -->
<!-- <div class="w-[90%] max-w-5xl grid grid-cols-2 sm:grid-cols-3
lg:grid-cols-4 gap-6 mt-10 px-4 z-20 pb-[40px] mx-auto"> -->
<div class="w-[90%] max-w-md grid grid-cols-2 gap-6 mt-10 px-2 z-20 pb-[40px]"
>
<div
v-for="hiburan in hiburans"
:key="hiburan.name"
@click="toMangas(hiburan.name)"
class="group cursor-pointer relative flex flex-col items-center justify-center p-4 rounded-2xl
backdrop-blur-xl bg-white/30 border border-white/40 shadow-lg
hover:shadow-[0_0_25px_rgba(34,197,94,0.4)] hover:bg-white/50
transform-gpu transition-all duration-300 hover:-translate-y-2 hover:scale-105"
@mousemove="onMouseMove($event, hiburan.name)"
@mouseleave="resetTilt(hiburan.name)"
:style="{ transform: tiltTransforms[hiburan.name] }"
>
<div v-if="hiburan.badge"
class="absolute top-2 right-2 bg-emerald-500 text-white text-[10px] font-bold px-2 py-1 rounded-full shadow">
{{ hiburan.badge }}
</div>
<div class="relative w-24 h-24 flex items-center justify-center">
<img
:src="hiburan.img"
:alt="hiburan.name"
class="w-full h-full object-contain drop-shadow-lg group-hover:scale-110 transition-transform duration-500"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-emerald-400/20 to-transparent rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"
></div>
</div>
<p class="mt-3 text-emerald-800 font-bold tracking-wide group-hover:text-emerald-600 transition-colors duration-300">
{{ hiburan.name }}
</p>
</div>
</div>
</div>
</template>
<script>
import router from '@/router';
import { getContent } from '@/services/content';
import { getGenre } from '@/services/genre';
export default {
name: "Entertain-ment",
data() {
return {
genres : [],
hiburans: [
{ name: "Manga", img: "/images/manga.png" , badge:"Popular"},
{ name: "Mini Game", img: "/images/char/fox.png",badge:"Popular" },
{ name: "Videos", img: "/images/char/bear.png",badge:"Popular" },
{ name: "Cerita Rakyat", img: "/images/char/fox.png",badge:"Popular" },
],
contents : getContent(),
tiltTransforms: {}
};
},
methods: {
toMangas(title) {
if(title == 'Manga'){
router.push('/entertainment/mangas')
}
},
async loadGenres() {
const res = await getGenre();
this.genres = res.results;
console.log(res);
},
onMouseMove(event, name) {
const card = event.currentTarget;
const rect = card.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const rotateY = ((x / rect.width) - 0.5) * 15;
const rotateX = ((y / rect.height) - 0.5) * -15;
// this.$set(this.tiltTransforms, name, `rotateY(${rotateY}deg) rotateX(${rotateX}deg)`);
this.tiltTransforms = {
...this.tiltTransforms,
[name]: `rotateY(${rotateY}deg) rotateX(${rotateX}deg)`
};
},
resetTilt(name) {
// this.$set(this.tiltTransforms, name, `rotateY(0deg) rotateX(0deg)`);
this.tiltTransforms = {
...this.tiltTransforms,
[name]: `rotateY(0deg) rotateX(0deg)`
};
},
},
mounted() {
this.loadGenres();
}
};
</script>
<style scoped>
@keyframes pulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.05); }
}
/* Tambahan efek glow lembut di hover */
.group:hover img {
filter: drop-shadow(0 0 10px rgba(72, 187, 120, 0.5));
}
.group {
perspective: 1000px;
}
</style>
<script setup>
</script>

View File

@ -1,13 +1,14 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-green-100 to-green-300 pb-28 pr-2 pl-2 mb-[-10px]"
style="background-image:url('/images/footer.png');
background-repeat:no-repeat;
background-position:bottom;
background-size: 100% 200px;"
margin-bottom=-10px;
<div class="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b
from-green-100 to-green-300 pb-28 pr-2 pl-2 mb-[-10px]"
style="background-image:url('/images/footer.png');
background-repeat:no-repeat;
background-position:bottom;
background-size: 100% 200px;"
margin-bottom=-10px;
>
<!-- Character Preview -->
<div class="w-full max-w-md flex flex-col items-center z-60 " >
<div class="w-full max-w-md flex flex-col items-center z-60 mb-8">
<div class="relative flex items-end justify-center z-30 px-[10px] mt-[40px]">
<img
src="/images/logo.png"
@ -15,24 +16,6 @@
class="object-contain relative z-30 pb-0"
/>
</div>
<div class="relative w-40 h-50 flex items-end justify-center z-30 px-[10px]">
<img
:src="selectedProfile.img"
:alt="selectedCharacter.name"
class="w-40 h-60 object-contain relative z-30 pb-12"
/>
<div
class="absolute w-[30px] h-[30px] right-0 top-[100px] z-40 bg-green-600 hover:bg-green-400 transition"
style="
-webkit-mask: url('/images/icon-edit.svg') no-repeat center;
-webkit-mask-size: contain;
mask: url('/images/icon-edit.svg') no-repeat center;
mask-size: contain;
"
@click="changeAva('profile')"
></div>
</div>
</div>
<div class="w-full max-w-md flex flex-col items-center z-60">
<div class="bg-lime-200 shadow-xl rounded-2xl p-6 flex flex-col items-center
@ -60,7 +43,7 @@
</div>
</div>
</div>
<div class="flex items-center justify-center w-[30px] h-[30px] rounded-full border-4 border-green-500 absolute top-2 right-2">
<div class="flex items-center justify-center w-[30px] h-[30px] rounded-full border-4 border-green-500 hover:bg-green-400 absolute top-2 right-2">
<img src="/images/but-nav-koleksi.png" alt="img" class="w-[20px] h-[20px] "
@click="changeAva('character')">
</div>
@ -75,7 +58,7 @@
</div>
<!-- Character Selection -->
<div class="w-full max-w-md flex flex-col items-center bg-green-grey py-[0px] mx-[10px] md:mx-0
<!-- <div class="w-full max-w-md flex flex-col items-center bg-green-grey py-[0px] mx-[10px] md:mx-0
transform transition-transform duration-300 hover:scale-105
">
<div class="rounded-3xl p-6 shadow-2xl-soft flex items-start gap-4 w-full py-[10px] my-[20px]"
@ -107,27 +90,39 @@
</div>
</div>
</div>
</div>
<div class="w-full max-w-md flex flex-col items-center bg-white-grey py-[0px] mx-2.5 md:mx-5
transform transition-transform duration-300 hover:scale-105
">
<div class="bg-white rounded-3xl p-6 shadow-2xl-soft flex items-start gap-4 w-full py-[10px] my-[20px]">
</div> -->
<div
v-for="mission in missions"
:key="mission.id"
class="w-full max-w-md flex flex-col items-center bg-white-grey py-[0px] mx-2.5 md:mx-5
transform transition-transform duration-300 hover:scale-105"
>
<!-- <div class="bg-white rounded-3xl p-6 shadow-2xl-soft flex items-start gap-4 w-full py-[10px] my-[20px]">
<div class="flex-shrink-0 mt-1">
<img src="/images/qicon2.png" alt="Quest Icon" class="w-12 h-12" />
</div>
<div class="flex-1">
<h2 class="text-xl font-bold text-gray-800">{{ 'MAKAN SIANG' }}</h2>
<p class="text-sm text-gray-600 mb-2">{{ 'Waktunya makan siang dan istirahat.' }}</p>
<h2 class="text-xl font-bold text-gray-800">{{ mission.name }}</h2>
<p class="text-sm text-gray-600 mb-2">{{ mission.description }}</p>
<p class="text-xs font-semibold"
:class="{
'text-green-600': mission.userStatus === 'completed',
'text-yellow-600': mission.userStatus === 'in_progress',
'text-gray-500': mission.userStatus === 'not_started'
}">
Status: {{ mission.userStatus }}
</p>
<div class="flex items-center gap-4 text-xs text-gray-700">
<span class="flex items-center gap-1">
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zM9 11V5a1 1 0 012 0v6a1 1 0 01-2 0zM10 13a1 1 0 100 2 1 1 0 000-2z"/></svg>
{{ '12:20' }}
</span>
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zM9 11V5a1 1 0 012 0v6a1 1 0 01-2 0zM10 13a1 1 0 100 2 1 1 0 000-2z"/>
</svg>
{{ timeLeft(mission.date_valid, mission.time_to_valid) }}
</span>
<span class="flex items-center gap-1">
<svg class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20"><path d="M10 15a5 5 0 100-10 5 5 0 000 10z"/><path fill-rule="evenodd" d="M10 0a10 10 0 100 20 10 10 0 000-20zm0 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg>
+{{ '1000' }} koin
+{{ mission.coin }} koin
</span>
<span class="flex items-center gap-1">
<svg class="w-4 h-4 text-green-600 transform rotate-180" fill="currentColor" viewBox="0 0 20 20"><path d="M10 14a1 1 0 01-.707-.293l-4-4a1 1 0 011.414-1.414L10 11.586l3.293-3.293a1 1 0 011.414 1.414l-4 4A1 1 0 0110 14z"/></svg>
@ -135,12 +130,112 @@
</span>
</div>
<div class="flex items-center gap-4 text-xs text-gray-700">
<button class="w-full mt-4 py-2 px-4 rounded-lg bg-red-600 text-white font-bold hover:bg-green-700 transition">MULAI QUEST</button>
<button v-if="!mission.userStatus ==='completed'" class="w-full mt-4 py-2 px-4 rounded-lg bg-red-600 text-white font-bold
hover:bg-green-700 transition"
@click="handleMissionClick(mission)"
>
{{ mission.userStatus === 'completed' ? 'SELESAI' :
mission.userStatus === 'in_progress' ? 'LANJUTKAN' :
'MULAI QUEST' }}
</button>
<span v-else >Selesai</span>
</div>
</div>
</div> -->
<div
class="relative rounded-3xl p-6 shadow-2xl-soft flex items-start gap-4 w-full py-[10px] my-[20px] overflow-hidden"
:class="{
'bg-green-100 pointer-events-none opacity-95': mission.userStatus === 'completed',
'bg-yellow-100': mission.userStatus === 'in_progress',
'bg-white hover:scale-105': mission.userStatus === 'not_started'
}"
>
<!-- Stamp layer BEHIND content -->
<div
v-if="mission.userStatus === 'completed'"
class="absolute inset-0 flex justify-end items-center pr-6 z-0"
>
<span
class="text-[64px] font-extrabold text-green-600/15 rotate-[-20deg] select-none pointer-events-none"
style="mix-blend-mode: multiply;"
>
SELESAI
</span>
</div>
<!-- Content layer ABOVE stamp -->
<div class="flex-shrink-0 mt-1 relative z-10">
<img src="/images/qicon2.png" alt="Quest Icon" class="w-12 h-12" />
</div>
<div class="flex-1 relative z-10">
<h2 class="text-xl font-bold text-gray-800">{{ mission.name }}</h2>
<p class="text-sm text-gray-600 mb-2">{{ mission.description }}</p>
<p
class="text-xs font-semibold"
:class="{
'text-green-600': mission.userStatus === 'completed',
'text-yellow-600': mission.userStatus === 'in_progress',
'text-gray-500': mission.userStatus === 'not_started'
}"
>
Status: {{ mission.userStatus }}
</p>
<div class="flex items-center gap-4 text-xs text-gray-700">
<span class="flex items-center gap-1">
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path
d="M10 2a8 8 0 100 16 8 8 0 000-16zM9 11V5a1 1 0 012 0v6a1 1 0 01-2 0zM10 13a1 1 0 100 2 1 1 0 000-2z"
/>
</svg>
{{ timeLeft(mission.date_valid, mission.time_to_valid) }}
</span>
<span class="flex items-center gap-1">
<svg class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 15a5 5 0 100-10 5 5 0 000 10z" />
<path
fill-rule="evenodd"
d="M10 0a10 10 0 100 20 10 10 0 000-20zm0 18a8 8 0 100-16 8 8 0 000 16z"
clip-rule="evenodd"
/>
</svg>
+{{ mission.coin }} koin
</span>
<span class="flex items-center gap-1">
<svg
class="w-4 h-4 text-green-600 transform rotate-180"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M10 14a1 1 0 01-.707-.293l-4-4a1 1 0 011.414-1.414L10 11.586l3.293-3.293a1 1 0 011.414 1.414l-4 4A1 1 0 0110 14z"
/>
</svg>
+100mm
</span>
</div>
<!-- Button or empty block -->
<div class="flex items-center gap-4 text-xs text-gray-700">
<button
v-if="mission.userStatus !== 'completed'"
class="w-full mt-4 py-2 px-4 rounded-lg bg-red-600 text-white font-bold hover:bg-green-700 transition"
@click="handleMissionClick(mission)"
>
{{ mission.userStatus === 'in_progress' ? 'LANJUTKAN' : 'MULAI QUEST' }}
</button>
<div v-else class="w-full mt-4 py-4 px-4"></div>
</div>
</div>
</div>
</div>
<div class="w-full max-w-md flex flex-col items-center bg-green-grey py-[0px] mx-2.5 md:mx-5
<!-- <div class="w-full max-w-md flex flex-col items-center bg-green-grey py-[0px] mx-2.5 md:mx-5
transform transition-transform duration-300 hover:scale-105
">
<div class="bg-white rounded-3xl p-6 shadow-2xl-soft flex items-start gap-4 w-full py-[10px] my-[20px]">
@ -171,7 +266,7 @@
</div>
</div>
</div>
</div>
</div> -->
<!-- Modal -->
<div
v-if="showModal"
@ -199,6 +294,23 @@
<img :src="prof.img" :alt="prof.name" class="w-16 h-16 rounded"/>
<span class="text-sm mt-1">{{ prof.name }}</span>
</div>
<!-- Full-width logout area -->
<div class="col-span-3 flex justify-center mt-6">
<button
@click="handleLogout"
class="flex items-center gap-2 bg-gradient-to-r from-red-500 to-rose-600
hover:from-red-600 hover:to-rose-700 text-white font-semibold py-2 px-6
rounded-full shadow-md hover:shadow-lg transform transition-all duration-200
hover:scale-105 active:scale-95 focus:outline-none focus:ring-2 focus:ring-red-400"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h6a2 2 0 012 2v1" />
</svg>
<span>Logout</span>
</button>
</div>
</div>
<div class="grid grid-cols-3 gap-4" v-if="modalType === 'character'">
<div
@ -218,7 +330,8 @@
</template>
<script>
import { getContent } from '@/services/content';
import { getMissions,getMissionLogs, createMissionLog } from '@/services/missions';
export default {
name: "App",
data() {
@ -245,6 +358,12 @@ export default {
selectedProfile : {name : "profile", img: "/images/propic-001.svg"},
modalType : "profile",
isDone : true,
contents : getContent(),
missions : [],
now : new Date(),
timer: null,
misi:[],
logs:[]
};
},
methods: {
@ -259,7 +378,142 @@ export default {
profileListSelected(profile) {
this.selectedProfile = profile;
this.showModal = false;
},
async getMission() {
const res = await getMissions();
this.missions = res.results;
console.log(this.missions);
},
async getLogs(){
const resLog = await getMissionLogs();
this.logs = resLog.results;
},
timeLeft2(dateValid, timeToValid) {
if (!dateValid || !timeToValid) return "—";
try {
const now = new Date(this.now);
const target = new Date(`${dateValid}T${timeToValid}`);
const diffMs = target - now;
if (diffMs <= 0) return "Waktu habis";
const totalMinutes = Math.floor(diffMs / (1000 * 60));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours} jam ${minutes} menit`;
} catch (e) {
return "—";
}
},
timeLeft(dateValid, timeToValid) {
if (!dateValid || !timeToValid) return "—";
const now = new Date(this.now);
const target = new Date(`${dateValid}T${timeToValid}`);
const diffMs = target - now;
if (diffMs <= 0) return "Waktu habis";
const totalSeconds = Math.floor(diffMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (num) => String(num).padStart(2, '0');
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
},
downtimeHours(from, to) {
if (!from || !to) return '-';
const [fh, fm] = from.split(':').map(Number);
const [th, tm] = to.split(':').map(Number);
const start = fh * 60 + fm;
const end = th * 60 + tm;
const diff = end - start;
const hours = (diff / 60).toFixed(2);
return hours;
},
async getMissions() {
const [resMissions, resLogs] = await Promise.all([
getMissions(),
getMissionLogs()
]);
const logs = resLogs.results || [];
this.misi = resMissions.results.map(mission => {
const log = logs.find(l => l.mission === mission.id) || {};
return {
...mission,
user_id: log.user_id || null,
userStatus: log.status || 'not_started', // status default
claimed_at: log.claimed_at || null,
completed_at: log.completed_at || null,
coin_earned: log.coin || mission.coin,
point_earned: log.point || mission.point,
image_log: log.image_log || null,
};
});
this.missions = this.misi
},
handleMissionClick(mission) {
//convert to array task
const missionWithTask = {
...mission,
tasks:[
{id: mission.id,name:mission.name, type: mission.task, completed:false}
]
}
if (mission.userStatus === 'completed') return;
if (mission.userStatus === 'not_started') {
this.startMission(mission);
}
if (mission.task === 'scan-qr') {
console.log(mission)
this.$router.push({
name: 'quest-missions',
params: { id: mission.id } ,
query: { mission: JSON.stringify(missionWithTask) }
});
} else {
this.$router.push({
name: 'quest-missions',
params: { id: mission.id } ,
query: { mission: JSON.stringify(missionWithTask) }
});
}
},
async startMission(mission) {
await createMissionLog({
mission: mission.id,
user_id: this.currentUser.id,
status: 'in_progress',
coin: mission.coin,
point: mission.point
});
mission.userStatus = 'in_progress';
}
},
mounted() {
this.getMission();
this.getMissions();
this.timer = setInterval(() => {
this.now = Date.now();
}, 1000);
},
beforeUnmount() {
clearInterval(this.timer);
},
};
</script>
<script setup>
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
const router = useRouter()
const auth = useAuthStore()
const handleLogout = () => {
auth.logout()
router.push('/login')
}
</script>

View File

@ -1,50 +1,71 @@
<template>
<div class="flex flex-col items-center justify-center bg-gradient-to-b from-green-100 to-green-300">
<div class="w-full max-w-md flex flex-col items-center z-60 mt-10">
<div class="relative flex items-end justify-center z-30">
<img
src="/images/logo.png"
:alt="selectedCharacter.name"
class="object-contain relative z-30 pb-0"
<div class="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-green-100 to-green-300 p-4
mb-[-100px]"
style="background-image:url('/images/footer.png');
background-repeat:no-repeat;
background-position:bottom;
background-size: 100% 200px;"
margin-bottom=-10px;
>
<!-- Container utama -->
<div class="w-full max-w-sm sm:max-w-md bg-white rounded-3xl shadow-lg p-6 sm:p-8 flex flex-col items-center">
<!-- Logo -->
<div class="flex flex-col items-center">
<img src="/images/logo.png" alt="Logo" class="w-24 h-auto mb-4" />
</div>
<!-- Avatar/Profile -->
<div class="relative w-32 h-32 flex items-center justify-center mb-6">
<img
:src="selectedProfile.img"
:alt="selectedCharacter.name"
class="w-32 h-32 object-contain rounded-full border-4 border-lime-400 shadow-md"
/>
</div>
<!-- Form -->
<div class="w-full">
<div class="mb-4">
<input
v-model="username"
placeholder="Username"
type="text"
class="w-full h-12 px-4 py-2 text-gray-700 bg-gray-50 border border-gray-300 rounded-2xl
focus:outline-none focus:ring-2 focus:ring-lime-400 focus:border-lime-400"
/>
</div>
<div class="relative w-40 h-50 flex items-end justify-center z-30 px-[10px]">
<img
:src="selectedProfile.img"
:alt="selectedCharacter.name"
class="w-40 h-60 object-contain relative z-30"
<div class="mb-6">
<input
v-model="password"
type="password"
placeholder="Password"
class="w-full h-12 px-4 py-2 text-gray-700 bg-gray-50 border border-gray-300 rounded-2xl
focus:outline-none focus:ring-2 focus:ring-lime-400 focus:border-lime-400"
/>
</div>
</div>
<!-- Character Selection -->
<div class="w-full flex flex-col items-center z-60">
<div class="w-full p-4">
<div class="mb-4">
<input type="text" placeholder="email"
class="w-full h-[50px] px-4 py-2 text-grey-700 bg-white border border-grey-300 rounded-2xl
focus:outline-none focus:ring focus:ring-grey-100 focus:border-grey-300"
/>
</div>
<div class="mb-4">
<input type="text" placeholder="Password"
class="w-full h-[50px] px-4 py-2 text-grey-700 bg-white border border-grey-300 rounded-2xl focus:outline-none focus:ring focus:ring-grey-100 focus:border-grey-300"
/>
</div>
<button class="w-full mt-6 h-[60px] bg-lime-500 border border-transparent rounded-lg px-4 py-2 text-sm font-medium text-white hover:bg-lime-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-lime-500"> R E G I S T E R </button>
<div class="w-full flex items-center justify-center mt-2">
<router-link to="/register">
<span class="cursor-pointer justify-center text-center text-sm mt-2 text-blue-600 font-bold">R E G I S T E R</span>
</router-link>
</div>
<button
@click="login"
class="w-full h-12 bg-lime-500 text-white font-semibold rounded-2xl
hover:bg-lime-400 transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-lime-500"
>
LOGIN
</button>
<div class="w-full flex items-center justify-center mt-4">
<router-link to="/register" class="text-blue-600 font-semibold hover:underline">
REGISTER
</router-link>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "App",
name: "LoginPage",
data() {
return {
characters: [
@ -58,16 +79,12 @@ export default {
{ name: "Sheep", img: "../images/char/sheep.png" },
{ name: "Tiger", img: "../images/char/tiger.png" },
],
profiles : [
{name : "profile", img: "/images/propic-001.svg"},
{name : "profile", img: "/images/propic-blank.svg"},
profiles: [
{ name: "profile", img: "/images/propic-001.svg" },
{ name: "profile", img: "/images/propic-blank.svg" },
],
selectedCharacter: { name: "Tiger", img: "../images/char/tiger.png" },
showModal:false,
selectedProfile : {name : "profile", img: "/images/propic-001.svg"},
modalType : "profile",
selectedProfile: { name: "profile", img: "/images/propic-001.svg" },
};
},
methods: {
@ -79,10 +96,39 @@ export default {
this.modalType = type;
this.showModal = true;
},
profileListSelected(profile) {
profileListSelected(profile) {
this.selectedProfile = profile;
this.showModal = false;
}
},
},
};
</script>
<script setup>
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
import { ref } from 'vue'
const router = useRouter()
const auth = useAuthStore()
const username = ref('')
const password = ref('')
const error = ref('')
async function login() {
try {
await
await auth.login(username.value, password.value)
router.push('/')
} catch (err) {
error.value = err.message
}
}
</script>
<style scoped>
/* Tambahan kecil untuk tampilan */
body {
font-family: 'Inter', sans-serif;
}
</style>

102
src/pages/Mangas.vue Normal file
View File

@ -0,0 +1,102 @@
<template>
<div
class="min-h-screen flex flex-col bg-gradient-to-b from-emerald-100 via-lime-50 to-white
text-gray-800 relative overflow-hidden mb-[-10px]"
>
<!-- Header Section -->
<div class="w-full px-6 py-4 flex items-center justify-between backdrop-blur-md bg-white/30 border-b border-emerald-100 z-20">
<!-- Back Button -->
<button
@click="goBack"
class="flex items-center gap-2 text-emerald-600 hover:text-emerald-800 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span class="font-semibold">Kembali</span>
</button>
<!-- Breadcrumb / Path -->
<div class="text-sm text-gray-600">
<span class="text-emerald-600 font-medium cursor-pointer hover:underline" @click="goHome">Beranda</span>
<span class="mx-2"></span>
<span class="text-gray-700 font-semibold">Manga</span>
</div>
</div>
<!-- Title -->
<div class="text-center mt-8">
<h1 class="text-4xl font-extrabold bg-gradient-to-r from-emerald-500 to-lime-500 bg-clip-text text-transparent">
Daftar Manga
</h1>
<p class="text-gray-500 mt-2">Temukan bacaan favoritmu di sini 📖</p>
</div>
<!-- Manga Grid -->
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6 mt-10 px-6 pb-20">
<div
v-for="manga in mangas"
:key="manga.title"
@click="toMangaDetaial(manga.title)"
class="group cursor-pointer rounded-2xl bg-white/60 backdrop-blur-lg p-4 shadow-md border border-white/40
hover:shadow-2xl hover:-translate-y-2 transition-all duration-300 relative overflow-hidden"
>
<!-- Manga Cover -->
<div class="relative">
<img
:src="manga.cover"
:alt="manga.title"
class="w-full h-48 object-cover rounded-xl group-hover:scale-105 transition-transform duration-500"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent rounded-xl opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
<!-- Manga Info -->
<div class="mt-3 text-center">
<h3 class="font-semibold text-lg text-gray-800 truncate">{{ manga.title }}</h3>
<p class="text-sm text-gray-500">{{ manga.genre }}</p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "MangaList",
data() {
return {
mangas: [
{ title: "Naruto", genre: "Action", cover: "/images/manga/naruto.png" },
{ title: "One Piece", genre: "Adventure", cover: "/images/manga/onepiece.png" },
{ title: "Demon Slayer", genre: "Fantasy", cover: "/images/manga/demonslayer.png" },
{ title: "Attack on Titan", genre: "Drama", cover: "/images/manga/attackot.png" },
{ title: "Full Metal Alchemist", genre: "Action", cover: "/images/manga/fullmetal.png" },
{ title: "Jujutsu Kaisen", genre: "Supernatural", cover: "/images/manga/jujutsu.png" },
],
};
},
methods: {
goBack() {
window.history.back('/entertainment/mangas');
},
goHome() {
this.$router.push("/");
},
toMangaDetaial(title){
const check = this.mangas.filter((manga) => manga.title == title)
if(check[0].title == 'One Piece'){
return this.$router.push(`/entertainment/manga/${check[0].id ?? 1}/chapters`)
}
}
},
};
</script>
<style scoped>
/* Tambahan animasi halus di hover */
.group:hover img {
filter: drop-shadow(0 0 10px rgba(72, 187, 120, 0.3));
}
</style>

304
src/pages/MissionPage.vue Normal file
View File

@ -0,0 +1,304 @@
<template>
<div
class="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-teal-100 to-lime-200 p-6"
>
<div
class="max-w-lg w-full bg-white/80 rounded-3xl shadow-lg p-6 backdrop-blur-lg"
>
<!-- Judul -->
<h2 class="text-2xl font-bold text-emerald-700 mb-2">
{{ mission.name }}
</h2>
<p class="text-gray-700 mb-4">{{ mission.description }}</p>
<!-- Daftar Task -->
<div
v-for="task in mission.tasks"
:key="task.id"
class="flex flex-col gap-3 mb-4 border-b pb-4"
>
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700">
{{ task.type === "scan-qr" ? "Scan QR Code" : "Upload Gambar" }}
</span>
<button
v-if="!task.completed"
@click="handleTask(task)"
class="px-3 py-1 bg-emerald-500 text-white text-xs rounded-lg hover:bg-emerald-600"
>
Selesaikan
</button>
<span v-else class="text-emerald-600 font-semibold"> Selesai</span>
</div>
<!-- SCAN QR -->
<div
v-if="activeTask && activeTask.id === task.id && task.type === 'scan-qr'"
class="border rounded-lg overflow-hidden"
>
<!-- Kamera tersedia -->
<div v-if="!cameraUnavailable">
<QrcodeStream
:constraints="constraints"
@decode="onScanSuccess"
@detect="onDetect"
@init="onInit"
/>
<p class="text-xs text-gray-500 text-center mt-2">
Arahkan kamera ke QR Code...
</p>
<button
@click="manualMode = !manualMode"
class="text-xs mt-2 px-2 py-1 bg-yellow-400 text-white rounded hover:bg-yellow-500"
>
{{ manualMode ? 'Kembali ke Kamera' : 'Gunakan Mode Manual (Upload QR)' }}
</button>
</div>
<!-- Fallback upload jika kamera tidak tersedia -->
<div v-else class="flex flex-col items-center gap-2 mt-2">
<p class="text-sm text-gray-600">📷 Kamera tidak tersedia. Unggah QR code secara manual:</p>
<input
type="file"
accept="image/*"
@change="onFileSelected"
class="text-sm border rounded p-2"
/>
<button
@click="uploadImage"
class="px-3 py-1 bg-blue-500 text-white text-xs rounded-lg hover:bg-blue-600"
>
Upload & Simpan
</button>
</div>
</div>
<!-- UPLOAD IMAGE -->
<div
v-if="activeTask && activeTask.id === task.id && task.type === 'upload-photo'"
class="flex flex-col gap-2"
>
<input
type="file"
accept="image/*"
@change="onFileSelected"
class="text-sm border rounded p-2"
/>
<button
@click="uploadImage"
class="px-3 py-1 bg-blue-500 text-white text-xs rounded-lg hover:bg-blue-600"
>
Upload & Simpan
</button>
</div>
</div>
<!-- Final Button -->
<button
v-if="isMissionComplete && mission.status !== 'claimed'"
@click="finishMission"
class="w-full mt-4 py-2 bg-yellow-500 text-white font-bold rounded-lg hover:bg-yellow-600 transition"
>
Tandai Sebagai Selesai
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue"
import { QrcodeStream } from "vue-qrcode-reader"
import api from "@/util/api"
import { useRoute } from "vue-router"
import { createMissionLog } from '@/services/missions';
import { useAuthStore } from '@/stores/auth'
const mission = ref({
id: 1,
name: "Cari Artefak Laut",
description: "Scan QR dan upload bukti penemuan artefak.",
status: "in_progress",
tasks: [
{ id: 1, type: "scan_qr", completed: false },
{ id: 2, type: "upload_image", completed: false },
],
})
const user_id = 42
const activeTask = ref(null)
const selectedFile = ref(null)
const cameraUnavailable = ref(false)
const constraints = ref({ facingMode: "environment" })
const scanning = ref(false)
const detectedOnce = ref(false)
const router = useRoute();
const authStore = useAuthStore()
const currentUser = computed(() => authStore.currentUser)
onMounted(async () => {
if(router.query.mission){
const data = router.query.mission
console.log(data)
mission.value = JSON.parse(data)
}
try {
const devices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = devices.filter((d) => d.kind === "videoinput")
if (videoDevices.length === 0) {
cameraUnavailable.value = true
} else {
constraints.value = {
facingMode: videoDevices.length > 1 ? "environment" : "user",
}
}
} catch (err) {
console.warn("Tidak bisa mendeteksi kamera:", err)
cameraUnavailable.value = true
}
})
async function onDetect(detectedCodes) {
if (!detectedCodes || detectedCodes.length === 0) return
if (detectedOnce.value) return
detectedOnce.value = true
const code = detectedCodes[0].rawValue
console.log("🎯 QR terdeteksi:", code)
try {
scanning.value = true
await api.post("task/tasks", {
mission: mission.value.id,
user_id,
type: "scan-qr",
data: code,
completed_at: new Date().toISOString(),
})
completeTask(activeTask.value)
activeTask.value = null
alert("QR berhasil disimpan ke server!")
} catch (error) {
console.error("Gagal menyimpan QR:", error)
alert("Gagal menyimpan data ke server.")
} finally {
scanning.value = false
}
}
const isMissionComplete = computed(() =>
mission.value.tasks?.every((t) => t.completed)
)
function handleTask(task) {
console.log(task)
activeTask.value = task
}
function onInit(promise) {
promise.catch((error) => {
console.error(error)
if (error.name === "NotAllowedError") {
alert("Akses kamera ditolak. Izinkan kamera di browser Anda.")
} else if (error.name === "NotFoundError") {
alert("Tidak ada kamera terdeteksi di perangkat ini.")
} else if (error.name === "NotReadableError") {
alert("Kamera sedang digunakan oleh aplikasi lain.")
} else {
alert("Gagal mengakses kamera: " + error.message)
}
cameraUnavailable.value = true
})
}
async function onScanSuccess(result) {
if (!result) return
console.log("QR ditemukan:", result)
try {
// await api.post("task/tasks", {
// mission: mission.value.id,
// user_id: user_id,
// type: "scan-qr",
// data: result,
// completed_at: new Date().toISOString(),
// })
this.startMission(data={
mission: mission.value.id,
user_id: user_id,
type: "scan-qr",
data: result,
coin: mission.value.coin,
point: mission.value.point,
completed_at: new Date().toISOString(),
claimed_at: new Date().toISOString(),
})
completeTask(activeTask.value)
activeTask.value = null
alert("QR berhasil disimpan ke server.")
} catch (error) {
console.error(error)
alert("Gagal menyimpan data ke server.")
}
}
function onFileSelected(e) {
selectedFile.value = e.target.files[0]
}
async function uploadImage() {
if (!selectedFile.value) return alert("Pilih gambar dulu!")
const formData = new FormData()
formData.append("mission", mission.value.id);
formData.append("user_id", user_id);
formData.append("status", "completed");
formData.append("coin", mission.value.coin);
formData.append("point", mission.value.point);
formData.append("image_log", selectedFile.value);
try {
await api.post("mission/mission-logs/", formData, {
headers: { "Content-Type": "multipart/form-data" },
})
completeTask(activeTask.value)
activeTask.value = null
selectedFile.value = null
alert("📸 Gambar berhasil diupload!")
} catch (error) {
console.error(error)
alert("Gagal upload gambar.")
}
}
function completeTask(task) {
task.completed = true
}
function finishMission() {
mission.value.status = "completed"
alert("🎉 Misi selesai! Semua task telah disimpan.")
}
async function startMission(mission) {
console.log("mission: ", mission)
await createMissionLog({
mission: mission.id,
user_id: currentUser.id,
status: 'in_progress',
coin: mission.coin,
point: mission.point
});
mission.userStatus = 'in_progress';
}
</script>
<style scoped>
video {
width: 100%;
height: auto;
border-radius: 0.75rem;
}
</style>

View File

@ -1,65 +1,76 @@
<template>
<div class="flex flex-col items-center justify-center bg-gradient-to-b from-green-100 to-green-300 pb-28 pr-4 pl-4">
<div class="max-w-md flex flex-col items-center z-60" >
<div class="relative flex items-end justify-center z-30 px-[10px] mt-[10px]">
<img
src="/images/logo.png"
:alt="selectedCharacter.name"
class="object-contain relative z-30 pb-0"
/>
</div>
<h2 class="mt-6 text-xl font-bold text-gray-700">
{{ selectedCharacter.name }}
</h2>
<div class="relative w-40 h-50 flex items-end justify-center z-30 px-[10px]">
<img
:src="selectedProfile.img"
:alt="selectedCharacter.name"
class="w-40 h-60 object-contain relative z-30 pb-12"
/>
</div>
</div>
<!-- Character Selection -->
<div class="w-full flex flex-col items-center z-60" >
<div class="w-full ">
<div class="mb-4">
<input type="text" placeholder="Real Name"
class="w-full h-[50px] px-4 py-2 text-grey-700 bg-white border border-grey-300 rounded-2xl
focus:outline-none focus:ring focus:ring-grey-100 focus:border-grey-300 shadow-lg"
/>
</div>
<div class="mb-4">
<input type="text" placeholder="Email"
class="w-full h-[50px] px-4 py-2 text-grey-700 bg-white border border-grey-300 rounded-2xl
focus:outline-none focus:ring focus:ring-grey-100 focus:border-grey-300 shadow-lg"
/>
</div>
<div class="mb-4">
<input type="text" placeholder="Phone"
class="w-full h-[50px] px-4 py-2 text-grey-700 bg-white border border-grey-300 rounded-2xl
focus:outline-none focus:ring focus:ring-grey-100 focus:border-grey-300 shadow-lg"
/>
</div>
<div >
<input type="text" placeholder="Password"
class="w-full h-[50px] px-4 py-2 text-grey-700 bg-white border border-grey-300 rounded-2xl
focus:outline-none focus:ring focus:ring-grey-100 focus:border-grey-300 shadow-lg"
/>
</div>
<button class="w-full mt-6 h-[60px] bg-lime-500 border border-transparent rounded-lg px-4 py-2 text-sm font-medium text-white hover:bg-lime-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-lime-500"> R E G I S T E R </button>
<div class="flex items-center justify-center mt-4">
<router-link to ="/login">
<span class="font-bold text-sm text-blue-600">L O G I N</span>
</router-link>
</div>
</div>
<div class="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-green-100 to-green-300 p-4
mb-[-100px]"
style="background-image:url('/images/footer.png');
background-repeat:no-repeat;
background-position:bottom;
background-size: 100% 200px;"
margin-bottom=-10px;
>
<!-- CARD / CONTAINER -->
<div class="w-full max-w-sm sm:max-w-md bg-white rounded-3xl shadow-xl p-6 sm:p-8 flex flex-col items-center">
<!-- LOGO -->
<div class="flex flex-col items-center mb-4">
<img src="/images/logo.png" alt="Logo" class="w-24 h-auto mb-2" />
<h2 class="text-xl font-bold text-gray-700">{{ selectedCharacter.name }}</h2>
</div>
<!-- PROFILE AVATAR -->
<div class="relative w-32 h-32 flex items-center justify-center mb-6">
<img
:src="selectedProfile.img"
:alt="selectedCharacter.name"
class="w-32 h-32 object-contain rounded-full border-4 border-lime-400 shadow-md"
/>
</div>
<!-- INPUT FIELDS -->
<div class="w-full space-y-4">
<input
v-model="username"
type="text"
placeholder="Phone"
class="w-full h-12 px-4 text-gray-700 bg-gray-50 border border-gray-300 rounded-2xl
focus:outline-none focus:ring-2 focus:ring-lime-400 focus:border-lime-400 shadow-sm"
/>
<input
v-model="password"
type="password"
placeholder="Password"
class="w-full h-12 px-4 text-gray-700 bg-gray-50 border border-gray-300 rounded-2xl
focus:outline-none focus:ring-2 focus:ring-lime-400 focus:border-lime-400 shadow-sm"
/>
<input
v-model="re_password"
type="password"
placeholder="Re-password"
class="w-full h-12 px-4 text-gray-700 bg-gray-50 border border-gray-300 rounded-2xl
focus:outline-none focus:ring-2 focus:ring-lime-400 focus:border-lime-400 shadow-sm"
/>
<!-- REGISTER BUTTON -->
<button
@click="register"
class="w-full h-12 bg-lime-500 text-white font-semibold rounded-2xl
hover:bg-lime-400 transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-lime-500"
>
REGISTER
</button>
<!-- LOGIN LINK -->
<div class="flex items-center justify-center mt-2">
<router-link to="/login">
<span class="font-bold text-sm text-blue-600 hover:underline">L O G I N</span>
</router-link>
</div>
<!-- ERROR MESSAGE -->
<p v-if="error" class="text-red-500 text-sm mt-2 text-center">{{ error }}</p>
</div>
</div>
</div>
</template>
<script>
export default {
name: "App",
name: "RegisterPage",
data() {
return {
characters: [
@ -73,16 +84,14 @@ export default {
{ name: "Sheep", img: "../images/char/sheep.png" },
{ name: "Tiger", img: "../images/char/tiger.png" },
],
profiles : [
{name : "profile", img: "/images/propic-001.svg"},
{name : "profile", img: "/images/propic-blank.svg"},
profiles: [
{ name: "profile", img: "/images/propic-001.svg" },
{ name: "profile", img: "/images/propic-blank.svg" },
],
selectedCharacter: { name: "Tiger", img: "../images/char/tiger.png" },
showModal:false,
selectedProfile : {name : "profile", img: "/images/propic-001.svg"},
modalType : "profile",
selectedProfile: { name: "profile", img: "/images/propic-001.svg" },
showModal: false,
modalType: "profile",
};
},
methods: {
@ -94,10 +103,39 @@ export default {
this.modalType = type;
this.showModal = true;
},
profileListSelected(profile) {
profileListSelected(profile) {
this.selectedProfile = profile;
this.showModal = false;
}
},
},
};
</script>
<script setup>
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
import { ref } from 'vue'
const router = useRouter()
const auth = useAuthStore()
const username = ref('')
const password = ref('')
const re_password = ref('')
const error = ref('')
async function register() {
try {
await auth.register(username.value, password.value, re_password.value)
router.push('/login')
} catch (err) {
console.error(err)
error.value = err.response?.data?.detail || err.message || 'Registration failed'
}
}
</script>
<style scoped>
body {
font-family: 'Inter', sans-serif;
}
</style>

111
src/pages/Synopsis.vue Normal file
View File

@ -0,0 +1,111 @@
<template>
<div class="w-full px-6 py-4 flex items-center justify-between backdrop-blur-md bg-white/30 border-b border-emerald-100 z-20">
<button
@click="goBack"
class="flex items-center gap-2 text-emerald-600 hover:text-emerald-800 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span class="font-semibold">Kembali</span>
</button>
<!-- Breadcrumb / Path -->
<div class="text-sm text-gray-600">
</div>
</div>
<section
class="relative w-full max-w-3xl mx-auto mt-10 p-6 rounded-3xl bg-white/60
backdrop-blur-lg border border-white/40 shadow-lg overflow-hidden"
>
<!-- Decorative gradient background glow -->
<div
class="absolute inset-0 bg-gradient-to-br from-emerald-100 via-lime-50 to-transparent
opacity-70 blur-2xl -z-10"
></div>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h2
class="text-2xl sm:text-3xl font-extrabold bg-gradient-to-r
from-emerald-500 to-lime-500 bg-clip-text text-transparent"
>
📖 Sinopsis
</h2>
<span
class="text-xs uppercase tracking-wide bg-emerald-100 text-emerald-700
font-semibold px-3 py-1 rounded-full"
>
Manga Detail
</span>
</div>
<!-- Synopsis Text / Empty State -->
<transition name="fade" mode="out-in">
<template v-if="synopsis">
<p
key="has-synopsis"
class="text-gray-700 leading-relaxed text-justify indent-6"
>
{{ synopsis }}
</p>
</template>
<template v-else>
<div
key="no-synopsis"
class="flex flex-col items-center justify-center py-8 text-gray-400 italic"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-10 h-10 mb-2 opacity-60"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8c-1.657 0-3 1.343-3 3 0 .85.355 1.622.93 2.172C8.355 13.622 8 14.394 8 15.244 8 17.82 10.24 20 12 20s4-2.18 4-4.756c0-.85-.355-1.622-.93-2.172.575-.55.93-1.322.93-2.172 0-1.657-1.343-3-3-3z"
/>
</svg>
<p>Sinopsis belum tersedia.</p>
</div>
</template>
</transition>
<!-- Gradient Border Glow -->
<div
class="absolute bottom-0 left-0 w-full h-1 bg-gradient-to-r
from-lime-400 via-emerald-300 to-cyan-300 animate-[pulse_3s_infinite]"
></div>
</section>
</template>
<script>
export default {
name: "MangaSynopsis",
props: {
synopsis: {
type: String,
required: false,
default: "",
},
},
methods: {
goBack() {
this.$router.go(-1);
},
},
};
</script>
<style scoped>
.fade-enter-active, .fade-leave-active {
transition: opacity 0.8s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
@keyframes pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
</style>

View File

@ -4,13 +4,32 @@ import ContentPage from '../pages/Home.vue'
import CharacterPage from '../pages/Character.vue'
import RegistePage from '../pages/Register.vue'
import loginPage from '../pages/Login.vue'
import Entertainemnt from '../pages/Entertainment.vue'
import { useAuthStore } from '@/stores/auth';
import MangasPage from '../pages/Mangas.vue'
import ChaptersPage from '@/pages/Chapters.vue'
import ChapterListPage from '@/pages/ChapterList.vue'
import SynopsisPage from '@/pages/Synopsis.vue'
import MissionPage from '@/pages/MissionPage.vue'
const routes = [
{ path: '/', name: 'home', component: HomePage },
{ path: '/content', name: 'content', component: ContentPage },
{ path: '/character', name: 'character', component: CharacterPage },
{path: '/register', name: 'register', component: RegistePage},
{path: '/login', name:'login', component:loginPage}
{ path: '/', name: 'home', component: HomePage , meta:{requiresAuth:true}},
{ path: '/content', name: 'content', component: ContentPage, meta:{requiresAuth:true}},
{ path: '/character', name: 'character', component: CharacterPage, meta:{requiresAuth:true}},
{ path: '/register', name: 'register', component: RegistePage,},
{ path: '/login', name:'login', component:loginPage},
{ path: '/entertainment', name:'entertainment', component:Entertainemnt},
{ path: '/entertainment/mangas', name:'mangas', component:MangasPage},
{ path: '/entertainment/manga/:id/chapters', name:'manga', component:ChaptersPage, props:true},
{ path: '/entertainment/manga/:id/chapters/synopsis', name:'manga-sinopsis', component:SynopsisPage,
props: route => ({
id: route.params.id,
synopsis: route.query.synopsis || ''
})
},
{ path: '/entertainment/manga/:manga_id/chapters/:chapter_id/', name:'manga-list', component:ChapterListPage, props:true},
{ path:'/mission/quest/:id/missions', name: 'quest-missions', component:MissionPage}
]
const router = createRouter({
@ -18,4 +37,22 @@ const router = createRouter({
routes,
})
router.beforeEach(async(to, from, next) => {
const auth = useAuthStore();
if (!auth.isLoggedIn) {
auth.restoreSession();
}
const isLoggedIn = auth.isLoggedIn;
console.log(isLoggedIn)
if(to.meta.requiresAuth && !isLoggedIn) {
return next('/login');
}
if (to.path === '/login' && isLoggedIn) {
return next('/')
}
return next();
});
export default router

13
src/services/content.js Normal file
View File

@ -0,0 +1,13 @@
import api from "@/util/api";
export const getContent = () =>{
return api.get('/content/contents')
}
export const getContentById =() =>{
return api.get('content/contents/$id')
}
export const createContent = (data) => {
return api.post('/content/contents',data)
}

5
src/services/genre.js Normal file
View File

@ -0,0 +1,5 @@
import api from "@/util/api";
export const getGenre = () => {
return api.get("/entertainment/genres").then((res) => res.data);
};

12
src/services/manga.js Normal file
View File

@ -0,0 +1,12 @@
import api from "@/util/api";
export const getManga = () => {
return api.get("/entertainment/manga");
};
export const getChapters = async(id) => {
return await api.get(`/entertainment/manga/${id}/chapters`).then((res) => res.data);
};
export const getMangaByChapter = async(manga_id, chapter_id) => {
return await api.get(`/entertainment/manga/${manga_id}/chapters/${chapter_id}/`).then((res) => res.data);
};

30
src/services/missions.js Normal file
View File

@ -0,0 +1,30 @@
import api from "@/util/api";
export const getMissions = async () => {
return await api.get("/mission/missions").then((res) => res.data);
};
export const getMissionLogs = async () => {
return await api.get("/mission/mission-logs/").then((res) => res.data);
}
export const createMissionLog = async (data = {}) => {
const timestp = new Date().toISOString();
console.log(data);
return await api.post('mission/mission-logs',{
mission: data.mission,
user_id: data.user_id,
status: 'completed',
coin: data.coin,
point: data.point,
image_log: data.image_log,
completed_at: timestp,
claimed_at: timestp,
});
}
export const updateMissionLog = async(missionId ,data = {}) =>{
return await api.patch(`missions/${missionId}/`, data, {
headers: { "Content-Type": "multipart/form-data" },
});
}

87
src/stores/auth.js Normal file
View File

@ -0,0 +1,87 @@
import {defineStore} from "pinia";
import axios from "axios";
export const useAuthStore = defineStore('auth', {
state: () => ({
baseUrl : process.env.VUE_APP_BASE_URL,
clientId : process.env.VUE_APP_CLIENT_ID,
clientSecret : process.env.VUE_APP_CLIENT_SECRET,
token: null,
refresh_token: null,
user: null
}),
getters: {
isLoggedIn: (state) => !!state.token && !!state.user,
},
actions: {
async register(username, password, re_password) {
const response =await axios.post(this.baseUrl + '/auth/users/', {
username: username,
password: password,
re_password: re_password
});
console.log("✅ Registration success:", response.data);
},
async login(username, password) {
// preparing data
const data = new URLSearchParams()
data.append('grant_type', 'password')
data.append('client_id', this.clientId)
data.append('client_secret', this.clientSecret)
data.append('username', username)
data.append('password', password)
const response = await axios.post(`${this.baseUrl}`+'/oauth/token/',data.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
}
})
const { access_token, refresh_token } = response.data;
const user = await axios.get(this.baseUrl + '/auth/users/me', {
headers: {
Authorization: `Bearer ${access_token}`
}
}).then(res => res.data)
this.refresh_token = refresh_token
this.token = access_token
this.user = user
console.log(JSON.stringify(user));
localStorage.setItem('token', this.token)
localStorage.setItem('user', JSON.stringify(user))
console.log("✅ Logged in as:", this.user.username);
},
async logout() {
// revoke token
if(this.token){
await axios.post(this.baseUrl + '/oauth/revoke_token/', {
token: this.token,
client_id: this.clientId,
client_secret: this.clientSecret
},
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
}else{
console.log("No token found");
}
this.token = null;
this.user = null;
localStorage.removeItem('token')
localStorage.removeItem('user')
},
restoreSession() {
const savedToken = localStorage.getItem('token')
const savedUser = localStorage.getItem('user')
if (savedToken && savedUser) {
this.token = savedToken
this.user = JSON.parse(savedUser)
}
}
}
})

50
src/util/api.js Normal file
View File

@ -0,0 +1,50 @@
import axios from "axios"
const api = axios.create({
baseURL: process.env.VUE_APP_BASE_URL,
clientId : process.env.VUE_APP_CLIENT_ID,
clientSecret : process.env.VUE_APP_CLIENT_SECRET,
headers:{
"Content-Type":"application/json"
}
})
api.interceptors.request.use(
config =>{
const token = localStorage.getItem('token')
console.log(token)
if(token){
config.headers.Authorization = `Bearer ${token}`
}
return config
}
)
api.interceptors.response.use(
response => response,
async error => {
if(error.response && error.response.status === 401){
const refresh_token = localStorage.getItem('refresh_token')
if(refresh_token){
try {
const res = await axios.post(`${this.baseURL}` + "/oauth/token/refresh/", {
refresh: refresh_token,
});
localStorage.setItem("access_token", res.data.access);
error.config.headers.Authorization = `Bearer ${res.data.access}`;
return api.request(error.config);
} catch (refreshError) {
// jika refresh juga gagal, logout
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location = "/login";
}
}
localStorage.removeItem('token')
window.location.href = '/'
}
return Promise.reject(error)
}
)
export default api