first commit
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
277
AGENTS.md
Normal file
277
AGENTS.md
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
|
User List
|
||||||
|
|
||||||
|
curl -X 'GET' \
|
||||||
|
'http://localhost:8080/api/v1/admin/users?page=1&limit=10' \
|
||||||
|
-H 'accept: application/json' \
|
||||||
|
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwidXNlcl9pZCI6IjEiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwidXNlcm5hbWUiOiJiZXloYW5vIiwiZXhwIjoxNzc2MjY0MTEzLCJpYXQiOjE3NzYyNjMyMTMsImp0aSI6Ijk5ODZmYjMzYWFhMzc5ZDRhODA3NjVhZTg1MjMwODcxIn0.wvYGM57tE9tV1MVGsjJ-nK8GXbzRXbPNbYKFHjN2_Ic'
|
||||||
|
|
||||||
|
Request URL
|
||||||
|
|
||||||
|
http://localhost:8080/api/v1/admin/users?page=1&limit=10
|
||||||
|
|
||||||
|
Server response
|
||||||
|
Code Details
|
||||||
|
200
|
||||||
|
Response body
|
||||||
|
Download
|
||||||
|
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"username": "beyhano",
|
||||||
|
"email": "beyhan@beyhan.dev",
|
||||||
|
"email_verified": false,
|
||||||
|
"is_active": true,
|
||||||
|
"is_admin": true,
|
||||||
|
"created_at": "2026-04-11T22:49:35+03:00",
|
||||||
|
"updated_at": "2026-04-11T23:15:42+03:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"page": 1,
|
||||||
|
"limit": 10,
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Response headers
|
||||||
|
|
||||||
|
access-control-allow-headers: Authorization,Content-Type
|
||||||
|
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
access-control-allow-origin: *
|
||||||
|
content-length: 246
|
||||||
|
content-type: application/json; charset=utf-8
|
||||||
|
date: Wed,15 Apr 2026 14:38:29 GMT
|
||||||
|
x-ratelimit-limit: 120
|
||||||
|
x-ratelimit-remaining: 112
|
||||||
|
x-ratelimit-reset: 40
|
||||||
|
|
||||||
|
|
||||||
|
Yeni Kullanıcı
|
||||||
|
|
||||||
|
curl -X 'POST' \
|
||||||
|
'http://localhost:8080/api/v1/admin/users' \
|
||||||
|
-H 'accept: application/json' \
|
||||||
|
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwidXNlcl9pZCI6IjEiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwidXNlcm5hbWUiOiJiZXloYW5vIiwiZXhwIjoxNzc2MjY0MTEzLCJpYXQiOjE3NzYyNjMyMTMsImp0aSI6Ijk5ODZmYjMzYWFhMzc5ZDRhODA3NjVhZTg1MjMwODcxIn0.wvYGM57tE9tV1MVGsjJ-nK8GXbzRXbPNbYKFHjN2_Ic' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"confirm_password": "123456",
|
||||||
|
"email": "asde@asd.com",
|
||||||
|
"is_active": true,
|
||||||
|
"is_admin": true,
|
||||||
|
"password": "123456",
|
||||||
|
"username": "1234"
|
||||||
|
}'
|
||||||
|
|
||||||
|
Request URL
|
||||||
|
|
||||||
|
http://localhost:8080/api/v1/admin/users
|
||||||
|
|
||||||
|
Server response
|
||||||
|
Code Details
|
||||||
|
201
|
||||||
|
Response body
|
||||||
|
Download
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"username": "1234",
|
||||||
|
"email": "asde@asd.com",
|
||||||
|
"email_verified": false,
|
||||||
|
"is_active": true,
|
||||||
|
"is_admin": true,
|
||||||
|
"created_at": "2026-04-15T17:39:39+03:00",
|
||||||
|
"updated_at": "2026-04-15T17:39:39+03:00"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response headers
|
||||||
|
|
||||||
|
access-control-allow-headers: Authorization,Content-Type
|
||||||
|
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
access-control-allow-origin: *
|
||||||
|
content-length: 187
|
||||||
|
content-type: application/json; charset=utf-8
|
||||||
|
date: Wed,15 Apr 2026 14:39:39 GMT
|
||||||
|
x-ratelimit-limit: 120
|
||||||
|
x-ratelimit-remaining: 119
|
||||||
|
x-ratelimit-reset: 59
|
||||||
|
|
||||||
|
|
||||||
|
User Detey
|
||||||
|
|
||||||
|
curl -X 'GET' \
|
||||||
|
'http://localhost:8080/api/v1/admin/users/2' \
|
||||||
|
-H 'accept: application/json' \
|
||||||
|
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwidXNlcl9pZCI6IjEiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwidXNlcm5hbWUiOiJiZXloYW5vIiwiZXhwIjoxNzc2MjY0MTEzLCJpYXQiOjE3NzYyNjMyMTMsImp0aSI6Ijk5ODZmYjMzYWFhMzc5ZDRhODA3NjVhZTg1MjMwODcxIn0.wvYGM57tE9tV1MVGsjJ-nK8GXbzRXbPNbYKFHjN2_Ic'
|
||||||
|
|
||||||
|
Request URL
|
||||||
|
|
||||||
|
http://localhost:8080/api/v1/admin/users/2
|
||||||
|
|
||||||
|
Server response
|
||||||
|
Code Details
|
||||||
|
200
|
||||||
|
Response body
|
||||||
|
Download
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"username": "1234",
|
||||||
|
"email": "asde@asd.com",
|
||||||
|
"email_verified": false,
|
||||||
|
"is_active": true,
|
||||||
|
"is_admin": true,
|
||||||
|
"created_at": "2026-04-15T17:39:39+03:00",
|
||||||
|
"updated_at": "2026-04-15T17:39:39+03:00"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response headers
|
||||||
|
|
||||||
|
access-control-allow-headers: Authorization,Content-Type
|
||||||
|
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
access-control-allow-origin: *
|
||||||
|
content-length: 187
|
||||||
|
content-type: application/json; charset=utf-8
|
||||||
|
date: Wed,15 Apr 2026 14:40:13 GMT
|
||||||
|
x-ratelimit-limit: 120
|
||||||
|
x-ratelimit-remaining: 118
|
||||||
|
x-ratelimit-reset: 25
|
||||||
|
|
||||||
|
User Update
|
||||||
|
|
||||||
|
curl -X 'PUT' \
|
||||||
|
'http://localhost:8080/api/v1/admin/users/2' \
|
||||||
|
-H 'accept: application/json' \
|
||||||
|
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwidXNlcl9pZCI6IjEiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwidXNlcm5hbWUiOiJiZXloYW5vIiwiZXhwIjoxNzc2MjY0MTEzLCJpYXQiOjE3NzYyNjMyMTMsImp0aSI6Ijk5ODZmYjMzYWFhMzc5ZDRhODA3NjVhZTg1MjMwODcxIn0.wvYGM57tE9tV1MVGsjJ-nK8GXbzRXbPNbYKFHjN2_Ic' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"confirm_password": "123456",
|
||||||
|
"email": "asde@asd.com",
|
||||||
|
"is_active": true,
|
||||||
|
"is_admin": true,
|
||||||
|
"password": "123456",
|
||||||
|
"username": "1234"
|
||||||
|
}'
|
||||||
|
|
||||||
|
Request URL
|
||||||
|
|
||||||
|
http://localhost:8080/api/v1/admin/users/2
|
||||||
|
|
||||||
|
Server response
|
||||||
|
Code Details
|
||||||
|
200
|
||||||
|
Response body
|
||||||
|
Download
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"username": "1234",
|
||||||
|
"email": "asde@asd.com",
|
||||||
|
"email_verified": false,
|
||||||
|
"is_active": true,
|
||||||
|
"is_admin": true,
|
||||||
|
"created_at": "2026-04-15T17:39:39+03:00",
|
||||||
|
"updated_at": "2026-04-15T17:40:48+03:00"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response headers
|
||||||
|
|
||||||
|
access-control-allow-headers: Authorization,Content-Type
|
||||||
|
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
access-control-allow-origin: *
|
||||||
|
content-length: 187
|
||||||
|
content-type: application/json; charset=utf-8
|
||||||
|
date: Wed,15 Apr 2026 14:40:48 GMT
|
||||||
|
x-ratelimit-limit: 120
|
||||||
|
x-ratelimit-remaining: 119
|
||||||
|
x-ratelimit-reset: 59
|
||||||
|
|
||||||
|
User Delete
|
||||||
|
|
||||||
|
curl -X 'DELETE' \
|
||||||
|
'http://localhost:8080/api/v1/admin/users/2' \
|
||||||
|
-H 'accept: application/json' \
|
||||||
|
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwidXNlcl9pZCI6IjEiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwidXNlcm5hbWUiOiJiZXloYW5vIiwiZXhwIjoxNzc2MjY0MTEzLCJpYXQiOjE3NzYyNjMyMTMsImp0aSI6Ijk5ODZmYjMzYWFhMzc5ZDRhODA3NjVhZTg1MjMwODcxIn0.wvYGM57tE9tV1MVGsjJ-nK8GXbzRXbPNbYKFHjN2_Ic'
|
||||||
|
|
||||||
|
Request URL
|
||||||
|
|
||||||
|
http://localhost:8080/api/v1/admin/users/2
|
||||||
|
|
||||||
|
Server response
|
||||||
|
Code Details
|
||||||
|
200
|
||||||
|
Response body
|
||||||
|
Download
|
||||||
|
|
||||||
|
{
|
||||||
|
"message": "kullanici silindi"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response headers
|
||||||
|
|
||||||
|
access-control-allow-headers: Authorization,Content-Type
|
||||||
|
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
access-control-allow-origin: *
|
||||||
|
content-length: 31
|
||||||
|
content-type: application/json; charset=utf-8
|
||||||
|
date: Wed,15 Apr 2026 14:41:18 GMT
|
||||||
|
x-ratelimit-limit: 120
|
||||||
|
x-ratelimit-remaining: 117
|
||||||
|
x-ratelimit-reset: 30
|
||||||
|
|
||||||
|
Responses
|
||||||
|
|
||||||
|
|
||||||
|
User Status change
|
||||||
|
curl -X 'PATCH' \
|
||||||
|
'http://localhost:8080/api/v1/admin/users/2/status' \
|
||||||
|
-H 'accept: application/json' \
|
||||||
|
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwidXNlcl9pZCI6IjEiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwidXNlcm5hbWUiOiJiZXloYW5vIiwiZXhwIjoxNzc2MjY0MTEzLCJpYXQiOjE3NzYyNjMyMTMsImp0aSI6Ijk5ODZmYjMzYWFhMzc5ZDRhODA3NjVhZTg1MjMwODcxIn0.wvYGM57tE9tV1MVGsjJ-nK8GXbzRXbPNbYKFHjN2_Ic' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"is_active": true
|
||||||
|
}'
|
||||||
|
|
||||||
|
Request URL
|
||||||
|
|
||||||
|
http://localhost:8080/api/v1/admin/users/2/status
|
||||||
|
|
||||||
|
Server response
|
||||||
|
Code Details
|
||||||
|
200
|
||||||
|
Response body
|
||||||
|
Download
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"username": "1234",
|
||||||
|
"email": "asde@asd.com",
|
||||||
|
"email_verified": false,
|
||||||
|
"is_active": true,
|
||||||
|
"is_admin": true,
|
||||||
|
"created_at": "2026-04-15T17:39:39+03:00",
|
||||||
|
"updated_at": "2026-04-15T17:41:02+03:00"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response headers
|
||||||
|
|
||||||
|
access-control-allow-headers: Authorization,Content-Type
|
||||||
|
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
access-control-allow-origin: *
|
||||||
|
content-length: 187
|
||||||
|
content-type: application/json; charset=utf-8
|
||||||
|
date: Wed,15 Apr 2026 14:41:02 GMT
|
||||||
|
x-ratelimit-limit: 120
|
||||||
|
x-ratelimit-remaining: 118
|
||||||
|
x-ratelimit-reset: 46
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
260
Login_Register.md
Normal file
260
Login_Register.md
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices...
|
||||||
|
|
||||||
|
|
||||||
|
Login
|
||||||
|
|
||||||
|
curl -X 'POST' \
|
||||||
|
'http://localhost:8080/api/v1/auth/login' \
|
||||||
|
-H 'accept: application/json' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"email": "beyhan@beyhan.dev",
|
||||||
|
"password": "1923btO**"
|
||||||
|
}'
|
||||||
|
|
||||||
|
Request URL
|
||||||
|
|
||||||
|
http://localhost:8080/api/v1/auth/login
|
||||||
|
|
||||||
|
Server response
|
||||||
|
Code Details
|
||||||
|
200
|
||||||
|
Response body
|
||||||
|
Download
|
||||||
|
|
||||||
|
{
|
||||||
|
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwidXNlcl9pZCI6IjEiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwidXNlcm5hbWUiOiJiZXloYW5vIiwiZXhwIjoxNzc2MjY2MDM2LCJpYXQiOjE3NzYyNjUxMzYsImp0aSI6IjQwN2ZhNmM2YjQwZjY3NTk5ODQ5YWYwNGM1ZjNkNzJiIn0.ie-4br4q5E8z24c4Zxj1pRp0XTudhKpv2nl1C-UJ-Z0",
|
||||||
|
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsInVzZXJfaWQiOiIxIiwiZXhwIjoxNzc2ODY5OTM2LCJpYXQiOjE3NzYyNjUxMzYsImp0aSI6IjZmNDlhZmE4ZmZmYjMxNjdmZTI3NDkyZDhhYjcyMWEyIn0.YJVnaE2ahOQgbOrkxCY2eyLn2W6upm6VQ9XmCmgROSk"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response headers
|
||||||
|
|
||||||
|
access-control-allow-headers: Authorization,Content-Type
|
||||||
|
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
access-control-allow-origin: *
|
||||||
|
content-length: 554
|
||||||
|
content-type: application/json; charset=utf-8
|
||||||
|
date: Wed,15 Apr 2026 14:58:56 GMT
|
||||||
|
x-ratelimit-limit: 120
|
||||||
|
x-ratelimit-remaining: 119
|
||||||
|
x-ratelimit-reset: 59
|
||||||
|
|
||||||
|
|
||||||
|
Register
|
||||||
|
|
||||||
|
|
||||||
|
curl -X 'POST' \
|
||||||
|
'http://localhost:8080/api/v1/auth/register' \
|
||||||
|
-H 'accept: application/json' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"confirm_password": "1923btO**",
|
||||||
|
"email": "aaaaa@ffff.com",
|
||||||
|
"first_name": "Ahmet",
|
||||||
|
"last_name": "Arfi",
|
||||||
|
"password": "1923btO**",
|
||||||
|
"username": "aaaaa@ffff.com"
|
||||||
|
}'
|
||||||
|
|
||||||
|
Request URL
|
||||||
|
|
||||||
|
http://localhost:8080/api/v1/auth/register
|
||||||
|
|
||||||
|
Server response
|
||||||
|
Code Details
|
||||||
|
201
|
||||||
|
Response body
|
||||||
|
Download
|
||||||
|
|
||||||
|
{
|
||||||
|
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwidXNlcl9pZCI6IjMiLCJlbWFpbCI6ImFhYWFhQGZmZmYuY29tIiwidXNlcm5hbWUiOiJhYWFhYUBmZmZmLmNvbSIsImV4cCI6MTc3NjI2NjE5MCwiaWF0IjoxNzc2MjY1MjkwLCJqdGkiOiIwNDc0NzllMTU5MDk1OGFlYTFhMzYyM2ZlOGRmNjY3MiJ9.v_bfsKeDjyIvo-g91zIjaV0tpbU8caSOlK_VR6p5mC8",
|
||||||
|
"message": "kayit basarili",
|
||||||
|
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsInVzZXJfaWQiOiIzIiwiZXhwIjoxNzc2ODcwMDkwLCJpYXQiOjE3NzYyNjUyOTAsImp0aSI6ImE3MDA2ZjdlNmE5NmFlMWQzMzdlMzk1ODZmODM4N2EwIn0.F0IQn_Ocg0D8CCXwxfBTo60jQGVv80WfhhGRz5eIj_I"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response headers
|
||||||
|
|
||||||
|
access-control-allow-headers: Authorization,Content-Type
|
||||||
|
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
access-control-allow-origin: *
|
||||||
|
content-length: 586
|
||||||
|
content-type: application/json; charset=utf-8
|
||||||
|
date: Wed,15 Apr 2026 15:01:30 GMT
|
||||||
|
x-ratelimit-limit: 120
|
||||||
|
x-ratelimit-remaining: 119
|
||||||
|
x-ratelimit-reset: 59
|
||||||
|
|
||||||
|
|
||||||
|
refresh token
|
||||||
|
|
||||||
|
curl -X 'POST' \
|
||||||
|
'http://localhost:8080/api/v1/auth/refresh' \
|
||||||
|
-H 'accept: application/json' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsInVzZXJfaWQiOiIxIiwiZXhwIjoxNzc2ODY5OTM2LCJpYXQiOjE3NzYyNjUxMzYsImp0aSI6IjZmNDlhZmE4ZmZmYjMxNjdmZTI3NDkyZDhhYjcyMWEyIn0.YJVnaE2ahOQgbOrkxCY2eyLn2W6upm6VQ9XmCmgROSk"
|
||||||
|
}'
|
||||||
|
|
||||||
|
Request URL
|
||||||
|
|
||||||
|
http://localhost:8080/api/v1/auth/refresh
|
||||||
|
|
||||||
|
Server response
|
||||||
|
Code Details
|
||||||
|
200
|
||||||
|
Response body
|
||||||
|
Download
|
||||||
|
|
||||||
|
{
|
||||||
|
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwidXNlcl9pZCI6IjEiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwidXNlcm5hbWUiOiJiZXloYW5vIiwiZXhwIjoxNzc2MjY2MDU2LCJpYXQiOjE3NzYyNjUxNTYsImp0aSI6Ijc4N2QzMzY2MjA5OWQ0ZDcxNTc4ZWVlMDYzMmYwNmM3In0.sqHvycsxQ_yPbq4VvsW6hKt-r72sq-ZvK03b_YisaLc",
|
||||||
|
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsInVzZXJfaWQiOiIxIiwiZXhwIjoxNzc2ODY5OTU2LCJpYXQiOjE3NzYyNjUxNTYsImp0aSI6IjQ2NmMwMDI4NmM5NDg2ZWMzNmExMjYxMjI4NWMxNTNmIn0.Sq3IVBYaJ94kjnR9DmSzELxOEFrWZ3gFYhUyuQtXtGg"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response headers
|
||||||
|
|
||||||
|
access-control-allow-headers: Authorization,Content-Type
|
||||||
|
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
access-control-allow-origin: *
|
||||||
|
content-length: 554
|
||||||
|
content-type: application/json; charset=utf-8
|
||||||
|
date: Wed,15 Apr 2026 14:59:16 GMT
|
||||||
|
x-ratelimit-limit: 120
|
||||||
|
x-ratelimit-remaining: 118
|
||||||
|
x-ratelimit-reset: 40
|
||||||
|
|
||||||
|
|
||||||
|
Me
|
||||||
|
|
||||||
|
curl -X 'GET' \
|
||||||
|
'http://localhost:8080/api/v1/me' \
|
||||||
|
-H 'accept: application/json' \
|
||||||
|
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwidXNlcl9pZCI6IjEiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwidXNlcm5hbWUiOiJiZXloYW5vIiwiZXhwIjoxNzc2MjY0MTEzLCJpYXQiOjE3NzYyNjMyMTMsImp0aSI6Ijk5ODZmYjMzYWFhMzc5ZDRhODA3NjVhZTg1MjMwODcxIn0.wvYGM57tE9tV1MVGsjJ-nK8GXbzRXbPNbYKFHjN2_Ic'
|
||||||
|
|
||||||
|
Request URL
|
||||||
|
|
||||||
|
http://localhost:8080/api/v1/me
|
||||||
|
|
||||||
|
Server response
|
||||||
|
Code Details
|
||||||
|
401
|
||||||
|
|
||||||
|
Error: Unauthorized
|
||||||
|
Response body
|
||||||
|
Download
|
||||||
|
|
||||||
|
{
|
||||||
|
"error": "token gecersiz"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response headers
|
||||||
|
|
||||||
|
access-control-allow-headers: Authorization,Content-Type
|
||||||
|
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
access-control-allow-origin: *
|
||||||
|
content-length: 26
|
||||||
|
content-type: application/json; charset=utf-8
|
||||||
|
date: Wed,15 Apr 2026 15:01:40 GMT
|
||||||
|
x-ratelimit-limit: 120
|
||||||
|
x-ratelimit-remaining: 118
|
||||||
|
x-ratelimit-reset: 50
|
||||||
|
|
||||||
|
profile guncelle
|
||||||
|
|
||||||
|
curl -X 'PUT' \
|
||||||
|
'https://mcp.beyhano.net.tr/api/v1/me/profile' \
|
||||||
|
-H 'accept: application/json' \
|
||||||
|
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwidXNlcl9pZCI6IjEiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwidXNlcm5hbWUiOiJCZXloYW4gT8SfdXIiLCJleHAiOjE3NzYyOTgwNzAsImlhdCI6MTc3NjI5NzE3MCwianRpIjoiZGUyZWNiZGY5ZjU4MGJhOTA4ODI1MGUyMWE0MTNjYTcifQ.NiT5spcCm9sd7S9DttuFHA__KzFq3pQIJ8xkJKtntnc' \
|
||||||
|
-H 'Content-Type: multipart/form-data' \
|
||||||
|
-F 'first_name=Beyhan' \
|
||||||
|
-F 'last_name=Ogıur' \
|
||||||
|
-F 'avatar=@f1f1db3e1b9396ad23d09fc92cee87ce.png;type=image/png'
|
||||||
|
|
||||||
|
Request URL
|
||||||
|
|
||||||
|
https://mcp.beyhano.net.tr/api/v1/me/profile
|
||||||
|
|
||||||
|
Server response
|
||||||
|
Code Details
|
||||||
|
200
|
||||||
|
Response body
|
||||||
|
Download
|
||||||
|
|
||||||
|
{
|
||||||
|
"user_id": 1,
|
||||||
|
"first_name": "Beyhan",
|
||||||
|
"last_name": "Ogıur",
|
||||||
|
"avatar_url": "/uploads/avatars/avatar_1776297238_5de7733ccb7677b5.png"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response headers
|
||||||
|
|
||||||
|
access-control-allow-headers: Authorization,Content-Type
|
||||||
|
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
access-control-allow-origin: *
|
||||||
|
alt-svc: h3=":443"; ma=86400
|
||||||
|
cf-cache-status: DYNAMIC
|
||||||
|
cf-ray: 9ecedf6b8c08d0ef-SOF
|
||||||
|
content-encoding: zstd
|
||||||
|
content-type: application/json; charset=utf-8
|
||||||
|
date: Wed,15 Apr 2026 23:53:58 GMT
|
||||||
|
nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}
|
||||||
|
priority: u=0,i=?0
|
||||||
|
report-to: {"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=bVAVFdOIo1q%2FsyLOzVfAgAMNtbfs6PGgP%2BJFFLY91j1HIEuBzU5Y51M7%2B3l90JcY9UxPMfxrrdbid%2FEiX326Y5PgU06xedtMasI6GppTIki8nzm7zzCpAZH9IMqaubLWz9bo1bA%3D"}]}
|
||||||
|
server: cloudflare
|
||||||
|
server-timing: cfExtPri
|
||||||
|
x-firefox-http3: h3
|
||||||
|
x-ratelimit-limit: 120
|
||||||
|
x-ratelimit-remaining: 119
|
||||||
|
x-ratelimit-reset: 59
|
||||||
|
|
||||||
|
|
||||||
|
profile getir
|
||||||
|
|
||||||
|
Next uygulaması `GET {API_BASE_URL}/api/v1/me/profile` ile aynı JSON’u kullanır; `avatar_url` göreliyse (`/uploads/...`) arayüzde `API_BASE_URL` ile birleştirilir.
|
||||||
|
|
||||||
|
curl -X 'GET' \
|
||||||
|
'https://mcp.beyhano.net.tr/api/v1/me/profile' \
|
||||||
|
-H 'accept: application/json' \
|
||||||
|
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwidXNlcl9pZCI6IjEiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwidXNlcm5hbWUiOiJCZXloYW4gT8SfdXIiLCJleHAiOjE3NzYyOTgwNzAsImlhdCI6MTc3NjI5NzE3MCwianRpIjoiZGUyZWNiZGY5ZjU4MGJhOTA4ODI1MGUyMWE0MTNjYTcifQ.NiT5spcCm9sd7S9DttuFHA__KzFq3pQIJ8xkJKtntnc'
|
||||||
|
|
||||||
|
Request URL
|
||||||
|
|
||||||
|
https://mcp.beyhano.net.tr/api/v1/me/profile
|
||||||
|
|
||||||
|
Server response
|
||||||
|
Code Details
|
||||||
|
200
|
||||||
|
Response body
|
||||||
|
Download
|
||||||
|
|
||||||
|
{
|
||||||
|
"user_id": 1,
|
||||||
|
"first_name": "Beyhan",
|
||||||
|
"last_name": "Ogıur",
|
||||||
|
"avatar_url": "/uploads/avatars/avatar_1776297372_406c31031e305f17.avif"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response headers
|
||||||
|
|
||||||
|
access-control-allow-headers: Authorization,Content-Type
|
||||||
|
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
access-control-allow-origin: *
|
||||||
|
alt-svc: h3=":443"; ma=86400
|
||||||
|
cf-cache-status: DYNAMIC
|
||||||
|
cf-ray: 9ecee49cffdbd100-SOF
|
||||||
|
content-encoding: zstd
|
||||||
|
content-type: application/json; charset=utf-8
|
||||||
|
date: Wed,15 Apr 2026 23:57:31 GMT
|
||||||
|
nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}
|
||||||
|
priority: u=0,i=?0
|
||||||
|
report-to: {"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=mJkvoje74AtnDa8%2Fk5bOvG8MyrM0geb%2FxNuSkRs3ee4lJb54eajtsm5QPIiPV2vGal6M%2FNL5tRTq7o87oZp81yGy7k4Xxro4J6wsVbEXDULczObm4sUgvl%2FtSwlSnGDkVlnIyMw%3D"}]}
|
||||||
|
server: cloudflare
|
||||||
|
server-timing: cfExtPri
|
||||||
|
x-firefox-http3: h3
|
||||||
|
x-ratelimit-limit: 120
|
||||||
|
x-ratelimit-remaining: 119
|
||||||
|
x-ratelimit-reset: 59
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
96
app/admin/layout.tsx
Normal file
96
app/admin/layout.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Users, FileText, ShoppingBag, UserRound } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
label: 'Kullanıcılar',
|
||||||
|
href: '/admin/users',
|
||||||
|
icon: Users,
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Profilim',
|
||||||
|
href: '/admin/profile',
|
||||||
|
icon: UserRound,
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Posts',
|
||||||
|
href: '#',
|
||||||
|
icon: FileText,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Shop',
|
||||||
|
href: '#',
|
||||||
|
icon: ShoppingBag,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-1 gap-3 pl-2 pr-3 pt-0 pb-0 lg:gap-4 lg:pl-3 lg:pr-4">
|
||||||
|
{/* ── Sidebar (sol) ── */}
|
||||||
|
<aside className="hidden w-64 shrink-0 lg:block">
|
||||||
|
<div className="sticky top-12 h-[calc(100dvh-3rem)] overflow-hidden rounded-none border-y-0 border-l-0 border-r bg-sidebar text-sidebar-foreground">
|
||||||
|
<div className="border-b border-sidebar-border px-4 py-4">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sidebar-foreground/70">
|
||||||
|
Admin Panel
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium">Yonetim Menusu</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b border-sidebar-border px-4 py-3">
|
||||||
|
<p className="text-xs text-sidebar-foreground/70">Hizli Erisim</p>
|
||||||
|
<p className="text-sm font-medium">Kullanicilar ve Icerik</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="space-y-1 p-2">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
|
||||||
|
if (item.disabled) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-sidebar-foreground/60"
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<Badge variant="secondary" className="ml-auto border-0 bg-sidebar-accent text-[10px] text-sidebar-accent-foreground">
|
||||||
|
Yakinda
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={item.label}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-10 w-full justify-start gap-3 rounded-md text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href={item.href}>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ── İçerik (sağ) ── */}
|
||||||
|
<main className="min-w-0 flex-1 overflow-hidden rounded-xl border bg-card shadow-sm">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
app/admin/page.tsx
Normal file
12
app/admin/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export default function AdminPage() {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1 className="text-2xl font-semibold text-[var(--foreground)]">
|
||||||
|
Admin Panel
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-[var(--foreground)]/75">
|
||||||
|
Sağdaki menüden Users, Posts ve Shop bölümlerine geçiş yapabilirsiniz.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
182
app/admin/profile/ProfileClient.tsx
Normal file
182
app/admin/profile/ProfileClient.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useState } from 'react'
|
||||||
|
import Swal from 'sweetalert2'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { updateProfile, type Profile, type ProfileFormState } from './actions'
|
||||||
|
|
||||||
|
function getSwalThemeOptions() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark')
|
||||||
|
return isDark
|
||||||
|
? { background: '#111827', color: '#f3f4f6' }
|
||||||
|
: { background: '#ffffff', color: '#111827' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_AVATAR_BYTES = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
function showToast(icon: 'success' | 'error', title: string) {
|
||||||
|
void Swal.fire({
|
||||||
|
...getSwalThemeOptions(),
|
||||||
|
toast: true,
|
||||||
|
position: 'top-end',
|
||||||
|
showConfirmButton: false,
|
||||||
|
timer: 2200,
|
||||||
|
timerProgressBar: true,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function showErrorDialog(title: string, text?: string) {
|
||||||
|
void Swal.fire({
|
||||||
|
...getSwalThemeOptions(),
|
||||||
|
icon: 'error',
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
confirmButtonText: 'Tamam',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileClient({ initialProfile }: { initialProfile: Profile }) {
|
||||||
|
const [state, formAction, pending] = useActionState<ProfileFormState, FormData>(updateProfile, {})
|
||||||
|
const [avatarFilePreviewUrl, setAvatarFilePreviewUrl] = useState<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.success) showToast('success', 'Profil güncellendi')
|
||||||
|
}, [state.success])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.error) showToast('error', state.error)
|
||||||
|
}, [state.error])
|
||||||
|
|
||||||
|
const profile = state.profile ?? initialProfile
|
||||||
|
const avatarSrc = avatarFilePreviewUrl || profile.avatar_url || ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (avatarFilePreviewUrl.startsWith('blob:')) URL.revokeObjectURL(avatarFilePreviewUrl)
|
||||||
|
}
|
||||||
|
}, [avatarFilePreviewUrl])
|
||||||
|
|
||||||
|
function onAvatarFileChange(file: File | null) {
|
||||||
|
if (!file) {
|
||||||
|
console.log('[profile:avatar] dosya seçimi temizlendi')
|
||||||
|
setAvatarFilePreviewUrl('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('[profile:avatar] dosya seçildi (henüz yüklenmedi)', {
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
})
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
showToast('error', 'Lütfen geçerli bir görsel seçin')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size > MAX_AVATAR_BYTES) {
|
||||||
|
showErrorDialog(
|
||||||
|
'Dosya çok büyük',
|
||||||
|
`Avatar en fazla 5 MB olabilir (seçilen: ${(file.size / (1024 * 1024)).toFixed(2)} MB).`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const previewUrl = URL.createObjectURL(file)
|
||||||
|
setAvatarFilePreviewUrl(previewUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-3xl p-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profilim</CardTitle>
|
||||||
|
<CardDescription>Ad, soyad ve avatar bilgilerinizi güncelleyin.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
encType="multipart/form-data"
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
const fd = new FormData(event.currentTarget)
|
||||||
|
const f = fd.get('avatar')
|
||||||
|
if (f instanceof File && f.size > MAX_AVATAR_BYTES) {
|
||||||
|
event.preventDefault()
|
||||||
|
showErrorDialog(
|
||||||
|
'Dosya çok büyük',
|
||||||
|
`Avatar en fazla 5 MB olabilir (seçilen: ${(f.size / (1024 * 1024)).toFixed(2)} MB). Sunucu gönderim limiti de 5 MB.`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const hidden = String(fd.get('avatar_url') ?? '')
|
||||||
|
console.log('[profile:avatar] form gönderiliyor', {
|
||||||
|
hasFile: f instanceof File && f.size > 0,
|
||||||
|
file:
|
||||||
|
f instanceof File && f.size > 0
|
||||||
|
? { name: f.name, type: f.type, size: f.size }
|
||||||
|
: null,
|
||||||
|
avatar_url_hidden_preview: hidden.slice(0, 120),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 rounded-lg border p-4">
|
||||||
|
<Label htmlFor="avatar">Avatar</Label>
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||||
|
<Avatar data-size="lg" className="size-16">
|
||||||
|
<AvatarImage src={avatarSrc || undefined} alt={`${profile.first_name} ${profile.last_name}`} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{(profile.first_name?.[0] ?? 'U') + (profile.last_name?.[0] ?? '')}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Input
|
||||||
|
id="avatar"
|
||||||
|
name="avatar"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(event) => void onAvatarFileChange(event.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Görsel, form submit edildiğinde sunucuya yüklenir ve profile kaydedilir.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="first_name">Ad</Label>
|
||||||
|
<Input id="first_name" name="first_name" defaultValue={profile.first_name ?? ''} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="last_name">Soyad</Label>
|
||||||
|
<Input id="last_name" name="last_name" defaultValue={profile.last_name ?? ''} required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="avatar_manual">Avatar URL (Opsiyonel)</Label>
|
||||||
|
<Input
|
||||||
|
id="avatar_manual"
|
||||||
|
name="avatar_url"
|
||||||
|
key={profile.avatar_url ?? ''}
|
||||||
|
type="url"
|
||||||
|
defaultValue={profile.avatar_url ?? ''}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? 'Kaydediliyor…' : 'Profili Kaydet'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
195
app/admin/profile/actions.ts
Normal file
195
app/admin/profile/actions.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import path from 'node:path'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { getAccessToken, refreshAccessToken } from '@/app/auth/actions'
|
||||||
|
|
||||||
|
const API_BASE = process.env.API_BASE_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
|
type ApiError = Record<string, unknown>
|
||||||
|
|
||||||
|
export type Profile = {
|
||||||
|
user_id: number
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
avatar_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProfileFormState = {
|
||||||
|
success?: boolean
|
||||||
|
error?: string
|
||||||
|
profile?: Profile
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getToken(): Promise<string> {
|
||||||
|
const token = await getAccessToken()
|
||||||
|
if (token) return token
|
||||||
|
const fresh = await refreshAccessToken()
|
||||||
|
if (fresh) return fresh
|
||||||
|
redirect('/auth/login?from=/admin/profile')
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractError(data: ApiError, fallback: string): string {
|
||||||
|
if (typeof data?.error === 'string' && data.error) return data.error
|
||||||
|
if (typeof data?.message === 'string' && data.message) return data.message
|
||||||
|
if (typeof data?.detail === 'string' && data.detail) return data.detail
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API'den gelen göreli yolu (/uploads/...) img ve multipart `avatar_url` için tam adrese çevirir. */
|
||||||
|
function absoluteProfileAvatarUrl(avatar_url: string): string {
|
||||||
|
const v = avatar_url.trim()
|
||||||
|
if (!v) return ''
|
||||||
|
if (/^https?:\/\//i.test(v)) return v
|
||||||
|
const pathPart = v.startsWith('/') ? v : `/${v}`
|
||||||
|
const base = API_BASE.replace(/\/$/, '')
|
||||||
|
return `${base}${pathPart}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionFromFilename(name: string): string | null {
|
||||||
|
const ext = path.extname(name).replace('.', '').toLowerCase()
|
||||||
|
if (['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext)) return ext === 'jpeg' ? 'jpg' : ext
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionFromMime(mimeType: string): string | null {
|
||||||
|
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') return 'jpg'
|
||||||
|
if (mimeType === 'image/png') return 'png'
|
||||||
|
if (mimeType === 'image/webp') return 'webp'
|
||||||
|
if (mimeType === 'image/gif') return 'gif'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAvatarFile(file: File): void {
|
||||||
|
const looksLikeImage =
|
||||||
|
file.type.startsWith('image/') ||
|
||||||
|
file.type === '' ||
|
||||||
|
file.type === 'application/octet-stream'
|
||||||
|
if (!looksLikeImage) {
|
||||||
|
throw new Error('Lütfen geçerli bir görsel seçin')
|
||||||
|
}
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
throw new Error('Avatar en fazla 5MB olabilir')
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext =
|
||||||
|
extensionFromMime(file.type) ?? extensionFromFilename(file.name)
|
||||||
|
if (!ext) {
|
||||||
|
throw new Error('Desteklenmeyen dosya türü (jpg, png, webp, gif)')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProfile(): Promise<Profile> {
|
||||||
|
let token = await getToken()
|
||||||
|
const url = `${API_BASE}/api/v1/me/profile`
|
||||||
|
|
||||||
|
const doFetch = () =>
|
||||||
|
fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await doFetch()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[getProfile] fetch failed', error)
|
||||||
|
throw new Error('API sunucusuna bağlanılamadı. Backend çalışıyor mu kontrol edin.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
const fresh = await refreshAccessToken()
|
||||||
|
if (!fresh) throw new Error('Oturum süresi doldu')
|
||||||
|
token = fresh
|
||||||
|
try {
|
||||||
|
res = await doFetch()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[getProfile] fetch failed after refresh', error)
|
||||||
|
throw new Error('API sunucusuna bağlanılamadı. Backend çalışıyor mu kontrol edin.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = (await res.json().catch(() => ({}))) as ApiError
|
||||||
|
throw new Error(extractError(data, 'Profil bilgisi alınamadı'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = (await res.json()) as Profile
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
avatar_url: absoluteProfileAvatarUrl(raw.avatar_url ?? ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(
|
||||||
|
_prev: ProfileFormState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ProfileFormState> {
|
||||||
|
try {
|
||||||
|
const token = await getToken()
|
||||||
|
const avatarFile = formData.get('avatar')
|
||||||
|
const avatarUrl = String(formData.get('avatar_url') ?? '')
|
||||||
|
|
||||||
|
const first_name = String(formData.get('first_name') ?? '')
|
||||||
|
const last_name = String(formData.get('last_name') ?? '')
|
||||||
|
|
||||||
|
const hasNewFile = avatarFile instanceof File && avatarFile.size > 0
|
||||||
|
if (hasNewFile && avatarFile instanceof File) {
|
||||||
|
validateAvatarFile(avatarFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
const outbound = new FormData()
|
||||||
|
outbound.append('first_name', first_name)
|
||||||
|
outbound.append('last_name', last_name)
|
||||||
|
|
||||||
|
if (hasNewFile && avatarFile instanceof File) {
|
||||||
|
const filename = avatarFile.name?.trim() || 'avatar.jpg'
|
||||||
|
outbound.append('avatar', avatarFile, filename)
|
||||||
|
} else {
|
||||||
|
const backendAvatarUrl = absoluteProfileAvatarUrl(avatarUrl.trim())
|
||||||
|
if (backendAvatarUrl) outbound.append('avatar_url', backendAvatarUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[profile:avatar] PUT /api/v1/me/profile (multipart)', {
|
||||||
|
API_BASE,
|
||||||
|
hasFile: hasNewFile,
|
||||||
|
has_avatar_url_field: !hasNewFile && Boolean(absoluteProfileAvatarUrl(avatarUrl.trim())),
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/me/profile`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: outbound,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[profile:avatar] backend yanıt', {
|
||||||
|
status: res.status,
|
||||||
|
ok: res.ok,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = (await res.json().catch(() => ({}))) as ApiError
|
||||||
|
console.log('[profile:avatar] backend hata gövdesi', data)
|
||||||
|
return { error: extractError(data, 'Profil güncellenemedi') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = (await res.json()) as Profile
|
||||||
|
const profile: Profile = {
|
||||||
|
...raw,
|
||||||
|
avatar_url: absoluteProfileAvatarUrl(raw.avatar_url ?? ''),
|
||||||
|
}
|
||||||
|
console.log('[profile:avatar] başarı', {
|
||||||
|
avatar_url: profile.avatar_url,
|
||||||
|
})
|
||||||
|
revalidatePath('/admin/profile')
|
||||||
|
return { success: true, profile }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[updateProfile]', error)
|
||||||
|
return { error: 'Sunucu hatası' }
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/admin/profile/page.tsx
Normal file
7
app/admin/profile/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import ProfileClient from './ProfileClient'
|
||||||
|
import { getProfile } from './actions'
|
||||||
|
|
||||||
|
export default async function ProfilePage() {
|
||||||
|
const profile = await getProfile()
|
||||||
|
return <ProfileClient initialProfile={profile} />
|
||||||
|
}
|
||||||
389
app/admin/users/UsersClient.tsx
Normal file
389
app/admin/users/UsersClient.tsx
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useActionState, useCallback, useEffect, useState, useTransition } from 'react'
|
||||||
|
import Swal, { type SweetAlertIcon } from 'sweetalert2'
|
||||||
|
import {
|
||||||
|
createUser,
|
||||||
|
updateUser,
|
||||||
|
deleteUser,
|
||||||
|
changeUserStatus,
|
||||||
|
type User,
|
||||||
|
type UserFormState,
|
||||||
|
type UsersResponse,
|
||||||
|
} from './actions'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { Loader2, Plus, Pencil, Trash2, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
type Props = { initialData: UsersResponse }
|
||||||
|
type ModalMode = 'create' | 'edit' | null
|
||||||
|
|
||||||
|
function getSwalThemeOptions() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark')
|
||||||
|
return isDark
|
||||||
|
? { background: '#111827', color: '#f3f4f6' }
|
||||||
|
: { background: '#ffffff', color: '#111827' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(icon: SweetAlertIcon, title: string) {
|
||||||
|
void Swal.fire({
|
||||||
|
...getSwalThemeOptions(),
|
||||||
|
toast: true,
|
||||||
|
position: 'top-end',
|
||||||
|
showConfirmButton: false,
|
||||||
|
timer: 2200,
|
||||||
|
timerProgressBar: true,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Field ─────────────────────────────────────────── */
|
||||||
|
function Field({ id, label, children }: { id?: string; label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── UserModal ─────────────────────────────────────── */
|
||||||
|
function UserModal({
|
||||||
|
open,
|
||||||
|
mode,
|
||||||
|
user,
|
||||||
|
onSaved,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
mode: 'create' | 'edit'
|
||||||
|
user?: User
|
||||||
|
onSaved: (savedUser: User, mode: 'create' | 'edit') => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const isEdit = mode === 'edit' && user != null
|
||||||
|
const boundAction = isEdit ? updateUser.bind(null, user.id) : createUser
|
||||||
|
const [state, formAction, pending] = useActionState<UserFormState, FormData>(boundAction, {})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.success) return
|
||||||
|
if (state.user) onSaved(state.user, mode)
|
||||||
|
showToast('success', mode === 'edit' ? 'Kullanıcı güncellendi' : 'Kullanıcı oluşturuldu')
|
||||||
|
onClose()
|
||||||
|
}, [state.success, state.user, mode, onSaved, onClose])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.error) return
|
||||||
|
showToast('error', state.error)
|
||||||
|
}, [state.error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Kullanıcıyı Düzenle' : 'Yeni Kullanıcı Ekle'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEdit ? 'Kullanıcı bilgilerini güncelleyin.' : 'Yeni bir kullanıcı hesabı oluşturun.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
{state.error && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||||
|
{state.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field id="username" label="Kullanıcı Adı">
|
||||||
|
<Input id="username" name="username" defaultValue={user?.username} required />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field id="email" label="E-posta">
|
||||||
|
<Input id="email" name="email" type="email" defaultValue={user?.email} required />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field id="password" label="Şifre">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required={!isEdit}
|
||||||
|
placeholder={isEdit ? '••••••' : ''}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field id="confirm_password" label="Şifre Tekrar">
|
||||||
|
<Input
|
||||||
|
id="confirm_password"
|
||||||
|
name="confirm_password"
|
||||||
|
type="password"
|
||||||
|
required={!isEdit}
|
||||||
|
placeholder={isEdit ? '••••••' : ''}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="is_active"
|
||||||
|
name="is_active"
|
||||||
|
value="true"
|
||||||
|
defaultChecked={user?.is_active ?? true}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="is_active" className="cursor-pointer font-normal">Aktif</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="is_admin"
|
||||||
|
name="is_admin"
|
||||||
|
value="true"
|
||||||
|
defaultChecked={user?.is_admin ?? false}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="is_admin" className="cursor-pointer font-normal">Admin</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>İptal</Button>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{pending ? 'Kaydediliyor…' : 'Kaydet'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── UsersClient ────────────────────────────────────── */
|
||||||
|
export default function UsersClient({ initialData }: Props) {
|
||||||
|
const [data, setData] = useState<UsersResponse>(initialData)
|
||||||
|
const [modal, setModal] = useState<ModalMode>(null)
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User | undefined>()
|
||||||
|
const [, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const openCreate = useCallback(() => {
|
||||||
|
setSelectedUser(undefined)
|
||||||
|
setModal('create')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openEdit = useCallback((user: User) => {
|
||||||
|
setSelectedUser(user)
|
||||||
|
setModal('edit')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const closeModal = useCallback(() => {
|
||||||
|
setModal(null)
|
||||||
|
setSelectedUser(undefined)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleUserSaved = useCallback((savedUser: User, mode: 'create' | 'edit') => {
|
||||||
|
setData((prev) => {
|
||||||
|
const existingIndex = prev.items.findIndex((u) => u.id === savedUser.id)
|
||||||
|
|
||||||
|
if (mode === 'edit' || existingIndex >= 0) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items: prev.items.map((u) => (u.id === savedUser.id ? savedUser : u)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items: [savedUser, ...prev.items],
|
||||||
|
meta: { ...prev.meta, total: prev.meta.total + 1 },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleUserDeleted = useCallback((id: number) => {
|
||||||
|
setData((prev) => {
|
||||||
|
const nextItems = prev.items.filter((u) => u.id !== id)
|
||||||
|
const removed = nextItems.length !== prev.items.length
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items: nextItems,
|
||||||
|
meta: {
|
||||||
|
...prev.meta,
|
||||||
|
total: removed ? Math.max(0, prev.meta.total - 1) : prev.meta.total,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async (user: User) => {
|
||||||
|
const result = await Swal.fire({
|
||||||
|
...getSwalThemeOptions(),
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'Kullanıcı silinsin mi?',
|
||||||
|
text: `${user.username} kalıcı olarak silinecek.`,
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: 'Evet, sil',
|
||||||
|
cancelButtonText: 'İptal',
|
||||||
|
confirmButtonColor: '#dc2626',
|
||||||
|
reverseButtons: true,
|
||||||
|
focusCancel: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.isConfirmed) return
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set('id', String(user.id))
|
||||||
|
const deleted = await deleteUser({}, formData)
|
||||||
|
if (!deleted.success) {
|
||||||
|
showToast('error', deleted.error ?? 'Kullanıcı silinemedi')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleUserDeleted(deleted.deletedId ?? user.id)
|
||||||
|
showToast('success', 'Kullanıcı silindi')
|
||||||
|
}, [handleUserDeleted])
|
||||||
|
|
||||||
|
async function handleStatusToggle(user: User) {
|
||||||
|
const nextStatus = !user.is_active
|
||||||
|
const statusLabel = nextStatus ? 'aktif' : 'pasif'
|
||||||
|
|
||||||
|
const decision = await Swal.fire({
|
||||||
|
...getSwalThemeOptions(),
|
||||||
|
icon: 'question',
|
||||||
|
title: 'Durum değiştirilsin mi?',
|
||||||
|
text: `${user.username} kullanıcısı ${statusLabel} yapılacak.`,
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: 'Evet',
|
||||||
|
cancelButtonText: 'İptal',
|
||||||
|
reverseButtons: true,
|
||||||
|
focusCancel: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!decision.isConfirmed) return
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await changeUserStatus(user.id, nextStatus)
|
||||||
|
if (!result.success || !result.user) {
|
||||||
|
showToast('error', result.error ?? 'Durum güncellenemedi')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
items: prev.items.map((u) => (u.id === result.user!.id ? result.user! : u)),
|
||||||
|
}))
|
||||||
|
showToast('success', `Kullanıcı ${statusLabel} yapıldı`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">Kullanıcılar</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Toplam <strong className="text-foreground">{data.meta.total}</strong> kullanıcı
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Yeni Kullanıcı
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-xl border overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-16">ID</TableHead>
|
||||||
|
<TableHead>Kullanıcı Adı</TableHead>
|
||||||
|
<TableHead>E-posta</TableHead>
|
||||||
|
<TableHead>Durum</TableHead>
|
||||||
|
<TableHead>Rol</TableHead>
|
||||||
|
<TableHead>Kayıt Tarihi</TableHead>
|
||||||
|
<TableHead className="text-right">İşlemler</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.items.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">{user.id}</TableCell>
|
||||||
|
<TableCell className="font-medium">{user.username}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{user.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<button onClick={() => void handleStatusToggle(user)} className="transition hover:opacity-75">
|
||||||
|
{user.is_active
|
||||||
|
? <Badge variant="outline" className="border-green-500/40 bg-green-500/10 text-green-600 dark:text-green-400">● Aktif</Badge>
|
||||||
|
: <Badge variant="outline" className="border-red-500/40 bg-red-500/10 text-red-600 dark:text-red-400">○ Pasif</Badge>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.is_admin
|
||||||
|
? <Badge variant="outline" className="border-violet-500/40 bg-violet-500/10 text-violet-600 dark:text-violet-400">Admin</Badge>
|
||||||
|
: <Badge variant="secondary">Kullanıcı</Badge>
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{new Date(user.created_at).toLocaleDateString('tr-TR')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(user)} aria-label="Düzenle">
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon-sm" onClick={() => void handleDelete(user)} aria-label="Sil" className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{data.items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
|
||||||
|
Henüz kullanıcı yok
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data.meta.total > data.meta.limit && (
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">
|
||||||
|
Sayfa {data.meta.page} / {Math.ceil(data.meta.total / data.meta.limit)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<UserModal
|
||||||
|
open={modal === 'create' || modal === 'edit'}
|
||||||
|
mode={modal === 'edit' ? 'edit' : 'create'}
|
||||||
|
user={selectedUser}
|
||||||
|
onSaved={handleUserSaved}
|
||||||
|
onClose={closeModal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
224
app/admin/users/actions.ts
Normal file
224
app/admin/users/actions.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { getAccessToken, refreshAccessToken } from '@/app/auth/actions'
|
||||||
|
|
||||||
|
const API_BASE = process.env.API_BASE_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
|
async function getToken(): Promise<string> {
|
||||||
|
const token = await getAccessToken()
|
||||||
|
if (token) return token
|
||||||
|
const fresh = await refreshAccessToken()
|
||||||
|
if (fresh) return fresh
|
||||||
|
redirect('/auth/login?from=/admin/users')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API'nin döndürdüğü hata yanıtından okunabilir bir mesaj çıkarır. */
|
||||||
|
function extractError(data: Record<string, unknown>, fallback: string): string {
|
||||||
|
if (typeof data?.error === 'string' && data.error) return data.error
|
||||||
|
if (typeof data?.message === 'string' && data.message) return data.message
|
||||||
|
if (typeof data?.detail === 'string' && data.detail) return data.detail
|
||||||
|
if (data?.errors && typeof data.errors === 'object') {
|
||||||
|
const msgs = Object.values(data.errors as Record<string, string>).filter(Boolean)
|
||||||
|
if (msgs.length > 0) return msgs.join(', ')
|
||||||
|
}
|
||||||
|
console.error('[API Error]', JSON.stringify(data))
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
email_verified: boolean
|
||||||
|
is_active: boolean
|
||||||
|
is_admin: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UsersResponse = {
|
||||||
|
items: User[]
|
||||||
|
meta: { page: number; limit: number; total: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserFormState = {
|
||||||
|
error?: string
|
||||||
|
success?: boolean
|
||||||
|
user?: User
|
||||||
|
deletedId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsers(page = 1, limit = 10): Promise<UsersResponse> {
|
||||||
|
let token = await getToken()
|
||||||
|
const url = `${API_BASE}/api/v1/admin/users?page=${page}&limit=${limit}`
|
||||||
|
|
||||||
|
const doFetch = () =>
|
||||||
|
fetch(url, { headers: { Authorization: `Bearer ${token}` }, cache: 'no-store' })
|
||||||
|
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await doFetch()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[getUsers] fetch failed', error)
|
||||||
|
throw new Error('API sunucusuna bağlanılamadı. Backend çalışıyor mu kontrol edin.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token süresi dolmuşsa yenile ve bir kez daha dene
|
||||||
|
if (res.status === 401) {
|
||||||
|
const fresh = await refreshAccessToken()
|
||||||
|
if (!fresh) throw new Error('Oturum süresi doldu')
|
||||||
|
token = fresh
|
||||||
|
try {
|
||||||
|
res = await doFetch()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[getUsers] fetch failed after refresh', error)
|
||||||
|
throw new Error('API sunucusuna bağlanılamadı. Backend çalışıyor mu kontrol edin.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit aşıldıysa kısa aralıkla sınırlı sayıda tekrar dene
|
||||||
|
if (res.status === 429) {
|
||||||
|
for (const waitMs of [500, 1000, 1500]) {
|
||||||
|
await new Promise((r) => setTimeout(r, waitMs))
|
||||||
|
res = await doFetch()
|
||||||
|
if (res.ok) break
|
||||||
|
if (res.status !== 429) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`[getUsers] API error: ${res.status} ${res.statusText}`)
|
||||||
|
if (res.status === 429) {
|
||||||
|
throw new Error('Çok fazla istek gönderildi, lütfen birkaç saniye sonra tekrar deneyin (429)')
|
||||||
|
}
|
||||||
|
throw new Error(`Kullanıcılar alınamadı (${res.status})`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(
|
||||||
|
_prev: UserFormState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<UserFormState> {
|
||||||
|
'use server'
|
||||||
|
try {
|
||||||
|
const token = await getToken()
|
||||||
|
const body = {
|
||||||
|
username: formData.get('username') as string,
|
||||||
|
email: formData.get('email') as string,
|
||||||
|
password: formData.get('password') as string,
|
||||||
|
confirm_password: formData.get('confirm_password') as string,
|
||||||
|
is_active: formData.get('is_active') === 'true',
|
||||||
|
is_admin: formData.get('is_admin') === 'true',
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/admin/users`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
return { error: extractError(data, 'Kullanıcı oluşturulamadı') }
|
||||||
|
}
|
||||||
|
revalidatePath('/admin/users')
|
||||||
|
const created = (await res.json()) as User
|
||||||
|
return { success: true, user: created }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[createUser]', e)
|
||||||
|
return { error: 'Sunucu hatası' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(
|
||||||
|
id: number,
|
||||||
|
_prev: UserFormState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<UserFormState> {
|
||||||
|
'use server'
|
||||||
|
try {
|
||||||
|
const token = await getToken()
|
||||||
|
const body = {
|
||||||
|
username: formData.get('username') as string,
|
||||||
|
email: formData.get('email') as string,
|
||||||
|
password: formData.get('password') as string,
|
||||||
|
confirm_password: formData.get('confirm_password') as string,
|
||||||
|
is_active: formData.get('is_active') === 'true',
|
||||||
|
is_admin: formData.get('is_admin') === 'true',
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/admin/users/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
return { error: extractError(data, 'Kullanıcı güncellenemedi') }
|
||||||
|
}
|
||||||
|
revalidatePath('/admin/users')
|
||||||
|
const updated = (await res.json()) as User
|
||||||
|
return { success: true, user: updated }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[updateUser]', e)
|
||||||
|
return { error: 'Sunucu hatası' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(
|
||||||
|
_prev: UserFormState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<UserFormState> {
|
||||||
|
'use server'
|
||||||
|
try {
|
||||||
|
const token = await getToken()
|
||||||
|
const id = formData.get('id') as string
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/admin/users/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
return { error: extractError(data, 'Kullanıcı silinemedi') }
|
||||||
|
}
|
||||||
|
revalidatePath('/admin/users')
|
||||||
|
return { success: true, deletedId: Number(id) }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[deleteUser]', e)
|
||||||
|
return { error: 'Sunucu hatası' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeUserStatus(
|
||||||
|
id: number,
|
||||||
|
isActive: boolean
|
||||||
|
): Promise<UserFormState> {
|
||||||
|
'use server'
|
||||||
|
try {
|
||||||
|
const token = await getToken()
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/admin/users/${id}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ is_active: isActive }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
return { error: extractError(data, 'Durum güncellenemedi') }
|
||||||
|
}
|
||||||
|
revalidatePath('/admin/users')
|
||||||
|
const updated = (await res.json()) as User
|
||||||
|
return { success: true, user: updated }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[changeUserStatus]', e)
|
||||||
|
return { error: 'Sunucu hatası' }
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/admin/users/error.tsx
Normal file
32
app/admin/users/error.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function UsersError({
|
||||||
|
error,
|
||||||
|
unstable_retry,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
unstable_retry: () => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('[UsersPage Error]', error)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center gap-4 p-6 text-center">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<AlertTriangle className="size-6 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">Kullanıcılar yüklenemedi</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={unstable_retry}>
|
||||||
|
Yeniden Dene
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
app/admin/users/page.tsx
Normal file
7
app/admin/users/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { getUsers } from './actions'
|
||||||
|
import UsersClient from './UsersClient'
|
||||||
|
|
||||||
|
export default async function UsersPage() {
|
||||||
|
const data = await getUsers(1, 10)
|
||||||
|
return <UsersClient initialData={data} />
|
||||||
|
}
|
||||||
6
app/api/auth/[...nextauth]/route.ts
Normal file
6
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import NextAuth from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions)
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST }
|
||||||
95
app/auth/actions.ts
Normal file
95
app/auth/actions.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { cookies, headers } from 'next/headers'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { getToken } from 'next-auth/jwt'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import {
|
||||||
|
applySessionCookie,
|
||||||
|
encodeSessionJwt,
|
||||||
|
fetchRefreshedBackendJwt,
|
||||||
|
shouldRefreshBackendToken,
|
||||||
|
} from '@/lib/backend-jwt-refresh'
|
||||||
|
|
||||||
|
const API_BASE = process.env.API_BASE_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
|
export type AuthFormState = {
|
||||||
|
error?: string
|
||||||
|
success?: boolean
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(
|
||||||
|
_prev: AuthFormState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<AuthFormState> {
|
||||||
|
const body = {
|
||||||
|
email: formData.get('email') as string,
|
||||||
|
username: formData.get('username') as string,
|
||||||
|
first_name: formData.get('first_name') as string,
|
||||||
|
last_name: formData.get('last_name') as string,
|
||||||
|
password: formData.get('password') as string,
|
||||||
|
confirm_password: formData.get('confirm_password') as string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
return { error: data?.error ?? 'Kayıt başarısız' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: 'Kayıt başarılı. Lütfen giriş yapın.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
redirect('/api/auth/signout?callbackUrl=/auth/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJwtFromRequest() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const headersList = await headers()
|
||||||
|
const secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET
|
||||||
|
const cookieMap = Object.fromEntries(cookieStore.getAll().map((c) => [c.name, c.value]))
|
||||||
|
return getToken({
|
||||||
|
req: {
|
||||||
|
headers: headersList,
|
||||||
|
cookies: cookieMap,
|
||||||
|
} as unknown as Parameters<typeof getToken>[0]['req'],
|
||||||
|
secret,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend access token’ı yeniler ve NextAuth session çerezini günceller.
|
||||||
|
* Sadece credentials (backend refresh) oturumunda anlamlıdır.
|
||||||
|
*/
|
||||||
|
export async function refreshAccessToken(): Promise<string | null> {
|
||||||
|
const token = await getJwtFromRequest()
|
||||||
|
if (!token?.refreshToken) return null
|
||||||
|
|
||||||
|
if (!shouldRefreshBackendToken(token)) {
|
||||||
|
return typeof token.accessToken === 'string' ? token.accessToken : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = await fetchRefreshedBackendJwt(token)
|
||||||
|
if (!next?.accessToken) return null
|
||||||
|
|
||||||
|
const jwt = await encodeSessionJwt(next)
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
applySessionCookie(cookieStore, jwt)
|
||||||
|
|
||||||
|
return next.accessToken as string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccessToken(): Promise<string | null> {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (session?.error === 'RefreshAccessTokenError') return null
|
||||||
|
if (!session?.accessToken) return null
|
||||||
|
return session.accessToken
|
||||||
|
}
|
||||||
115
app/auth/login/page.tsx
Normal file
115
app/auth/login/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { signIn } from 'next-auth/react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
const [providerPending, setProviderPending] = useState<null | 'google' | 'github'>(null)
|
||||||
|
|
||||||
|
async function onSubmit(formData: FormData) {
|
||||||
|
setError(null)
|
||||||
|
setPending(true)
|
||||||
|
|
||||||
|
const email = String(formData.get('email') ?? '')
|
||||||
|
const password = String(formData.get('password') ?? '')
|
||||||
|
|
||||||
|
const result = await signIn('credentials', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
redirect: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result || result.error) {
|
||||||
|
setError('Giriş başarısız')
|
||||||
|
setPending(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = '/admin/users'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onProviderLogin(provider: 'google' | 'github') {
|
||||||
|
setError(null)
|
||||||
|
setProviderPending(provider)
|
||||||
|
await signIn(provider, { callbackUrl: '/admin/users' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-12">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">Hoş Geldiniz</CardTitle>
|
||||||
|
<CardDescription>Hesabınıza giriş yapın</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="mb-4 grid grid-cols-1 gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={providerPending !== null}
|
||||||
|
onClick={() => void onProviderLogin('google')}
|
||||||
|
>
|
||||||
|
{providerPending === 'google' && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
Google ile giris yap
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={providerPending !== null}
|
||||||
|
onClick={() => void onProviderLogin('github')}
|
||||||
|
>
|
||||||
|
{providerPending === 'github' && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
GitHub ile giris yap
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="h-px flex-1 bg-border" />
|
||||||
|
<span className="text-xs text-muted-foreground">veya e-posta ile devam et</span>
|
||||||
|
<div className="h-px flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={onSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="email">E-posta</Label>
|
||||||
|
<Input id="email" name="email" type="email" required autoComplete="email" placeholder="ornek@mail.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="password">Şifre</Label>
|
||||||
|
<Input id="password" name="password" type="password" required autoComplete="current-password" placeholder="••••••••" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={pending} className="w-full">
|
||||||
|
{pending && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{pending ? 'Giriş yapılıyor…' : 'Giriş Yap'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="justify-center text-sm text-muted-foreground">
|
||||||
|
Hesabınız yok mu?
|
||||||
|
<Link href="/auth/register" className="font-medium text-primary underline-offset-4 hover:underline">
|
||||||
|
Kayıt Ol
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
app/auth/register/page.tsx
Normal file
87
app/auth/register/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useActionState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { register, type AuthFormState } from '../actions'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const [state, formAction, pending] = useActionState<AuthFormState, FormData>(
|
||||||
|
register,
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-12">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">Hesap Oluştur</CardTitle>
|
||||||
|
<CardDescription>Bilgilerinizi girerek kayıt olun</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
{state.error && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||||
|
{state.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state.success && (
|
||||||
|
<div className="rounded-lg border border-green-500/30 bg-green-500/10 px-3 py-2.5 text-sm text-green-700 dark:text-green-400">
|
||||||
|
{state.message ?? 'Kayıt başarılı. Lütfen giriş yapın.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="first_name">Ad</Label>
|
||||||
|
<Input id="first_name" name="first_name" type="text" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="last_name">Soyad</Label>
|
||||||
|
<Input id="last_name" name="last_name" type="text" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="username">Kullanıcı Adı</Label>
|
||||||
|
<Input id="username" name="username" type="text" required autoComplete="username" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="email">E-posta</Label>
|
||||||
|
<Input id="email" name="email" type="email" required autoComplete="email" placeholder="ornek@mail.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="password">Şifre</Label>
|
||||||
|
<Input id="password" name="password" type="password" required autoComplete="new-password" placeholder="••••••••" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="confirm_password">Şifre Tekrar</Label>
|
||||||
|
<Input id="confirm_password" name="confirm_password" type="password" required autoComplete="new-password" placeholder="••••••••" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={pending} className="w-full">
|
||||||
|
{pending && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{pending ? 'Kayıt yapılıyor…' : 'Kayıt Ol'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="justify-center text-sm text-muted-foreground">
|
||||||
|
Zaten hesabınız var mı?
|
||||||
|
<Link href="/auth/login" className="font-medium text-primary underline-offset-4 hover:underline">
|
||||||
|
Giriş Yap
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
app/components/ThemeToggle.tsx
Normal file
51
app/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useSyncExternalStore } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Moon, Sun } from 'lucide-react'
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
|
function getSnapshot(): Theme {
|
||||||
|
const saved = localStorage.getItem('theme')
|
||||||
|
if (saved === 'dark' || saved === 'light') return saved
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerSnapshot(): Theme {
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(cb: () => void): () => void {
|
||||||
|
window.addEventListener('storage', cb)
|
||||||
|
return () => window.removeEventListener('storage', cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThemeToggle() {
|
||||||
|
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const next: Theme = theme === 'dark' ? 'light' : 'dark'
|
||||||
|
localStorage.setItem('theme', next)
|
||||||
|
window.dispatchEvent(new StorageEvent('storage', { key: 'theme', newValue: next }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
suppressHydrationWarning
|
||||||
|
aria-label="Tema değiştir"
|
||||||
|
>
|
||||||
|
{theme === 'dark'
|
||||||
|
? <Sun className="size-4" />
|
||||||
|
: <Moon className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
59
app/components/TopBar.tsx
Normal file
59
app/components/TopBar.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import ThemeToggle from './ThemeToggle'
|
||||||
|
import { signOut } from 'next-auth/react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { LogOut, Zap } from 'lucide-react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isLoggedIn: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TopBar({ isLoggedIn }: Props) {
|
||||||
|
async function onLogout() {
|
||||||
|
await signOut({ callbackUrl: '/auth/login' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 border-b bg-background/80 backdrop-blur-sm">
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl items-center justify-between px-4 py-2.5">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||||
|
<Zap className="size-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-base font-bold tracking-tight">NextGo</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<nav className="flex items-center gap-1">
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href="/admin/users">Admin</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="button" onClick={onLogout} variant="destructive" size="sm">
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
Çıkış
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href="/auth/login">Giriş</Link>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" asChild>
|
||||||
|
<Link href="/auth/register">Kayıt Ol</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
173
app/globals.css
Normal file
173
app/globals.css
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* ── Theme tokens (light) ───────────────────────────── */
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--background: oklch(0.97 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.546 0.245 262.881);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.96 0.005 247);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.96 0.005 247);
|
||||||
|
--muted-foreground: oklch(0.5 0.02 247);
|
||||||
|
--accent: oklch(0.96 0.005 247);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.9 0.005 247);
|
||||||
|
--input: oklch(0.9 0.005 247);
|
||||||
|
--ring: oklch(0.546 0.245 262.881);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.96 0.005 247);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.9 0.005 247);
|
||||||
|
--sidebar-ring: oklch(0.546 0.245 262.881);
|
||||||
|
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Theme tokens (dark) ────────────────────────────── */
|
||||||
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.623 0.214 259.815);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.623 0.214 259.815);
|
||||||
|
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.623 0.214 259.815);
|
||||||
|
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form controls (dark-mode safe) ───────────────────── */
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
color: var(--foreground);
|
||||||
|
background-color: var(--card);
|
||||||
|
caret-color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:-webkit-autofill,
|
||||||
|
input:-webkit-autofill:hover,
|
||||||
|
input:-webkit-autofill:focus,
|
||||||
|
textarea:-webkit-autofill,
|
||||||
|
select:-webkit-autofill {
|
||||||
|
-webkit-text-fill-color: var(--foreground);
|
||||||
|
box-shadow: 0 0 0 1000px var(--card) inset;
|
||||||
|
transition: background-color 9999s ease-in-out 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ─────────────────────────────────────────── */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); }
|
||||||
46
app/layout.tsx
Normal file
46
app/layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono, Inter } from "next/font/google";
|
||||||
|
import TopBar from "./components/TopBar";
|
||||||
|
import "./globals.css";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
|
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Create Next App",
|
||||||
|
description: "Generated by create next app",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
const isLoggedIn = !!session;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
suppressHydrationWarning
|
||||||
|
className={cn("h-full", "antialiased", geistSans.variable, geistMono.variable, "font-sans", inter.variable)}
|
||||||
|
>
|
||||||
|
<body className="min-h-full flex flex-col bg-background text-foreground transition-colors">
|
||||||
|
<TopBar isLoggedIn={isLoggedIn} />
|
||||||
|
<main className="flex flex-1 flex-col">{children}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
app/page.tsx
Normal file
65
app/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||||
|
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||||
|
<Image
|
||||||
|
className="dark:invert"
|
||||||
|
src="/next.svg"
|
||||||
|
alt="Next.js logo"
|
||||||
|
width={100}
|
||||||
|
height={20}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||||
|
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||||
|
To get started, edit the page.tsx file.
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||||
|
Looking for a starting point or more instructions? Head over to{" "}
|
||||||
|
<a
|
||||||
|
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||||
|
>
|
||||||
|
Templates
|
||||||
|
</a>{" "}
|
||||||
|
or the{" "}
|
||||||
|
<a
|
||||||
|
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||||
|
>
|
||||||
|
Learning
|
||||||
|
</a>{" "}
|
||||||
|
center.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||||
|
<a
|
||||||
|
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||||
|
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
className="dark:invert"
|
||||||
|
src="/vercel.svg"
|
||||||
|
alt="Vercel logomark"
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
/>
|
||||||
|
Deploy Now
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||||
|
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Documentation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components.json
Normal file
25
components.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "radix-nova",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
112
components/ui/avatar.tsx
Normal file
112
components/ui/avatar.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
|
size?: "default" | "sm" | "lg"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn(
|
||||||
|
"aspect-square size-full rounded-full object-cover",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
AvatarBadge,
|
||||||
|
}
|
||||||
49
components/ui/badge.tsx
Normal file
49
components/ui/badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
67
components/ui/button.tsx
Normal file
67
components/ui/button.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
103
components/ui/card.tsx
Normal file
103
components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
33
components/ui/checkbox.tsx
Normal file
33
components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
168
components/ui/dialog.tsx
Normal file
168
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
19
components/ui/input.tsx
Normal file
19
components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
components/ui/label.tsx
Normal file
24
components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
116
components/ui/table.tsx
Normal file
116
components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
146
lib/auth.ts
Normal file
146
lib/auth.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import type { NextAuthOptions } from 'next-auth'
|
||||||
|
import type { JWT } from 'next-auth/jwt'
|
||||||
|
import CredentialsProvider from 'next-auth/providers/credentials'
|
||||||
|
import GitHubProvider from 'next-auth/providers/github'
|
||||||
|
import GoogleProvider from 'next-auth/providers/google'
|
||||||
|
import {
|
||||||
|
fetchRefreshedBackendJwt,
|
||||||
|
getJwtExpMs,
|
||||||
|
shouldRefreshBackendToken,
|
||||||
|
} from '@/lib/backend-jwt-refresh'
|
||||||
|
|
||||||
|
const API_BASE = process.env.API_BASE_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
|
type AuthUser = {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
accessTokenExpires: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken(token: JWT): Promise<JWT> {
|
||||||
|
const next = await fetchRefreshedBackendJwt(token)
|
||||||
|
if (!next) {
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
error: 'RefreshAccessTokenError',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authOptions: NextAuthOptions = {
|
||||||
|
session: {
|
||||||
|
strategy: 'jwt',
|
||||||
|
/** NextAuth oturum çerezi ömrü (saniye) — backend refresh ile uyumlu uzun süre */
|
||||||
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
|
},
|
||||||
|
secret: process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET,
|
||||||
|
providers: [
|
||||||
|
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
||||||
|
? [
|
||||||
|
GoogleProvider({
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
|
||||||
|
? [
|
||||||
|
GitHubProvider({
|
||||||
|
clientId: process.env.GITHUB_CLIENT_ID,
|
||||||
|
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
CredentialsProvider({
|
||||||
|
name: 'Credentials',
|
||||||
|
credentials: {
|
||||||
|
email: { label: 'Email', type: 'email' },
|
||||||
|
password: { label: 'Password', type: 'password' },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
const email = credentials?.email
|
||||||
|
const password = credentials?.password
|
||||||
|
if (!email || !password) return null
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) return null
|
||||||
|
|
||||||
|
const data = (await res.json()) as { access: string; refresh: string }
|
||||||
|
const accessToken = data.access
|
||||||
|
const refreshToken = data.refresh
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: email,
|
||||||
|
email,
|
||||||
|
username: email,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
accessTokenExpires: getJwtExpMs(accessToken),
|
||||||
|
} satisfies AuthUser
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
const authUser = user as AuthUser
|
||||||
|
|
||||||
|
// OAuth login akışında backend access/refresh token'ı yoksa refresh denemeyelim.
|
||||||
|
if (!authUser.accessToken || !authUser.refreshToken) {
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
user: {
|
||||||
|
id: authUser.id,
|
||||||
|
email: authUser.email,
|
||||||
|
username: authUser.username ?? authUser.email,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
accessToken: authUser.accessToken,
|
||||||
|
refreshToken: authUser.refreshToken,
|
||||||
|
accessTokenExpires: authUser.accessTokenExpires,
|
||||||
|
user: {
|
||||||
|
id: authUser.id,
|
||||||
|
email: authUser.email,
|
||||||
|
username: authUser.username,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.error === 'RefreshAccessTokenError') {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldRefreshBackendToken(token)) {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshAccessToken(token)
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
session.user = {
|
||||||
|
...(session.user ?? {}),
|
||||||
|
...(token.user ?? {}),
|
||||||
|
}
|
||||||
|
session.accessToken = token.accessToken
|
||||||
|
session.refreshToken = token.refreshToken
|
||||||
|
session.error = token.error
|
||||||
|
return session
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: '/auth/login',
|
||||||
|
},
|
||||||
|
}
|
||||||
116
lib/backend-jwt-refresh.ts
Normal file
116
lib/backend-jwt-refresh.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import type { JWT } from 'next-auth/jwt'
|
||||||
|
import { encode } from 'next-auth/jwt'
|
||||||
|
|
||||||
|
const API_BASE = process.env.API_BASE_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
|
/** Access token bitiminden önce yenile (ms) — backend 15 dk ise güvenli tampon */
|
||||||
|
const REFRESH_BUFFER_MS = 120_000
|
||||||
|
|
||||||
|
/** NextAuth JWT şifreli cookie ömrü (saniye) — varsayılan NextAuth ile uyumlu */
|
||||||
|
const JWT_COOKIE_MAX_AGE_SEC = 30 * 24 * 60 * 60
|
||||||
|
|
||||||
|
export function sessionCookieName(): string {
|
||||||
|
return isSecureSessionCookieEnabled()
|
||||||
|
? '__Secure-next-auth.session-token'
|
||||||
|
: 'next-auth.session-token'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJwtExpMs(accessToken: string): number {
|
||||||
|
try {
|
||||||
|
const payloadPart = accessToken.split('.')[1]
|
||||||
|
if (!payloadPart) return Date.now()
|
||||||
|
const payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8')) as {
|
||||||
|
exp?: number
|
||||||
|
}
|
||||||
|
if (!payload.exp) return Date.now()
|
||||||
|
return payload.exp * 1000
|
||||||
|
} catch {
|
||||||
|
return Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Access süresi dolmak üzereyse veya dolmuşsa true */
|
||||||
|
export function shouldRefreshBackendToken(token: JWT | null): boolean {
|
||||||
|
if (!token?.refreshToken) return false
|
||||||
|
const exp = token.accessTokenExpires as number | undefined
|
||||||
|
if (!exp) return true
|
||||||
|
return Date.now() >= exp - REFRESH_BUFFER_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRefreshedBackendJwt(token: JWT): Promise<JWT | null> {
|
||||||
|
const refreshToken = token.refreshToken
|
||||||
|
if (!refreshToken) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) return null
|
||||||
|
|
||||||
|
const data = (await res.json()) as { access?: unknown; refresh?: unknown }
|
||||||
|
// Backend: { "access": "...", "refresh": "..." } — Login_Register.md ile aynı
|
||||||
|
if (typeof data.access !== 'string' || !data.access) return null
|
||||||
|
|
||||||
|
const accessToken = data.access
|
||||||
|
const nextRefresh =
|
||||||
|
typeof data.refresh === 'string' && data.refresh.length > 0
|
||||||
|
? data.refresh
|
||||||
|
: refreshToken
|
||||||
|
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
accessToken,
|
||||||
|
refreshToken: nextRefresh,
|
||||||
|
accessTokenExpires: getJwtExpMs(accessToken),
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encodeSessionJwt(token: JWT): Promise<string> {
|
||||||
|
const secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET
|
||||||
|
if (!secret) throw new Error('NEXTAUTH_SECRET eksik')
|
||||||
|
|
||||||
|
return encode({
|
||||||
|
token,
|
||||||
|
secret,
|
||||||
|
maxAge: JWT_COOKIE_MAX_AGE_SEC,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionCookieOptions(): {
|
||||||
|
httpOnly: boolean
|
||||||
|
secure: boolean
|
||||||
|
sameSite: 'lax'
|
||||||
|
path: string
|
||||||
|
maxAge: number
|
||||||
|
} {
|
||||||
|
const secure = isSecureSessionCookieEnabled()
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: JWT_COOKIE_MAX_AGE_SEC,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSecureSessionCookieEnabled(): boolean {
|
||||||
|
return (
|
||||||
|
!!process.env.NEXTAUTH_URL?.startsWith('https://') ||
|
||||||
|
!!process.env.VERCEL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server action / Route Handler: güncellenmiş JWT’yi NextAuth session çerezine yazar */
|
||||||
|
export function applySessionCookie(
|
||||||
|
cookieStore: { set: (name: string, value: string, options: Record<string, unknown>) => void },
|
||||||
|
jwt: string
|
||||||
|
): void {
|
||||||
|
cookieStore.set(sessionCookieName(), jwt, sessionCookieOptions())
|
||||||
|
}
|
||||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
54
mpc/MCP.md
Normal file
54
mpc/MCP.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# MCP Service Guide
|
||||||
|
|
||||||
|
Bu proje için MCP servis kullanım rehberi.
|
||||||
|
|
||||||
|
## Proje Bilgisi
|
||||||
|
- Proje adı: ginimageApi
|
||||||
|
- Dil: Go
|
||||||
|
- Framework: Gin
|
||||||
|
- ORM: Gorm
|
||||||
|
|
||||||
|
## Amaç
|
||||||
|
Bu MCP dokümanı, Copilot ve diğer agent'ların proje yapısını doğru anlaması ve admin user management endpointlerini tutarlı şekilde üretmesi için hazırlanmıştır.
|
||||||
|
|
||||||
|
## Klasör Yapısı
|
||||||
|
- `main.go` uygulama giriş noktası
|
||||||
|
- `app/` iş mantığı modülleri
|
||||||
|
- `config/` veritabanı ve redis ayarları
|
||||||
|
- `router/router.go` route tanımları
|
||||||
|
|
||||||
|
## Ana Modüller
|
||||||
|
### accounts
|
||||||
|
Kullanıcı işlemleri ve auth ile ilgili alanlar.
|
||||||
|
|
||||||
|
### settings
|
||||||
|
Uygulama ayarları.
|
||||||
|
|
||||||
|
### shop
|
||||||
|
Ürün ve sepet işlemleri.
|
||||||
|
|
||||||
|
### blog
|
||||||
|
Blog işlemleri.
|
||||||
|
|
||||||
|
## MCP Kullanım Notları
|
||||||
|
- Yeni endpoint eklerken mevcut yapı korunmalı.
|
||||||
|
- Handler logic sade tutulmalı.
|
||||||
|
- Model, handler ve router ayrımı bozulmamalı.
|
||||||
|
- Admin işlemler için ayrıca yetkilendirme düşünülmeli.
|
||||||
|
|
||||||
|
## Admin User Management
|
||||||
|
Beklenen admin endpointleri:
|
||||||
|
- `GET /admin/users`
|
||||||
|
- `GET /admin/users/:id`
|
||||||
|
- `POST /admin/users`
|
||||||
|
- `PUT /admin/users/:id`
|
||||||
|
- `PATCH /admin/users/:id/status`
|
||||||
|
- `DELETE /admin/users/:id`
|
||||||
|
|
||||||
|
## Güvenlik
|
||||||
|
- Password hash zorunlu.
|
||||||
|
- Role-based access önerilir.
|
||||||
|
- Response içinde hassas alan dönülmemeli.
|
||||||
|
|
||||||
|
## Not
|
||||||
|
Bu servis dosyası, MCP uyumlu otomasyon ve Copilot yönlendirmesi için referans dokümandır.
|
||||||
28
mpc/docs/admin-user-api.md
Normal file
28
mpc/docs/admin-user-api.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Admin User Management API
|
||||||
|
|
||||||
|
Bu doküman admin panel için kullanıcı yönetimi endpointlerini açıklar.
|
||||||
|
|
||||||
|
## Base Path
|
||||||
|
`/admin/users`
|
||||||
|
|
||||||
|
## Endpointler
|
||||||
|
|
||||||
|
### 1. Kullanıcı Listesi
|
||||||
|
`GET /admin/users`
|
||||||
|
|
||||||
|
#### Query Params
|
||||||
|
- `page` (optional)
|
||||||
|
- `limit` (optional)
|
||||||
|
- `search` (optional)
|
||||||
|
- `status` (optional)
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [],
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"limit": 10,
|
||||||
|
"total": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
24
mpc/docs/mcp-usage.md
Normal file
24
mpc/docs/mcp-usage.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# MCP Usage
|
||||||
|
|
||||||
|
## Bu dosyanın amacı
|
||||||
|
Copilot veya başka bir agent'ın bu repo için görev alırken izlemesi gereken kullanım rehberi.
|
||||||
|
|
||||||
|
## Çalışma Prensibi
|
||||||
|
- Önce mevcut klasör yapısını analiz et.
|
||||||
|
- Sonra ilgili modülün handler ve model dosyalarını incele.
|
||||||
|
- Yeni kod eklerken mevcut naming convention’a uy.
|
||||||
|
- Değişiklikleri minimum etkiyle yap.
|
||||||
|
|
||||||
|
## Admin User Endpoint Beklentisi
|
||||||
|
- Listeleme
|
||||||
|
- Detay
|
||||||
|
- Oluşturma
|
||||||
|
- Güncelleme
|
||||||
|
- Durum değiştirme
|
||||||
|
- Silme
|
||||||
|
|
||||||
|
## Çıkış Kuralları
|
||||||
|
- Hassas bilgi döndürme.
|
||||||
|
- Validation ekle.
|
||||||
|
- Hata kodlarını doğru kullan.
|
||||||
|
- Router’ı güncelle.
|
||||||
27
mpc/docs/project-structure.md
Normal file
27
mpc/docs/project-structure.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Project Structure
|
||||||
|
|
||||||
|
## Root
|
||||||
|
- `main.go` uygulama başlangıcı
|
||||||
|
- `.env` ortam değişkenleri
|
||||||
|
|
||||||
|
## app
|
||||||
|
Uygulama modülleri burada bulunur.
|
||||||
|
|
||||||
|
### accounts
|
||||||
|
Kullanıcı ve hesap yönetimi.
|
||||||
|
|
||||||
|
### settings
|
||||||
|
Site ayarları ve yapılandırmalar.
|
||||||
|
|
||||||
|
### shop
|
||||||
|
Ürün ve sepet yönetimi.
|
||||||
|
|
||||||
|
### blog
|
||||||
|
Blog yönetimi.
|
||||||
|
|
||||||
|
## config
|
||||||
|
- `db.go` veritabanı bağlantısı
|
||||||
|
- `redis.go` redis bağlantısı
|
||||||
|
|
||||||
|
## router
|
||||||
|
- `router.go` tüm route tanımları
|
||||||
13
next.config.ts
Normal file
13
next.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
allowedDevOrigins: ["127.0.0.1"],
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
/** Avatar vb. multipart; varsayılan 1 MB yetmez */
|
||||||
|
bodySizeLimit: "5mb",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
11506
package-lock.json
generated
Normal file
11506
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "nextgo",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
|
"next": "16.2.3",
|
||||||
|
"next-auth": "^4.24.14",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"shadcn": "^4.2.0",
|
||||||
|
"sweetalert2": "^11.26.24",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.3",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
71
proxy.ts
Normal file
71
proxy.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import type { JWT } from 'next-auth/jwt'
|
||||||
|
import { getToken } from 'next-auth/jwt'
|
||||||
|
import {
|
||||||
|
applySessionCookie,
|
||||||
|
encodeSessionJwt,
|
||||||
|
fetchRefreshedBackendJwt,
|
||||||
|
shouldRefreshBackendToken,
|
||||||
|
} from '@/lib/backend-jwt-refresh'
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = ['/auth/login', '/auth/register']
|
||||||
|
|
||||||
|
const secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET
|
||||||
|
|
||||||
|
export async function proxy(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p))
|
||||||
|
|
||||||
|
let token = (await getToken({
|
||||||
|
req: request,
|
||||||
|
secret,
|
||||||
|
})) as JWT | null
|
||||||
|
|
||||||
|
let refreshedJwt: string | null = null
|
||||||
|
|
||||||
|
if (token && shouldRefreshBackendToken(token)) {
|
||||||
|
const next = await fetchRefreshedBackendJwt(token)
|
||||||
|
if (next) {
|
||||||
|
refreshedJwt = await encodeSessionJwt(next)
|
||||||
|
token = next as JWT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBackendAccessToken =
|
||||||
|
typeof token?.accessToken === 'string' && token.accessToken.length > 0
|
||||||
|
const isLoggedIn = !!token && hasBackendAccessToken
|
||||||
|
|
||||||
|
const withCookie = (res: NextResponse) => {
|
||||||
|
if (refreshedJwt) applySessionCookie(res.cookies, refreshedJwt)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// Giriş yapmış kullanıcı auth sayfasına gitmesin
|
||||||
|
if (isPublic && isLoggedIn) {
|
||||||
|
return withCookie(NextResponse.redirect(new URL('/admin/users', request.url)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Herkese açık sayfalara izin ver
|
||||||
|
if (isPublic) {
|
||||||
|
return withCookie(NextResponse.next())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token varsa devam et
|
||||||
|
if (isLoggedIn) {
|
||||||
|
return withCookie(NextResponse.next())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oturum yok → login
|
||||||
|
const loginUrl = new URL('/auth/login', request.url)
|
||||||
|
loginUrl.searchParams.set('from', pathname)
|
||||||
|
return withCookie(NextResponse.redirect(loginUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Auth sayfaları, static dosyalar ve API route'ları hariç her şeyi yakala
|
||||||
|
*/
|
||||||
|
'/((?!_next/static|_next/image|favicon.ico|api/).*)',
|
||||||
|
],
|
||||||
|
}
|
||||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
1
tmp/build-errors.log
Normal file
1
tmp/build-errors.log
Normal file
@@ -0,0 +1 @@
|
|||||||
|
exit status 1
|
||||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
27
types/next-auth.d.ts
vendored
Normal file
27
types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { DefaultSession } from 'next-auth'
|
||||||
|
|
||||||
|
declare module 'next-auth' {
|
||||||
|
interface Session {
|
||||||
|
accessToken?: string
|
||||||
|
refreshToken?: string
|
||||||
|
error?: string
|
||||||
|
user: DefaultSession['user'] & {
|
||||||
|
id?: string
|
||||||
|
username?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'next-auth/jwt' {
|
||||||
|
interface JWT {
|
||||||
|
accessToken?: string
|
||||||
|
refreshToken?: string
|
||||||
|
accessTokenExpires?: number
|
||||||
|
error?: string
|
||||||
|
user?: {
|
||||||
|
id?: string
|
||||||
|
email?: string
|
||||||
|
username?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user